传送模块

入职 16 个月里投入最大的模块:从传送吟唱(读条技能)起步, 到 传送全流程(发包 → 吟唱 → 遮罩 → 流送 → 落地收尾), 再到 服务器立刻传送、同 WP 无缝传送、淡入淡出, 最后主导 传送流程重构。 模块规模 36 文件约 1 万行,是面试讲「流程编排 + 异步治理」的最佳素材。

命令模式编排 Round 令牌防重入 协议 23707 / 15512 冒烟流水线准入 36 文件 / 10082 行

1. 工作清单

内容性质
传送吟唱:起读条技能,吟唱被打断/完成驱动流程功能开发
传送全流程:请求 → 吟唱 → 遮罩 → 场景流送 → 落地收尾全链路流程建设
CBT1 传送稳定性 + 服务器立刻传送模式(跳过吟唱直发)版本保障
同 WP 无缝传送、淡入淡出传送、传送流程重构体验 + 重构

2. 理论:为什么传送是「命令编排」而不是一坨 if

2.1 请求链路:通知驱动,不是请求响应驱动

客户端发 WorldTeleportCSReq(23707) 只是「申请」,真正的传送由服务端 15512 同步瞬移通知驱动表现——服务端什么时候挪角色,客户端就什么时候放遮罩, 两端不会各跑各的状态机。

1
Up 段
InitCmd → 吟唱(技能+Promise 桥接)→ EntryCoverCmd 遮罩
2
Load 段
LoadTransitionCmd → StreamingCmd「只启动流送并登记外部回执」,与服务器时序解耦
3
Down 段
Sequence 过场 → Down → Landing 落地 → Finalize 收尾

2.2 Round 令牌:异步世界的「通行证」

吟唱是异步的(可能 2 秒,可能被打断),流送是异步的,15512 也可能随时插进来。 每个流程开始时 round++,异步回调里先问 IsRoundValid(capturedRound)—— 令牌过期就说明本轮已被新传送抢占,直接 Skip。没有它,旧流程的回调会污染新流程的状态。

2.3 冒烟准入

传送的影响面在客户端数一数二:传送期间改的是位置、场景流送这一串底层数据, 流送源、动画、碰撞、重力、交互、怪物选主控、传送后立刻播剧情,全都挂在传送链路后面—— 落地只是传送的一半,另一半是「到了之后世界还是不是对的」。 所以用例不能只断言传送本身,关联功能的回归也计入冒烟判定。

重构出来的新流程要替换线上跑着的老实现,放不放量不拍脑袋,由冒烟流水线说了算——机制三步:

1
搭建测试场景
把典型传送固化成用例:同图 / 跨图 / 同 WP 无缝 / 吟唱被打断 / 服务器立刻传送,GM 命令把角色摆到出发点,目的地摆上怪物 / 交互物 / 剧情触发器,不靠手工跑图
2
录制操作流程
每条用例录成可重放的操作脚本:发起传送 → 等 15512 → 观察遮罩 / 流送 / 落地,关键节点挂断言——遮罩出没出、流送完没完、输入锁解没解、落地坐标对不对,落地后关联功能逐项回归
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;
}
「多状态防重入」是踩坑后的产物 流程忙碌、同图流送、安全等待、死亡处理是各自独立的忙碌标志—— 历史上出现过「上轮传送未结束又触发新一轮」「流送中玩家再点传送点」导致双黑屏。 入口门卫一次聚合判定,注释里写明「只守主动入口」: 服务端通知推进的传送(15512)不拦,否则立刻传送会被自己的门卫卡死。

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();
}
「Abort / Skip / Complete」三种出口各有语义 Abort=本阶段失败要终止整个流程;Skip=被更高优先级抢占,安静让位; Complete=正常走完。await 恢复后第一件事就是验 Round—— 吟唱 2 秒里可能已经来了新一轮传送,旧回调必须闭嘴。

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 [];
        }
    }
}
装配即文档 看这个 switch 就能画出整个传送的流程图,不需要读任何实现。 「主动传送省掉 Init(因为请求阶段已做)」「服务器立刻传送直接从 Load 段切入」 都是换一组命令数组实现的新模式,而不是新写一条流程。

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; }
}
「开遮罩之后还要再验一次 Round」是本模块最细的一个坑 OpenSeamlessTransitionMask 内部会走黑屏链路,可能同步触发重入回调, 重入时 round 已被换掉。所以紧跟着再验一次。 同 WP 无缝(开遮罩盖住一帧闪变)与跨 WP 淡入淡出(DA 资源 800ms 渐变)由 enterShowType 分流,这是两项体验优化的落点。

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();
    }
});
请求-响应路径也要写失败分支 正常链路是「请求 → 15512 通知」,容易忘记请求被拒绝时 15512 永远不来。 不主动收尾,玩家会卡在「请求中」状态:黑屏不退、输入锁不解除。 异常路径显式收尾,是所有「通知驱动」设计的必备件。

4. 面试怎么讲

传送模块整体架构是怎么设计的设计
按命令模式分三段编排:Up(吟唱+入遮罩)、Load(流送)、Down(过场+落地+收尾), 每段是一组命令数组按需装配,服务器立刻传送就换一组数组切入 Load 段。
链路上客户端只发请求(23707),表现由服务端 15512 同步瞬移通知驱动, 两端状态不漂移。异步安全靠 Round 令牌: 每轮流程一个自增号,吟唱/流送这些异步回调恢复后先验令牌,过期就 Skip 让位。
流程重构上线前先过冒烟流水线,全绿后逐步放量。
讲一个传送的线上问题或坑难点
「传送被服务端拒绝后黑屏残留」。根因是链路设计成通知驱动后, 请求失败的分支里没人发通知——15512 永远不来,客户端一直停在「传送中」。 修法是在请求回包的失败分支里显式调 OnTeleportRequestFailed 主动收尾。
另一个是「多状态防重入」:流程忙碌、同图流送、安全等待、死亡处理这些忙碌标志, 历史上双流程并发导致过双黑屏,现在主动入口一次聚合判定, 但只拦主动传送——服务端通知推进的流程不拦,否则立刻传送模式会被自己的门卫卡死。 门卫要拦入口,不能拦系统回调,这是那次学到的。
多人传送的落点是怎么分配的关联
编辑器侧把旧「分散半径」升级成了落点配置双模式:RandomInRadius(半径内随机)和 Formation(阵型)。阵型用方形螺旋格点——第 N 个点按层号(平方根反推)映射到 320cm 间距的网格,间距不足自动外扩,且只补齐不截断策划手改的点。 旧配置走 OnFix 自动迁移,校验期报错引导点「自动修复」。 详见 行为树工具链页的阵型一节。