传送模块
入职 16 个月里投入最大的模块:从传送吟唱(读条技能)起步, 到 传送全流程(发包 → 吟唱 → 遮罩 → 流送 → 落地收尾), 再到 服务器立刻传送、同 WP 无缝传送、淡入淡出, 最后主导 传送流程重构。 模块规模 36 文件约 1 万行,是面试讲「流程编排 + 异步治理」的最佳素材。
1. 工作清单
| 内容 | 性质 |
|---|---|
| 传送吟唱:起读条技能,吟唱被打断/完成驱动流程 | 功能开发 |
| 传送全流程:请求 → 吟唱 → 遮罩 → 场景流送 → 落地收尾全链路 | 流程建设 |
| CBT1 传送稳定性 + 服务器立刻传送模式(跳过吟唱直发) | 版本保障 |
| 同 WP 无缝传送、淡入淡出传送、传送流程重构 | 体验 + 重构 |
2. 理论:为什么传送是「命令编排」而不是一坨 if
2.1 请求链路:通知驱动,不是请求响应驱动
客户端发 WorldTeleportCSReq(23707) 只是「申请」,真正的传送由服务端 15512 同步瞬移通知驱动表现——服务端什么时候挪角色,客户端就什么时候放遮罩, 两端不会各跑各的状态机。
2.2 Round 令牌:异步世界的「通行证」
吟唱是异步的(可能 2 秒,可能被打断),流送是异步的,15512 也可能随时插进来。
每个流程开始时 round++,异步回调里先问 IsRoundValid(capturedRound)——
令牌过期就说明本轮已被新传送抢占,直接 Skip。没有它,旧流程的回调会污染新流程的状态。
2.3 冒烟准入
传送的影响面在客户端数一数二:传送期间改的是位置、场景流送这一串底层数据, 流送源、动画、碰撞、重力、交互、怪物选主控、传送后立刻播剧情,全都挂在传送链路后面—— 落地只是传送的一半,另一半是「到了之后世界还是不是对的」。 所以用例不能只断言传送本身,关联功能的回归也计入冒烟判定。
重构出来的新流程要替换线上跑着的老实现,放不放量不拍脑袋,由冒烟流水线说了算——机制三步:
3. 代码片段
3.1 入口防重入:多个并发状态一起判
Src\Game\Module\Teleport\TeleportController.ts:413-441
public async OnStartUp(type: ETeleportType, prefabDataId = 0): Promise<void> {
if (type === ETeleportType.ActiveTrigger) {
if (!this.CheckActiveTeleportIdle() || !this.CheckTeleportFunctionOpen()) {
return;
}
}
await this.TransitionFlow.OnStartUp(type, prefabDataId);
}
/** 只守主动入口,加载、下降及服务端通知仍推进当前传送。 */
private CheckActiveTeleportIdle(): boolean {
if (
!this.IsHandlingDeath &&
!this.TransitionFlow.IsBusy &&
!this.V2Flow.IsBusy &&
!ModelManager.TeleportModel?.IsTeleporting &&
!this.SameSceneFlow.IsTeleportStreaming &&
!this.SameSceneFlow.IsSafetyWaitPending
) {
return true;
}
Log.Error(ELogModule.Debug, ELogAuthor.ZZB, '传送-当前传送尚未结束,拦截重复主动传送请求');
ControllerHolder.ErrorCodeController.OpenConfirmBoxByText('正在传送中,请等待本次传送结束后重试。');
return false;
}
3.2 吟唱:Promise 桥接技能事件 + Round 判过期
Src\Game\Module\Teleport\Cmd\TeleportCastingCmd.ts:33-70(节选)
private async RunCasting(context: T): Promise<void> {
const flow = context.Flow!;
const capturedRound = context.Round; // 进阶段时先领令牌
const castingWait = new Promise<boolean | undefined>((resolve) => {
this.CastingResolve = resolve; // 事件回调里 resolve
});
EventSystem.Once(EEventName.TeleportCastingSkillComplete, this.OnCastingSkillComplete);
const result = ControllerHolder.LevelPlayController.BeginLevelSkill(
Global.PlayerEntity, TeleportDefineConstants.TeleportCastingSkillId, param1);
if (!result) { this.Abort('吟唱技能启动失败'); return; }
const isSuccess = await castingWait;
if (!flow.IsRoundValid(capturedRound) || isSuccess === undefined) {
this.Skip('传送流程被打断'); return;
}
if (!isSuccess) { this.Abort('吟唱未完成'); return; }
this.Complete();
}
3.3 三段命令装配
Src\Game\Module\Teleport\Cmd\TeleportSeqV1Cmd.ts:309-333
export class TeleportSeqV1Assembler {
public static BuildCommands(stage: TTeleportSeqV1Stage): readonly TeleportCommandBase<...>[] {
switch (stage) {
case 'Up':
return [new TeleportSeqV1InitCmd(), new TeleportCastingCmd<...>(), new TeleportSeqV1EntryCoverCmd(true)];
case 'ActiveUp':
return [new TeleportCastingCmd<...>(), new TeleportSeqV1EntryCoverCmd(false)];
case 'Load':
return [new TeleportSeqV1LoadTransitionCmd(), new TeleportStreamingCmd<...>()];
case 'Down':
return [new TeleportSeqV1SequenceCmd(), new TeleportSeqV1DownCmd(),
new TeleportLandingCmd<...>(), new TeleportFinalizeCmd<...>()];
default: return [];
}
}
}
3.4 15512 到达:同图 / 跨图 / 淡入淡出三分支
Src\Game\Module\Teleport\TeleportSameSceneFlow.ts:135-174(节选)
if (ModelManager.GameModeModel?.IsLoadingAnyScene === true) {
this.HandoverCurrentRequest('cross-scene-notify');
// 跨图加载进行中,同步瞬移流程不接管,交还原跨图链路
return;
}
const startPosition = playerPos ? Vector.Create(...) : undefined;
const round = this.CancelCurrentRequest();
if (!this.IsRoundValid(round)) { return; }
this.IsWaitingForSafety = true;
this.IsPreparingLevelStreaming = true;
this.PendingBlackScreenType = blackScreenType;
if (ModelManager.TeleportModel!.IsTeleporting || this.Presentation.IsFadeInOutTeleport(enterShowType)) {
this.Presentation.OpenSeamlessTransitionMask(enterShowType);
// 开遮罩会走黑屏/V2 遮盖链路,可能重入;复检本轮次是否仍有效再继续
if (!this.IsRoundValid(round)) { return; }
}
3.5 服务端拒绝的收尾:黑屏不会残留
Src\Game\World\Entity\Character\SceneItem\SceneItemController.ts:637-671(节选)
Net.Call(ERequestMessageId.WorldTeleportCSReq, teleportCsReq, (response) => {
ModelManager.GameModeModel!.IsTeleporting = false;
if (response!.retcode !== Game.Protocol.Retcode.RET_SUCC) {
ConfigManager.ErrorCodeConfig!.GetTextByErrorId(response!.retcode);
// 服务端拒绝后不会下发同步瞬移,必须主动退出传送状态,否则黑屏与输入锁残留
ControllerHolder.TeleportController!.OnTeleportRequestFailed();
}
});
4. 面试怎么讲
链路上客户端只发请求(23707),表现由服务端 15512 同步瞬移通知驱动, 两端状态不漂移。异步安全靠 Round 令牌: 每轮流程一个自增号,吟唱/流送这些异步回调恢复后先验令牌,过期就 Skip 让位。
流程重构上线前先过冒烟流水线,全绿后逐步放量。
另一个是「多状态防重入」:流程忙碌、同图流送、安全等待、死亡处理这些忙碌标志, 历史上双流程并发导致过双黑屏,现在主动入口一次聚合判定, 但只拦主动传送——服务端通知推进的流程不拦,否则立刻传送模式会被自己的门卫卡死。 门卫要拦入口,不能拦系统回调,这是那次学到的。