Files
erqiwang_youle/docs/client/development-guide/04-网络对接与启动编排.md
T
2026-08-11 22:17:54 +08:00

247 lines
15 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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) 汇总前端所有工程纪律。