《Cocos 游戏联机实战》10.2 与既有服务端书稿的对接约定

全卷最后一节,只做客户端与服务端的接口交接:把连接鉴权、匹配、房间、对局、断线重连五类接口逐条写成字段表与端点约定,给出客户端可配置参数清单、跨端端点选择与四条联调冒烟用例,最后把客户端联机收成对齐世界、消除延迟感、扛住弱网、守住边界四件事并收尾全卷。

10.2 与既有服务端书稿的对接约定

全卷最后一节。前面九章加 10.1,客户端侧已经完整;这一节只做交接:把客户端需要的每一条消息、每一个端点写成约定,让服务端实现方(可能是你团队的后端,也可能就是你照站内书稿自己写)知道要提供什么。

先划边界。 这一节只写约定,不写实现。凡是「服务端内部怎么裁决、怎么做 AOI、怎么匹配」的问题,一律交接——服务端实现见站内游戏服务端专题 与《游戏服务端编程实践》书稿。本节给的是客户端发什么、服务端回什么,以及两端联调前必须对齐的参数。

10.2.1 五类接口

客户端与后端打交道,按生命周期分五类。先给全景,再逐类展开:

类别触发时机传输客户端关心什么
连接与鉴权启动HTTP + WebSocket端点、握手字段、会话 ID
匹配点「开始」后WebSocket入队 / 出队 / 匹配确认
房间匹配成功后WebSocket创建 / 加入 / 配置下发 / 解散
对局房间就绪后WebSocket输入上行、快照下行、结算
断线重连连接断开后WebSocket恢复端点、恢复点协商

一个约定:除了握手与鉴权走一次 HTTP,其余全在一条 WebSocket 上做——这样重连时只需恢复一条连接,不必重新走一遍 HTTP 鉴权。WebSocket 的可用性跨端一致(H5 / 微信小游戏 / 原生均支持),这也是卷四选它的原因。

10.2.2 连接与鉴权

端点约定:

端点方法请求响应
/auth/loginPOST{ token, clientVersion }{ sessionId, serverTime, wsUrl }
/wsGET(升级)查询参数 ?session=<sessionId>WebSocket 升级

wsUrl 由服务端下发而不是客户端写死,是为了让灰度、多区服、就近接入都能在服务端切换。握手字段:

字段类型含义方向
tokenstring登录凭证客户端 → 服务端
clientVersionstring客户端版本,用于兼容判定客户端 → 服务端
sessionIdstring会话 ID,重连时复用服务端 → 客户端
serverTimenumber服务器当前时间(毫秒)服务端 → 客户端
protocolVersionnumber协议版本,两端必须一致双向

serverTime 是客户端时钟同步(1.3)的锚点,第一个收到的 serverTime 就用于建立「本地时钟 → 服务器时钟」的映射。protocolVersion 不一致时客户端应提示升级而不是硬连——这是防「新旧协议混跑出诡异 bug」的第一道门。

10.2.3 匹配

匹配的客户端角色是「入队、等待、确认」,不做匹配算法(那是服务端的事)。消息:

消息字段类型含义方向
MatchEnqueuemodestring匹配模式(如 race-1v1)客户端 → 服务端
MatchEnqueueratingnumber客户端上报名次分(服务端可不信)客户端 → 服务端
MatchCancelticketIdstring出队凭证客户端 → 服务端
MatchFoundroomIdstring匹配到的房间 ID服务端 → 客户端
MatchFoundopponentobject对手展示信息(昵称、段位)服务端 → 客户端
MatchFounddeadlinenumber进入房间的服务器时间截止服务端 → 客户端

客户端要注意两点:入队后 rating 只是「自报」,服务端有权不采信(8.1 的权威边界);deadline 是服务器时间,客户端要用时钟映射把它换算成本地时间做倒计时,超时未进房按匹配失败处理。MatchCancel 要幂等——重复点「取消」不应报错(5.2)。

10.2.4 房间

房间从创建到解散是一条客户端状态机(6.2 已展开),这里只定消息:

消息字段类型含义方向
RoomJoinroomIdstring加入指定房间客户端 → 服务端
RoomJoinreadyboolean准备状态客户端 → 服务端
RoomConfiglevelIdstring跑道 / 关卡 ID服务端 → 客户端
RoomConfigseednumber确定性随机种子(两端必须一致)服务端 → 客户端
RoomConfigtickRatenumber逻辑帧率(约定 60)服务端 → 客户端
RoomConfigcountdownAtnumber起跑服务器时间戳服务端 → 客户端
RoomDismissreasonstring解散原因(对手掉线 / 超时 / 主动退)服务端 → 客户端

seed 是确定性核心的命脉:两端用同一个种子生成跑道与道具分布,才能算出同一世界(2.1)。种子必须由服务端下发,客户端绝不能自己生成——否则两个客户端的世界从一开始就不一样。countdownAt 同理,用服务器时间而非本地倒计时。

10.2.5 对局

对局是消息量最大的部分。上行是输入,下行是快照,最后是结算:

消息字段类型含义方向
InputFrameframenumber逻辑帧号客户端 → 服务端
InputFramemovenumber水平输入(-1 / 0 / 1)客户端 → 服务端
InputFramejumpboolean跳跃输入客户端 → 服务端
Snapshotframenumber快照对应帧号服务端 → 客户端
Snapshotentitiesarray实体状态(增量或全量)服务端 → 客户端
SnapshotackFramenumber已处理到的输入帧(和解用)服务端 → 客户端
RaceResultwinnernumber名次服务端 → 客户端
RaceResultelapsedMsnumber用时服务端 → 客户端
RaceResultAckframenumber客户端确认收到结算客户端 → 服务端

三条客户端纪律:输入上行要能丢能重传(InputFrame 无状态、按帧号幂等,见 5.2);快照下行要能丢能等(Snapshot 丢了等下一帧,不要卡住);ackFrame 是和解的锚点(客户端用它决定回滚到哪一帧重演,见 4.2)。结算由服务端裁决,客户端的 RaceResultAck 只是确认,不是「我说了算」(8.1)。

10.2.6 断线重连

重连的关键是恢复点协商——客户端不知道自己在服务端看来停在哪一帧,必须问:

消息字段类型含义方向
ResumeRequestsessionIdstring原会话 ID客户端 → 服务端
ResumeRequestlastFramenumber客户端最后处理到的帧客户端 → 服务端
ResumeAcceptresumeFramenumber服务端指定的恢复帧服务端 → 客户端
ResumeRejectreasonstring拒绝原因(对局已结束 / 超时)服务端 → 客户端

客户端在 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 把《方块冲刺》做成双人竞速 · 下一节:回到目录 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画