Files
youle_cocos/docs/protocol/01-传输层与架构.md
T
joywayerandClaude Opus 4.8 96c6c7c914 fix(framework): decodeFrame 改单层(服务器协议非双层)
真机联调发现:服务器实发单层 {app,route,rpc,data};旧客户端的'外层 data'是浏览器
MessageEvent(00_minhttp.js:266 ws.onmessage=config.onmessage),非协议层。框架传输层
已取 ev.data,decodeFrame 不应再剥一层。修正后 kick_server 等业务包能正确解析。
- decodeFrame 单层化 + 测试改单层帧
- 更正 docs/protocol/01 §3.2(双层→单层,附 MessageEvent 依据)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 20:25:36 +08:00

20 KiB
Raw Blame History

01 · 传输层与架构

本篇是「框架模板(00_Surface 平台层)↔ 服务器」协议规范的传输层部分:连接建立、握手、心跳、断线重连、服务器切换、收发包封装与过滤。 双向完整(C→S 与 S→C)。不含子游戏对局包(见 05 章)。 所有 文件:行 引用均按当前源码核对。源码 bug 用 🐛 标注;需后端抓包确认的用 ⚠️待服务器确认 标注。


1. 整体架构

┌─────────────┐   配置请求(HTTP)   ┌──────────────────┐
│  新前端      │ ─────────────────► │ 配置服务器        │  返回 update_json (含 urlserver)
│ (Cocos)     │ ◄───────────────── │ (gameserver 地址) │
└─────────────┘                    └──────────────────┘
       │ ws://ip:port (拿到 urlserver 后)
       ▼
┌─────────────────────────────────────────────────────┐
│  大厅服务器(agent)  ←切换→  房间服务器(room)            │
│  登录/房间列表/排行/任务/支付   开局/聊天/解散/对局      │
└─────────────────────────────────────────────────────┘
  • 同一时刻只保持一条 WebSocket 连接(Net.ws_tcp)。
  • 大厅与房间是不同的服务器地址,通过 connect_roomserver / connect_agentserver 指令切换: 服务器下发新地址 → 客户端把 GameData.Server 替换为新地址 → 关闭当前连接(Net.ws_tcp.close())→ onclose 触发后用新地址重连 → 重新登录/握手。
  • 路由空间 platform / agent / room(02_Const.js:8-10,字面值已确认与变量名一致)。

2. 传输方式

源码支持两种传输,由 ConstVal.netType 决定(02_Const.js:654,默认值 0):

netType 方式 说明
0 WebSocket(默认,正式使用) Net.ws_tcp.send(JSON.stringify(msg))
1 HTTP Ajax(备用/降级) Func.AjaxHttp2(...),POST JSON

大厅模式(ConstVal.isGameHall==true)启动时会被强制改成 ConstVal.netType=1(12_Logic.js:383)。子游戏(本框架模板的目标场景)走 netType=0 / WebSocket,这是线上实际通道,新前端按 WebSocket 实现即可。 收包路径在两种传输下不同,详见 3.3 节对比。

WebSocket 连接地址

WebSocket 在 min_tcp(config)(00_minhttp.js:256)内创建:

var ws = new WebSocket("ws://" + config.ipport);   // 00_minhttp.js:258,例: ws://127.0.0.1:5414
  • config.ipport 来自 GameData.Server(12_Logic.js:856 / 900 / 941 等连接函数把 server 赋给 config.ipport)。
  • GameData.Server 的来源(netType==0 分支):配置服务器回包 ServerUrl_Succ 内 GameData.Server=_msg.data.urlserver(12_Logic.js:549)。
    • ⚠️ 注意:Logic.setGameServer()(12_Logic.js:1270)只设置 Game_Config.Debugger.gameserver(配置文件 URL),不设置 GameData.Server;两者是两步、不可混淆。
  • 切换房间/大厅服时,GameData.Server 被替换为 _msg.data.roomserver / _msg.data.agentserver(09_Net.js:509 / 526)。
  • GameData.Server 可为单个字符串,也可为数组(多候选服务器)。为数组时按 GameData.serverIndex 轮询,详见第 7 节。

3. 消息信封(Envelope)

3.1 客户端 → 服务器(发送)

统一出口 Net._SendData(_app, _route, _rpc, _data)(09_Net.js:12)。组装的是单层信封:

{
  "app":   "youle",
  "route": "agent",            // platform | agent | room
  "rpc":   "player_login",     // RPC 名称
  "data":  { ... }             // 业务数据
}
  • app 字段固定取 AppList.app,值为 "youle"(02_Const.js:5,已确认恒为 "youle")。
  • route 取 RouteList.platform/agent/room(02_Const.js:8-10)。
  • netType==0:Net.ws_tcp.send(JSON.stringify(_msg))(09_Net.js:20)。单层,直接发信封。
  • netType==1:走 Func.AjaxHttp2(GameData.Server, _msg, ...)(09_Net.js:24),POST JSON。

3.2 服务器 → 客户端(接收)—— WebSocket 路径:单层(「外层 data」是浏览器 MessageEvent,非协议层)

⚠️ 重要更正(2026-06-28 真机联调确认):早期本节误记为「双层 {data:<inner>}」。实测+源码核对确认:服务器实发的协议帧是单层 {route, rpc, data}(实测还带 app 字段)。所谓"外层 data"是浏览器 WebSocket MessageEvent 对象,不是服务器的协议层。

依据 00_minhttp.js:266 的 min_tcp():ws.onmessage = config.onmessage;——把浏览器原生 MessageEvent 直接交给 12_Logic.js:119 的 this.onmessage(_msg)。所以 _msg 是 MessageEvent,_msg.data(= MessageEvent.data)才是服务器实发帧(源码注释原文:「msg.data 才是服务器发过来的业务数据」00_minhttp.js:265)。12_Logic.js 里 data=_msg.data(137) 与 _msg=_msg.data(224) 读的是同一个 MessageEvent.data,只剥掉这一层浏览器事件壳,没有第二层。

MessageEvent.data(= 服务器实发帧,单层)= {
    "app":   "youle",       // 实测服务器回包带 app(与客户端发包同形)
    "route": "...",
    "rpc":   "...",
    "data":  { ... }        // 真正的业务数据
}
// 握手包:MessageEvent.data 为原始串 "@toconcon...";心跳包:{ "com": "@serverheartbeat" }

新前端落地(YouleNexus):传输层(CocosWebSocketTransport / 联调用 WsTransport)已取 ev.data(= MessageEvent.data = 服务器单层帧)交给 decodeFrame,故 decodeFrame 不得再剥一层 .data——直接把 frame 当单层 {route,rpc,data} 解。(曾因沿用"双层"误解多剥一层,导致 kick_server 等业务包被误丢弃,真机联调暴露并已修正。)

完整解包顺序(含三道前置过滤,务必照此实现,否则会误处理旧连接包/错误回包/握手心跳):

  1. 旧连接去重:if(GameData.TcpID != this.id) return;(12_Logic.js:122)——每次重连 GameData.TcpID++,每个 WebSocket 实例闭包持有自己的 this.id,只有最新连接的包才被处理,旧连接残留包直接丢弃。
  2. 若网络状态关闭 if(!GameData.netWorkSate) return;(12_Logic.js:127)。
  3. _msg(= MessageEvent;若传输直接给字符串则 JSON.parse,12_Logic.js:133-136)→ 取 data = _msg.data(= 服务器实发帧,12_Logic.js:137)。
  4. 特殊错误包:若 data === "webserve-服务器未工作" → 进入重发登录分支后 return(详见 4 节)。
  5. 若 data 是字符串:
    • 若 data.substr(0,9) === "@toconcon" → 握手包,直接 return 忽略(12_Logic.js:176-179)。
    • 否则 data = JSON.parse(data)(12_Logic.js:180)→ 得到单层 {route,rpc,data}。
  6. 重置收包超时定时器(仅 !ConstVal.isGameHall 时创建,见第 4 节)(12_Logic.js:182-217)。
  7. 若 data.com === "@serverheartbeat" → 心跳包,直接 return 忽略,不回包(12_Logic.js:218-223)。
  8. 令 _msg = _msg.data(仍是同一个 MessageEvent.data),必要时再 JSON.parse(12_Logic.js:224-229)→ 得到 {route, rpc, data}。
  9. 错误回包忽略:if(_msg.rpc == "submit_error") return;(12_Logic.js:235)。
  10. 登录态门控(isSendLoginState):若 GameData.isSendLoginState==true(已发登录、等待 player_login 响应期间),则除 player_login(清门控)与 kick_server 外,其它收包一律 return 丢弃(12_Logic.js:238-257)。
  11. 按 _msg.route 分发(见第 5 节,12_Logic.js:258)。

注:源码对 MessageEvent.data 做了 typeof == "string" 判断后再 JSON.parse,是为兼容服务器有时发对象、有时发字符串(同一层的两种编码,非两层)。新前端「是字符串就 parse 一次」即可。

3.3 服务器 → 客户端 —— HTTP(netType==1) 路径:单层、无信封过滤

netType==1 收包走 Func.AjaxHttp2 的成功回调(09_Net.js:24-44),与 WebSocket 路径完全不同:

  • 回调直接拿到 _msg(字符串则 JSON.parse),单层:直接读 _msg.route / _msg.rpc 分发,没有外层 data 信封、没有 @toconcon 握手、没有 @serverheartbeat 心跳、没有 30s 超时、没有 TcpID/isSendLoginState 三道过滤。
  • 分发逻辑同 5 节:route ∈ {platform,agent,room} → Net[rpc](_msg),否则 → Game_Modify._ReceiveData(_msg)。
  • 失败回调里若是登录请求(input_msg=="playerLogin")→ GameUI.OpenTips("网络状况不好")(09_Net.js:38-43)。

新前端只需实现 WebSocket(netType==0) 路径;此节列出仅供对照,说明降级通道的差异。


4. 握手与心跳

包 方向 触发判定 频率 客户端动作
握手包 服务器→客户端 外层 data 为字符串且 substr(0,9)=="@toconcon" 连接成功后首包 忽略 return
心跳包 服务器→客户端 解包后 data.com === "@serverheartbeat" ~20s 一次 ⚠️待服务器确认 忽略,不回包
收包超时 客户端本地 —— 每次收包重置定时器 仅 !isGameHall 启用;超时见下方降级逻辑

关键常量:ConstVal.Max.heartbeat = 30000(02_Const.js:144),即 30 秒收包超时阈值。

⚠️待服务器确认:心跳"约 20s 一次"仅源码注释佐证(12_Logic.js:219 注释「20秒一次」);客户端侧唯一硬常量是收包超时 30000ms。实际心跳间隔需抓包确认。

4.1 收包超时定时器(仅子游戏模式)

  • 仅当 !ConstVal.isGameHall 时才 setTimeout(..., ConstVal.Max.heartbeat) 创建(12_Logic.js:185-217);大厅模式不启用此定时器。
  • 每次收到任意包都先 clearTimeout 再重建(滑动窗口)。
  • 30s 内未再收包 → GameUI.OpenTips("网络较慢!") + GameData.heartBeatStage=true,并按当前连接 readyState 分两条路径(仅非 Debugger 模式):
    • readyState == CLOSED:disType=true; ConstVal? NetType=1; Logic.TryConnect();(直接进重连)(12_Logic.js:190-195)。
    • readyState == OPEN:NetType=1; GameUI.StartLoad(); isClose=true; Net.ws_tcp.close(); 并启 setInterval(..., 5000) 反复 close() 直到真正断开(12_Logic.js:196-214)。
  • 下次正常收包时若 heartBeatStage==true,会 GameUI.CloseTips() 并复位(12_Logic.js:129-132)。

结论:心跳是服务器单向 push,新前端无需主动发送任何心跳包,只需:① 收到 @serverheartbeat 时不当作业务包丢弃;② 子游戏模式下维护一个「收到任意包就重置」的 30s 超时定时器用于断线判断与降级。

4.2 特殊错误包 webserve-服务器未工作

外层 data === "webserve-服务器未工作" 时(12_Logic.js:138):

  • 前置条件 if(get_self(225,37,0,0,0) == 0)(12_Logic.js:139,某 UI 状态判定)成立时:setTimeout(..., 10000) 即 10s 后用 C_Player 现有信息重新组装 data 并 Net.Send_login(data)(12_Logic.js:140-159)。
  • 然后 return,不继续后续解包(12_Logic.js:161)。

5. 路由分发逻辑

解包得到内层 {route, rpc, data} 后(WS 路径 12_Logic.js:258;HTTP 路径 09_Net.js:31;_SendData 内 netType==1 回调亦同):

if (route === "platform" || route === "agent" || route === "room") {
    if (typeof Net[rpc] === "function") Net[rpc](msg);   // 平台层:调用 Net.<rpc>(内层msg)
} else {
    Game_Modify._ReceiveData(msg);                       // 其它路由:交给子游戏
}
  • 平台层:route ∈ {platform, agent, room} → 调用 Net[rpc](内层msg),Net.<rpc> 再转 Desk.<rpc> / C_Player.<rpc> 处理。
  • 游戏内:其它 route → Game_Modify._ReceiveData(内层msg),由子游戏自行按 rpc 分发(见 05 章)。

在新前端中,等价于实现一个 dispatch(inner):先判断 route 是否平台层,是则查表调用对应处理器,否则进入游戏内协议处理器。


6. 登录前的完整握手流程(netType==0)

1. 启动 AppStart → HTTP 请求配置服务器(gameserver=Game_Config.Debugger.gameserver)
   → 回包 ServerUrl_Succ → GameData.Server = _msg.data.urlserver  (12_Logic.js:549)
2. (netType==0 时) AppStart 启 GameData.TcpTimer = setInterval(..., 3*GameData.timer) = 30s 首连看门狗
   (12_Logic.js:485-496):超时则 GameUI.OpenTips("网络状况不好...") 并 Net.ws_tcp.close()
   首连成功(onopen)或拿到配置后会 clearTimeout 它。
3. Logic.firstConnect(GameData.Server) → new game_websocket(TcpID) → min_tcp() → new WebSocket("ws://"+server)
4. onopen 触发 (12_Logic.js:3)。是否发 player_login 取决于状态(见 6.1)。
5. 服务器先回握手包 @toconcon (忽略)。
6. 服务器回 player_login 响应 (route=agent, rpc=player_login) → Net.player_login → Desk.login 处理。
   (登录响应字段见 02 章)
7. 进入大厅或恢复房间/对局。

6.1 onopen 发送 player_login 是有条件的(修正:非无条件)

onopen(12_Logic.js:3-118)按 GameData.ConnectType 与登录状态分支:

  • 若 !GameData.ConnectType(普通连接,非服务器切换)且 NetType==0:
    • 重连场景 GameData.isLogin == true:用 C_Player 现有信息(agentid/openid/gameid/nickname/avatar/sex/province/unionid/city/version/channelid/marketid,含 deviceLogin 时附 telphone)组装 data 并 Net.Send_login(data)(12_Logic.js:34-52)。
    • 首登场景 GameData.isLogin == false:读 Utl.getCookie(Utl.Config.wxinfo);cookie 为 null 时不发 player_login(12_Logic.js:53-79);非 null 时 SetWxInfo 后组装并 Net.Send_login(data)。
  • 若 GameData.ConnectType(服务器切换重连):按 GameData.ConnectRpc 重发 connect_roomserver/connect_agentserver,或在 disType 下补发登录(12_Logic.js:82-117)。

修正要点:文档不可写「onopen 立即/无条件发 player_login」。首登必须先有 wxinfo cookie。

6.2 Send_login 的 4s 守护超时(子游戏模式)

Net.Send_login(_data)(09_Net.js:122-238)在 !ConstVal.isGameHall 且 sendLoginTimer==null 时,设 setTimeout(..., 4000)(09_Net.js:155-172):4s 内未收到 player_login 响应则 ConnectType=false; disType=true; NetType=1; isClose=true; Net.ws_tcp.close(); 并启 setInterval(..., 5000) 反复 close() 直到断开(再由 onclose 走重连/降级)。 发出登录时设 GameData.isSendLoginState=true(09_Net.js:152),由 3.2 第 10 步门控收包。


7. 断线重连与服务器切换

7.1 onclose(12_Logic.js:278)

onclose 按 GameData.ConnectType 与 GameData.firstConnect 分流:

  • GameData.urlFail 为真直接 return(配置都没拿到,不重连)(12_Logic.js:281)。
  • 非切换 !ConstVal? !GameData.ConnectType:
    • 若 !GameData.firstConnect(已过首连):NetType=1; Logic.TryConnect();,按 disType 显示断线 UI(12_Logic.js:297-306)。
    • 若 GameData.firstConnect(仍在首连阶段):GameData.tryTimes++;候选数 ttimes(数组时取 Server.length,否则 3);tryTimes % ttimes == 0 时提示「网络状况不好...」;Logic.Connect(GameData.Server)(12_Logic.js:307-317)。
  • 切换 GameData.ConnectType:!disType → Logic.Connect(GameData.Server),否则 GameUI.StartLoad()(12_Logic.js:319-326)。

7.2 重连间隔与重连函数

  • GameData.timer = 10000(04_Data.js:42)= 重连定时器间隔(Logic.Connect/Logic.TryConnect 内 setTimeout(..., GameData.timer),12_Logic.js:910 / 1021)。
  • Logic.firstConnect(server)(12_Logic.js:826):首连,不延时。
  • Logic.Connect(server)(12_Logic.js:869):setTimeout timer 后连接。
  • Logic.TryConnect()(12_Logic.js:915):netType==1 时直接 return(不重连);否则立即连一次 + 再挂 setTimeout timer 一次。

7.3 多候选服务器轮询(GameData.Server 为数组)

两套独立计数路径:

  • 首连阶段(onclose, firstConnect):GameData.tryTimes++;候选数取 Server.length;提示节流用 tryTimes % ttimes(12_Logic.js:308-316)。firstConnect/Connect 内若 isArray(server) 则 serverIndex 取 0 或 (serverIndex+1)%length 切换(12_Logic.js:833-836 / 876-880)。
  • 已登录后断线(TryConnect):GameData.tryReconnectTimes++;tryReconnectTimes % 3 == 0 时归零并 serverIndex = (serverIndex+1)%Server.length 切下一个(12_Logic.js:952-962 / 1001-1011)。即每失败约 3 次轮换一个候选地址。
  • 数组模式下每次连接都把 GameData.sendLoginTimes = -1 复位(12_Logic.js:834 / 877 / 938 / 989)。

7.4 服务器切换指令(S→C 推送)

connect_roomserver · route=room · S→C 推送;服务器要求客户端切到房间服;推送字段(S→C) roomserver(string)—新房间服 ip:port;客户端动作:GameData.ConnectType=true; GameData.ConnectRpc=connect_roomserver; GameData.ConnectPack=data; GameData.Server=data.roomserver; Net.ws_tcp.close()(onclose 后用新地址重连,重连 onopen 会重发 Send_connect_roomserver(ConnectPack));接收 09_Net.js:503-512;备注 —

connect_agentserver · route=agent · S→C 推送;服务器要求客户端切回大厅服;推送字段(S→C) agentserver(string)—新大厅服 ip:port、opt(string)—切换原因;客户端动作:若 opt == other_break_room || opt == free_room 则先 GameUI.StartLoad();随后 ConnectType=true; ConnectRpc=connect_agentserver; ConnectPack=data; GameData.Server=data.agentserver; Net.ws_tcp.close();接收 09_Net.js:518-529;备注 ⚠️待服务器确认 data.opt 完整取值域(源码仅见其与 other_break_room / free_room 比较)。

这两个指令客户端也可主动发起:Net.Send_connect_roomserver / Net.Send_connect_agentserver(09_Net.js:500 / 515,route 分别为 room/agent,rpc 同名)。


8. 错误上报(可选实现,C→S)

submit_error · route=agent · C→S 单向上报(无响应);上报客户端异常;请求字段(C→S) packet(string)—出错的包、msg(string)—错误堆栈、playerid/agentid/gameid;发送 09_Net.js:55-80(Net.submit_error,同 msg 去重,仅当 errorMsg 变化才发);备注:catch 中由 12_Logic.js:265-274 在 isSubmitError 开启时调用。

submit_log · route=agent · C→S 单向上报;与 submit_error 同结构;发送 09_Net.js:81-101(Net.submit_log);备注 ⚠️待服务器确认:其内部 rpc 字段写死为 "submit_error"(09_Net.js:85),疑似复用——是否存在独立 submit_log 协议待后端确认。🐛 若服务端需区分两类上报,此处 rpc 名错误。

注意:客户端对收到的 rpc=="submit_error" 回包直接 return 忽略(12_Logic.js:235)。新前端对适配非必需,可选实现。

{ "app":"youle", "route":"agent", "rpc":"submit_error",
  "data": { "packet":"<出错的包>", "msg":"<错误堆栈>",
            "playerid":..., "agentid":..., "gameid":... } }