10.2 与既有服务端书稿的对接约定
全卷最后一节。前面九章加 10.1,客户端侧已经完整;这一节只做交接:把客户端需要的每一条消息、每一个端点写成约定,让服务端实现方(可能是你团队的后端,也可能就是你照站内书稿自己写)知道要提供什么。
先划边界。 这一节只写约定,不写实现。凡是「服务端内部怎么裁决、怎么做 AOI、怎么匹配」的问题,一律交接——服务端实现见站内游戏服务端专题 与《游戏服务端编程实践》书稿。本节给的是客户端发什么、服务端回什么,以及两端联调前必须对齐的参数。
10.2.1 五类接口
客户端与后端打交道,按生命周期分五类。先给全景,再逐类展开:
| 类别 | 触发时机 | 传输 | 客户端关心什么 |
|---|---|---|---|
| 连接与鉴权 | 启动 | HTTP + WebSocket | 端点、握手字段、会话 ID |
| 匹配 | 点「开始」后 | WebSocket | 入队 / 出队 / 匹配确认 |
| 房间 | 匹配成功后 | WebSocket | 创建 / 加入 / 配置下发 / 解散 |
| 对局 | 房间就绪后 | WebSocket | 输入上行、快照下行、结算 |
| 断线重连 | 连接断开后 | WebSocket | 恢复端点、恢复点协商 |
一个约定:除了握手与鉴权走一次 HTTP,其余全在一条 WebSocket 上做——这样重连时只需恢复一条连接,不必重新走一遍 HTTP 鉴权。WebSocket 的可用性跨端一致(H5 / 微信小游戏 / 原生均支持),这也是卷四选它的原因。
10.2.2 连接与鉴权
端点约定:
| 端点 | 方法 | 请求 | 响应 |
|---|---|---|---|
/auth/login | POST | { token, clientVersion } | { sessionId, serverTime, wsUrl } |
/ws | GET(升级) | 查询参数 ?session=<sessionId> | WebSocket 升级 |
wsUrl 由服务端下发而不是客户端写死,是为了让灰度、多区服、就近接入都能在服务端切换。握手字段:
| 字段 | 类型 | 含义 | 方向 |
|---|---|---|---|
token | string | 登录凭证 | 客户端 → 服务端 |
clientVersion | string | 客户端版本,用于兼容判定 | 客户端 → 服务端 |
sessionId | string | 会话 ID,重连时复用 | 服务端 → 客户端 |
serverTime | number | 服务器当前时间(毫秒) | 服务端 → 客户端 |
protocolVersion | number | 协议版本,两端必须一致 | 双向 |
serverTime 是客户端时钟同步(1.3)的锚点,第一个收到的 serverTime 就用于建立「本地时钟 → 服务器时钟」的映射。protocolVersion 不一致时客户端应提示升级而不是硬连——这是防「新旧协议混跑出诡异 bug」的第一道门。
10.2.3 匹配
匹配的客户端角色是「入队、等待、确认」,不做匹配算法(那是服务端的事)。消息:
| 消息 | 字段 | 类型 | 含义 | 方向 |
|---|---|---|---|---|
MatchEnqueue | mode | string | 匹配模式(如 race-1v1) | 客户端 → 服务端 |
MatchEnqueue | rating | number | 客户端上报名次分(服务端可不信) | 客户端 → 服务端 |
MatchCancel | ticketId | string | 出队凭证 | 客户端 → 服务端 |
MatchFound | roomId | string | 匹配到的房间 ID | 服务端 → 客户端 |
MatchFound | opponent | object | 对手展示信息(昵称、段位) | 服务端 → 客户端 |
MatchFound | deadline | number | 进入房间的服务器时间截止 | 服务端 → 客户端 |
客户端要注意两点:入队后 rating 只是「自报」,服务端有权不采信(8.1 的权威边界);deadline 是服务器时间,客户端要用时钟映射把它换算成本地时间做倒计时,超时未进房按匹配失败处理。MatchCancel 要幂等——重复点「取消」不应报错(5.2)。
10.2.4 房间
房间从创建到解散是一条客户端状态机(6.2 已展开),这里只定消息:
| 消息 | 字段 | 类型 | 含义 | 方向 |
|---|---|---|---|---|
RoomJoin | roomId | string | 加入指定房间 | 客户端 → 服务端 |
RoomJoin | ready | boolean | 准备状态 | 客户端 → 服务端 |
RoomConfig | levelId | string | 跑道 / 关卡 ID | 服务端 → 客户端 |
RoomConfig | seed | number | 确定性随机种子(两端必须一致) | 服务端 → 客户端 |
RoomConfig | tickRate | number | 逻辑帧率(约定 60) | 服务端 → 客户端 |
RoomConfig | countdownAt | number | 起跑服务器时间戳 | 服务端 → 客户端 |
RoomDismiss | reason | string | 解散原因(对手掉线 / 超时 / 主动退) | 服务端 → 客户端 |
seed 是确定性核心的命脉:两端用同一个种子生成跑道与道具分布,才能算出同一世界(2.1)。种子必须由服务端下发,客户端绝不能自己生成——否则两个客户端的世界从一开始就不一样。countdownAt 同理,用服务器时间而非本地倒计时。
10.2.5 对局
对局是消息量最大的部分。上行是输入,下行是快照,最后是结算:
| 消息 | 字段 | 类型 | 含义 | 方向 |
|---|---|---|---|---|
InputFrame | frame | number | 逻辑帧号 | 客户端 → 服务端 |
InputFrame | move | number | 水平输入(-1 / 0 / 1) | 客户端 → 服务端 |
InputFrame | jump | boolean | 跳跃输入 | 客户端 → 服务端 |
Snapshot | frame | number | 快照对应帧号 | 服务端 → 客户端 |
Snapshot | entities | array | 实体状态(增量或全量) | 服务端 → 客户端 |
Snapshot | ackFrame | number | 已处理到的输入帧(和解用) | 服务端 → 客户端 |
RaceResult | winner | number | 名次 | 服务端 → 客户端 |
RaceResult | elapsedMs | number | 用时 | 服务端 → 客户端 |
RaceResultAck | frame | number | 客户端确认收到结算 | 客户端 → 服务端 |
三条客户端纪律:输入上行要能丢能重传(InputFrame 无状态、按帧号幂等,见 5.2);快照下行要能丢能等(Snapshot 丢了等下一帧,不要卡住);ackFrame 是和解的锚点(客户端用它决定回滚到哪一帧重演,见 4.2)。结算由服务端裁决,客户端的 RaceResultAck 只是确认,不是「我说了算」(8.1)。
10.2.6 断线重连
重连的关键是恢复点协商——客户端不知道自己在服务端看来停在哪一帧,必须问:
| 消息 | 字段 | 类型 | 含义 | 方向 |
|---|---|---|---|---|
ResumeRequest | sessionId | string | 原会话 ID | 客户端 → 服务端 |
ResumeRequest | lastFrame | number | 客户端最后处理到的帧 | 客户端 → 服务端 |
ResumeAccept | resumeFrame | number | 服务端指定的恢复帧 | 服务端 → 客户端 |
ResumeReject | reason | string | 拒绝原因(对局已结束 / 超时) | 服务端 → 客户端 |
客户端在 ResumeAccept 后应用 resumeFrame 重建世界(可能补一段快照),而不是从 lastFrame 硬接——两者的差就是断线期间丢掉的帧。ResumeReject 要能优雅处理:对局已结束就回到结算页,超时就回主菜单,不要让玩家卡在「重连中」。完整流程见 5.1。
10.2.7 版本与兼容
联机最怕「新旧客户端混连」。约定两条:客户端启动时把 protocolVersion 上报,服务端不匹配就拒绝并提示升级;服务端下发 minClientVersion,低于它的客户端不得入队。这样灰度发布时,老客户端不会被新服务端「打懵」,而是被明确挡在门外。
字段层面,新增字段必须可选(老客户端收到不认识的下一个字段应忽略而不是崩溃),删除字段要经过弃用期(服务端先停止下发、再停止接收)。这条纪律比任何文档都管用——它是「协议不会因为一次发布就崩掉所有人」的保证。
10.2.8 客户端侧对接清单
联调前,下面这些参数必须在客户端可配置(写死在代码里是联调最大的返工来源):
// assets/scripts/net/session/net-config.ts —— 纯 TS,不 import 'cc'
export interface NetConfig {
readonly authBaseUrl: string; // 鉴权 HTTP 基址
readonly wsUrl: string; // WebSocket 地址(也可用服务端下发的)
readonly clientVersion: string; // 客户端版本
readonly protocolVersion: number;
readonly tickRate: number; // 逻辑帧率,约定 60
readonly snapshotIntervalMs: number; // 快照下发间隔(服务端也可指定)
readonly reconnect: {
readonly maxRetries: number;
readonly baseDelayMs: number;
readonly maxDelayMs: number;
};
}
export function normalizeNetConfig(raw: Partial<NetConfig>): NetConfig {
return {
authBaseUrl: raw.authBaseUrl ?? 'https://api.example.com',
wsUrl: raw.wsUrl ?? 'wss://api.example.com/ws',
clientVersion: raw.clientVersion ?? '0.0.0',
protocolVersion: raw.protocolVersion ?? 1,
tickRate: raw.tickRate ?? 60,
snapshotIntervalMs: raw.snapshotIntervalMs ?? 50,
reconnect: {
maxRetries: raw.reconnect?.maxRetries ?? 5,
baseDelayMs: raw.reconnect?.baseDelayMs ?? 500,
maxDelayMs: raw.reconnect?.maxDelayMs ?? 8000,
},
};
}
normalizeNetConfig 给了每个字段一个默认值,这样即使配置不全也能启动——但它本身是可单测的纯逻辑:
import { describe, expect, it } from 'vitest';
import { normalizeNetConfig } from '../assets/scripts/net/session/net-config';
describe('normalizeNetConfig', () => {
it('缺省字段回落到默认值', () => {
const cfg = normalizeNetConfig({});
expect(cfg.tickRate).toBe(60);
expect(cfg.reconnect.maxRetries).toBe(5);
});
it('保留显式传入的值', () => {
const cfg = normalizeNetConfig({
tickRate: 30,
reconnect: { maxRetries: 2, baseDelayMs: 1, maxDelayMs: 2 },
});
expect(cfg.tickRate).toBe(30);
expect(cfg.reconnect.maxRetries).toBe(2);
});
});
跨端差异也落在配置里。下面这个 L2 片段按平台挑默认端点——用 sys.platform 判断,成员已在 cc.d.ts 3.8.8 中核到:
import { sys } from 'cc';
import { NetConfig, normalizeNetConfig } from '../net/session/net-config';
export function defaultNetConfig(): NetConfig {
// 微信小游戏要求 wss 且域名白名单,开发期常指向独立环境
const isWeChat = sys.platform === sys.Platform.WECHAT_GAME;
return normalizeNetConfig({
wsUrl: isWeChat ? 'wss://wx.example.com/ws' : 'wss://api.example.com/ws',
clientVersion: '0.1.0',
});
}
配置从哪来:NetConfig 的字段有三个来源——构建期注入(clientVersion、authBaseUrl 按环境打包)、服务端下发(wsUrl、snapshotIntervalMs)、本地覆盖(开发期手动指向测试环境)。优先级是「本地覆盖 > 服务端下发 > 构建期注入」,normalizeNetConfig 只做兜底,不做来源判定。
联调前必须与服务端对齐的四项:protocolVersion 的当前值、Snapshot 是增量还是全量、seed 的下发时机、ResumeReject 的各类 reason 取值。
10.2.9 联调检查表
约定齐了,用几条冒烟用例确认两端真的接上了(前四条覆盖连接、匹配、对局、重连四类主链路):
| 环节 | 冒烟用例 | 通过标准 |
|---|---|---|
| 连接 | 启动客户端,观察 /auth/login 与 /ws 升级 | 收到 sessionId 与 serverTime,连接保持不断 |
| 匹配 | 两个客户端同时入队 | 双方收到同一 roomId,deadline 内进房 |
| 对局 | 打完一局,注入 200 ms 延迟 + 5% 丢包 | 双方名次一致,客户端无长时间冻结 |
| 重连 | 对局中断网 3 秒再恢复 | 收到 ResumeAccept,世界重建后继续,无卡死 |
| 鉴权 | 用过期 token 启动 | 收到明确错误码,引导重新登录而非卡死 |
| 解散 | 对局中一方主动退出 | 另一方收到 RoomDismiss 并回到房间列表 |
每条都要在有干扰的网络下跑一遍——只在内网跑通的联调,等于没联调。7.1 的弱网模拟可以本地复现这些场景。
10.2.10 全卷回顾:客户端联机的四件事
走完十章,客户端侧的联机其实只做四件事:
| 四件事 | 回答的问题 | 对应章节 |
|---|---|---|
| 对齐世界 | 两个客户端怎么看到同一个世界 | 1–3 章 |
| 消除延迟感 | 操作为什么能「立刻」有反应 | 4、9 章 |
| 扛住弱网 | 丢包、抖动、断线怎么还能玩 | 5、7 章 |
| 守住边界 | 客户端能信什么、不能信什么 | 8 章 |
对齐世界是地基:没有确定性核心与快照同步,后面三件都无从谈起。消除延迟感是体验:预测让本地先动、和解让预测不跑偏、插值让远端不卡顿。扛住弱网是韧性:重连、重传、降级,让「网络不好」从「玩不了」变成「还能玩」。守住边界是底线:客户端永远不可信,真正的裁决在服务端,客户端能做的只是降低被篡改的面。
四件事背后是同一种工程习惯:先把能脱离网络的逻辑写成可测的纯逻辑,再去谈联调。卷三讲引擎时的口头禅是「先量化,再下判断」;落到联机上,就是这一句——先让逻辑能脱离网络单测,再谈联调。因为联机里最难定位的问题,几乎都不是「网络坏了」,而是「你的逻辑在某种时序下算错了」,而后者只有脱离网络才复现得出来。
这一节的接口清单还有一个用法:把它当成服务端契约测试的输入。每条消息的字段表就是一份 schema,客户端可据此写契约测试——服务端字段改名或类型变化时测试先红,而不是等到线上联调才发现。
到这里,《Cocos 游戏开发》四卷本就全部讲完了。从卷一手写 mini2d,到卷二发布产品,到卷三读懂引擎,再到卷四让两个客户端对上同一个世界——四卷合起来是一条从「会拆」到「会用」到「懂」再到「能协作」的完整路径。引擎会升级,协议会换代,但这套「把复杂问题拆成可测小步」的习惯不会过时。 愿它在你下一个项目里,照样管用。
阅读导航:上一节:10.1 把《方块冲刺》做成双人竞速 · 下一节:回到目录 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。