案例一 · loadwp 的 cmd
一条命令,装下整条场景加载主链。
SceneLoadingWpMapCmd 是这条链的根命令,但它自己不干活:
它在构链期用 OnAddInitCommand 把二十多条子命令原地展开,执行期只 Succeed() 让位。
看懂它,就理解了「初始化型命令(InitCommand)」这个设计在整个框架里扮演的角色。
1. 它在哪、长什么样
文件在 Src/Game/World/SceneLoading/Commands/SceneLoadingCmds/SceneLoadingWpMapCmd.ts。
它继承场景加载自己的命令基类 SceneLoadingCommandBase<SceneLoadingCmdContext>:
export class SceneLoadingWpMapCmd extends SceneLoadingCommandBase<SceneLoadingCmdContext> {
protected override OnAddInitCommand(context: SceneLoadingCmdContext): void { ... } // 构链期
protected override OnExecute(context: SceneLoadingCmdContext | undefined): void { this.Succeed(); }
protected override GetCommandDesc(): string { return '初始化场景加载关卡命令'; }
protected readonly Kind = ESceneLoadingCommandKind.Load;
}
三个要点:方法名是 OnAddInitCommand(不是 OnExecute);执行体只有一句 Succeed();
Kind 标成 Load。这三点合起来说明——它是一条「初始化型命令」。
2. 关键:子命令在「构链期」就展开,不是执行时才展开
框架里命令分两类。OnExecute 是普通命令的执行入口,被 Executor 逐条 PopCommandAndExecute 调用;
而 OnAddInitCommand 属于 InitCommand,它在整条链被构建的那一刻(AddInitCommand(rootCmd) 时)
就被调用,把自己要跑的子命令原地铺开进主链,并让 depth + 1。
// 构链阶段被调用,不是执行阶段
exec.AddInitCommand(new SceneLoadingWpMapCmd());
// → Executor 调 rootCmd.OnAddInitCommand(context)
// → 里面每条 this.AddInitCommand(sub) 都展开成主链上的一环
AddInitCommand 那一刻,早于 Execute()。
所以这条根命令跑起来时,队列里早已是那二十多条子命令;它自身的 OnExecute 只是队列里的
一个「占位步骤」,跑完就 Succeed()。这也是排查时容易误判的地方:日志里看到根命令「秒过」,别以为它没生效——
它的活在建链时就干完了。
3. 它展开出的主链
OnAddInitCommand 里一共 24 处 AddInitCommand,其中 3 处带条件。
真正的展开顺序如下(带 条件 标记的三步不是每次都会进队):
shouldRaiseMapChangeEvents 置 trueSucceed() 跳过)4. 三处条件分支由什么决定
展开前先算两个上下文布尔值,它们决定了是否多进三条命令:
| 变量 | 来源 | 影响 |
|---|---|---|
| isWpMapChanged | context.IsWpMapChanged() | 决定是否进 SceneLoadingMainMapCmd(第 4 步),并把 shouldRaiseMapChangeEvents 置 true |
| isFromLoginScene | context.CurLevelId === 0 | 配合 PublicUtil.GetIsSilentLogin() 判断:初次登录 + 静默登录时也抬 shouldRaiseMapChangeEvents |
| shouldRaiseMapChangeEvents | 上面两者推导 | 决定是否进 StartEnterMap(第 6 步)与 EndEnterMap(第 19 步) |
也就是说:换图(IsWpMapChanged)一定会走地图进退事件;不换图但属于「静默登录的初次登录」时,
也会把这些事件补上,好让依赖「进入地图」事件的系统拿到通知。其余两种事件(StartEnterLevel / EndEnterLevel)是无条件进队的。
5. 基类给了它什么:Kind 与 FailCritical
SceneLoadingCommandBase 很薄,只加了两样东西:
export const enum ESceneLoadingCommandKind { Load, Unload, Destroy }
export abstract class SceneLoadingCommandBase<T extends CommandContextBase> extends CommandBase<T> {
protected abstract get Kind(): ESceneLoadingCommandKind;
// 关键节点出严重错误时,把错误升级成「需立即删除关卡」的统一包装
protected FailCritical(reason: string): void {
const wrappedReason = `关卡加载关键节点出现严重错误,客户端状态转为 ExceptionalDestroyed\n上级错误原因:${reason}`;
this.Fail(wrappedReason);
}
}
Kind:让同一条链里的命令能按 Load / Unload / Destroy 归类,基类强制子类声明。FailCritical:不是新出口,而是对Fail()的语义包装——把失败原因统一成「关键节点严重错误,转 ExceptionalDestroyed」, 方便上层识别「这是需要销毁关卡级别的失败」。注意它最终仍走Fail(),所以结果码是Fail,会触发回滚。
6. 这个案例的四个看点
OnAddInitCommand 在 AddInitCommand(rootCmd) 那一刻(构链期)被调,用来展开子命令;
OnExecute 在 Execute() 之后由 Executor 逐条驱动时(执行期)被调。
本案例里两者都存在:前者搭链,后者(只 Succeed())是链里的一个占位步骤。
Bail() 会终止整条 executor 并回滚(见专栏 §6 Bail 语义)。
链里那些「进来判断条件、不满足就跳过本步」的命令(如 SceneWaitWpStreamingCmd、SceneLoadingMoveCmd)
必须让链条继续往后走,所以调的是 Succeed()——结果是 Succeeded,不是 Bailed。