流送与场景加载
底座页:进一个关卡时客户端到底发生了什么—— 命令链把服务器全量数据一次性灌进各玩法 Model(全量初值模式), 运行时各系统增量协议接管; 64 位 Long ID 转 JS number 的精度边界用统一收口 + 溢出 Critical治理; 同 WP 无缝传送靠流送源跟随(两盏灯)+ 三段式预加载和传送链路衔接。
底座治理
命令链模式
全量初值 + 运行时增量
64 位 ID 精度治理
无缝传送衔接流送
1. 工作清单
| 内容 | 性质 |
|---|---|
| 传送全流程中的流送衔接、15 位 uid 治理(64 位 ID 的 JS 类型转换统一收口) | 底座治理 |
| 序章/试用角色相关场景加载适配 | 扩展 |
| 同 WP 无缝传送(源目标同关卡不动大世界格子)、传送重构冒烟、流送与传送链路协作收口 | 重构 + 扩展 |
2. 理论:进关那一刻的客户端管线
2.1 全量初值 + 运行时增量:框架级同步模式
每个玩法系统都遵循同一条数据节奏:进关卡/重连时服务器一次性给全量初始态, 运行时走增量协议。落点全部集中在一条命令里:
| 系统 | 全量初值(进关 ServerSceneData) | 运行时增量 |
|---|---|---|
| 燃值 | firePointInfo | 战斗服 Buff 消耗裁定 |
| 毒圈 | poisonZoneInfo | 服务器关键帧推送 |
| 集合区域 | gatherInfo | 14050 广播 |
| 计时器 | levelBt.timerList | 行为树运行时增删 |
| 宝箱标记 | SyncTreasureChestMarksFull | 开箱单点刷新 |
| 行为树音频 | akStateInfo.akState | 行为树节点切换 |
为什么增量之前必须有全量
断线重连和切场景是同一条管线:重连时客户端空模型要被同一个入口回填。
增量协议只描述「变化」,无法从零重建状态;
把全量回填收在进关命令链里,意味着
任何玩法系统接入只需两件事:在全量命令里加一行 SetInfo、
在自己模块里订阅增量——不用关心进关/重连的差异。
2.2 命令链:进关流程的积木化
1
SceneLoadingCommandBase
OnExecute(context) + Succeed/FailCritical——每个命令自校验自汇报
2
命令排队执行
ESceneLoadingCommandKind.Load / Unload 分类;上下文 SceneLoadingCmdContext 传递服务器数据
3
ApplyLevelInfo 灌全量
各 Model 的 SetInfo 逐个回填,最后 Emit OnLevelInfoUpdated
4
FailCritical 终止链
缺关卡信息这种致命错误立刻终止并留日志,不带着半初始化状态进游戏
命令模式在这里的收益
进关顺序 = 命令顺序,插一个新步骤(比如新增一个玩法的全量回填)= 排一个新命令,
不动既有命令的内部分支;每个命令 GetCommandDesc 自带日志标签,
加载卡住时日志能精确到「卡在哪块积木」。
2.3 64 位 ID 治理:Long → number 的精度边界
| 问题 | 后果 | 治理 |
|---|---|---|
| 协议 64 位 Long 在 JS 里是 Long 对象 | 同值不同实例,直接当 Map key 永远查不到 | 统一转 number / bigint 再做 key |
| number 精度上限 2^53-1(约 15-16 位十进制) | 超过上限的 ID 转换后静默失真,查无此实体 | 与服务器约定有效位 ≤ 52 位,转换处溢出检查 |
| 裸转换散落各处 | 同一 ID 两处转换两种结果,Map 键不一致 | LongIdOps / GameIdConversions 统一收口 |
「静默失真」是最坏的一类 bug
精度丢失不抛错、不打日志,症状是「实体查不到」「掉落物消失」这类
看起来像时序问题的幽灵表现,排查成本极高。
治理手段是把它变成显性错误:
收口函数 GetConstrained64BitId 检查 [MIN, MAX]_SAFE_INTEGER 区间,
越界打 Log.Critical(Id + Context 全打)——
溢出一旦发生,日志直接指到调用上下文。
带符号判断的坑也写在文件头:LongToNumber 高位按原样转换,
high=-1 会得到负数,上下限必须用带符号的。
2.4 无缝传送与流送:两盏灯 + 三段式预加载
普通跨关卡传送 = 卸载 → 加载两段场景;同 WP 无缝传送 = 源和目标在同一大世界流送范围内时,不整卸整载, 流送源(两盏灯:跟玩家走的加载范围灯 + 跟传送预加载走的灯)直接滑过去; 跨范围传送走三段式预加载:预加载 → 完成 → 还原 / 取消, 传送吟唱期间把目标格子先拉起来,取消则还原。 细节展开在深页:
深页直达
传送与流送的完整代码链路(八个文件分工、加载命令排队、两盏灯、三段式预加载、排查速记)
整理在《流送与 WP · 代码实现》,
本页只保留底座视角的两条结论:
① 无缝/普通传送的分支点在流送范围判定,不在传送业务层;
② 预加载的「取消还原」是传送吟唱可打断的前提。
3. 代码片段
3.1 全量初值:一条命令灌满所有玩法
Src\Game\World\SceneLoading\Commands\SceneLoadingCmds\ServerLevelCmd\SceneLoadingApplyLevelInfoCmd.ts:9-55
export class SceneLoadingApplyLevelInfoCmd extends SceneLoadingCommandBase<SceneLoadingCmdContext> {
protected override OnExecute(context: SceneLoadingCmdContext | undefined): void {
const levelInfo = context!.ServerSceneData;
if (!levelInfo) {
this.FailCritical('未提供关卡信息');
return;
}
ModelManager.DungeonModel!.SetCurrentDungeonInfo(levelInfo.dungeonInfo);
if (levelInfo.dungeonInfo?.settleInfo) {
ModelManager.DungeonModel!.SetSettleInfo(levelInfo.dungeonInfo.settleInfo);
}
const isInMatch = levelInfo.dungeonInfo?.matchInfo?.isInMatch;
if (isInMatch !== undefined && !isInMatch) {
ControllerHolder.MatchingController.TryCloseMatchingView();
}
// 燃值信息
ModelManager.FirePointModel!.SetInfo(levelInfo.firePointInfo);
ModelManager.CombatMessageModel!.SynchronizeSequenceInfo(levelInfo.battleSeqInfo);
ModelManager.PoisonZoneModel!.SetInfo(levelInfo.poisonZoneInfo);
// 副本集合区域全量初值(进关卡时生效的集合区,运行时增量走 14050)
ModelManager.DungeonGatherModel!.SetInfo(levelInfo.gatherInfo);
ModelManager.MapModel!.SyncTreasureChestMarksFull(
levelInfo.levelConfigId,
levelInfo.markTreasureChestConfigIdList,
);
if (levelInfo.levelBt?.timerList) {
// 计时器全量带上归属行为树,行为树移除时才能精确清掉它名下的计时器
const treeIncId = ControllerHolder.GeneralLogicTreeController.GetTreeIncId(
levelInfo.levelBt.type,
levelInfo.levelBt.btTypeId,
);
ModelManager.BombTimerModel!.SetList(levelInfo.levelBt.timerList, treeIncId, levelInfo.levelBt.type);
}
// 行为树音频状态:进关卡/重连时按服务器全量数据恢复,空串表示无行为树音乐
AudioMusicBehaviorManager.ApplyAkState(levelInfo.akStateInfo?.akState ?? '', 'EnterLevel');
EventSystem.Emit(EEventName.OnLevelInfoUpdated);
this.Succeed();
}
}
这页代码的面试价值在「模式」不在「行数」
六个系统的全量回填排在一个 OnExecute 里,每行都带业务注释
(集合区域的注释写明「运行时增量走 14050」、计时器注释写明
「带上归属行为树才能精确清理」——归属关系在全量阶段就要绑定)。
FailCritical vs Succeed 的语义:缺关卡信息是致命(链终止),
某系统数据缺失是可容忍(各 Model 的 SetInfo 自己防御空数据)。
结尾统一 Emit OnLevelInfoUpdated,UI 只订阅一个事件就能刷新全量态。
3.2 溢出即 Critical:精度边界的显性化
Src\Core\Utils\LongIdOps.ts:26-38(文件头注释节选)
// 注意:
// 1. bigint 永远是带符号的
// 2. MathUtils.LongToNumber() 的高位是按原样转换的(即,如果 high=-1 low=999 则会转为 ((-1n << 32) | 999n),是个负数)
// 所以判断位有效范围时需要指定带符号的上下限。
public static GetConstrained64BitId(id: Long | number, context?: string): number {
const bigIntId = MathUtils.LongToBigInt(id);
if (bigIntId < LongIdOpsConstants.MinSafe64BitId || bigIntId > LongIdOpsConstants.MaxSafe64BitId) {
const logPairs: [string, ILogValue][] = [['Id', id]];
if (context) {
logPairs.push(['Context', context]);
}
Log.Critical(ELogModule.Entity, ELogAuthor.HCS, 'Id位溢出', ...logPairs);
}
return Number(bigIntId);
}
注意溢出打日志但不抛错
设计取舍:溢出仍然返回转换值(打 Critical 留痕),
而不是抛异常打断流程——ID 失真的实体查不到是可降级的坏体验,
加载链被异常打断是必现的坏体验。文件头三条注释
(bigint 带符号 / 高位原样转换 / 上下限用带符号)
就是「为什么这么写」的全部依据,后来者不用重新踩一遍 Long 库的坑。
3.3 业务层统一收口:GameIdConversions
Src\Game\Utils\GameIdConversions.ts:3-33(节选)
/**
* {@link LongIdOps} 在业务层的具体使用。
*/
export class GameIdConversions {
public static ToProtocolId(id: Long | bigint | number): Long | number {
return LongIdOps.ToProtocolId(id);
}
public static GetCreatureDataId(creatureDataId: Long | number): number {
return LongIdOps.GetConstrained64BitId(creatureDataId, 'CreatureDataId');
}
public static GetPlayerId(playerId: Long | number): number {
return LongIdOps.GetConstrained64BitId(playerId, 'PlayerId');
}
public static GetBuffApplicantId(netBuffApplicantId: Long | number): number {
return LongIdOps.GetConstrained64BitId(netBuffApplicantId, 'BuffId');
}
public static GetPrefabDataId(netPrefabDataId: Long | number): number {
return LongIdOps.GetConstrained64BitId(netPrefabDataId, 'PrefabDataId');
}
按「语义 ID」收口,不是按「转换函数」收口
业务代码不写 LongIdOps.GetConstrained64BitId(id, 'xxx'),
写 GameIdConversions.GetCreatureDataId(id)——
每个 ID 语义(CreatureDataId/PlayerId/PrefabDataId/...)一个具名函数,
Context 标签固定、返回类型固定(能安全转 number 的返回 number,
必须保精度的返回 bigint)。
Map 的 key 用哪类、什么时候必须 bigint,在函数签名层面就定了型。
4. 面试怎么讲
进一个关卡时,客户端的数据初始化是怎么组织的设计
两条机制:
① 命令链:场景加载流程拆成命令队列(SceneLoadingCommandBase), 每个命令 OnExecute 自校验、Succeed/FailCritical 自汇报, 进关顺序 = 命令顺序,加载卡住时日志能定位到具体命令。
② 全量初值模式:ApplyLevelInfo 命令把 ServerSceneData 里 所有玩法的初始态(副本信息/燃值/毒圈/集合区域/计时器/宝箱标记/行为树音频) 一次性灌进各自 Model,结尾统一 Emit OnLevelInfoUpdated; 运行时各系统走自己的增量协议。 重连和进关走同一条链路,玩法接入只需「全量加一行 SetInfo + 订阅增量」两件事。
① 命令链:场景加载流程拆成命令队列(SceneLoadingCommandBase), 每个命令 OnExecute 自校验、Succeed/FailCritical 自汇报, 进关顺序 = 命令顺序,加载卡住时日志能定位到具体命令。
② 全量初值模式:ApplyLevelInfo 命令把 ServerSceneData 里 所有玩法的初始态(副本信息/燃值/毒圈/集合区域/计时器/宝箱标记/行为树音频) 一次性灌进各自 Model,结尾统一 Emit OnLevelInfoUpdated; 运行时各系统走自己的增量协议。 重连和进关走同一条链路,玩法接入只需「全量加一行 SetInfo + 订阅增量」两件事。
服务器的 64 位 ID 在 TypeScript 客户端怎么处理难点
三个问题叠在一起:Long 对象同值不同实例不能做 Map key;
转 number 有 2^53-1 精度上限,超限静默失真;
裸转换散落各处会键不一致。
治理方案:与服务器约定 ID 有效位 ≤ 52 位; LongIdOps 收口转换——GetConstrained64BitId 检查带符号安全区间, 越界 Log.Critical(Id + Context)但不抛错(失真可降级、加载链断不得); 业务层再用 GameIdConversions 按 ID 语义具名封装(GetCreatureDataId/GetPlayerId...), 返回 number 还是 bigint 在签名层面定型。 文件头注释还钉了「bigint 带符号、LongToNumber 高位原样转换」这两个坑。
治理方案:与服务器约定 ID 有效位 ≤ 52 位; LongIdOps 收口转换——GetConstrained64BitId 检查带符号安全区间, 越界 Log.Critical(Id + Context)但不抛错(失真可降级、加载链断不得); 业务层再用 GameIdConversions 按 ID 语义具名封装(GetCreatureDataId/GetPlayerId...), 返回 number 还是 bigint 在签名层面定型。 文件头注释还钉了「bigint 带符号、LongToNumber 高位原样转换」这两个坑。
无缝传送和流送是怎么衔接的衔接
分支点在流送范围判定不在传送业务层:
源和目标在同一大世界流送范围内 → 无缝传送,不整卸整载,
流送源跟着走(两盏灯:玩家加载范围灯 + 传送预加载灯)滑过去;
跨范围 → 走三段式预加载(预加载 → 完成 → 还原/取消),
传送吟唱期间把目标格子先拉起来,玩家取消传送则还原——
「取消还原」正是吟唱可打断的实现前提。
完整链路(八个文件分工、命令排队、排查速记)我整理过一份文档。
完整链路(八个文件分工、命令排队、排查速记)我整理过一份文档。