首页 / 命令模式专栏 / 案例二 · 传送

案例二 · 传送的 cmd

把一次传送拆成一条阶段链,每个阶段只负责自己的成败。 传送比场景加载更「动态」:随时可能被新请求抢占、被打断、要能中途让位。 于是它在命令框架上做了两层包装——阶段命令基类的四个出口解决「单阶段怎么收口」, 轮次令牌解决「迟到异步怎么判断自己还算不算数」。 本案例看的就是这套「阶段化 + 轮次隔离」怎么和 CommandExecutor 拼在一起。

阶段命令基类 四出口 Skip 走 Bail 轮次令牌 超时兜底

1. 传送为什么适合用命令组织

一次传送天然是有阶段、有前置条件、可被打断的长流程:校验 → 吟唱 → 上升遮挡 → 发包 → 流送 → 落地 → 收尾。 每一步都可能「成功继续」「主动让位」「失败还原」「致命终止」——这正是命令框架擅长的模型。 传送的做法是把每个阶段包成一条阶段命令,串到同一个 executor 上,再由流程在外部持有 executor 的句柄以便随时取消。

命令层 vs 编排层,职责是分开的 阶段命令只回答「这个阶段成了没有」(四出口之一); 「整轮传送该怎么走」由编排层 TeleportTransitionFlow 决定。 所以命令里能读到的是一个受限的 ITeleportStageFlow 接口,而不是完整的流程对象——避免命令反向依赖流程实现。

2. 两套实现

目录是什么典型文件
Module/Teleport/Cmd/ 经典阶段链:TeleportCommandBase 四出口 + 六个阶段命令 + TeleportCmdContext TeleportInitCmd / Casting / EntryCover / Streaming / Landing / Finalize
Module/Teleport/Cmd/(简化子集) 普通表现层的简化分段:一条 TeleportSimplifiedCmdContext 驱动 Down / Load / Seamless 三段 TeleportSimplifiedCmd + TeleportSimplifiedAssembler
Module/Teleport/V2/ V2 表现层(飞天遁地 / Seq 过场)的私有编排,不走这里 (本案例不展开)

3. TeleportCommandBase:约定四个出口

阶段命令统一继承 TeleportCommandBase<T>。它只做一件事:把「阶段名 + 日志前缀」和「四个收口出口」固定下来。

export abstract class TeleportCommandBase<T extends CommandContextBase> extends CommandBase<T> {
    protected abstract get StageName(): string;
    protected get StageLogPrefix(): string { return `传送-阶段[${this.StageName}]`; }

    protected Complete(): void  { if (Executing) this.Succeed(); }                       // 阶段完成,进下一阶段
    protected Skip(reason): void { if (Executing) this.Bail(`${StageLogPrefix} 主动跳过:${reason}`); }  // 主动退出(预期内)
    protected Abort(reason): void { if (Executing) this.Fail(`${StageLogPrefix} 失败:${reason}`); }   // 失败,触发还原
    protected FatalError(reason): void { if (Executing) this.Fatal(...); }               // 致命错误
}
出口底层调用语义结果码
Complete()Succeed()阶段完成,进下一阶段Succeeded(链继续)
Skip(reason)Bail(...)主动退出 / 让位(预期内)Bail(终止整条 executor 并回滚)
Abort(reason)Fail(...)失败,触发中断与还原Fail
FatalError(reason)Fatal(...)致命错误,直接终止并告警Fatal
注意:Skip 走的是 Bail,不是「跳过本步」 在框架语义里 Bail = 终止整条 executor(见专栏 §6)。 传送故意这么设计:「本轮让位/被打断」在传送里就该结束当前这条阶段链,而不是让后面的阶段继续跑。 后续动作交给编排层——不是链条自己往下走。所以这里的 Skip 和场景加载里那种「条件不满足就 Succeed() 跳过继续」是两种不同语义。

四个出口都先判 GetState() === Executing 才结算,作用是防止迟到回执重复收口——阶段被取消后,晚到的技能完成回调再进来也不会二次结算。

4. 经典阶段命令一览

阶段类StageName做什么出口
TeleportInitCmdInit 悬赏事件校验 → 接管标记 → InitData → 快照本轮资源 被抢占/悬赏阻断 → Skip;快照失败 → Abort;成功 → Complete
TeleportCastingCmdCasting 起吟唱技能并等完成回执 起播失败/未完成 → Abort;被打断 → Skip;成功 → Complete
TeleportEntryCoverCmdEntryCover 播上升蒙太奇 → 入口遮挡就绪 → MarkServerRequestSent() + 发包 无实体且起播失败/遮挡未就绪 → Abort;被打断 → Skip;成功 → Complete
TeleportStreamingCmdStreaming 登记流送完成回执并放行(BeginStreaming()) 同步 → Complete(真正完成由 TeleportSteamingComplete 事件驱动后续)
TeleportLandingCmdLanding 等落地回执 → CompleteLanding() 轮次失效 → Skip;未落地/收尾异常 → Abort;成功 → Complete
TeleportFinalizeCmdFinalize FinishTeleportFlow() 收尾整次传送 → Complete
Streaming 是「同步放行」型命令的样板 它在 OnExecute 里只做「登记回执 + 启动流送」就立刻 Complete(),不等流送真正结束。 真正的完成信号由事件 TeleportSteamingComplete 异步驱动后续段。这样命令不会长时间挂在 Executing, 但也意味着「链条放行」不等于「业务已完成」——进度语义要靠事件侧把握。

5. 上下文与轮次令牌

阶段命令之间要共享状态,靠的是 TeleportCmdContext(泛型 T 的具体化):

export class TeleportCmdContext extends CommandContextBase {
    public TeleportType = ETeleportType.SeamlessLevel;
    public Param = 0;                                   // 传送参数,如传送点实体配置 Id
    public IsLocalPlayer = true;                          // 是否本地主控玩家
    public Flow: ITeleportStageFlow | undefined = undefined;   // 受限的流程访问面
    public Round: TeleportRoundToken | undefined = undefined; // 本轮身份令牌
}

其中两个类型是本案例的核心:

TeleportCmdContext.OnDispose() 会把 Flow 与 Round 置空——整条链执行完、上下文释放时,不留悬挂引用。

6. 简化分段:现场编排一个 CommandExecutor

普通表现层不走六阶段那条长链,而是按 Down / Load / Seamless 三段临时组装短链。 分段类型定义在 TeleportSimplifiedCmd 里:

export type TTeleportSimplifiedStage = 'Down' | 'Load' | 'Seamless';

export class TeleportSimplifiedAssembler {
    public static BuildCommands(stage: TTeleportSimplifiedStage) {
        switch (stage) {
            case 'Load':   return [new TeleportSimplifiedCoverCmd(), new TeleportSimplifiedStreamingCmd()];
            case 'Down':   return [new TeleportSimplifiedExitCmd()];
            default:         return [new TeleportSeamlessStartCmd()];
        }
    }
}

这段短链的基类 TeleportSimplifiedCmd 把「四出口」进一步简化成「执行 → 判成败」: 子类只管实现 ExecuteStage()(返回 boolean 或 Promise<boolean>),基类负责收口—— 轮次已取消 → Skip;阶段未完成 → Skip(阶段中断);完成 → Complete;抛异常 → Abort。

真正创建并驱动 executor 的地方是 TeleportTransitionFlow.RunSimplifiedStage():

private async RunSimplifiedStage(context: TeleportSimplifiedCmdContext, stage: TTeleportSimplifiedStage): Promise<boolean> {
    if (!context.IsCurrent()) return false;
    const executor = new CommandExecutor<TeleportSimplifiedCmdContext>();
    executor.Init(
        context,
        `传送-普通阶段-${stage}`,
        false,
        TeleportTransitionFlow.EnterSeqBackstopTimeoutMs,   // 40s 兜底超时
        false,
        true,
    );
    for (const command of TeleportSimplifiedAssembler.BuildCommands(stage)) {
        executor.AddInitCommand(command);
    }
    this.SimplifiedExecutor = executor;
    let completed = false;
    try {
        await executor.Execute((result) => {                 // 终态回调:先结算,再决定整轮走向
            if (this.SimplifiedExecutor === executor) this.SimplifiedExecutor = undefined;
            if (!context.IsCurrent()) return;
            completed = result === ECommandExecutorResult.Success;
            if (completed) { if (stage === 'Seamless') this.OnEnd(); return; }
            this.FinishEnterSeqWait(false);
            this.InvalidateFlow();                          // 中断:作废本轮流送与 executor
        });
    } catch (error) { /* 记录 + FinishEnterSeqWait(false) + InvalidateFlow() */ }
    finally {
        executor.RemoveExpireTime();                       // 正常结束就撤掉兜底超时
        if (this.SimplifiedExecutor === executor) this.SimplifiedExecutor = undefined;
    }
    return completed;
}

源码注释点破了这段的关键顺序:「执行器终态先结算,再同步通知无缝上层结束,避免 OnEnd 把自身取消成 Bail。」 ——所以回调里第一件事是把 SimplifiedExecutor 引用清掉,防止后续 OnEnd 触发 InvalidateFlow 时又把自己取消掉。

取消整轮的收口在 InvalidateFlow():

private InvalidateFlow(): void {
    this.FlowGeneration.Cancelled = true;                 // 旧令牌作废
    this.FlowGeneration = new TeleportRoundToken();       // 换新令牌,旧令牌永不复用
    const executor = this.SimplifiedExecutor;
    const context = this.SimplifiedContext;
    this.SimplifiedExecutor = undefined;
    this.SimplifiedContext = undefined;
    context?.FinishStreamingWait(false);             // 兑现挂起的流送等待
    executor?.CommandBail();                            // 立刻终止分段 executor
}

7. 这个案例的五个关键点

单阶段结果和整轮走向为什么要分开设计
命令只回答「本阶段成了没有」;「整轮要不要继续、要不要作废」是编排层的事。 所以命令里读不到完整流程对象,只能通过 ITeleportStageFlow 干受控的事, 整轮的去留由 Execute 的终态回调和 InvalidateFlow 决定。这避免了「命令自己决定整轮命运」的耦合。
轮次令牌解决了什么问题细节
传送会被新请求抢占。一个已经发出去的异步等待(等技能回执、等落地)回来时, 它可能已经不属于当前这轮了。令牌就是它的「身份」:每轮新建、取消后永不复用; 异步回执拿着 capturedRound 调 IsRoundValid 或 context.IsCurrent() 自检, 不成立就 Skip 让位,绝不推进一个已经作废的流程。
InvalidateFlow 里为什么要主动 CommandBail设计
因为 executor 仍在 Executing,不能等它自己跑完。主动 executor?.CommandBail() 会立即终止这条分段链, 和 context?.FinishStreamingWait(false) 一起,把「挂起的等待」和「还在跑的命令」都收干净——这正对应 Bail「终止整条链」的语义。
异步等待为什么都「先监听再启动」易错
TeleportCastingCmd / TeleportEntryCoverCmd 都是先登记 EventSystem.Once(或 Add)再启动技能。 如果反了,技能瞬间完成、事件同步派发时监听还没挂上,就会永久卡在等待。 另外都配了 RegisterStageCancel 的注销句柄,统一在 SettleXxxWait 里兑现并注销,避免重复兑现。
这套链有超时兜底吗易错
有,且和案例一形成对照。RunSimplifiedStage 给 executor 设了 EnterSeqBackstopTimeoutMs(40s)兜底,正常结束时在 finally 里 RemoveExpireTime() 撤掉。 也就是说——传送这条链是「显式设超时 + 用后移除」的正面用法; 而框架默认行为是「超时只打日志、不结算」(见专栏 §5),所以要靠自己设,别默认它有。