首页 / 大项目模块 / 传送 / 技术方案文档

传送 · 技术方案文档

全项目最复杂的一条客户端链路。表面上是「把玩家从 A 挪到 B」,实际要同时解决 多人同步栅栏、World Partition 流送等待、黑屏遮丑的引用计数、 三种视觉表现的分流、9 种触发原因的差异化处理、分层级的安全等待与告警 六件事。 现由 TeleportController 保留稳定门面,表现、地面探测、同场景、过场序列、Seq3 绑定与转场编排分别由子对象承接。

MVC 协议 15512/14716 协议 23707 WP 流送 LevelSequence 6 类典型 bug

1. 这个模块解决什么问题

1.1 一句话定义

把本地玩家(以及其他客户端上的同一玩家的镜像实体)从当前坐标移动到目标坐标, 过程中保证:不穿地、不下坠、不看到穿帮帧、多端进度一致;同图传送不再靠时间兜底强制放行, 卡住时以每 100 秒一条红色告警暴露。

1.2 为什么不能只写一句 SetActorLocation

朴素做法 会出什么问题 本模块的对策
SetActorLocation(目标点) 目标点地形还没流送出来,角色直接穿地无限下坠 3×3 贴地预检 + 等 WP 流送完成
等流送完成就解锁 WP 的 IsStreamingCompleted 会假阳性:cell 已 Activated ≠ 碰撞体已 build 完 不信这个标志,改为主动射线打地面确认
单机直接传 多人副本里 A 已经到了、B 还在加载,A 看到 B 站在旧位置 LoadDone(14716) 作为多人栅栏,全员到齐才放行
加个黑屏遮丑 黑屏由多个系统各自 Add/Remove,互相踩,出现黑屏永不消失 黑屏引用计数 + HasOwnBlackViewTag 自己只 pop 自己那份
所有传送一个流程 复活传送要立刻能动,GM 传送要瞬间到位,剧情传送要播过场 按 reason 分 9 条支路
信任服务器一定回包 服务器丢包/异常,客户端永远黑屏 同图安全等待不设客户端退出上限,改为每 100 秒红色告警(只告警不退出);20s 序列 / 40s 兜底 / 75s 硬超时 / 10s 遮罩告警(只告警不撤罩)

2. 全项目的「传送」谱系

「传送」在这个工程里是一个词对五套东西。它们分属不同层、走不同协议、失败表现完全不同。 拿到一个「传送有问题」的反馈,第一步是先归类到下面哪一行,否则会去改错的文件。

# 叫法 做的事 入口 协议 要不要等地形
① 同步瞬移 / 跨图传送
本页主题
把玩家从当前坐标搬到目标坐标,含流送等待、遮丑、多端栅栏 TeleportController 15512 ↓
14716 ↑
要
② 传送点 场景里的传送水晶:解锁状态 + 地图标记。位移本身转交给 ① SceneItemController
EtTeleportComponent
23707 ↑
23705 ↓
转交 ① 处理
③ 战斗位移传送 把一个 Actor 瞬移到另一个 Actor 旁边(闪现突进、抓取拉近)。纯本地表现,不切场景 TeleportComponent
TsTeleportBlueprintLibrary
无(技能驱动) 不要
④ 位移原语 TeleportTo():一次 Actor 位置写入。上面三种最终都落到它 BaseActorComponent — 不要(调用方自己保证)
⑤ 无缝换落点 无缝转场里服务器指定了落点坐标,于是在无缝流程内补做一次地形流送再落位 SceneLoadingAfterCmd 随场景加载 要
最容易改错的是 ① 和 ③ 两者都叫 Teleport,类名甚至都有 TeleportComponent:
· ① 是 Module/Teleport/ 下的 MVC 模块,管的是场景切换与落地安全;
· ③ 是 World/Entity/Teleport/ 下的 ECS 实体组件,管的是战斗中一次带前后摇的位移表演。
反馈「传送特效不对」大概率是 ③;反馈「传送后掉下去了」一定是 ①。

2.1 ③ 战斗位移传送长什么样

它是个标准 ECS 组件,唯一调用方是蓝图库 TsTeleportBlueprintLibrary.NamiTeleport—— 也就是技能/蓝图侧发起,不经过任何协议。三段式:

1
PreTeleport() · 前摇
暂停 Montage、挂传送专用 buff(BUFF_APPLY_REASON_TELEPORT)、放消失特效。 特效句柄要注册进 RegisterDilatedFx,否则时停时特效不跟着变速
2
Teleporting() · 真位移
四条前置判断任一不满足直接 ForceEndTeleport();然后 NamiTeleportActorToOther(自己, 目标, 坐标系, 额外Yaw, 距离, Z偏移, 旋转模式, 目标插槽)
3
PostTeleport() · 后摇
放出现特效、恢复 Montage。前后摇时长由 TeleportFxInfo.BeforeTime / AfterTime 配, 为 0 时跳过等待直接进下一段
Z 偏移为什么要加胶囊体半高 目标点在空中时(BattleLandCheck 判定不在地面),直接对齐目标 Actor 的原点会让自己半个身子插进对方脚下。 所以目标是 UE.Character 时额外叠加它的胶囊体半高。在地面上则不叠——地面已经决定了高度。

2.2 ④ 位移原语 TeleportTo 的调用点

BaseActorComponent.TeleportTo() 是全工程唯一的「直接写 Actor 位置」出口。 它不做任何地形检查,安全性由调用方负责。全部调用点分三类:

调用方 用途 为什么安全
SceneLoadingAfterCmd ⑤ 无缝换落点落位 前面已跑完流送等待 + 贴地预检
EntityActivateCmd
EntityActivateProcessResponseCmd
EntityUpdateCmd
实体激活/更新时按服务器坐标就位 实体是刚创建的,本来就没有「原位置」,不存在穿地观感
BaseMoveSyncComponent
BaseCharacterComponent
移动同步纠偏 纠偏幅度小,且目标点由服务器权威给出
EtQteTimeScaleComponent QTE 剧情时间缩放期间的强制摆位 剧情期间玩家无操作权,落点是策划摆好的
PlayerRoleTransformsHelper 静默登录出生点、关 Loading 时最终落位 加载流程末端,地形已就绪
要新增一处 TeleportTo 时先自问 「这个落点的地形此刻一定加载好了吗?」
答不上来就不要直接调,走 ① 的完整流程,或者至少复用 3×3 贴地预检。 本模块最难查的那类 bug(落地穿地、一分钟后坠落)根源都是在地形没准备好的时刻写了位置。

2.3 ① 本模块的文件与职责

文件 职责
Module/Teleport/TeleportController.ts 稳定门面。保留公开接口、网络入口、相机事件和 Tick(帧更新)转发,不再承载全部流程细节
Module/Teleport/TeleportPresentation.ts 表现层能力:玩家显隐、输入与主界面、相机复位、淡入淡出、黑屏和无缝遮罩
Module/Teleport/TeleportGroundProbe.ts 地面探测:3×3 可站立检查、贴地位置计算、持续轮询(无次数上限)
Module/Teleport/TeleportSameSceneFlow.ts 同场景流程:ServerMove(服务器移动)接管、流送、移动锁、LoadDone 上报重试和协议缓存收尾
Module/Teleport/TeleportSequenceFlow.ts 过场序列:过场关卡、Seq1/Seq2/Seq3、替身角色、序列相机和资源释放
Module/Teleport/TeleportSeq3Binder.ts Seq3 绑定:真实角色换装等待、轨道重绑、移动模拟冻结、落点纠正、尾段融合和还原
Module/Teleport/TeleportTransitionFlow.ts 转场编排:传送类型分流、上升/下降技能、淡入淡出、入场等待、流程代次与统一结束
Module/Teleport/TeleportModel.ts 纯数据:ServerMove(服务器移动)快照、传送点数据缓存、待处理的黑屏类型与进入表现类型
Module/Teleport/TeleportEntityBufferController.ts 协议缓存:传送期间到达的实体协议先存下,落地后统一回放
Module/Teleport/TeleportDefine.ts 枚举 ETeleportType 与常量,包括技能 Id、资源路径和淡入淡出时长

2026-09-07 的完整拆分范围、依赖方向、回调治理和验证记录见重构文档。

真正跑场景加载的那一半不在这里,而在 World/SceneLoading/ 的命令链—— 见 场景加载命令链。 本模块负责「决定要传、准备表现、确认能落地」,命令链负责「把场景换过去」。

3. 类型与关键常量

3.1 ETeleportType 五种类型

export const enum ETeleportType {
    SeamlessLevel = 0,  // 无缝传送:同一 WP 内挪位,不切 level,走流送等待
    ActiveTrigger = 1,  // 主动触发传送:玩家点传送点发起
    Dungeon       = 2,  // 副本传送:进/出副本,跨 world
    Respawn       = 3,  // 死亡复活传送
    SceneLoad     = 4,  // 带 load 的切换关卡:走完整 Loading 界面
}

3.2 关键常量:含义、单位、由来

下面每个常量都带单位和它到底在计量什么。 只记数字没用,被追问「为什么是这个值」时答不出来的常量,往往就是下一个 bug 的位置。

超时类(单位:毫秒 ms)

常量 值 计量的是什么 为什么是这个值
SafetyWaitWarnIntervalMs 100000
= 100 秒
安全等待告警的间隔(2026-09-21 起取代原 15 秒兜底) 不设客户端退出上限:每累计 100 秒打一条 Log.Error 红告警,不改状态、不退出。用 10 秒轮询累计 10 次实现(单次定时器上限 90 秒)。真正的收尾只由「地面确认可站」触发,异常分支只记日志、不退登,见 同图安全等待
LoadDoneTotalWindowMs 80000
= 80 秒
LoadDone 从首次发出到停止重试的总窗口 5 次间隔为 5s→5s→10s→20s→40s;用于网络回包异常时的多人同步收尾
SeamlessTransitionMaskTimeoutMs 10000
= 10 秒
无缝遮罩停留多久后开始告警 只打一条告警日志,不再强制关遮罩;超时说明流送卡住了,需要人去看日志定位。强制撤罩会把「卡住」变成「穿帮」,比留着遮罩更糟
ApplyAvatarToLevelSequenceTimeout 10000
= 10 秒
过场角色模型准备上限 资源或绑定异常时不无限等待
EnterSeqWaitTimeoutMs 20000
= 20 秒
等落地过场序列开播的时长 一级兜底:序列没起来就不再等,直接放行后续流程
EnterSeqBackstopTimeoutMs 40000
= 40 秒
同上的二级兜底 一级兜底本身也可能因为回调被覆盖而不触发,所以再压一层
TeleportFadeDurationMs 800
= 0.8 秒
淡入或淡出单次的表现时长 硬编码在 TS 侧,应当 ≥ 美术 DA 曲线的实际时长;短于曲线会在淡出没完成时就切场景 待配置化
GroundReadyCheckIntervalMs 50
= 0.05 秒
两次贴地射线之间的间隔 约 3 帧一次。再密没意义(流送是异步的,一帧内不会有变化),再疏则总等待时间被拉长
TeleportCacheDefaultTtlMs 15000
= 15 秒
传送期实体协议在缓存里的存活时长 到期强制回放。宁可让实体表现错乱一下,也不能让协议永久积压——积压意味着实体状态和服务器永久脱节

贴地预检的空间参数(单位:厘米 cm,即 UE 的世界单位)

常量 值 计量的是什么 为什么是这个值
GroundReadyTraceHorizontalDist 132
= 1.32 米
3×3 射线网格相邻两条射线的水平间距 约等于角色胶囊体直径。取这个值使 9 条射线正好铺满玩家落脚时会踩到的那一片面积,不多也不漏
GroundReadyTraceVerticalDist 200
= 2 米
每条射线向下探测的长度 够穿透正常的地面厚度与轻微高低差;再长就会打到落点下方另一层地形,误判为「有地」

次数与开关(无单位)

常量 值 计量的是什么 为什么是这个值
LoadDoneMaxAttempts 5 LoadDone 最多发几次 配合退避阶梯 5s→5s→10s→20s→40s。前两次密集应对偶发丢包,后三次拉长应对服务器真的处理不过来——这时高频重试只会雪上加霜
(次数已取消) 持续轮询
原 20 次
贴地预检的最多尝试次数(原 GroundReadyCheckMaxAttempts = 20,2026-09-21 移除) 改成打到就绪为止,没有次数上限。原先 20 × 50ms ≈ 1 秒后失败也放行,等于把「地形没好」当成「可以落地」,玩家会当着镜头的面穿地下坠,详见 为什么现在不再放行
TeleportUpSkillId 1019 「飞天」表现用的技能 Id 表现走技能系统而不是自己写动画,是为了复用技能的 Montage/特效/时停管线
TeleportCastingSkillId 1013 传送起手动作的技能 Id 同上
UseSystemLoadingForTransition false 跨图传送是否复用系统 Loading 界面(旧临时路径) 与 DisableUiLoadingForTransition 一起构成 ShouldShowUiLoadingForSequence:开启本项即回到旧路径,由它自己管遮挡,不再叠加新的 uiload。默认双 false ⇒ 主动传送由系统 Loading 遮丑,详见 系统 Loading 与黑屏的互斥
这些时间的层级 50ms × 持续轮询管贴地预检(没有次数上限);10 秒只管无缝遮罩(且只告警、不撤罩); 100 秒是安全等待的红告警间隔(只告警、不退出);协议缓存另有独立的 15 秒 TTL; 20/40 秒管入场表现闸门;80 秒只管 LoadDone 网络重试。 它们保护的是不同阶段,不能互相替代。

4. 完整时序

4.1 同关卡无缝传送(最常见路径)

1
玩家交互 / 点地图传送点
UI 侧确定 TeleportPointId 写入 TeleportModel
2
立刻盖瞬时遮罩 OpenSeamlessTransitionMask()
易漏 此时角色已经隐身但镜头还停在世界画面,不盖遮罩会出现穿帮帧。 后续入口处会复用同一个遮罩,不会重复 Add。
3
发包 WorldTeleportCSReq(23707)
经 SceneItemController.RequestTeleportByEntityPrefabDataId → RequestTeleport; 同时置 GameModeModel.IsTeleporting = true
4
播「遁地」表现 EndTeleportUp()
技能 TeleportUpSkillId=1019 / 起手 TeleportCastingSkillId=1013
5
服务器下发 TeleportSelfSCNotify(15512)
携带目标坐标 + reason。这里按 reason 分 9 条支路
6
开启协议缓存 TeleportEntityBufferController
传送途中到达的实体协议全部缓存,TTL 15s;按 reason 分桶引用计数,避免嵌套传送互相清空
7
执行位移 + 等待 WP 流送
8
3×3 贴地预检 ↓详见
不信 IsStreamingCompleted,主动打 9 条射线确认脚下真有碰撞体; 打到就绪为止(没有次数上限),后面的发 LoadDone 与解锁移动都排在它之后,详见 同图安全等待
9
上报 LoadDone(ClientSameSceneLoadDoneCSReq 14716,多人栅栏)
带 5 次退避重试。REVIVE 分支曾漏发这一步,导致复活后永远不能被选为主控
10
收到 SCRsp 14717 → 播「飞天」落地表现
技能 TeleportDownSkillId=1018
11
解锁移动 + 撤遮罩 + 回放缓存协议
坑 撤屏必须 TimerSystem.Next 延后一帧, 否则出现「亮了却动不了」——解锁与撤屏同帧,输入还没恢复画面已经亮了

4.2 跨关卡传送(走完整 Loading)

1
同上 1-5 步
2
LoadTransitionLevelAsync 加载过场 level
TPrefab_LoadScene
3
注册 EventSystem.Once(TeleportSteamingComplete)
时序雷 这个 Once 注册必须在 LoadTransitionLevelAsync 之后。 早注册会被过场 level 加载附带的 EndClientLoading 提前消费掉, 导致 卡在 Seq2 Loop
4
切 world → 目标场景流送 → UnLoadTransitionLevel
崩溃点 卸载前必须先 ReleaseAvatar() + ReleaseSeqCamera(), 否则序列里持有的模型/相机变野指针直接崩
5
复位 IsStreamingLevel = false
漏复位 早期版本在 executor 回调里漏了这行, 导致「做过一次同图无缝传送后,之后所有跨图传送必卡 OnEndClientLoading」

上面只讲了「加载什么」,没讲「谁遮屏」。这条链路里黑屏与系统 Loading 是分段交接的: 普通分支在 OnStartLoad 开 Loading、到 Seq3 就绪才关;淡入淡出分支则用黑屏当淡出起点, 且跳过 Seq1/2/3。开合顺序、失败兜底与 V2 的例外,见 复杂表现里「谁开谁关」。

5. reason 分支决策表

15512 携带的 reason 决定后续走哪条路。这是整个模块最需要背下来的一张表, 改任何传送逻辑前先确认影响哪些 reason。

reason 典型场景 黑屏 进入表现 发 LoadDone 特别处理
map 主动 玩家点大地图传送点 无缝遮罩 飞天遁地 是 需过场 level;Once 注册时序雷
REVIVE 死亡后复活 短黑屏 无(要立刻能动) 是(曾漏) 提前 return 前必须补发 LoadDone,否则服务端「不可主控」标记永不重置
GM GM 命令瞬移 无 无 是 要求瞬间到位,跳过所有演出
集合 队长召唤队员 无缝遮罩 飞天遁地 是 多人同时触发,栅栏压力最大
交互 剧情/机关触发 看配置 可配 ShowInfo 是 编辑器可选参数「进入/离开」表现
ACTION 行为树 Action 节点 看配置 可配 是 —
DROP_RESET 坠落重生拉回 0.5s 黑屏 无 是 见 坠落重生
被动 BLACK 服务器强制拉回 全黑 淡入淡出 是 玩家无感知,要遮全
ROLLBACK 反作弊回滚 无 无 是 越快越好,不做任何演出

5.1 isHeavy 判定

HandleLocalPlayerTeleportApplied 里用 isHeavy 区分「重量级传送」(需要流送等待 + 完整演出) 与「轻量级传送」(原地小挪,直接 SetLocation 完事)。判定依据是 reason + 目标点与当前点的距离/是否跨 cell。

6. 四个核心机制

下面四件事是本模块的技术主体,也是其它模块无法从中学到别处的部分: ① 怎么确认「地真的能站」;② 怎么让多个客户端在同一时刻继续; ③ 怎么让黑屏既遮得住又一定撤得掉;④ 怎么在换 world 的同时播一段不崩的过场。

6.1 机制一:3×3 贴地预检

前提:IsStreamingCompleted 不能当「可以站人了」用 UE 的 World Partition 提供了 IsStreamingCompleted,看起来直接等它就行。 但它只断言 cell 进入 Activated 状态,不断言碰撞体已经 build 完。 在它返回 true 的瞬间解锁玩家,角色会直接穿过地面无限下坠。 机制层面的原因见 为什么 cell 完成了碰撞体还没完成。

做法:不信标志位,直接问物理系统

在落点周围打 3×3 共 9 条向下的射线,全部命中才算地形真的好了。

// 参数
GroundReadyTraceHorizontalDist = 132;  // 3x3 网格水平间距(cm),≈ 角色胶囊体直径
GroundReadyTraceVerticalDist   = 200;  // 向下探测距离
GroundReadyCheckIntervalMs     = 50;   // 每 50ms 试一次;打到就绪为止,没有次数上限

// 伪代码
for (dx of [-132, 0, 132]) {
    for (dy of [-132, 0, 132]) {
        const start = target + Vector(dx, dy, +100);
        const end   = target + Vector(dx, dy, -200);
        if (!UE.KismetSystemLibrary.LineTraceSingle(start, end, Visibility, ...)) {
            return false;  // 任一条没打到 → 地形还没好
        }
    }
}
return true;

为什么是 3×3 而不是 1 条

单条中心射线在斜坡边缘、平台边界、镂空网格上会假阳性:中心刚好命中一块已加载的地砖, 但玩家胶囊体的边缘还悬空。3×3 覆盖了胶囊体直径范围,把这类边界情况挡住。

为什么现在不再放行

原实现是 20 次都没成功(约 1 秒)就放行:宁可让他下坠(还有坠落重生兜底), 也不能让他卡在黑屏里。 2026-09-21 起这条放行被移除:放行意味着在没确认地面时解锁移动、撤掉遮罩, 玩家会当着镜头的面穿地下坠,而且此时 LoadDone 也已提前发出——等于骗服务器「本端就绪」。

现在改为持续轮询到就绪,同时把「发 LoadDone + 解锁移动 + 撤遮罩」整体后移到地面确认之后。 代价是真正卡住时没有放行兜底,玩家会一直停在遮罩里,可发现性靠每 100 秒一条红色告警提供, 见 同图安全等待。

6.2 机制二:LoadDone 多人栅栏

语义

LoadDone(WorldClientSameSceneLoadDoneCSReq = 14716,回包 SCRsp = 14717) 不是「加载完了」,而是「本端已经准备好,可以让所有人一起继续了」。 它同时承担第二个职责:重置服务端「该玩家不可被选为主控」标记。 服务器靠请求里的 serverMoveVersion 做幂等去重,所以重发不怕重复处理。

推论:绝不能提前发 以前 15 秒兜底到点会强制结束等待并上报当前版本的 LoadDone; 这正是 2026-09-21 移除它的原因——超时就发等于骗服务器说「好了」, 本端画面还是黑的、脚下还没地,别人已经开始动了。 现在 LoadDone 只在地面确认可站之后才发,安全等待本身不设退出上限, 见 同图安全等待。

退避重试

LoadDoneMaxAttempts   = 5;
LoadDoneTotalWindowMs = 80000;
// 退避阶梯:5s → 5s → 10s → 20s → 40s  (合计 80s,正好等于总窗口)

前两次间隔短,针对偶发丢包;后三次指数拉长,针对服务器侧真的处理不过来—— 这时候高频重试只会雪上加霜。

REVIVE 漏发的教训

复活后无法被选为主控
现象
玩家死亡复活后,队友切换主控时永远选不到他
根因
HandleLocalPlayerTeleportApplied 的 REVIVE 分支为了「复活要立刻能动」提前 return,把后面统一发 LoadDone 的代码跳过了。服务端的「该玩家不可主控」标记依赖 LoadDone 重置,于是永久锁死
修法
在 REVIVE 分支 return 之前补发 LoadDone(14716)
教训
任何提前 return 的分支,都要检查它跳过了哪些「必须执行的收尾」。这类 bug 在长函数里极难看出来,写代码时应把收尾逻辑放 finally 或独立的 Finish() 里

6.3 机制三:黑屏与遮罩

三种遮挡手段

手段 用在哪 特点
无缝遮罩 OpenSeamlessTransitionMask 同图无缝传送 轻量,不走 Loading 界面;10s 后只打告警日志,不强制撤罩
系统 Loading 主动传送的复杂表现 / 跨图传送 / 无缝升格 完整加载界面 + 加载百分比。它与黑屏不能并存:「这次由谁遮丑」由 ShouldUseUiLoadingInsteadOfBlackScreen 统一判定;「界面要不要真的开」另由 ShouldShowUiLoadingForSequence 把关(见下)
淡入淡出 DA 被动 BLACK / 可配 ShowInfo 材质曲线驱动,TeleportFadeDurationMs=800;它需要黑屏当淡出起点, 所以是唯一被判回黑屏的表现

系统 Loading 与黑屏的互斥

需求写的是「传送点、传送门发起的传送都要有 Loading 界面」,但落到实现上,难点不在把 Loading 打开, 而在它和黑屏不能同时存在:黑屏挂在 ELayerType.CG(1<<10), 系统 Loading 挂在 Loading(1<<8),层级上黑屏恒高于 Loading。 两者并存时玩家只看得到黑屏,连加载百分比一起被盖住。

所以「这一次传送由谁遮丑」必须由一条统一判定给出,不能让黑屏和 Loading 各按各的节奏自行开合。 判定收口在 TeleportPresentation:

// 本次传送是否该用系统 Loading 顶替黑屏
ShouldUseUiLoadingInsteadOfBlackScreen(enterShowType?) {
    if (IsFadeInOutTeleport(enterShowType)) return false;  // 淡入淡出要黑屏当淡出起点,保持原行为
    return ShouldShowUiLoadingForSequence();
}

// 两个静态开关共同决定:默认双 false ⇒ 所有主动传送都交给系统 Loading
ShouldShowUiLoadingForSequence() {
    return !UseSystemLoadingForTransition() && !DisableUiLoadingForTransition();
}
硬约束:这条判定不能依赖 TeleportModel.IsTeleporting 该字段只由 InitData() 置位,而传送门走的是「同步瞬移 + ServerMove」链路,不经过 InitData(), 在传送门分支上恒为 false。拿它做前置,传送门会照旧叠出黑屏。

复杂表现里「谁开谁关」:飞天遁地 + Seq 过场

「复杂表现」指主动传送走飞天遁地(TeleportUpSkillId=1019),中间再夹一段 TPrefab_LoadScene 过场序列(Seq1/Seq2/Seq3)的那条链路。它由 TeleportSameSceneFlow.HandleLocalPlayerTeleportApplied 里的一个闸门分流:

isHeavy = (blackScreenType === BLACK_SCREEN_TYPE_BLACK)
       || (enterShowType  === TELEPORT_SHOW_TYPE_FADE_IN_OUT);

isHeavy  → OnStartLoad() / OnFadeInOutLevel()   // 上天入地 + Seq1/2/3 过场
!isHeavy → OnSeamlessLevel()                    // 纯无缝,见 4.1

黑屏与系统 Loading 在这条链路里是分段交接的,不是一次性开到底:

阶段 系统 Loading 黑屏
OnStartUp() 飞天起手 不动 调 OpenBlackView(),但内部吃统一判定——复杂表现默认由 Loading 遮丑,这里是空操作
OnStartLoad · 淡入淡出分支 先 GameModeOpenLoading() 后 CloseBlackView()(黑屏只当淡出起点,且跳过 Seq)
OnStartLoad · 普通分支 ShouldShowUiLoadingForSequence() 为真才 GameModeOpenLoading() 全程不开。日志原话:「传送-OnStartLoad 打开系统Loading遮罩(Seq1/Seq2 仍在底下播)」
载入过场关卡 LoadTransitionLevelAsync() 正常路径不经手;只有旧临时路径 UseSystemLoadingForTransition 下才关遮罩 + 复用 Loading 正常路径由 StartSeq1AfterMaskClosed 里 Promise.all([CloseBlackView, CloseSeamlessTransitionMaskAsync]) 在 Seq1 起播前一起撤
OnStartDown · 淡入淡出分支 在淡入遮罩回调里 GameModeCloseLoading() 同一个回调里 CloseBlackView()——黑屏一路盖到淡入开始,绝不提前撤
OnStartDown · 普通分支 OpenUiLoadingForSeq3() 开;Seq3 就绪或任一条失败分支都要关 不参与
无缝升格(!isHeavy 的兜底) PromoteSeamlessToUiLoading() 开 撤掉自己的无缝遮罩,否则升格出来的 Loading 看不到
顺序硬约束:新的遮挡先就位,旧的才撤 淡入淡出分支里 GameModeOpenLoading() 必须排在 CloseBlackView() 之前。 反过来写会露出空窗帧:黑屏已经撤了、Loading 界面还没建出来,玩家会看到没加载完的场景。 「黑屏 ↔ Loading」这类交接一律按这个顺序写。

另外,淡入淡出的「离开淡出(out)」不在 OnStartLoad 里播, 而是在 ServerLevelUnInitCmd 清实体之前播——那里才是旧世界还没销毁、角色还在原地的最后一个时机。 清实体之后再播 out,就是拿新场景的空镜头做淡出,时序上根本不成立。配合 SetPendingTeleportEnterShowType 把表现类型跨过 UnInit 传给 in,才凑齐 out → UnInit → in 的完整曲线。

关 Loading 有 7 个出口,一条都不能漏 CloseUiLoadingForSeq3(reason) 的 reason 清单: 配置缺失 / Seq3 播放器失效 / Seq3 已就绪 / 绑真实角色失败 / 序列播放器缺失 / Seq3 资源加载失败 / 过场序列 Actor 缺失。
只有第一个和第三个是正常路径,其余全是失败兜底。每一条失败分支都必须自己显式关 Loading, 因为最恶性的故障不是「表现难看」,而是「Seq3 起不来 ⇒ Loading 永久挂在屏幕上,玩家彻底动不了」。 写这类「开了就得关」的界面,失败分支的关闭调用比成功路径更重要。

另一条线 TeleportV2Flow 反过来强制拉黑屏: OpenBlackView(true, true)——第一个 true 是 force(绕过上面的统一判定), 第二个是 immediate(不做动画立即黑,否则中止本次传送)。V2 由序列演出独占屏幕, 不参与「有 Loading 就不叠黑屏」这条规则,所以必须显式绕开。

三处黑屏入口的收敛点

入口 覆盖路径 命中判定时的行为
OpenSeamlessTransitionMask() 同图无缝的入口瞬时遮罩 打日志跳过,不 Add 遮罩
OpenBlackView() 跨图传输入口(注释标明「所有传送链路的黑屏入口都收敛到本函数」) 打日志跳过,不 Add 黑屏
BaseMoveSyncComponent 同步瞬移(自持 tag [传送-黑屏-同步瞬移]) 打日志跳过,不 Add 黑屏

三条路都走 ShouldUseUiLoadingInsteadOfBlackScreen,不再各自判断。 判定覆盖不到的调用点另有全局兜底:BlackScreenController.AddBlackScreenAsync 里查 LoadingModel.IsLoadingView,系统 Loading 界面正在显示时直接跳过黑屏并打日志。 以 IsLoadingView 而不是 IsLoading 为准是有意的——后者只发 Start/FinishLoadingState,不一定有界面。

无缝升格 uiload

无缝传送依赖「目的地已就绪」这个前提。前提不成立时,入口遮罩和自身黑屏又都被上面那条判定跳过了,就会出现无遮挡窗口。 对策是按位移与流送状态把这次传送升格成 uiload:

// TeleportTransitionFlow
ShouldPromoteSeamlessToUiLoading(distanceMeters) {
    if (SameSceneFlow.IsTeleportStreaming) return false;  // 已在流送中,交给流送链自己收尾
    return distanceMeters > TeleportDefineConstants.SeamlessPromoteDistanceMeters;  // 100 米
}

判定为真时 PromoteSeamlessToUiLoading() 串行开系统 Loading(PromotedUiLoadingTask), 并撤掉自己的无缝遮罩——黑屏/遮罩还盖着的话,升格出来的 uiload 和百分比同样看不到; 收尾对称走 ClosePromotedUiLoading(),重复收尾复用同一任务。 开关任务外面包了一层 AwaitWithTimeout,避免单次异常把串行链永久卡住。

现场排障用的三个 GM TeleportSysLoadingToggle / TeleportNoUiLoadingToggle 分别切两个静态开关(互斥,开一个会把另一个置 false), TeleportFirstStageTest 置成「不走系统 Loading、也不显示 UI Loading」后跑首段用例。

按日志区分当前走了哪种遮挡

排查「传完没遮住」时,先看这几条日志落在哪一条上。表里是日志前缀,实际文案后面可能还带距离、reason 等补充字段。

日志前缀 由哪条路径打出
传送-OnTeleportSelfSCNotify TeleportSameSceneFlow 收到 15512 主动传送通知,记录 blackScreenType / enterShowType;同图无缝会另打「已盖瞬时遮罩」, 跨图加载进行中则打「不接管」后 return
传送-无缝升格 uiload TeleportTransitionFlow.ShouldPromoteSeamlessToUiLoading 判定为真(位移超阈值且流送未完成), 或 TeleportPresentation.PromoteSeamlessToUiLoading 实际开出系统 Loading; 配置里不使用系统 Loading 时打「跳过」
传送-LocalPlayerServerMoveApplied 触发 TeleportSameSceneFlow 收到 ServerMove 应用回调,进入收尾;reason 不是传送类会打「继续等待下一个」
传送-同步瞬移 跳过 AddBlackScreen BaseMoveSyncComponent 侧同步瞬移命中统一判定,或黑屏已由其它 tag 显示
黑屏跳过(系统 Loading 界面正在显示…) BlackScreenController.AddBlackScreenAsync 的全局兜底命中——已有 LoadingView,不再叠黑屏

这五条一条都不出时,说明这次传送既没有黑屏也没有 Loading,对应 无缝升格 uiload 里那段「位移 ≤ 100 米的流送窗口」。

引用计数:HasOwnBlackViewTag

黑屏是全局资源,多个系统(传送、重生、结算、剧情)都会 Add/Remove。早期直接 Add/Remove 造成两类问题:

对策是每个调用方持有自己的 tag,只能 pop 自己那一份,且 HasOwnBlackViewTag 保证幂等—— 重复 Add 不会累加计数。

ResultCallback 被覆盖(黑屏残留 13 秒)

换落点遮丑黑屏残留 13 秒
现象
QA 报「传送后黑屏很久才消失」,主观感觉「卡了一下」
误判
最初以为是渲染帧问题,去查了一圈 blend 和帧率——方向完全错了
根因
SetResultCallback 是覆盖语义不是追加。Helper 里设的「解黑屏」回调,被后来 SceneLoadingController 又调一次 SetResultCallback 冲掉了。于是解黑屏永远不会被调用,只能等 20s 超时兜底
实测
残留时长实测约 13 秒,不是「1 帧」也不是「一下」
教训
黑屏残留先查「解除回调有没有被覆盖」,别扎进渲染帧。凡是 SetXxxCallback 命名的 API,第一件事是确认它是覆盖还是追加

6.4 机制四:跨图过场序列

跨地图传送要在「旧 world 已经没了、新 world 还没好」这段时间里给玩家看东西。做法是加载一个 只有演出、没有玩法的过场关卡(TPrefab_LoadScene), 在里面播一段 LevelSequence。它是本模块唯一会主动创建/销毁 UObject 的部分, 因此崩溃与残留都集中在这里。

三段式序列

段 角色 结束条件
Seq1 · 进入 从世界画面切进过场,播「离开」表现 播完自然结束
Seq2 · Loop 循环等待段。目标场景流送多久就循环多久 由 TeleportSteamingComplete 事件打断
Seq3 · 落地 切到新场景,播「进入」表现 OnFinished 里恢复相机碰撞
Seq2 是唯一一个「靠外部事件退出」的段 所以它同时也是唯一会卡死不出来的段。事件没来就永远 Loop—— 雷区 1 就是这个形态。任何改动 Seq2 退出条件的地方都要问一句: 「这个事件有没有可能被别人提前消费掉、或者根本不发?」

演出闸门:等 Seq3 真的开播

落地后的解锁不能在切完 world 的那一刻做,否则玩家在 Seq3 还没起来时就恢复了操作。 所以设了一道闸门:等 Seq3 开播事件,EnterSeqWaitTimeoutMs = 20000 一级兜底, EnterSeqBackstopTimeoutMs = 40000 二级兜底。

为什么一个兜底不够,要压两层 一级兜底本身也是一个回调,它同样可能因为被覆盖(见 上一节) 或所在对象被提前销毁而不触发。二级兜底挂在更外层、生命周期更长的计时器上, 用来兜「兜底自己也失效」这种情况。关键路径的兜底要独立于被兜的那条路径。

三个必须做的清理

清理 不做会怎样
ReleaseAvatar() 过场关卡卸载后,序列里绑的角色模型变野指针 → 崩溃
ReleaseSeqCamera() 同上,序列相机变野指针 → 崩溃
恢复相机碰撞 相机永久无碰撞,之后所有镜头都会穿墙(见 §8)

UE 的 level 卸载不会通知 TS 侧的持有者。凡是跨 level 生命周期的 UObject 引用, 都要自己在卸载前主动置空。

播序列前必须避开引擎 Tick

// SafePlaySequencePlayer:World 正在 Tick 时调 Play 会崩,
// 所以先判 !World->bInTick,处于 Tick 内则推迟到下一帧再播
if (World.bInTick) { TimerSystem.Next(() => player.Play()); }
else { player.Play(); }

在 Tick 内启动序列会改动正在被遍历的 Actor 集合,引擎侧直接崩。 凡是由引擎回调触发、又要反过来改引擎状态的操作,都要考虑推迟一帧。

试用角色要重建一次序列绑定

ApplyAvatarToLevelSequence 把玩家当前外观塞进序列的角色轨道。 试用角色(TrialRole)的实体是被整体替换的, 外观数据在实体重建时才最终确定,所以这里必须在序列播之前重新取一次, 不能沿用传送发起时的快照——否则过场里出现的是换角色之前的形象。

相机侧对应的是 ApplyCameraToLevelSequence,其中会把 bConstrainAspectRatio 置 false:序列相机自带的宽高比约束会在 非标准分辨率下压出黑边,过场是全屏演出,不能有黑边。

7. 六类典型雷区(改代码前必读)

雷区 1 · Once 监听被提前消费(卡 Seq2 Loop)
现象
map 主动传送时卡在过场序列的 Loop 段不出来
根因
EventSystem.Once(TeleportSteamingComplete) 注册得太早,被过场 level 自身加载附带的 EndClientLoading 事件消费掉了。真正目标场景流送完时,已经没有监听者
修法
把 Once 注册挪到 LoadTransitionLevelAsync 之后
教训
Once 语义是「消费第一个」,在有多个同类事件源的场景里必须确保注册时机晚于无关事件源,或者改用带标识的 Add + 手动 Remove
雷区 2 · IsStreamingLevel 未复位
现象
做过一次同图无缝传送后,之后所有跨图传送必卡 OnEndClientLoading
根因
IsStreamingLevel 在无缝流程里置 true 后,executor 回调里漏了复位
修法
executor 回调补 IsStreamingLevel = false
教训
「做过一次 X 之后,Y 必然失败」这种描述,几乎 100% 是某个全局状态标志没复位。排查时直接 grep 所有 = true 的赋值点,逐个检查有没有配对的 = false
雷区 3 · WP 流送永久 false → 地形卸载坠落
现象
同图传送成功落地约 1 分钟后,脚下地形突然消失,玩家坠落
排查链
流送 IsStreamingCompleted 永久 false → SceneWaitWpStreamingCmd 卡死 → MoveCmd 里的「玩家灯交接」从未执行 → 落点的临时流送灯到期关闭后,没有任何源持有那个 cell → cell 被回收,地形卸载
修法
治本:给 SceneWaitWpStreamingCmd 加 10s 超时放行;双保险 + 3×3 贴地后主动关灯
教训
「延迟 N 秒后才出现」的现象,去找那个「N 秒 TTL 的资源」。这里是流送灯的持有期
雷区 4 · 撤屏与解锁同帧(亮了却动不了)
现象
画面已经亮了,但角色还有一小段时间不响应输入
根因
解锁移动与撤屏在同一帧执行,输入系统恢复要等下一帧生效
修法
TimerSystem.Next(() => 撤屏) 延后一帧
雷区 5 · UnLoadTransitionLevel 野指针崩溃
根因
过场 level 卸载后,序列里持有的 Avatar / Seq 相机变野指针
修法
卸载前先 ReleaseAvatar() + ReleaseSeqCamera()
教训
UE 的 level 卸载不会通知 TS 侧的持有者。凡是跨 level 生命周期的 UObject 引用,必须自己在卸载前主动置空
雷区 6 · 朝向包晚到,相机指错方向
现象
传送落地瞬间相机朝向是旧的,隔一会儿才转过来
根因
位置包和朝向包分开下发,朝向包晚到,相机已经按旧朝向初始化了
修法
加 WaitingForCameraDirAfterTeleport 状态,朝向包到达前不初始化相机

8. 落地穿模裁剪

两个时机没有对齐

现象:传送落地时偶尔看到穿墙/裁剪。

根因:OnStartDown 强行把进度条冲到 100,使 Loading 遮罩在 1 帧内消失; 但相机碰撞关闭状态持续长达 2 秒,直到 Seq3 的 OnFinished 才恢复。 这 2 秒窗口内相机 blend 轨迹如果穿过墙体,就会看到裁剪。

思路:让遮罩消失时机与相机碰撞恢复时机对齐,而不是各自独立。

9. TeleportEntityBufferController 协议缓存

传送过程中,服务器可能持续下发实体的创建/移动/删除协议。如果直接应用,会出现 「角色还在黑屏里,实体已经在新场景创建又销毁」的错乱。

机制 说明
TeleportCacheDefaultTtlMs = 15000 缓存 TTL。超时强制回放,避免协议永久积压
RefCountByReason 按 reason 分桶的引用计数。同时存在多个传送原因时(如复活+集合),任一未结束就保持缓存

分桶的必要性:如果只用一个全局计数,先结束的那个传送会把还在进行中的传送的缓存也一起清空。

10. 编辑器可选参数:传送表现

三个编辑器节点(退出副本 / 传送副本 / 传送关卡)带可选的 ShowInfo 参数,策划可以逐节点选「进入/离开」的表现:飞天遁地(默认)或淡入淡出。

同一个 action 有 player 版和 entity 版两套注册 两套处理各自独立解析参数,曾出现只有 player 版透传了 ShowInfo 的情况。 交互触发走的是 entity 版,于是「退出副本」配了淡入淡出仍然表现为飞天遁地。
改任何 action 之前先 grep 有没有第二个注册点——这是全项目复发次数最多的一类问题。

11. 日志约定

硬规矩 本模块所有 Log 的 message 必须带「传送」前缀。 复测时只会过滤「传送」这个关键字,不带前缀的日志等于没打。
Log.Info(ELogModule.Net, ELogAuthor.ZZB,
    '传送-发包 WorldTeleportCSReq(23707)', ['teleportId', teleportId]);

Log.Info(ELogModule.Net, ELogAuthor.ZZB,
    '传送-收包 WorldTeleportCSRsp',
    ['teleportId', teleportId], ['retcode', response?.retcode]);

12. 面试怎么讲这个模块

这个模块复杂在哪概览
多人游戏的传送系统。表面是坐标位移,实际要同时解决六件事: 多人栅栏同步(全员就绪才推进)、World Partition 流送等待、 黑屏遮丑的引用计数、三种视觉表现分流、 九种触发原因的差异化处理、分层级的安全等待与告警。 当前采用“稳定门面 + 子流程”的结构:控制器保留外部入口,六个协作对象分别承接表现、探测、同场景、序列、Seq3 绑定和转场编排。
为什么不能直接信任 WP 的 IsStreamingCompleted设计
因为它只保证 cell 进入 Activated 状态,不保证碰撞体已经 build 完。 这两者之间有几十毫秒到几百毫秒的窗口,在这个窗口里解锁玩家,角色会穿地无限下坠。 解决办法是不信标志位,改成主动向物理系统提问:在落点打 3×3 共 9 条向下射线, 全部命中才认为地形真的可站。3×3 是为了覆盖胶囊体直径,单条中心射线在斜坡边缘会假阳性。
超时兜底怎么分层设计
同图传送没有客户端退出上限:安全等待期间每累计 100 秒打一条红色告警(只打日志、不改状态), 收尾只由「地面确认可站」触发,异常分支只记日志、不退登,2026-09-21 已移除原 15 秒强制 Done 兜底。 其余按阶段分层:10 秒无缝遮罩告警(只打日志、不撤罩)、20/40 秒入场表现闸门、 80 秒 LoadDone 网络重试总窗口(5s/5s/10s/20s/40s 退避)。 注意命令执行器的超时不是兜底:建链时 triggerCallbackOnTimeout=false,超时只有日志、Promise 永不 resolve。 详见 同图安全等待。
这个模块最难排的一个 bug难点
「同图传送落地约 1 分钟后地形消失、玩家坠落」。难在延迟一分钟才发作, 现场看不出因果。排查链是:WP 流送的 IsStreamingCompleted 永久返回 false → SceneWaitWpStreamingCmd 一直卡着不返回 → 它后面的「玩家流送灯交接」从没执行 → 落点原本由传送临时灯持有,临时灯到期关闭后没有任何源持有那个 cell → cell 被回收,地形卸载。
修法分两层:治本是给 WaitCmd 加 10 秒超时放行,不让它无限卡; 双保险是贴地检测通过后主动做一次关灯交接。
可复用的教训:「延迟 N 秒后才出现」的现象,去找那个 TTL 是 N 秒的资源。
这个模块的重构已经做了什么重构
2026-09-07 已把 TeleportController 从 3526 行收敛到 467 行。 控制器继续作为稳定门面,保留公开接口、网络入口、相机事件和 Tick(帧更新)转发; 表现、地面探测、同场景流程、过场序列、Seq3 绑定和转场编排拆到六个独立对象。 子对象不反向持有控制器,而是通过小型回调接口取得必要能力;事件、计时器和异步等待也分别有成对清理与流程代次保护。 Prettier(代码格式化)通过、ESLint(静态检查)为 0 error、npm run tsc-native 通过,7 个 JavaScript(脚本)产物已更新;PIE(编辑器内运行)功能回归仍待执行。 完整结构与迁移记录见重构文档。