首页 / 横向专题 / 场景加载命令链

场景加载命令链 SceneLoading

整个客户端最长的一条链路,也是所有「卡 Loading / 黑屏不消 / 落地穿地 / 实体没出来」问题的共同上游。 传送、进副本、登录、重连、序章演出——全部都是这条链的不同走法。 先看懂这里,再去看 ① 传送 才不会迷路。

命令模式 19 节点主链 五条入口 World Partition 无兜底超时

1. 为什么是命令链,而不是一个大函数

1.1 场景加载到底要做多少事

换一次场景,客户端要按严格顺序完成:进入加载模式 → 开加载界面 → 切 WPMap → 重置 DataLayer → 加载过场关卡 → 更新子关卡 → 开始 WPLayer 流送 → 预加载实体资源 → 预加载 Actor → 等流送完成 → 把玩家搬过去 → 预加载战斗 UI → 预加载通用 UI → 落地后处理 → 清理无引用资源 → 等传送指令收尾 → 关加载界面 → 通知加载完成 → 播落地表现。

这里面大部分步骤是异步的(资源加载、地图切换、WP 流送), 而且不同场合要跳过不同的步骤(无缝切换跳流送、序章要保住演出 Actor、重连要复用已有关卡)。 写成一个大函数的话,会变成回调套回调 + 到处 if (是不是无缝)。

1.2 命令链换来的四件事

能力具体表现
顺序即代码「先做什么后做什么」写在 builder 里一眼可读,不用在回调里翻
异步统一每个命令自己决定何时 Succeed(),上层不关心它是同步还是等了 3 秒
可跳过命令自己判断前置条件不满足,就 Succeed() 收口跳过,不需要 builder 里堆条件
可观测统一的日志前缀 + 进度上报,线上日志能看出卡在第几步

2. 命令框架本身

框架在 Src/Core/Utils/Command/,不只服务场景加载,但场景加载是它最重的用户。

2.1 七个状态

export const enum ECommandState {
    Idle,        // 已创建未执行
    Executing,   // 正在执行
    Succeeded,   // 成功;"跳过本步、链条继续"也走这里
    Failed,      // 失败,链条中断(可配置回滚)
    Bailed,      // 主动中止整条链(不算失败),会回滚已执行命令
    Disposed,    // 已销毁
    Fatal,       // 严重错误,无法继续
}
Bailed 不是「跳过本步、继续下一条」 Bail() 会终止整个 executor 并回滚已执行的命令,语义是「不继续了」,只是不算失败。
真正「跳过本步、链条继续」的命令调的是 Succeed()——所以排查「某步好像没生效」时, 看到 Succeeded 才是被条件跳过(链继续),看到 Bailed 是整条链被中止。 详见 命令模式专栏 · Bail 语义。

2.2 InitCommand:子命令在初始化时就展开

这是这套框架最容易理解错的地方。InitCommand 类型的命令, 其 OnAddInitCommand(context) 在整条链构建阶段就被调用, 把自己的子命令原地展开进主链——不是执行到它时才展开。

// 展开发生在构链阶段,不是执行阶段
protected override OnAddInitCommand(context: SceneLoadingCmdContext): void {
    this.AddInitCommand(new SubCmdA());   // → Executor.AddInitCommandAdvance(cmd, depth + 1)
    this.AddInitCommand(new SubCmdB());
}
推论:子命令的展开不能依赖运行期数据 展开时协议还没到、玩家还没搬过去、context 里很多字段还是初始值。 所以「根据服务器下发的内容决定加几个子命令」这种写法在这里是行不通的—— 要做条件分支,只能是把命令都加进去,让每个命令自己在执行时判断条件、不满足就 Succeed() 跳过。
这也解释了为什么链里有那么多「进来就判断条件然后跳过」的命令:这是框架结构决定的,不是写得随意。

2.3 四种结束方式

调用效果什么时候用
Succeed()正常完成,继续下一条;前置条件不满足时也用它「跳过本步」默认路径 / 跳过本步
Fail()中断整条链;若配了 RollbackOnTimeout 会反向回滚已完成的命令无法继续的错误
Bail()终止整条执行链并回滚已执行命令,不算失败执行中出现意外、不想继续后续命令
PauseExecution()让出本帧,下帧继续。不在时间切片环境下会退化成 Succeed()分帧处理大批量数据
PauseExecution 的退化行为要当心 它在非时间切片环境下不是「等下一帧」而是直接成功。 如果一段逻辑靠反复 PauseExecution 来分批处理,在退化路径下会只处理第一批就往下走, 而且不报任何错。用它之前要确认当前 executor 真的开了时间切片。

2.4 FailCritical:从「加载失败」升级为「客户端异常」

// 关键节点出错时不是简单 Fail,而是把客户端整体状态标记为异常销毁
context.FailCritical(reason);
// → 日志:'关卡加载关键节点出现严重错误,客户端状态转为 ExceptionalDestroyed'

普通 Fail() 只是这条链断了,玩家可能还能操作; FailCritical 表示「场景处于不可用状态,继续玩下去只会产生更奇怪的问题」, 于是把客户端推到 ExceptionalDestroyed,交给上层做重连或回登录。

2.5 关键事实:场景加载的 executor 没有超时兜底

executor.Init(context, name, true, TimerSystemConstants.MaxTime);
//                                    ↑ 过期时间取"最大时间" = 实际等于永不过期

框架自带 CommandExecutor.DefaultExpireTime = 60000(60 秒)以及 RollbackOnTimeout、TriggerCallbackOnTimeout 这些机制, 但所有场景加载 executor 都显式传入 TimerSystemConstants.MaxTime 把它关掉了。

这是「卡 Loading 永远卡着」的结构性原因 链上任何一个命令不调 Succeed(),整条链就永久停住, 没有任何一层会把它踢出去。玩家表现为卡在加载界面或黑屏里,不报错、不超时、不恢复。
所以这条链上每一个「等待型」命令都必须自带超时—— 这不是防御式编程的锦上添花,而是唯一的止损点。 同图传送一分钟后地形卸载就是某个等待型命令没有自带超时的直接后果。

3. 五条入口链

3.1 三路选择器

SceneLoadingController.LoadSceneMain 按玩家当前处境选链,不按目标场景选:

const executor = isReconnecting
    ? SceneLoadingCommandHelper.BuildReconnectSceneLoadingCommand(context, cb)
    : isNewPlayer   // gameModeModel.TagComponent.HasTag(GameModeModel.TagNewUser)
    ? SceneLoadingCommandHelper.BuildPrologueSceneLoadingCommand(context, cb)
    : SceneLoadingCommandHelper.BuildSceneLoadingCommand(context, cb);

第四条 BuildSceneTeleportCommand 由传送流程单独调用,第五条是卸载链 SceneUnloadCmd。

注意每个 Build* 其实只往 executor 里塞了一个根命令, 19 个节点是这个根命令在构链阶段展开出来的:

Build 函数唯一的根命令
BuildSceneLoadingCommandSceneLoadingWpMapCmd
BuildSceneTeleportCommandSceneLoadingTeleportCmd
BuildReconnectSceneLoadingCommandReconnectSceneLoadingWpMapCmd
BuildPrologueSceneLoadingCommandSceneLoadingPrologueCmd
BuildSceneUnloadCommandSceneUnloadCmd
BuildSceneLogicLoadingCommandSceneLoadingLogicSceneCmd

所以读链要读根命令的 OnAddInitCommand,不是读 Helper。 Helper 只负责选链、拼 context、装超时和回调。

3.2 五条链的差异

链入口与主链的差异
主链
BuildSceneLoadingCommand
常规切场景基准。19 个节点全在这里
序章链
BuildPrologueSceneLoadingCommand
新号首次进游戏手动改写 context.CurLevelId,详见 §3.3
传送链
BuildSceneTeleportCommand
传送流程调用beforeCb 做去重:目标相同直接 CommandBail();目标不同则先 bail 掉旧的
重连链
BuildReconnectSceneLoadingCommand
断线重连可能复用已有 ServerLevel(IsReusingSameServerLevel),跳过一部分初始化通知。用 require 延迟加载避免循环依赖
卸载链
SceneUnloadCmd
离开场景反向:销毁实体 → 清 UI → 卸 DataLayer → 释放资源

3.3 序章链为什么要伪造 CurLevelId

context.CurLevelId = PrologueFlowController.GetPrologueLevelId();
// 使得 context.IsWpChanged() === false,引擎加载地图时不会走硬加载流程,
// 避免演出Actor被意外销毁

序章是一段已经在播的演出:玩家进游戏时演出 Actor 已经存在于场景里。 如果按正常判定,「当前关卡」和「目标关卡」不同 → IsWpMapChanged() 为真 → 走硬加载 → 演出 Actor 连带被销毁,序章直接黑掉。

所以这里反向利用了判定函数:把 CurLevelId 直接改成序章关卡 Id, 让 IsWpMapChanged() 返回 false,从而绕开硬加载分支。 这是全链路唯一一处「主动改写 context 来影响判定结果」的地方。

读代码时的启示 看到 context.CurLevelId 与真实当前关卡不符时,不要当 bug—— 先确认自己是不是在序章链上。同理,改动 IsWpMapChanged() 的实现时要考虑序章链, 它依赖这个函数返回 false。

4. ESceneLoadingType 与它的哨兵值

4.1 枚举

export const enum ESceneLoadingType {
    LoadingWpMap        = 0,  // 换 WPMap:最重,走完整加载
    LoadingWpLayer      = 1,  // 同 WPMap 换 Layer,非无缝
    FlagSeamlessLoading = 2,  // ← 哨兵:不是真实类型,只是"无缝起点"的分界线
    SwitchDimension     = 3,  // 无缝切换
    Teleport            = 4,  // 传送
}
// 哨兵的用法:一次比较代替一串枚举罗列
public IsSeamlessLoading(): boolean {
    return this.SceneLoadingType >= ESceneLoadingType.FlagSeamlessLoading;
}
加新类型时必须先想清楚它落在哨兵的哪一侧 FlagSeamlessLoading = 2 本身永远不会被赋给任何一次加载, 它只是「≥ 2 即无缝」这条判定的分界线。
所以新增枚举值时:要走无缝逻辑的必须 > 2,不走的必须 < 2。 在末尾顺手 = 5 加一个非无缝类型,会让它被 IsSeamlessLoading() 误判为无缝, 连带跳过流送等待——表现就是穿地。

4.2 GetSceneLoadingType 的五步判定链

顺序即优先级,命中即返回:

0
IsWpMapChanged() 为真 → LoadingWpMap
换地图是最强条件,后面几条都不用看了
1
进出副本(任一侧 LevelType === Dungeon)→ 非无缝
副本与大世界的场景内容差异太大,不可能无缝
2
GameModeModel.IsTeleporting → 非无缝
走传送点的传送要有明确的传送表现
3
GameModeModel.IsMovingToLeader → 非无缝
传送到队长身边
4
服务器 enterMode.isSeamless === false → 非无缝
服务器有权强制指定
5
否则:newMapPath === oldMapPath 才无缝
同一张 WPMap 内挪位
6
补一刀:换了角色 → 非无缝
playerRole.CreatureDataId !== playerEntity.CreatureDataId。 玩家实体本身要重建,没法无缝
这个判定链会把整条决策快照打进日志 '[传送]无缝加载判定' 一条日志带 13 个字段(两侧 levelId / levelType / mapPath、 各个开关的值、最终结论)。排查「这次为什么走了非无缝」不需要读代码,grep 这条日志就够。
这是值得复制的做法:多条件判定的地方,把所有输入和结论一次性打出来, 而不是每个条件各打一条——后者在日志里会被其它输出冲散,拼不回一次完整决策。

5. SceneLoadingCmdContext:谓词的唯一权威

context 既是链上共享的数据袋,也是所有「这步要不要做」判断的唯一出处。 命令里不允许自己重新推导条件,必须问 context。

谓词含义关键实现细节
IsWpMapChanged()是否换了 WPMap不在任何关卡里时,返回 GlobalData.IsFromLauncher——从启动器进入视为要切图
IsSeamlessLoading()是否无缝SceneLoadingType >= FlagSeamlessLoading
IsSeamlessRelocate()无缝换落点无缝 且 enterMode.useServerPos === true。 无缝转场但服务器要求以其坐标为落点,需在无缝流程内补做目标地形流送再落位
NeedsTerrainStreaming()本次是否真要跑地形流送非无缝一律要;无缝仅「换落点」要,普通无缝沿用原地坐标可跳过
IsTeleport()是否是传送CurLevelId !== 0 && Cur !== Target && IsWpMapChanged()
IsLogin是否是登录CurLevelId === 0——用「没有当前关卡」表达「还没进过游戏」
IsReusingSameServerLevel重连时复用同一 ServerLevel为真则跳过 ServerLevel 初始化完成通知
IsSeamlessRelocateMaskOn换落点遮丑黑屏是否已开幂等开关:流送前盖、落位后解,防穿帮镜头

5.1 深挖:跳过条件不是同一个开关

流程图上把 ⑦⑧⑩⑪ 四个节点画成「受同一个无缝开关影响」。实际代码里它们分成两组:

节点实际跳过条件
⑦ SceneLoadingNewSceneCmd
⑩ SceneWaitWpStreamingCmd
⑪ SceneLoadingMoveCmd
!context.NeedsTerrainStreaming()
⑧ ScenePreloadEntityAssetsCmdSceneLoadingType === SwitchDimension
于是「无缝换落点」这一种情况下四个节点行为不一致 无缝换落点时 NeedsTerrainStreaming() 为真、SceneLoadingType 仍是 SwitchDimension, 所以它会跑 ⑦⑩⑪,但依然跳过 ⑧。
即:它做了地形流送,却没有预加载实体资源。 排查「换落点后实体资源首次出现时卡顿」要从这里入手,而不是去怀疑流送。

6. 主链 19 节点完整时间轴

下面是 SceneLoadingWpMapCmd.OnAddInitCommand 的完整顺序, 已省略夹在中间的纯事件分发命令(SceneLoadingDispatchEventCmd,见 §8)。

6.1 初始化(①②)

1
进入加载模式 · SceneLoadingSetLoadModeCmd
切到加载状态,屏蔽玩法输入
2
打开加载界面 · SceneLoadingOpenUiCmd
无缝路径下 BindSceneLoadingProgress 会跳过 UI 绑定 (SceneLoadingType >= SwitchDimension 时不显示进度条)

6.2 地图切换(③,带分支)

?
判定 context.IsWpMapChanged()
这是主链上唯一的真分支
3
是 · 加载新 WPMap · SceneLoadingMainMapCmd
跨关卡传送走这里
3'
否 · 沿用当前 WPMap
例外:首次登录 + 静默登录 时, 仍强制补一次 StartEnterMap/EndEnterMap 事件和一次 DataLayer 重置

6.3 场景搭建(④⑤⑥)

4
重置 DataLayer · SceneLoadingResetDataLayerCmd
把上一张图残留的 DataLayer 状态清回默认,见 DataLayer 重置
5
加载 Load 过场场景 · SceneLoadLevelCmd
包了 try/catch:失败时调 RecoverFromTransitionFailure() 然后仍旧 Succeed()。 过场关卡是纯表现,加载不出来不该把玩家卡死在这里
6
更新子关卡任务 · SceneLoadingUpdateLayerTasksCmd
Client/Server 两套 LayerTasks 求差集,见 子关卡

6.4 资源预加载(⑦⑧⑨)

7
开始加载 WPLayer · SceneLoadingNewSceneCmd
可跳过 条件 !NeedsTerrainStreaming()
8
预加载实体资源 · ScenePreloadEntityAssetsCmd
跳过条件与⑦不同 条件是 SceneLoadingType === SwitchDimension,见 §5.1
9
预加载 Actor · ScenePreloadActorCmd
当前被注释掉 源码里 import 和 AddInitCommand 两行都是注释状态,实际不在链上。
排查「Actor 没预加载」类问题时先看这里是否还是注释—— 文件保留着说明是有意暂时下线,不是删掉了

6.5 流送与落位(⑩⑪)

10
等待 WP 流送完成 · SceneWaitWpStreamingCmd
可跳过 条件 !NeedsTerrainStreaming()。
整条链最危险的一个节点:executor 没有超时, 所以它必须自带超时放行——否则流送一旦永久不完成,整条链永久停在这里, 后面的「玩家流送灯交接」也永远不会执行,一分钟后地形被回收, 玩家坠落(案例)
11
移动到新场景 · SceneLoadingMoveCmd
可跳过 条件 !NeedsTerrainStreaming()。 这里同时做流送灯交接:把落点的临时灯换成跟随玩家的常驻灯, 见 两盏灯
⑤ 加载的过场场景,谁来卸? 主链里 SceneUnLoadLevelCmd 那一行是注释状态—— 过场场景不由场景加载链卸载,而是由 TeleportController.UnLoadTransitionLevel() 在传送收尾时统一卸。
顺序有讲究:它内部先 ReleaseAvatar / ReleaseSeqCamera 再卸 Level, 否则销毁相机时 ViewTarget 还指着它就成野指针。 调试可用 GM TeleportUnLoadTransitionLevel 手动触发。

6.6 UI 资源预加载(⑫⑬)

12
预加载战斗 UI 资源 · SceneLoadingBattleUiAssetsCmd
13
预加载通用 UI 资源 · ScenePreloadCommonUiAssetsCmd
放在流送之后:此时玩家已经在新场景,UI 预加载的卡顿被加载界面遮住

6.7 收尾(⑭~⑲)

14
加载完成后处理 · SceneLoadingAfterCmd
无缝换落点在这里做真正的落位 (RelocateLocalPlayerForSeamlessRelocate → TeleportTo),并解掉换落点遮丑黑屏
15
清理预加载资源 · SceneLoadingClearUnrefAssetsCmd
释放没有引用的预加载资源。放在落位之后,避免把马上要用的资源提前释放掉
16
等待传送指令完成 · SceneLoadingWaitTeleportCmdFinishedCmd
场景加载链与传送链是两条独立的链,这里做汇合
17
关闭加载界面 · SceneLoadingCloseUiCmd
18
关卡加载完成 · SceneLoadingCompleteCmd
对外广播完成事件,各业务模块以此为起点初始化
19
落地表现 · SceneLoadingAnimDownCmd
播落地动作。注释里明确写了规避死锁的考虑: 这一步不能反过来等待任何加载相关的信号,否则和上游互相等

7. 两个值得单独看的子链

7.1 SceneLoadingLogicSceneCmd:7 个子命令

逻辑场景(ServerLevel)的初始化被打包成一个 InitCommand,展开成 7 条子命令。 它管的是服务器概念上的关卡——玩法数据、实体列表、玩法组件—— 与前面那 19 个节点管的「引擎概念上的地图」是两回事。

两个「关卡」要分清 · WPMap / WPLayer:引擎侧,决定地形和 Actor 从哪儿加载;
· ServerLevel:服务器侧,决定这局玩法是什么、有哪些实体。
同一张 WPMap 可以承载多个 ServerLevel(同图不同玩法), 这也是为什么「不换图」也可能需要走一遍完整加载。

7.2 SceneUnloadCmd:卸载链

卸载不是加载的简单逆序,它有自己的顺序约束: 先销毁实体,再清 UI,最后卸 DataLayer 与释放资源。

顺序不能颠倒的原因是引用方向:UI 可能持有实体引用,实体持有资源引用。 先卸资源会让实体和 UI 拿到野引用;先清 UI 则会在实体销毁时少一次刷新通知, 表现为「界面上还残留已销毁实体的信息」。

8. 事件分发:为什么单独抽一个命令

链上散布着 SceneLoadingDispatchEventCmd——它本身不做任何加载, 只负责在确定的时间点向外广播事件。业务模块通过监听这些事件参与加载流程, 而不需要被写进链里。

这是这套设计里最值得学的一点 如果每个业务模块都要在加载链里插一个自己的命令,这条链会膨胀到无法维护, 而且业务模块的顺序依赖会渗透进框架。
抽成事件分发之后:链只负责定义时间点,业务自己决定在哪个时间点做什么。 新增一个模块不需要改链。

9. 日志:一条前缀定位到步

SceneLoadingLogContext 给这条链的所有日志加统一前缀:

[Loading|当前关卡→目标关卡|资源名|阶段|已完成/总数] 具体内容
字段取值规则
关卡CurLevelId → TargetLevelId
资源名ResolveAssetName():换图时用 {MapName}_WP, 不换图时用 DataLayerRuntime_EntityLevel_{targetLevelId}
阶段SetStage() 设置,对应上面六个分组
进度SetStageProgress(),已完成/总数
排查「卡 Loading」的第一步 grep [Loading,看最后一条日志的阶段字段——那就是卡住的位置。 不用去猜、也不用加日志。Begin/End 之间用 IsActive 做守卫, 所以不在加载期间的日志不会误挂这个前缀。

10. 出生点是怎么定下来的

SetLoadingContextBornTransform 按优先级取坐标,先命中的赢:

1
静默登录 → 用 DevLogin 配置的出生点
2
无缝且非 useServerPos → 用玩家当前 Actor 坐标
这是「无缝」的字面含义:位置根本不变,所以不会闪现
3
用协议里的 moveComp.position / rotation
来自 msg.compList
4
零值兜底 + 打 Error
走到这里说明协议缺字段。兜底成功但打 Error—— 不让玩家卡住,但一定要在日志里留下痕迹

10.1 朝向的两个补救

补救一:dirPos 全零时反算方向

/** 兜底朝向目标点与出生点的距离:取足够远以逼近"纯方向",
  * 避免实际落点(如队伍站位重寻路)相对出生点的微小偏移干扰求出的朝向角 */
private static readonly FallbackCameraDirDistance = 100000;

取 100000(1 公里)而不是随便一个值,是因为朝向是用「出生点 → 目标点」两点求出来的。 如果目标点离出生点很近,出生点的微小偏移(比如组队时被重新寻路挪了半米) 会让求出的角度偏很多。把目标点放到 1 公里外,两点连线几乎就是纯方向,偏移可以忽略。

补救二:只在旋转为零时才用 dirPos

换落点时服务器 moveComp.rotation 常为零 如果无条件用 dirPos 表达朝向,玩家会被摆成朝 +X。 所以只在 bornRot.IsNearlyZero() 时才用 Vector.FindLookAtRotation 反算 yaw, 服务器给了有效旋转就以它为准。
「字段为零」和「字段没给」在协议里是同一件事,这类补救在整个项目里反复出现。

11. 静默登录

开发/编辑器专用的快速登录:跳过登录界面,读本地配置直接把角色扔进指定地图。 PublicUtil.GetIsSilentLogin() 需要三个条件同时满足:

1非 Shipping 正式包
2命令行带 -StartWithEditorConfig
3本地存在对应的编辑器登录配置文件

因为它跳过了正式登录该做的初始化,所以在本链里有一条特例: 即使目标地图与当前地图是同一张 WPMap(不满足跨关卡分支), 只要是首次登录且处于静默登录,也会强制补一次 StartEnterMap/EndEnterMap 事件和一次 DataLayer 重置。

const isFromLoginScene = context.CurLevelId === 0;   // 没有当前关卡 = 还没进过游戏
if (isWpMapChanged) {
    this.AddInitCommand(new SceneLoadingMainMapCmd());
    shouldRaiseMapChangeEvents = true;
} else if (isFromLoginScene && PublicUtil.GetIsSilentLogin()) {
    shouldRaiseMapChangeEvents = true;   // 不加载新 WPMap,但事件照发
}

注意 shouldRaiseMapChangeEvents 与「是否加载新 WPMap」是两个独立的东西: 静默登录这条分支只抬事件旗子,不加地图命令。 把二者当成一个开关来读,就会以为「没换图就不会有 EnterMap 事件」—— 有模块靠 EndEnterMap 做初始化,这个误解会让人找错初始化时机。

排查时的用处 「编辑器里正常、正式包里不对」或者反过来,先确认是不是静默登录路径的差异。 它是唯一一条只在开发环境走的分支,因此也是最容易被忽略的一条。

12. 这条链上的五种防御式写法

写法出处防的是什么
表现失败不阻塞流程SceneLoadLevelCmd 的 catch → 恢复 + Succeed()过场关卡加载不出来时把玩家卡死
兜底成功但打 Error出生点零值兜底静默兜底导致问题永远查不出来
坐标合法性校验IsLegalPosition:拒 NaN、非有限值、Z < PositionMinZ ?? -10000把非法坐标写进 Actor 导致物理系统整体异常
幂等开关IsSeamlessRelocateMaskOn重复盖遮罩导致引用计数对不上、撤不掉
去重式 bailBuildSceneTeleportCommand 的 beforeCb连点两次传送同一目标,跑两条链互相踩

13. 常见排查

13.1 卡在加载界面 / 黑屏不消

1
grep [Loading,看最后一条的阶段字段
直接定位到卡在哪个分组
2
对照 19 节点表 找到具体命令
阶段 + 已完成数就能定位到节点
3
看这个命令有没有自带超时
executor 层没有兜底,只能靠命令自己。没有超时的等待型命令就是嫌疑犯
4
如果是 ⑩,去查 WP 流送

13.2 某个步骤「好像没执行」

先确认它是被条件跳过、还是根本没到:
· 被跳过(该命令返回 Succeeded)→ 去查它的跳过条件,注意 ⑦⑩⑪ 与 ⑧ 用的不是同一个条件;
· 没到 → 前面某条卡住了,回到 13.1。

13.3 落地位置不对 / 朝向不对

grep '[传送]无缝加载判定' 拿到完整决策快照,先确认走的是哪条路径; 然后对照 出生点优先级:无缝非换落点用的是玩家当前坐标, 这时候「位置没变」是正确行为,不是 bug。朝向不对则看 10.1 两个补救。

14. 面试怎么讲

场景加载为什么要做成命令链概览
因为它有三个特征同时成立:步骤多且顺序严格(19 步)、 大部分步骤是异步的(地图切换、资源加载、WP 流送)、 不同场合要跳过不同的步骤(无缝跳流送、序章要保演出 Actor、重连复用关卡)。
写成回调链会变成回调地狱加满地条件判断。做成命令链之后:顺序写在 builder 里一眼可读, 异步由每个命令自己 Succeed() 收口,跳过也是命令自己判断后用 Succeed() 收口, 再配一个统一日志前缀,线上就能直接看出卡在第几步。
这套框架有什么设计上的取舍设计
最大的一个是 InitCommand 在构链阶段就展开子命令, 不是执行到它时才展开。好处是整条链的形状在开跑前就确定,可以统计总步数、上报进度; 代价是不能根据运行期数据决定加几个命令。
所以这条链上有大量「进来就判断条件然后跳过」的命令—— 这是框架结构逼出来的写法,不是随意。
另一个取舍是事件分发抽成独立命令:链只定义时间点,业务模块监听事件自己接入。 否则每加一个业务模块就要往链里插一条,业务的顺序依赖会渗透进框架。
「卡 Loading」这类问题的结构性原因是什么难点
所有场景加载 executor 都传了 TimerSystemConstants.MaxTime 作为过期时间, 等于把框架自带的 60 秒超时关掉了。
后果是:链上任何一个命令不调 Succeed(),整条链永久停住, 没有任何一层会把它踢出去——不报错、不超时、不恢复。
所以这条链上每一个等待型命令都必须自带超时,这是唯一的止损点。 真实案例是等 WP 流送的命令没有超时,流送假死后整条链停住, 连带「玩家流送灯交接」这一步也从未执行,一分钟后落点的临时流送灯到期, 没有任何源持有那个 cell,地形被回收,玩家坠落。
修法分两层:给等待命令加超时放行是治本;贴地检测通过后主动做一次灯交接是双保险。
举一个「看着像 bug 其实是设计」的地方细节
序章链会手动把 context.CurLevelId 改写成序章关卡 Id。 单看这行像是脏改数据,实际是反向利用 IsWpMapChanged():
序章是一段已经在播的演出,Actor 已经在场景里。按正常判定会认为「换图了」→ 走硬加载 → 演出 Actor 连带被销毁,序章直接黑掉。改写 CurLevelId 让判定返回 false, 就绕开了硬加载分支。
可复用的经验:改动一个被多处依赖的判定函数之前,要先找出「依赖它返回 false」的调用方。