传送 · 技术方案文档
全项目最复杂的一条客户端链路。表面上是「把玩家从 A 挪到 B」,实际要同时解决 多人同步栅栏、World Partition 流送等待、黑屏遮丑的引用计数、 三种视觉表现的分流、9 种触发原因的差异化处理、分层级的安全等待与告警 六件事。 现由 TeleportController 保留稳定门面,表现、地面探测、同场景、过场序列、Seq3 绑定与转场编排分别由子对象承接。
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 | 随场景加载 | 要 |
TeleportComponent:· ① 是 Module/Teleport/ 下的 MVC 模块,管的是场景切换与落地安全;
· ③ 是 World/Entity/Teleport/ 下的 ECS 实体组件,管的是战斗中一次带前后摇的位移表演。
反馈「传送特效不对」大概率是 ③;反馈「传送后掉下去了」一定是 ①。
2.1 ③ 战斗位移传送长什么样
它是个标准 ECS 组件,唯一调用方是蓝图库 TsTeleportBlueprintLibrary.NamiTeleport—— 也就是技能/蓝图侧发起,不经过任何协议。三段式:
PreTeleport() · 前摇BUFF_APPLY_REASON_TELEPORT)、放消失特效。
特效句柄要注册进 RegisterDilatedFx,否则时停时特效不跟着变速Teleporting() · 真位移ForceEndTeleport();然后
NamiTeleportActorToOther(自己, 目标, 坐标系, 额外Yaw, 距离, Z偏移, 旋转模式, 目标插槽)
PostTeleport() · 后摇TeleportFxInfo.BeforeTime / AfterTime
配,
为 0 时跳过等待直接进下一段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 与黑屏的互斥
|
4. 完整时序
4.1 同关卡无缝传送(最常见路径)
TeleportPointId 写入 TeleportModelOpenSeamlessTransitionMask()WorldTeleportCSReq(23707)RequestTeleport;
同时置 GameModeModel.IsTeleporting = true
EndTeleportUp()TeleportUpSkillId=1019 / 起手
TeleportCastingSkillId=1013
TeleportSelfSCNotify(15512)LoadDone(ClientSameSceneLoadDoneCSReq 14716,多人栅栏)
SCRsp 14717 → 播「飞天」落地表现TeleportDownSkillId=1018TimerSystem.Next 延后一帧,
否则出现「亮了却动不了」——解锁与撤屏同帧,输入还没恢复画面已经亮了
4.2 跨关卡传送(走完整 Loading)
LoadTransitionLevelAsync 加载过场 levelEventSystem.Once(TeleportSteamingComplete)LoadTransitionLevelAsync
之后。
早注册会被过场 level 加载附带的 EndClientLoading 提前消费掉,
导致 卡在 Seq2 Loop
UnLoadTransitionLevelReleaseAvatar() +
ReleaseSeqCamera(),
否则序列里持有的模型/相机变野指针直接崩
IsStreamingLevel = falseOnEndClientLoading」
上面只讲了「加载什么」,没讲「谁遮屏」。这条链路里黑屏与系统 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 做幂等去重,所以重发不怕重复处理。
退避重试
LoadDoneMaxAttempts = 5;
LoadDoneTotalWindowMs = 80000;
// 退避阶梯:5s → 5s → 10s → 20s → 40s (合计 80s,正好等于总窗口)
前两次间隔短,针对偶发丢包;后三次指数拉长,针对服务器侧真的处理不过来—— 这时候高频重试只会雪上加霜。
REVIVE 漏发的教训
HandleLocalPlayerTeleportApplied 的 REVIVE 分支为了「复活要立刻能动」提前
return,把后面统一发 LoadDone 的代码跳过了。服务端的「该玩家不可主控」标记依赖 LoadDone 重置,于是永久锁死
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 的完整曲线。
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,避免单次异常把串行链永久卡住。
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 造成两类问题:
- 提前撤:A 系统 Add 了,B 系统 Remove 了一下,A 的黑屏没了
- 永不撤:A Add 两次只 Remove 一次,计数永远 > 0
对策是每个调用方持有自己的 tag,只能 pop 自己那一份,且 HasOwnBlackViewTag 保证幂等——
重复 Add 不会累加计数。
ResultCallback 被覆盖(黑屏残留 13 秒)
SetResultCallback 是覆盖语义不是追加。Helper 里设的「解黑屏」回调,被后来
SceneLoadingController 又调一次 SetResultCallback
冲掉了。于是解黑屏永远不会被调用,只能等 20s 超时兜底
SetXxxCallback 命名的
API,第一件事是确认它是覆盖还是追加6.4 机制四:跨图过场序列
跨地图传送要在「旧 world 已经没了、新 world 还没好」这段时间里给玩家看东西。做法是加载一个 只有演出、没有玩法的过场关卡(TPrefab_LoadScene), 在里面播一段 LevelSequence。它是本模块唯一会主动创建/销毁 UObject 的部分, 因此崩溃与残留都集中在这里。
三段式序列
| 段 | 角色 | 结束条件 |
|---|---|---|
| Seq1 · 进入 | 从世界画面切进过场,播「离开」表现 | 播完自然结束 |
| Seq2 · Loop | 循环等待段。目标场景流送多久就循环多久 | 由 TeleportSteamingComplete 事件打断 |
| Seq3 · 落地 | 切到新场景,播「进入」表现 | OnFinished 里恢复相机碰撞 |
演出闸门:等 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. 六类典型雷区(改代码前必读)
EventSystem.Once(TeleportSteamingComplete) 注册得太早,被过场 level
自身加载附带的 EndClientLoading 事件消费掉了。真正目标场景流送完时,已经没有监听者LoadTransitionLevelAsync 之后Once
语义是「消费第一个」,在有多个同类事件源的场景里必须确保注册时机晚于无关事件源,或者改用带标识的 Add + 手动
Remove
OnEndClientLoadingIsStreamingLevel 在无缝流程里置 true 后,executor 回调里漏了复位IsStreamingLevel = false= true 的赋值点,逐个检查有没有配对的 = false
IsStreamingCompleted 永久 false → SceneWaitWpStreamingCmd 卡死 → MoveCmd 里的「玩家灯交接」从未执行 →
落点的临时流送灯到期关闭后,没有任何源持有那个 cell → cell 被回收,地形卸载SceneWaitWpStreamingCmd 加 10s 超时放行;双保险 + 3×3 贴地后主动关灯TimerSystem.Next(() => 撤屏) 延后一帧ReleaseAvatar() + ReleaseSeqCamera()WaitingForCameraDirAfterTeleport 状态,朝向包到达前不初始化相机8. 落地穿模裁剪
现象:传送落地时偶尔看到穿墙/裁剪。
根因:OnStartDown 强行把进度条冲到 100,使 Loading 遮罩在 1 帧内消失;
但相机碰撞关闭状态持续长达 2 秒,直到 Seq3 的 OnFinished 才恢复。
这 2 秒窗口内相机 blend 轨迹如果穿过墙体,就会看到裁剪。
思路:让遮罩消失时机与相机碰撞恢复时机对齐,而不是各自独立。
9. TeleportEntityBufferController 协议缓存
传送过程中,服务器可能持续下发实体的创建/移动/删除协议。如果直接应用,会出现 「角色还在黑屏里,实体已经在新场景创建又销毁」的错乱。
| 机制 | 说明 |
|---|---|
| TeleportCacheDefaultTtlMs = 15000 | 缓存 TTL。超时强制回放,避免协议永久积压 |
| RefCountByReason | 按 reason 分桶的引用计数。同时存在多个传送原因时(如复活+集合),任一未结束就保持缓存 |
分桶的必要性:如果只用一个全局计数,先结束的那个传送会把还在进行中的传送的缓存也一起清空。
10. 编辑器可选参数:传送表现
三个编辑器节点(退出副本 / 传送副本 / 传送关卡)带可选的
ShowInfo 参数,策划可以逐节点选「进入/离开」的表现:飞天遁地(默认)或淡入淡出。
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. 面试怎么讲这个模块
triggerCallbackOnTimeout=false,超时只有日志、Promise 永不 resolve。
详见 同图安全等待。
IsStreamingCompleted 永久返回 false
→ SceneWaitWpStreamingCmd 一直卡着不返回 → 它后面的「玩家流送灯交接」从没执行
→ 落点原本由传送临时灯持有,临时灯到期关闭后没有任何源持有那个 cell
→ cell 被回收,地形卸载。修法分两层:治本是给 WaitCmd 加 10 秒超时放行,不让它无限卡; 双保险是贴地检测通过后主动做一次关灯交接。
可复用的教训:「延迟 N 秒后才出现」的现象,去找那个 TTL 是 N 秒的资源。
TeleportController 从 3526 行收敛到 467 行。
控制器继续作为稳定门面,保留公开接口、网络入口、相机事件和 Tick(帧更新)转发;
表现、地面探测、同场景流程、过场序列、Seq3 绑定和转场编排拆到六个独立对象。
子对象不反向持有控制器,而是通过小型回调接口取得必要能力;事件、计时器和异步等待也分别有成对清理与流程代次保护。
Prettier(代码格式化)通过、ESLint(静态检查)为 0 error、npm run tsc-native 通过,7 个
JavaScript(脚本)产物已更新;PIE(编辑器内运行)功能回归仍待执行。
完整结构与迁移记录见重构文档。