Files
youle_framework/docs/client/development-guide/04-网络对接与启动编排.md
T
2026-08-19 08:14:48 +08:00

17 KiB
Raw Blame History

04 · 网络对接与启动编排

本篇讲前端怎么和服务端打通、一局怎么启动:发包链路、收包分发、新旧架构的对接边界、成败判定、启动顺序与 controllers/managers 的职责。

想看前后端端到端全链路(一个包从前端出发 → 服务端三层路由/handler → 推回前端分发的完整往返、及各步在谁的哪个文件)见服务端 03 §6「端到端收发全链路」。本篇聚焦前端这一侧的收发细节。


1. 发包:语义化发包封装 → RpcHelper

前端发起操作时不直接拼包,走两层封装:

业务/控制器
  └─ 语义化发包封装(子游戏:一个操作一个语义化方法)
       └─ RpcHelper(框架基座:自动注入平台字段,调引擎发送)
            └─ Utl.sendData(app, route, rpc, data)

RpcHelper(自动注入平台字段)

RpcHelper 把每个请求自动补齐平台必需字段(agentid / gameid / playerid / roomcode / seat ...),业务只传业务数据:

RpcHelper.sendGameRpc(rpc, gameData, options);   // route = "room"/子游戏路由

语义化发包封装(子游戏)

子游戏为每个操作暴露一个语义化发包方法,内部补 seat/roomcode 等业务字段后经 RpcHelper 发送;业务侧只调这些语义化方法,不关心平台字段。

DO:发包一律走语义化发包封装 / RpcHelper。DON'T:在业务里直接 Utl.sendData 手拼包、漏注入平台字段。

平台字段缺失(如 playerid 为 0/空)应 fail-fast 暴露,不静默发出残缺包。

请求包只带「意图」,不带结论

请求包表达的是「我想做什么」,不是「结果是什么」。 这是前端数据驱动架构(05 §6)在发包侧的必然推论:前端不是数据源,凡由前端算出并回传的"结论",都等于把裁定权交给了不可信的客户端。

可以带(意图) 禁止带(结论)
操作类型(出牌/碰/杠/过/胡/叫分…) 算好的得分、番数、结算金额
目标标识(牌的 uniqueId、targetCard、choiceIndex) "我胡了 / 我听了 / 这步合法" 之类的判定结果
座位号(仅供服务端做一致性校验,不作身份依据) 下一阶段是什么、下一个该谁、剩余时间
纯客户端偏好(音量、语言等非对局字段) 手牌全量、他人信息等本应由服务端持有的状态
  • 服务端按自己的权威数据重算,对请求里出现的结论字段一律忽略(服务端侧见 04 §8);协议设计阶段就不应该定义这类入参——定义了它,就是留了一个可被伪造的洞。
  • 前端 shared/ 算出的结果不回传:它只用于本地提示与预校验(见 05 §6.1、§8),发包时只发意图。
  • 违例信号:发包方法里出现 score、isWin、nextSeat、phase、result、handCards 之类"由前端填的结论字段"。

2. 收包:统一分发

服务端的主动推送统一格式 { rpc, data },前端有唯一收包入口,再按 rpc 分发到对应处理器。收包分发器是纯路由表 + 分发器(一 rpc 一处理器):

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 先按本座位取出自己的数据,再交给新架构的开局处理:

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 数据收发与通信协议 同一条协议。

前端 RPC 没有“同步返回”,操作结果由服务端后续主动推送告知。判成败的唯一权威是推送 data 里的 success:

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),再据包字段渲染;无对应点击也能正确渲染。
  • 不得据本地推断补齐服务端未下发的状态:包里没有的阶段/控制权/可用操作,不由前端算出来顶上——那是服务端漏发,修在服务端发包处(见 05 §6.1、服务端 03 §1.2)。

5.4 适用范围

凡「随对局状态变化」的表现均适用,包括但不限于:交互提示(手动掷骰提示文字 / 请出牌 / 等待其他玩家听牌 / 报定等待)、当前控制权高亮(按包内 nextControlSeat)、倒计时(按包内剩余时间启停)、手牌/牌河/亮牌、他人副露、阶段与托管图标。

例外:本玩家点击的响应/掷骰交互按钮(碰/杠/过/胡按钮、手动掷骰按钮)的隐藏允许乐观清除(见 5.6);但这些按钮的显示,以及一切提示(含掷骰提示文字、等待听牌、报定等待),仍严格收包驱动。

5.5 验收标准

每处改动须验两条路径表现一致:

  1. 真人手动操作:点击后界面在收到回包时才更新(点击瞬间不抢先变化)。
  2. 服务端自动发包(AI 托管 / 他人操作广播):无任何点击,界面同样在收到推送包后正确更新,且与真人路径表现完全一致。

两条都验过且一致,方为合规。

5.6 受控例外:响应交互按钮乐观清除 + 服务端合法性验证

对本玩家点击触发的响应交互按钮(碰/杠/过/胡 操作按钮、手动掷骰按钮),允许在点击发包时乐观隐藏该按钮——它同时充当防连点(按钮没了就点不了第二次)与即时反馈。这是对 5.1 悲观 UI 的受控例外,成立必须同时满足下列三条,缺一不可:

  1. 服务端合法性验证兜底(正确性根本):正确性绝不依赖前端乐观隐藏。服务端对每个操作请求做完整合法性验证(座位鉴权 / 阶段门 / 操作可用性 / 幂等去重),默认拒绝非法/重复/越权/乱序包并回 success:false 且不改状态。即便乐观隐藏被绕过、或非正规客户端狂发包,服务端也正确拒绝。详见服务端 04 §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-开发规范与红线 汇总前端所有工程纪律。