同图传送安全等待
同图(无缝)传送的收尾条件从「等待超过 15 秒就当作完成」改成「落点地面真的能站人」。 改造后客户端不再设安全等待的退出上限,改为每 100 秒打一次红色告警; 异常分支只记录日志,不再把玩家退回登录——原来的失败恢复整段已删除。 本页说明新顺序、告警口径、卡住时会发生什么、没被改动的部分,以及这次改造踩到的框架事实。
1. 为什么要改:15 秒兜底是「伪成功」
原实现里 TeleportSelfFallbackTimeoutMs = 15000 是「收到 15512 起,到同关卡传送完成」的整体兜底。
它到点做三件事:强制结束等待、上报当前版本的 LoadDone、清理移动锁与遮罩。
问题出在第一件事和第二件事的组合上。
| 旧做法 | 代价 | 新做法 |
|---|---|---|
等满 15 秒就发 LoadDone |
LoadDone(14716)的语义是「本端准备好了,可以让所有人继续」。地形还没流送出来就发,等于骗服务器:别人开始动了,本端脚下还是空的 |
发 LoadDone 的时机后移到地面安全确认通过之后 |
| 等满 15 秒就撤无缝遮罩、解锁移动 | 遮罩撤掉、移动解锁,但贴地预检这时才刚开始,玩家当着镜头的面穿地下坠(再靠坠落重生兜回来) | 解锁与撤罩都排在地面确认之后,不再由时间触发 |
| 贴地预检 20 次(约 1 秒)失败放行 | 落点地形慢一点就放行,等于把「地形没好」当成「可以落地」 | 贴地预检持续轮询到就绪,不再有「用完就放行」 |
2. 新的收尾顺序
顺序集中在 TeleportSameSceneFlow.StartLevelStreamingFromSnapshot 的结果回调里。
每一步之间都插了 FlowGeneration(流程代次)复查,任一步发现代次变了(被新请求或 Clear() 作废)就立刻退出,不再往下推进。
SafeMoveTeleport 把角色放到目标点;这一步只是「搬过去」,不代表脚下有地WaitForGroundReadyready-on-first-check 或 ready-after-N-retriesIsWaitingForSafety = false、IsStreamingLevel = false;如果朝向包还没到,这里补一次 ResetCamera()LoadDone(14716)UnlockLocalPlayerMovementForTeleport(true):带 groundConfirmed 时恢复为 MOVE_Walking,而不是落回坠落态CloseSeamlessTransitionMask('streaming-complete') → 清告警定时器 → EndActiveCache → 发 TeleportSteamingComplete
整段包在 try / catch 里:回调里抛异常只打一条 传送-安全完成处理异常,不会把流程推进到别的状态。
3. 安全等待:不设上限,只打告警
3.1 两个常量
| 常量 | 值 | 含义 |
|---|---|---|
| SafetyWaitWarnIntervalMs | 100000 = 100 秒 |
告警间隔。每累计这么久,就打一条 Log.Error,不改状态、不退出 |
| SafetyWaitWarnTickMs | 10000 = 10 秒 |
告警定时器的轮询步长 |
TimerSystemConstants.MaxTime = 90000,单次定时器不允许超过 90 秒;
而 CheckInterval 超上限时只打一条日志、仍然放行——也就是说传 100000 不会报错,但会留下一条越界日志。
所以改用 TimerSystem.Forever 每 10 秒跑一次,用计数器累计到第 10 次才输出,正好 100 秒,且完全不触发上限检查。
3.2 告警里带什么
告警只有一条,但一行里带 7 个诊断字段,目的是一眼看出卡在哪一段:
Log.Error(ELogModule.Debug, ELogAuthor.ZZB,
'传送-安全等待超时告警(不设客户端上限,仅告警不退出)',
['已等待毫秒', ...], // 累计已等待时长
['安全等待中', ...], // IsWaitingForSafety
['地形流送中', ...], // IsStreamingLevel
['等相机朝向', ...], // WaitingForCameraDirAfterTeleport
['执行器状态', ...], // ActiveExecutor.State(None 表示执行器已摘除)
['ServerMove监听中', ...], // 是否还在等 15512 对应的移动应用
['普通场景加载中', ...]); // GameModeModel.IsLoadingAnyScene
| 字段组合 | 说明卡在 |
|---|---|
| 地形流送中 = true,执行器状态 = Executing | 流送链本身没回来,去看 [Loading| 前缀的日志卡在第几节点 |
| 地形流送中 = true,执行器状态 = None | 执行器被摘掉了但标记没复位,属于状态泄漏,要查清理路径 |
| 地形流送中 = false,安全等待中 = true | 卡在地面就绪等待(3×3 一直打不到地)。先按 §3.3 grep「传送-找不到合适的地面」对照目标坐标 |
| 等相机朝向 = true | 在等朝向包(WorldSyncDirPosSCNotify),是协议侧没到 |
| 普通场景加载中 = true | 本端其实在做跨图加载,同图流程不该接管,检查交接判断 |
3.3 地面没就绪时先单独报一条
WaitForGroundReady 首次 3×3 检查没通过时,先打一条 Log.Error 再进入 50ms 轮询——
这样既不会让重试把日志刷爆,又能一眼看到卡在地面这一段:
Log.Error(ELogModule.Debug, ELogAuthor.ZZB,
'传送-找不到合适的地面,九点探地未通过,继续等待',
['targetX', ...], ['targetY', ...], ['targetZ', ...]);
带出来的是服务器下发的目标坐标。配合 §7.1 的两个条件, 可以区分「地形还在加载」(再等就会好)和「这个落点本来就过不了」(等多久都一样)。
4. 卡住与异常:只记录,不退登
这次改造同时把失败退登整段删掉了:原来的 RecoverFromLoadingFailure、失败遮罩
TeleportSafetyFailure、IsRecoveringFromFailure 门面拦截和 BackLoginView 都不再存在。
所有异常分支现在的动作都只有一件事——打日志,然后让等待维持原样:
| 触发点 | 日志 | 之后会怎样 |
|---|---|---|
| 移动快照为空 | 传送-快照为空 | 此时安全等待与移动锁已经先设好,这里直接 return:等待与锁都保持,靠告警可发现 |
| 流送执行器返回非 Success 且非 Bail | 传送-流送失败 + result | 不做任何收尾,遮罩与移动锁保留 |
| 执行器 Promise 抛异常 | 传送-流送执行异常 | 同上 |
| 构造执行器抛异常 / 返回空 | 传送-流送创建异常 / 传送-未创建流送执行器 | 同上 |
| 安全完成回调里抛异常 | 传送-安全完成处理异常 | 两个标记可能停在中间值,等下一次请求接管或 Clear() |
| LoadDone 重试 5 次耗尽 | 传送-LoadDone 重试耗尽… | 只停重试。此时本地安全检查已完成、遮罩与移动都已正常收尾 |
| 地面九点探地未通过 | 传送-找不到合适的地面… | 继续每 50ms 重试,没有次数上限 |
4.1 谁来解除等待
等待并非无人接管,出口只有三个:
- 新请求接管:收到新的 15512 时先
CancelCurrentRequest()——FlowGeneration += 1、摘本地移动监听、停安全等待告警、停 LoadDone 重试、停地面轮询、CommandBail()掉执行器、EndActiveCache('Cancel'),然后进入新的等待。代次 +1 放在最前面,是为了让在途的旧异步回调全部作废。 - 交接给别人:跨图(
IsLoadingAnyScene)、复活(SERVER_MOVE_REASON_REVIVE)、执行器 Bail 三种情况走HandoverCurrentRequest——取消本次并解移动锁、关无缝遮罩,把请求交回对应链路。 - 流程清理:
Clear()走同一套取消,再清空字段。
4.2 安全等待期间被拦住的调用
IsSafetyWaitPending(就是 IsWaitingForSafety 的 getter)只做一件事:
挡住「安全还没确认就抢先动」的调用,共 8 处——门面 3 处、过场编排 4 处、V2 流程 1 处:
| 位置 | 挡住的后果 |
|---|---|
TeleportController.EmitTeleportDownSkillComplete | 不发落地技能事件,避免在遮罩里播落地表现 |
TeleportController.ResetCameraByLevelSeqForTeleport | 不按关卡 Seq 复位相机 |
TeleportController.OnTeleportDownCameraTransition | 不启动下降过程的相机过渡 |
TeleportTransitionFlow.StartTeleportDown / OnStartDownInternal | 过场编排不推进到「下降」阶段 |
TeleportTransitionFlow.OnFadeInOutStreamingComplete | 淡入淡出链路不播淡入 |
TeleportTransitionFlow.OnEndClientLoading | 外部的加载结束事件不被当成传送完成,不据此发 TeleportSteamingComplete |
TeleportV2Flow.OnStartDown | V2 流程不在安全未确认时进入下降 |
这些 guard 只挡调用、不改状态;真正的收尾始终只由 §2 的顺序里那个「安全条件就绪」回调完成。
5. 这次没动的部分
| 保留项 | 为什么不能顺手一起改 |
|---|---|
| 协议缓存 15 秒 TTL TeleportCacheDefaultTtlMs = 15000 |
它管的是「传送途中到达的实体协议在缓存里活多久」,到期强制回放。 和「等地面」是两件事:延长它会拖长实体状态与服务端脱节的时间;缩短它会丢协议。保持独立 |
| 跨图传送交接 | 收到 15512 时若本端正在普通场景加载中(IsLoadingAnyScene),同图流程直接不接管,把请求交还原跨图链路。跨图自己怎么收尾由跨图链路决定,同图流程不介入 |
| 复活(REVIVE)交接 | SERVER_MOVE_REASON_REVIVE 走专属路径:发 LoadDone → 解锁移动 → 关遮罩,不进入同图安全失败链路。复活要立刻能动,不能等地形 |
| 执行器 Bail 交接 | 命令链返回 Bail 表示「这条链被别人接手了」,属于正常交接,走 HandoverCurrentRequest 把请求交回原链路,不当作异常 |
| LoadDone 重试阶梯 | 仍是 5 次、5/5/10/20/40 秒,总窗口 LoadDoneTotalWindowMs = 80000。重试耗尽只打一条 Error 日志,因为此时本地安全检查已经完成 |
| 无缝遮罩 10 秒告警 SeamlessTransitionMaskTimeoutMs = 10000 |
在 TeleportPresentation 里,同样只打日志、不撤罩。它管的是「遮罩挂了多久」,与安全等待是两个层级 |
6. 关键发现:命令超时不是兜底
改造过程中一度打算「把客户端上限去掉,反正 SceneWrapCommand 自己有 90 秒超时能兜底」。
读代码后这条路被否掉了,记在这里免得下次再想一遍:
// CommandExecutor.Init 的两个收尾参数默认都是 false
public Init(context, name = '', logDetail = false,
expireTime = CommandExecutorConstants.DefaultExpireTime,
rollbackOnTimeout = false,
triggerCallbackOnTimeout = false): boolean
// 场景加载侧建链时只传了前四个参数
executor.Init(context, 'SceneWrapCommand', true, TimerSystemConstants.MaxTime);
triggerCallbackOnTimeout = false 的真实语义
超时只打印日志:不改状态、不做回滚、不结算 Promise、不触发回调。
而本次流程是靠 executor.Execute() 返回的 Promise 接结果的——一旦超时,Promise 永远不会 resolve。
所以「命令超时」在客户端不构成兜底,不能指望它把卡住的流程推下去。服务端那侧虽有 120 秒的
SERVER_MOVE_LOAD_TIMEOUT_SEC,但它也不会驱动客户端的终态。
同一份事实在 场景加载命令链里已经记过一次: 链上所有 executor 都等于没有超时兜底,任何新加的等待节点必须自带放行条件。
7. 已知代价与待验证项
7.1 地面判据本身的风险(已知,未修复)
九点探地既然是唯一的完成条件,它自己也可能对某些落点永远给不出结果。两个成因:
- 探的是服务器下发的目标坐标,不是角色实际位置。角色被落地修正挪走以后,检测还在查原来那点,
而每一根射线只向下探
GroundReadyTraceVerticalDist = 200——原坐标与角色实际高度的落差超过这个值就永远打不到。 - 九点采样跨 ±132cm。
GroundReadyTraceHorizontalDist = 132,外围八点落在窄平台 / 斜坡边缘之外时, 那几个位置本来就没有地面,射线再等也不会通过。
结果是:这类落点会一路重试到玩家自己重登。目前不改判据,只在首次未通过时打一条
§3.3 的 传送-找不到合适的地面(带目标坐标),便于先收集实际发生的落点样本。
这条风险在 §8 里也留了排查入口。
| 必须实机验证的路径 | 期望表现 |
|---|---|
| 流送正常但不快(例如 20~60 秒) | 遮罩一直持有,地形好了以后正常解锁,不出现穿地;LoadDone 在这之后才发出 |
| 地形一直不出来 | 遮罩不撤、角色不可动,日志里每 100 秒一条红色告警,字段能指明卡在哪一段 |
| 多人同场 | 本端 LoadDone 晚发期间,队友侧不会因为本端「假就绪」而出现位置错乱 |
| 跨图 / 复活 / Bail 三条交接 | 仍走各自链路,不被同图安全等待接管,也不会因为同图等待而卡在遮罩里 |
| 地面判据过不了的落点 | 首次未通过先出一条 传送-找不到合适的地面(带目标坐标),之后持续重试但日志不刷屏;见 §7.1 |
8. 排查速查
| 现象 | 先看什么 |
|---|---|
| 同图传送后长时间黑屏 / 不能动 | grep 传送-安全等待超时告警,对照 §3.2 字段表定位卡在哪一段 |
| 传送「过了很久才动」 | grep 传送-安全条件就绪,看 result 是 ready-on-first-check 还是 ready-after-N-retries,N 就是等了多少个 50ms |
| 地面一直确认不了 | grep 传送-找不到合适的地面,用带出的 targetX/Y/Z 对照 §7.1 判断是「地形还没到」还是「这个落点本身就过不了」 |
| 怀疑是队友侧栅栏问题 | grep 传送-LoadDone,确认本端发出的 version 与服务器移动版本一致 |