真机联调发现:服务器实发单层 {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>
20 KiB
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"是浏览器 WebSocketMessageEvent对象,不是服务器的协议层。
依据 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 等业务包被误丢弃,真机联调暴露并已修正。)
完整解包顺序(含三道前置过滤,务必照此实现,否则会误处理旧连接包/错误回包/握手心跳):
- 旧连接去重:
if(GameData.TcpID != this.id) return;(12_Logic.js:122)——每次重连GameData.TcpID++,每个 WebSocket 实例闭包持有自己的this.id,只有最新连接的包才被处理,旧连接残留包直接丢弃。 - 若网络状态关闭
if(!GameData.netWorkSate) return;(12_Logic.js:127)。 _msg(= MessageEvent;若传输直接给字符串则JSON.parse,12_Logic.js:133-136)→ 取data = _msg.data(= 服务器实发帧,12_Logic.js:137)。- 特殊错误包:若
data === "webserve-服务器未工作"→ 进入重发登录分支后return(详见 4 节)。 - 若
data是字符串:- 若
data.substr(0,9) === "@toconcon"→ 握手包,直接return忽略(12_Logic.js:176-179)。 - 否则
data = JSON.parse(data)(12_Logic.js:180)→ 得到单层{route,rpc,data}。
- 若
- 重置收包超时定时器(仅
!ConstVal.isGameHall时创建,见第 4 节)(12_Logic.js:182-217)。 - 若
data.com === "@serverheartbeat"→ 心跳包,直接return忽略,不回包(12_Logic.js:218-223)。 - 令
_msg = _msg.data(仍是同一个MessageEvent.data),必要时再JSON.parse(12_Logic.js:224-229)→ 得到{route, rpc, data}。 - 错误回包忽略:
if(_msg.rpc == "submit_error") return;(12_Logic.js:235)。 - 登录态门控(
isSendLoginState):若GameData.isSendLoginState==true(已发登录、等待player_login响应期间),则除player_login(清门控)与kick_server外,其它收包一律return丢弃(12_Logic.js:238-257)。 - 按
_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":... } }