场景加载命令链 SceneLoading
整个客户端最长的一条链路,也是所有「卡 Loading / 黑屏不消 / 落地穿地 / 实体没出来」问题的共同上游。 传送、进副本、登录、重连、序章演出——全部都是这条链的不同走法。 先看懂这里,再去看 ① 传送 才不会迷路。
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, // 严重错误,无法继续
}
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());
}
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 把它关掉了。
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 函数 | 唯一的根命令 |
|---|---|
| BuildSceneLoadingCommand | SceneLoadingWpMapCmd |
| BuildSceneTeleportCommand | SceneLoadingTeleportCmd |
| BuildReconnectSceneLoadingCommand | ReconnectSceneLoadingWpMapCmd |
| BuildPrologueSceneLoadingCommand | SceneLoadingPrologueCmd |
| BuildSceneUnloadCommand | SceneUnloadCmd |
| BuildSceneLogicLoadingCommand | SceneLoadingLogicSceneCmd |
所以读链要读根命令的 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 的五步判定链
顺序即优先级,命中即返回:
IsWpMapChanged() 为真 → LoadingWpMapLevelType === Dungeon)→ 非无缝GameModeModel.IsTeleporting → 非无缝GameModeModel.IsMovingToLeader → 非无缝enterMode.isSeamless === false → 非无缝newMapPath === oldMapPath 才无缝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() |
| ⑧ ScenePreloadEntityAssetsCmd | SceneLoadingType === SwitchDimension |
NeedsTerrainStreaming() 为真、SceneLoadingType 仍是 SwitchDimension,
所以它会跑 ⑦⑩⑪,但依然跳过 ⑧。即:它做了地形流送,却没有预加载实体资源。 排查「换落点后实体资源首次出现时卡顿」要从这里入手,而不是去怀疑流送。
6. 主链 19 节点完整时间轴
下面是 SceneLoadingWpMapCmd.OnAddInitCommand 的完整顺序,
已省略夹在中间的纯事件分发命令(SceneLoadingDispatchEventCmd,见 §8)。
6.1 初始化(①②)
BindSceneLoadingProgress 会跳过 UI 绑定
(SceneLoadingType >= SwitchDimension 时不显示进度条)6.2 地图切换(③,带分支)
context.IsWpMapChanged()6.3 场景搭建(④⑤⑥)
RecoverFromTransitionFailure() 然后仍旧 Succeed()。
过场关卡是纯表现,加载不出来不该把玩家卡死在这里6.4 资源预加载(⑦⑧⑨)
!NeedsTerrainStreaming()import 和 AddInitCommand 两行都是注释状态,实际不在链上。排查「Actor 没预加载」类问题时先看这里是否还是注释—— 文件保留着说明是有意暂时下线,不是删掉了
6.5 流送与落位(⑩⑪)
!NeedsTerrainStreaming()。整条链最危险的一个节点:executor 没有超时, 所以它必须自带超时放行——否则流送一旦永久不完成,整条链永久停在这里, 后面的「玩家流送灯交接」也永远不会执行,一分钟后地形被回收, 玩家坠落(案例)
SceneUnLoadLevelCmd 那一行是注释状态——
过场场景不由场景加载链卸载,而是由
TeleportController.UnLoadTransitionLevel() 在传送收尾时统一卸。顺序有讲究:它内部先
ReleaseAvatar / ReleaseSeqCamera 再卸 Level,
否则销毁相机时 ViewTarget 还指着它就成野指针。
调试可用 GM TeleportUnLoadTransitionLevel 手动触发。
6.6 UI 资源预加载(⑫⑬)
6.7 收尾(⑭~⑲)
RelocateLocalPlayerForSeamlessRelocate → TeleportTo),并解掉换落点遮丑黑屏7. 两个值得单独看的子链
7.1 SceneLoadingLogicSceneCmd:7 个子命令
逻辑场景(ServerLevel)的初始化被打包成一个 InitCommand,展开成 7 条子命令。
它管的是服务器概念上的关卡——玩法数据、实体列表、玩法组件——
与前面那 19 个节点管的「引擎概念上的地图」是两回事。
· 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,看最后一条日志的阶段字段——那就是卡住的位置。
不用去猜、也不用加日志。Begin/End 之间用 IsActive 做守卫,
所以不在加载期间的日志不会误挂这个前缀。
10. 出生点是怎么定下来的
SetLoadingContextBornTransform 按优先级取坐标,先命中的赢:
useServerPos → 用玩家当前 Actor 坐标moveComp.position / rotationmsg.compList10.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 | 重复盖遮罩导致引用计数对不上、撤不掉 |
| 去重式 bail | BuildSceneTeleportCommand 的 beforeCb | 连点两次传送同一目标,跑两条链互相踩 |
13. 常见排查
13.1 卡在加载界面 / 黑屏不消
[Loading,看最后一条的阶段字段13.2 某个步骤「好像没执行」
先确认它是被条件跳过、还是根本没到:
· 被跳过(该命令返回 Succeeded)→ 去查它的跳过条件,注意 ⑦⑩⑪ 与 ⑧ 用的不是同一个条件;
· 没到 → 前面某条卡住了,回到 13.1。
13.3 落地位置不对 / 朝向不对
grep '[传送]无缝加载判定' 拿到完整决策快照,先确认走的是哪条路径;
然后对照 出生点优先级:无缝非换落点用的是玩家当前坐标,
这时候「位置没变」是正确行为,不是 bug。朝向不对则看 10.1 两个补救。
14. 面试怎么讲
写成回调链会变成回调地狱加满地条件判断。做成命令链之后:顺序写在 builder 里一眼可读, 异步由每个命令自己
Succeed() 收口,跳过也是命令自己判断后用 Succeed() 收口,
再配一个统一日志前缀,线上就能直接看出卡在第几步。
InitCommand 在构链阶段就展开子命令,
不是执行到它时才展开。好处是整条链的形状在开跑前就确定,可以统计总步数、上报进度;
代价是不能根据运行期数据决定加几个命令。所以这条链上有大量「进来就判断条件然后跳过」的命令—— 这是框架结构逼出来的写法,不是随意。
另一个取舍是事件分发抽成独立命令:链只定义时间点,业务模块监听事件自己接入。 否则每加一个业务模块就要往链里插一条,业务的顺序依赖会渗透进框架。
TimerSystemConstants.MaxTime 作为过期时间,
等于把框架自带的 60 秒超时关掉了。后果是:链上任何一个命令不调
Succeed(),整条链永久停住,
没有任何一层会把它踢出去——不报错、不超时、不恢复。所以这条链上每一个等待型命令都必须自带超时,这是唯一的止损点。 真实案例是等 WP 流送的命令没有超时,流送假死后整条链停住, 连带「玩家流送灯交接」这一步也从未执行,一分钟后落点的临时流送灯到期, 没有任何源持有那个 cell,地形被回收,玩家坠落。
修法分两层:给等待命令加超时放行是治本;贴地检测通过后主动做一次灯交接是双保险。
context.CurLevelId 改写成序章关卡 Id。
单看这行像是脏改数据,实际是反向利用 IsWpMapChanged():序章是一段已经在播的演出,Actor 已经在场景里。按正常判定会认为「换图了」→ 走硬加载 → 演出 Actor 连带被销毁,序章直接黑掉。改写
CurLevelId 让判定返回 false,
就绕开了硬加载分支。可复用的经验:改动一个被多处依赖的判定函数之前,要先找出「依赖它返回 false」的调用方。