案例二 · 传送的 cmd
把一次传送拆成一条阶段链,每个阶段只负责自己的成败。
传送比场景加载更「动态」:随时可能被新请求抢占、被打断、要能中途让位。
于是它在命令框架上做了两层包装——阶段命令基类的四个出口解决「单阶段怎么收口」,
轮次令牌解决「迟到异步怎么判断自己还算不算数」。
本案例看的就是这套「阶段化 + 轮次隔离」怎么和 CommandExecutor 拼在一起。
1. 传送为什么适合用命令组织
一次传送天然是有阶段、有前置条件、可被打断的长流程:校验 → 吟唱 → 上升遮挡 → 发包 → 流送 → 落地 → 收尾。 每一步都可能「成功继续」「主动让位」「失败还原」「致命终止」——这正是命令框架擅长的模型。 传送的做法是把每个阶段包成一条阶段命令,串到同一个 executor 上,再由流程在外部持有 executor 的句柄以便随时取消。
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 |
Bail = 终止整条 executor(见专栏 §6)。
传送故意这么设计:「本轮让位/被打断」在传送里就该结束当前这条阶段链,而不是让后面的阶段继续跑。
后续动作交给编排层——不是链条自己往下走。所以这里的 Skip 和场景加载里那种「条件不满足就 Succeed() 跳过继续」是两种不同语义。
四个出口都先判 GetState() === Executing 才结算,作用是防止迟到回执重复收口——阶段被取消后,晚到的技能完成回调再进来也不会二次结算。
4. 经典阶段命令一览
| 阶段类 | StageName | 做什么 | 出口 |
|---|---|---|---|
| TeleportInitCmd | Init | 悬赏事件校验 → 接管标记 → InitData → 快照本轮资源 |
被抢占/悬赏阻断 → Skip;快照失败 → Abort;成功 → Complete |
| TeleportCastingCmd | Casting | 起吟唱技能并等完成回执 | 起播失败/未完成 → Abort;被打断 → Skip;成功 → Complete |
| TeleportEntryCoverCmd | EntryCover | 播上升蒙太奇 → 入口遮挡就绪 → MarkServerRequestSent() + 发包 |
无实体且起播失败/遮挡未就绪 → Abort;被打断 → Skip;成功 → Complete |
| TeleportStreamingCmd | Streaming | 登记流送完成回执并放行(BeginStreaming()) |
同步 → Complete(真正完成由 TeleportSteamingComplete 事件驱动后续) |
| TeleportLandingCmd | Landing | 等落地回执 → CompleteLanding() |
轮次失效 → Skip;未落地/收尾异常 → Abort;成功 → Complete |
| TeleportFinalizeCmd | Finalize | FinishTeleportFlow() 收尾整次传送 |
→ Complete |
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; // 本轮身份令牌
}
其中两个类型是本案例的核心:
ITeleportStageFlow:命令能调用的流程能力清单(RegisterStageCancel/PlayUpSkill/OpenEntryCoverForStage/BeginStreaming/AwaitDownLanding/IsRoundValid/FinishTeleportFlow…)。 命令只依赖接口,不依赖TeleportTransitionFlow具体实现——这是它能在两套流程里复用的原因。TeleportRoundToken:一轮传送的身份令牌,{ Cancelled = false }。命令在异步等待前抓一份capturedRound = context.Round,回执到达后用flow.IsRoundValid(capturedRound)判断自己还算不算当前轮;不成立就Skip让位。这就是「迟到异步的归属判据」。
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 让位,绝不推进一个已经作废的流程。
Executing,不能等它自己跑完。主动 executor?.CommandBail() 会立即终止这条分段链,
和 context?.FinishStreamingWait(false) 一起,把「挂起的等待」和「还在跑的命令」都收干净——这正对应 Bail「终止整条链」的语义。
TeleportCastingCmd / TeleportEntryCoverCmd 都是先登记 EventSystem.Once(或 Add)再启动技能。
如果反了,技能瞬间完成、事件同步派发时监听还没挂上,就会永久卡在等待。
另外都配了 RegisterStageCancel 的注销句柄,统一在 SettleXxxWait 里兑现并注销,避免重复兑现。