# 04 · 网络对接与启动编排 本篇讲**前端怎么和服务端打通、一局怎么启动**:发包链路、收包分发、新旧架构的对接边界、成败判定、启动顺序与 controllers/managers 的职责。 > **想看前后端端到端全链路**(一个包从前端出发 → 服务端三层路由/handler → 推回前端分发的完整往返、及各步在谁的哪个文件)见服务端 [03 §6「端到端收发全链路」](../../server/development-guide/03-数据收发与通信协议.md#6-端到端收发全链路前后端对照)。本篇聚焦**前端这一侧**的收发细节。 --- ## 1. 发包:语义化发包封装 → RpcHelper 前端发起操作时**不直接拼包**,走两层封装: ``` 业务/控制器 └─ 语义化发包封装(子游戏:一个操作一个语义化方法) └─ RpcHelper(框架基座:自动注入平台字段,调引擎发送) └─ Utl.sendData(app, route, rpc, data) ``` ### RpcHelper(自动注入平台字段) `RpcHelper` 把每个请求自动补齐平台必需字段(`agentid / gameid / playerid / roomcode / seat ...`),业务只传业务数据: ```js RpcHelper.sendGameRpc(rpc, gameData, options); // route = "room"/子游戏路由 ``` ### 语义化发包封装(子游戏) 子游戏为每个操作暴露一个语义化发包方法,内部补 `seat/roomcode` 等业务字段后经 `RpcHelper` 发送;业务侧只调这些语义化方法,不关心平台字段。 **DO**:发包一律走语义化发包封装 / `RpcHelper`。**DON'T**:在业务里直接 `Utl.sendData` 手拼包、漏注入平台字段。 > 平台字段缺失(如 `playerid` 为 0/空)应 fail-fast 暴露,不静默发出残缺包。 --- ## 2. 收包:统一分发 服务端的主动推送统一格式 `{ rpc, data }`,前端有**唯一收包入口**,再按 `rpc` 分发到对应处理器。收包分发器是**纯路由表 + 分发器**(一 rpc 一处理器): ```js var _handlers = { 'someRpc': function (d) { return someHandler.handleSomeRpc(d); }, 'error': function (d) { console.error('[dispatch]', d.message); } // ...每个 rpc 一条 }; function dispatch(msg) { // msg = { rpc, data } var h = _handlers[msg.rpc]; if (h) { h(msg.data); } else { console.warn('[dispatch] 未处理的 rpc:', msg.rpc); } } ``` **新增一种服务端推送** = 在路由表加一条 `rpc → 处理器` + 在对应处理器里写业务。**收包分发器只分发,不写业务逻辑。** --- ## 3. 新旧架构对接边界 平台事件先进旧的受限入口 `Game_Modify.*`,旧入口**只解包、只转交**,把数据委托给新架构: | 平台入口(受限文件,尽量不改) | 转交到(新架构) | 作用 | |--------------------------------|------------------|------| | `Game_Modify.appStart()` | 启动编排 | 启动初始化 | | `Game_Modify.StartWar(_msg)` | 开局处理 | 开局 | | `Game_Modify._ReceiveData(_msg)` | 收包分发 | 对局推送分发 | | `Game_Modify.Reconnect(info)` | 重连处理 | 断线重连/重画 | ### 开局解包要点(StartWar) 服务端 `makewar` 常用**差异化下发**(`sendtype:1` + `seatlist[]`)。`StartWar` 先按本座位取出自己的数据,再交给新架构的开局处理: ```js Game_Modify.StartWar = function (_msg) { var raw = _msg.data.deskwar || _msg.data; var gameData = raw; if (raw && raw.sendtype === 1 && Array.isArray(raw.seatlist)) { for (var i = 0; i < raw.seatlist.length; i++) { if (raw.seatlist[i].seat === C_Player.seat) { gameData = raw.seatlist[i].data; break; } } } // 委托新架构的开局处理,受限入口本身不写业务 }; ``` ### 重连(Reconnect)= 重画 前端**必须同时处理两种恢复场景**,二者本质相同、复用同一条重画路径: - **断线重连**:`Game_Modify.Reconnect` 拿到服务端 `get_deskinfo` 的完整快照,交给新架构的重连处理。 - **硬刷新 / 页面重载**:浏览器刷新后从零重启,同样要能还原到当前对局界面。 **重连处理的本质 = 恢复数据 → 恢复界面**:先据快照把各组件的 `this.data` 恢复齐,再逐个调用各 UI 组件的 `setXxx`/`refreshXxx`(或组件总 `refresh()`)据数据重建界面与交互状态。**绝不为重连单写一套渲染**——它复用「组件数据自持 + set-refresh」范式(见 02 §3、05 §6)的同一条重画路径,因此**任何时候执行重画都能从数据无歧义地还原正确界面**。这与「数据优先、表现延后」一脉相承。 **红线**:受限文件 `Game_Modify.*` 里**只接不写**,业务逻辑全部在新架构 handler 中。 --- ## 4. 成败判定:只认 `data.success` > 与服务端 [03 数据收发与通信协议](../../server/development-guide/03-数据收发与通信协议.md) 同一条协议。 前端 RPC 没有“同步返回”,操作结果由服务端**后续主动推送**告知。判成败的唯一权威是推送 `data` 里的 **`success`**: ```js function handleXxx(data) { if (!data || !data.success) { /* 失败处理 */ return; } // 成功逻辑 } ``` - **只认 `data.success`**,不用 `status`/`code` 判成败,不写 `status` 兼容兜底。 - 个别综合推送的 success 在**嵌套字段**(如 `gameSettleComplete` 的 `data.gameSettle.success`)——按该推送的契约取对应位置,仍以 `success` 语义为准,不改用 status。 --- ## 5. 输入—渲染解耦:发包只请求,收包才表现 > 专业名:**服务端权威的悲观 UI 更新(Server-Authoritative Pessimistic Rendering)** / 单向数据流下的**输入-渲染解耦**。 **核心时序原则:用户点击只负责发出请求包,绝不直接改动任何对局状态界面;一切随对局状态变化的表现,只在收到服务端下发的结果/推送包后才更新。** 发包与表现是两条独立通道:**点击发「命令」,收包应「事件」,UI 只订阅事件**。界面永远是服务端已确认状态的投影,不做「点击即更新」的乐观预测。 ### 5.1 点击回调的职责边界 点击回调**只做两件事**:①组织并发送请求包(走 §1 语义化发包封装);②(可选)纯本地物理反馈。**不得**在点击回调里改动任何对局状态界面。 | 归类 | 例子 | 点击时可否做 | |------|------|--------------| | 纯本地物理反馈(不碰领域状态) | 按钮按下高亮/缩放/音效 | ✅ 可即时 | | **响应/掷骰交互按钮的隐藏**(本玩家点击的碰/杠/过/胡按钮、手动掷骰按钮) | 点击发包即隐藏该按钮(兼作防连点) | ⚠️ **受控例外**允许乐观清除——**前提是服务端合法性验证 + 收包侧兜底**(见 5.6) | | 其余对局状态表现(领域状态) | 各类提示(等待听牌/报定等待/掷骰提示文字)、当前控制权高亮、启停倒计时、落牌进牌河、阶段/托管图标、他人手牌/副露 | ❌ **禁止**乐观,一律等收包 | 判别标准:**这个变化服务端要不要确认?** 要 → 收包后更新(除 5.6 例外的交互按钮);纯本地物理反馈、服务端根本不关心 → 可即时。 ### 5.2 为什么必须如此(与 AI 托管同源) 真人操作与 AI 托管/他人操作**共用同一后半段**:`服务端处理 → 下发结果包 → 前端更新`。 ``` 真人: 点击 → 发请求包 →┐ ├→ 服务端处理 → 下发结果包 → 收包处理器(唯一更新点) AI 托管:服务端 AI 决策 →┘ ``` 把界面更新一律挂在「**收包**」这个节点,则无论操作由真人点击还是服务端 AI 自动触发,前端表现都自动一致、**无需区分触发源**(渲染透明)。反之若挂在「点击」节点:AI 托管时**根本没有点击动作**,服务端自动发包后对应更新代码永不触发 → 界面卡死、提示不消失、按钮不刷新。 这正是根目录 CLAUDE.md「AI 托管数据一致性 / 对前端透明」在**前端时序维度**的必然要求,也与「前端不推测、当前玩家由服务端权威字段(如 `nextControlSeat`)给出」一脉相承。 ### 5.3 收包处理器必须「触发源无关」 同一收包处理器既服务真人操作回包、也服务 AI 托管与他人操作广播。因此: - **不得假设「本座刚点过」**:所需数据一律从**包内权威字段**读取,**禁止**依赖「点击时暂存的本地变量」。 - **先判 `data.success`**(§4),再据包字段渲染;无对应点击也能正确渲染。 ### 5.4 适用范围 凡「随对局状态变化」的表现均适用,包括但不限于:交互提示(手动掷骰提示文字 / 请出牌 / 等待其他玩家听牌 / 报定等待)、当前控制权高亮(按包内 `nextControlSeat`)、倒计时(按包内剩余时间启停)、手牌/牌河/亮牌、他人副露、阶段与托管图标。 > **例外**:本玩家点击的**响应/掷骰交互按钮**(碰/杠/过/胡按钮、手动掷骰按钮)的**隐藏**允许乐观清除(见 5.6);但这些按钮的**显示**,以及一切**提示**(含掷骰提示文字、等待听牌、报定等待),仍严格收包驱动。 ### 5.5 验收标准 每处改动须验**两条路径表现一致**: 1. **真人手动操作**:点击后界面在收到回包时才更新(点击瞬间不抢先变化)。 2. **服务端自动发包**(AI 托管 / 他人操作广播):无任何点击,界面同样在收到推送包后正确更新,且与真人路径**表现完全一致**。 两条都验过且一致,方为合规。 ### 5.6 受控例外:响应交互按钮乐观清除 + 服务端合法性验证 对**本玩家点击触发的响应交互按钮**(碰/杠/过/胡 操作按钮、手动掷骰按钮),**允许**在点击发包时**乐观隐藏**该按钮——它同时充当防连点(按钮没了就点不了第二次)与即时反馈。这是对 5.1 悲观 UI 的**受控例外**,成立必须**同时满足**下列三条,缺一不可: 1. **服务端合法性验证兜底(正确性根本)**:正确性**绝不依赖**前端乐观隐藏。服务端对每个操作请求做完整合法性验证(座位鉴权 / 阶段门 / 操作可用性 / 幂等去重),默认拒绝非法/重复/越权/乱序包并回 `success:false` 且**不改状态**。即便乐观隐藏被绕过、或非正规客户端狂发包,服务端也正确拒绝。详见服务端 [04 §8 操作请求合法性验证](../../server/development-guide/04-开发规范与红线.md#8-操作请求合法性验证不可信客户端)。 2. **收包侧兜底同样成立(AI 托管一致)**:因 AI 托管/他人操作**无点击**、不触发乐观隐藏,该按钮的清除**必须**在收包侧同样能发生——碰/杠/吃后操作者收非空 `availableActions` 覆盖刷新、胡后收包隐藏操作按钮、掷骰结果收包 `hideManualDiceUI`、「过」由收包据服务端下发的空 `availableActions` 清(或"进入托管即隐藏交互"覆盖)。**不得因加了乐观清除就删除收包兜底**。 3. **仅限交互按钮、不外扩**:例外只覆盖"玩家自己点击的响应/掷骰交互按钮"。其余一切表现(等待听牌/报定等待/掷骰提示文字等**提示**、当前控制权高亮、倒计时、落牌进牌河、阶段/托管图标、他人手牌/副露)**仍严格收包驱动**。 一句话:**乐观清除是即时反馈 + 防连点,收包侧与服务端验证才是权威——三者并存,不是用乐观清除替代收包/服务端。** **例外项验收**(5.5 两条路径一致仍成立,只是真人侧多了"乐观隐藏"一步): - 真人:点击 → 按钮立即隐藏(乐观);服务端验证通过走正常流程,若拒绝(`success:false`)则按钮由收包纠正(重显或按服务端权威保持隐藏)。 - AI 托管:无点击 → 按钮由收包侧隐藏,表现与真人一致(**重点验「过」不残留**)。 - 连点/非法包:服务端拒绝,状态不变。 --- ## 6. 启动编排 一局前端的启动由一段**启动编排**一次性完成(经 `Game_Modify.appStart` 触发): ``` 启动编排 1) 检查依赖 检查 EventBus/SpriteManager/UIManager/各 View 就绪 2) 初始化系统 SpriteManager.init / AnimationManager.init / UIManager.init 3) 注册视图 创建并注册各 View 到 UIManager 4) 标记初始化完成 ``` 启动后即等待平台事件:`StartWar` 开局、`_ReceiveData` 推送、`Reconnect` 重连,分别转交新架构。 --- ## 7. controllers 与 managers 职责 | 类别 | 角色 | 典型成员 | |------|------|----------| | **controllers**(处理器) | 接收网络消息 → 改数据/UI → 必要时 `emit` 事件 | 按消息类型分工的处理器(流程、操作、结算、特殊规则等) | | **managers**(资源/状态) | 管资源与跨模块能力 | 音频、Spine 回调分发、玩法标记、特效管理等 | > 精灵交互事件**不属于**子游戏 controllers:它由框架 `gameabc-framework/system/SpriteEventController.js` 统一分发。子游戏在组件 `init` 里用 `SpriteEventController.registerMouseDown/registerMouseUp/registerMouseMove/...` 注册按精灵 ID 的回调;需对每个绘制精灵统一处理的能力用 `registerGlobalDraw` 注册全局 draw 钩子。平台入口 `Game_Modify.utlmousedown/mouseup/...` 只把事件转调给它。 调用链示例: ``` 平台 _ReceiveData(_msg) → 收包分发(_msg) → 操作处理器(data) ├ 改数据模型 ├ 播动画(经游戏动画封装) ├ 动画回调里刷新静态界面 └ 播音效(经游戏音频管理) ``` **红线**:业务逻辑放 controllers/managers,**不**写进受限的 `Game_Modify.*`;处理器只处理自己那类消息,需要别的能力调对应 manager,不重造。 --- ## 8. 本篇 DO / DON'T | DO ✅ | DON'T ❌ | |------|---------| | 发包走语义化发包封装/`RpcHelper` | 业务里直接 `Utl.sendData` 手拼包 | | 收包统一进收包分发器,一 rpc 一处理器 | 在 `Game_Modify.*` 或散点里直接处理推送 | | 受限入口只解包转交,业务在新架构 handler | 在受限文件堆业务逻辑 | | 成败只认 `data.success` | 用 `status`/`code` 判成败、写 status 兜底 | | 点击只发请求包,对局状态表现等收包后更新(响应/掷骰交互按钮乐观清除为 5.6 受控例外) | 提示/控制权/倒计时/落牌等点击即更新(乐观预测)——AI 托管无点击时界面卡死 | | 收包处理器触发源无关,数据从包字段读 | 依赖「点击时暂存的本地变量」渲染 | | 重连/重画复用同一路径,据数据重建界面 | 重连单写一套与正常对局不同的渲染 | | 业务在 controllers/managers,各司其职 | 处理器交叉重造别人的能力 | 下一篇 [05-开发规范与红线](./05-开发规范与红线.md) 汇总前端所有工程纪律。