首页 / 入职工作清单 / 流送与场景加载

流送与场景加载

底座页:进一个关卡时客户端到底发生了什么—— 命令链把服务器全量数据一次性灌进各玩法 Model(全量初值模式), 运行时各系统增量协议接管; 64 位 Long ID 转 JS number 的精度边界用统一收口 + 溢出 Critical治理; 同 WP 无缝传送靠流送源跟随(两盏灯)+ 三段式预加载和传送链路衔接。

底座治理 命令链模式 全量初值 + 运行时增量 64 位 ID 精度治理 无缝传送衔接流送

1. 工作清单

内容性质
传送全流程中的流送衔接、15 位 uid 治理(64 位 ID 的 JS 类型转换统一收口)底座治理
序章/试用角色相关场景加载适配扩展
同 WP 无缝传送(源目标同关卡不动大世界格子)、传送重构冒烟、流送与传送链路协作收口重构 + 扩展

2. 理论:进关那一刻的客户端管线

2.1 全量初值 + 运行时增量:框架级同步模式

每个玩法系统都遵循同一条数据节奏:进关卡/重连时服务器一次性给全量初始态, 运行时走增量协议。落点全部集中在一条命令里:

系统全量初值(进关 ServerSceneData)运行时增量
燃值firePointInfo战斗服 Buff 消耗裁定
毒圈poisonZoneInfo服务器关键帧推送
集合区域gatherInfo14050 广播
计时器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 + 订阅增量」两件事。
服务器的 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 高位原样转换」这两个坑。
无缝传送和流送是怎么衔接的衔接
分支点在流送范围判定不在传送业务层: 源和目标在同一大世界流送范围内 → 无缝传送,不整卸整载, 流送源跟着走(两盏灯:玩家加载范围灯 + 传送预加载灯)滑过去; 跨范围 → 走三段式预加载(预加载 → 完成 → 还原/取消), 传送吟唱期间把目标格子先拉起来,玩家取消传送则还原—— 「取消还原」正是吟唱可打断的实现前提。
完整链路(八个文件分工、命令排队、排查速记)我整理过一份文档。