地图流送与 DataLayer
World Partition 是这个项目大世界的地基。「传送完掉下去」「地形突然消失」「实体没出来」「卡在等流送」 这几类问题的根都在这里。 本页把 WP / DataLayer / 流送源 / 子关卡这四组概念讲清, 再回答一个最容易被当成引擎 bug 的问题:cell 已 Activated,为什么碰撞体还没好?
1. 三组概念,先分清
1.1 World Partition 是什么
UE5 把一整张大地图切成网格 cell,按玩家所在位置动态加载/卸载附近的 cell, 远处的用 HLOD 代理顶着。这样一张几平方公里的图不需要一次性载进内存。
一张 WP 地图(本项目里叫 WPMap)内部又通过 DataLayer 划分出可独立开关的内容层。 所以「换场景」在这个项目里有两种量级:
| 量级 | 做什么 | 代价 |
|---|---|---|
| 换 WPMap | 引擎级别的换图,整张地图卸载重载 | 最重,必然有加载界面 |
| 同 WPMap 换 DataLayer | 只开关内容层,地形骨架不动 | 轻,有机会做成无缝 |
反过来,一旦
newMapPath !== oldMapPath,无缝就无从谈起——地形要整体重来。
1.2 DataLayer:本项目的六种前缀
DataLayer 的命名前缀就是它的类型,运行时靠 startsWith 分派处理。
这是一个用命名约定代替枚举的设计。
| 前缀 | 用途 | 运行时默认 |
|---|---|---|
| DataLayerRuntime_EntityLevel_{levelId} | 一个 ServerLevel 对应一层。玩法实体、关卡专属内容 | 只开当前关卡那一层,其余全关 |
| DataLayerRuntime_SubMap_{name} | 子关卡(LayerTasks)。任务、活动这类可动态开关的内容 | 默认全关,按 子关卡列表逐个开 |
| DataLayerRuntime_*(其它) | 其它运行时层 | 默认开 |
| DataLayerEditor_* | 编辑器专用,如 DataLayerEditor_BaimoDesigner(策划白盒) | 一律关 |
| DataLayer_Layer_* | 实体编辑器使用 | 一律关 |
| DataLayerPlatform_{Desktop|Mobile} | 按渲染等级二选一 | GlobalData.IsSm5 → Desktop;IsEs3 → Mobile;都不满足则全关 |
MyLayer_Test),
它不会被任何一条分支命中——既不会被重置也不会被开启,
表现是「有时在有时不在」,取决于上一张图留下的状态。排查「某个 DataLayer 状态莫名其妙」,第一件事是看它的名字有没有落在上表里。
1.3 两个重置函数,管的不是同一批
| 函数 | 管哪些前缀 | 什么时候调 |
|---|---|---|
| ResetDefaultWpDataLayerSetting | DataLayerEditor_ / DataLayer_Layer_ / DataLayerPlatform_ | 换 WPMap 之后。管「美术场景」这一批 |
| ResetDefaultRuntimeDataLayerSetting | 只管 DataLayerRuntime_ | SceneLoadingResetDataLayerCmd(主链 ④)。管「关卡 Id 场景」这一批,末尾还会
ClientLayerTasks.clear() + ServerLayerTasks.clear() |
把 Runtime 那个当成「重置全部 DataLayer」来用,会导致编辑器层和平台层保持上一张图的状态; 反之则关卡层不会切换,表现为「进了新关卡还看得到旧关卡的实体内容」。
2. 流送源:谁决定加载哪些 cell
2.1 流送源就是一个「灯」
WP 不知道玩家在哪,它只认流送源(streaming source)——
场景里一个带 WorldPartitionStreamingSourceComponent 的 Actor
(本项目用 BP_StreamingSource_C)。引擎加载它周围的 cell。
可以把它想成一盏灯:灯照到哪,那块地形就存在;灯灭了或移开,地形被回收。
| 本项目枚举 | 对应引擎 TargetState | 含义 |
|---|---|---|
| EStreamingSourceState.Unload | DisableStreamingSource() | 灯灭,不再持有任何 cell |
| LoadAndHide | EStreamingSourceTargetState.Loaded | 加载但不显示。碰撞在、看不见——用于预加载 |
| LoadAndVisible | EStreamingSourceTargetState.Activated | 加载并显示。正常玩法状态 |
2.2 两盏灯:跟随玩家的 + 锁定落点的
| StreamingSourceInfo | TargetStreamingSourceInfo | |
|---|---|---|
| 位置 | 跟随玩家,OnPrePhysicsTick 每帧同步 Global.BaseCharacter 坐标 | 锁定在传送落点,不动 |
| 存续 | 常驻整局 | 只在加载期间存在,落位完成后关灯 |
| 作用 | 保证玩家脚下永远有地形 | 让落点地形提前加载好,玩家到达时不悬空 |
为什么要两盏:玩家还没到落点,跟随玩家的那盏灯照不到落点。 如果等玩家搬过去再加载,那一瞬间脚下是空的,直接穿地。 所以先在落点点一盏灯把地形烤好,再把玩家搬过去。
2.3 灯交接:主链 ⑪ 真正在做的事
// SceneLoadingMoveCmd.MoveToNewScene()
gameModeModel.StreamingSourceInfo.EnsureStreamingSource();
StreamingSourceInfo.CopyStreamingSourceInfo(
gameModeModel.StreamingSourceInfo, // to:跟随玩家那盏
gameModeModel.TargetStreamingSourceInfo, // from:落点那盏
);
// 移动完成,关闭TargetStreamingSource流送
gameModeModel.TargetStreamingSourceInfo.SetStreamingSourceState(EStreamingSourceState.Unload);
CopyStreamingSourceInfo 拷三样东西:位置、LevelLayerName、流送状态。
交接完成后落点那盏灯立刻关掉——两盏灯照同一片地,留着是浪费。
如果它没执行(比如上游
SceneWaitWpStreamingCmd 卡住了、整条链停在 ⑩),
落点地形就只由那盏临时灯持有。临时灯的生命周期一到,
没有任何源持有那些 cell,地形被回收——玩家站在空气上,开始下坠。这就是「传送后过一会儿突然掉下去」的完整因果链: 流送假死 → 链停在 ⑩ → ⑪ 从未执行 → 灯没交接 → 临时灯到期 → 地形卸载 → 坠落。 症状出现在一分钟后,根因发生在第一秒。案例详情
NeedsTerrainStreaming(),不是 IsSeamlessLoading()
「无缝换落点」这种情况也要做灯交接——它虽然是无缝,但落点变了。
代码注释写得很直白:「无缝换落点也需交接流送源,使新位置地形持续保留,否则落点灯关掉后地形被卸载又坠落」。这是踩过之后补上的判断:如果这里用
IsSeamlessLoading(),无缝换落点就会跳过交接,
重现同一个坠落。
2.4 锁定流送
SetStreamingLockStateAndPosition(isLock: boolean, lockPosition?: Vector)
锁定后 Tick() 直接 return,灯不再跟随玩家,停在 lockPosition。
用于「玩家位置暂时不可信」的时刻——比如传送过程中玩家 Actor 已经被挪走但地形还没准备好。
SetStreamingLockStateAndPosition(true, ...)
都必须有对应的解锁路径,包括早 return 分支和异常分支。
2.5 第三种灯:临时流送源
TransientStreamingSourceManager 提供按需创建的额外流送源, 典型用途是过场镜头飞到远处——镜头看得到的地方必须加载,但玩家并没有过去。
| 设计点 | 做法 | 解决什么 |
|---|---|---|
| 句柄而非对象 | 对外只给 number,InvalidHandle = -1 | 调用方拿不到内部对象,不可能持有已销毁实例的野引用 |
| Outer 绑定生命周期 | 创建时传一个 Actor 作为 Outer,Tick 里发现它失效就自毁并从表里移除 | 忘记 Destroy 也不会泄漏。用「借主还在不在」代替「有没有人记得释放」 |
| 初始化失败即标记已销毁 | 创建流送源失败时立刻 DestroyedInternal = true | 注释写的是「防止半死不活状态」——不留下「句柄有效但底层没有」的对象 |
| 重入保护 | TickAll / DestroyAll 都判 IsTickingInternal,重入直接报 Error 并忽略 | 遍历中修改容器 |
| 先收集后删除 | TickAll 把要删的 handle 推进 HandlesToRemove,遍历结束后统一删 | 同上,遍历中删除元素 |
| 句柄不回绕 | DestroyAll 明确不重置 NextHandle | 注释:「避免旧 handle 引用与新分配的 handle 冲突」——否则旧代码拿着过期 handle 操作到了新实例 |
凡是「创建了必须记得销毁」的东西(定时器、订阅、临时 Actor),都可以照这个套。
3. 等待流送完成
3.1 查询口径:五个字段决定「等什么」
const querySource = new UE.WorldPartitionStreamingQuerySource();
querySource.Location = location; // 检查位置 = 落点
querySource.Radius = ResourceSystemConstants.StreamingSourceRadius;
querySource.bUseGridLoadingRange = false;
querySource.DataLayers = dataLayers;
querySource.bDataLayersOnly = false;
querySource.bSpatialQuery = true;
querySource.bClampedByGridLoadingRange = true;
| 字段 | 值 | 含义与后果 |
|---|---|---|
| Location | 落点坐标 | 以落点为圆心查,不是以玩家当前位置 |
| Radius | 7000 = 70 米 | 只等这个半径内的 cell。来自 ResourceSystemConstants.StreamingSourceRadius,
注释标了单位是厘米。70 米外的地形不在等待范围内 |
| bUseGridLoadingRange | false | 用上面的 Radius,不用网格自己配的加载范围 |
| bClampedByGridLoadingRange | true | 再被网格加载范围裁一次上限。代码注释:「超过范围会有多余查询,卡住加载」。 即:查得太大反而会等一堆根本不会加载的 cell,永远等不完 |
| DataLayers | 目标层 + 子关卡层 | 只等这些层里的 cell。不在列表里的层,加载状态不影响判定 |
| bDataLayersOnly | false | 不限定「只查 DataLayer 内的」 |
| bSpatialQuery | true | 只查空间哈希的 cell,不含 always-loaded。always-loaded 内容本来就一直在,不需要等 |
等待层的组装:目标关卡的 EntityLevel 层 + Client 与 Server 两套子关卡层,
用 Set 去重后逐个转成 FName:
dataLayerLabels.add(gameModeModel.TargetStreamingSourceInfo.GetLevelLayerName()!.toString());
for (const label of ControllerHolder.WorldController.ClientLayerTasks) {
dataLayerLabels.add(`DataLayerRuntime_SubMap_${label}`);
}
for (const label of ControllerHolder.WorldController.ServerLayerTasks) {
dataLayerLabels.add(`DataLayerRuntime_SubMap_${label}`);
}
3.2 轮询而不是回调
const timerId = TimerSystem.Forever(() => {
const completed = worldPartitionSubsystem.IsStreamingCompleted(
UE.EWorldPartitionRuntimeCellState.Activated, querySources, true);
if (!completed) { return; }
TimerSystem.Remove(timerId);
completedPromise.SetResult(true);
}, ResourceSystemConstants.CheckStreamingInterval)!;
| 常量 | 值 | 计量的是什么 |
|---|---|---|
| CheckStreamingInterval | 100 = 0.1 秒 | 轮询间隔。够密以免白等,够疏以免每帧压测引擎 |
| StreamingSourceRadius | 7000 = 70 米 | 流送检测半径,单位厘米(UE 世界单位) |
| AsyncLoadTimeoutMs | 60000 = 60 秒 | 资源系统异步加载的超时。注意它管的是资源加载,不管 WP 流送 |
progress = matchCellNum / max(cellNum, 1)。
分母兜底成 1 是为了防除零——但也意味着 cellNum 为 0 时进度直接是 0/1,
进度条不动。看到进度条一直 0% 不一定是没在加载,可能是 cell 计数没拿到。
4. 核心问题:cell 已 Activated,为什么碰撞体还没好
这是本项目最典型的一个「引擎说完成了,实际没完成」。 表现是:流送等待返回 true,解锁放人,玩家直接穿过地面持续下坠,直到坠落重生兜底。
4.1 三个原因叠在一起
原因一:Activated 断言的是流送状态,不是物理状态
EWorldPartitionRuntimeCellState.Activated 的语义是
「这个 cell 的 Level 已经完成 AddToWorld、进入可见状态」。
而 Actor 的组件注册和物理刚体创建是引擎分帧摊平的增量工作——
Level 变可见和「里面所有 collision 都 build 完」不是同一个时刻。
IsStreamingCompleted(Activated, ...) 是一个流送状态断言,
从来不承诺物理已就绪。把它当成「地形能站人了」用,是调用方读错了语义,不是引擎的 bug。
原因二:查询口径本身有边界
由 3.1 的字段直接推出来的三条盲区:
| 盲区 | 成因 |
|---|---|
| 70 米外的地形不等 | Radius = 7000 + bClampedByGridLoadingRange |
| always-loaded 的内容不等 | bSpatialQuery = true 只查空间哈希的 cell |
| 不在 DataLayers 列表里的层不等 | 列表只装了 EntityLevel 层 + 子关卡层。落点脚下的地形若属于别的层,它加载没加载完,判定完全不看 |
原因三:HLOD 代理会让「看起来有地」
远处 cell 未加载时由 HLOD 代理顶着显示。代理是给眼睛看的,不是给物理用的。 所以画面上地形完好、脚下却没有碰撞,这个组合是正常的引擎行为。
4.2 实测结论
代码注释里的原话:「SceneWaitWpStreamingCmd 的 IsStreamingCompleted 只判 WP cell 是否进入 Activated, 不等同于 UE 物理 collision 已 build」。
4.3 解法:不信标志位,直接问物理系统
既然「流送完成」不能证明「能站人」,那就换一个能直接证明它的检查: 以落点为中心做 3×3 网格向下射线,九条全部命中才算脚下有地。
private static readonly GroundReadyTraceHorizontalDist = 132; // 相邻射线水平间距,约等于角色胶囊体直径
private static readonly GroundReadyTraceVerticalDist = 200; // 向下探测深度
for (let i = -1; i <= 1; i++) {
for (let j = -1; j <= 1; j++) {
trace.SetStartLocation(targetPos.X + h * i, targetPos.Y + h * j, targetPos.Z);
trace.SetEndLocation (targetPos.X + h * i, targetPos.Y + h * j, targetPos.Z - v);
if (!TraceElementCommon.LineTrace(trace, '[传送-解锁前贴地预检]')) { return false; }
}
}
| 参数 | 值 | 为什么是这个值 |
|---|---|---|
| GroundReadyTraceHorizontalDist | 132 = 1.32 米 | 约等于角色胶囊体直径。九条射线正好铺满玩家落脚会踩到的那片面积,不多也不漏 |
| GroundReadyTraceVerticalDist | 200 = 2 米 | 往下探两米。再深就会命中悬崖底部,把「悬空但下方远处有地」误判为安全 |
这个多点策略是从坠落重生的地面检测借过来的——同一个项目里,同类判断用同一套手法, 比各写一套更不容易出错。
所以改成持续轮询到就绪为止,没有次数上限,并且把「发 LoadDone + 解锁移动 + 撤遮罩」整体后移到地面确认之后。 代价是地形真的永远好不了时没有放行兜底,玩家会一直停在遮罩里,只能重登; 可发现性由每 100 秒一条红色告警提供。详见 传送 · 同图安全等待。
4.4 另一面:流送假死而不是假阳性
假阳性是「说完成了其实没完成」,还有一种相反的失效:永远不说完成。
const timerId = TimerSystem.Forever(() => { ... }); // Forever:没有次数上限
TimerSystem.Forever;
SceneWaitWpStreamingCmd 只在 catch 里 FailCritical,
而「一直返回 false」不会抛异常;
外层 executor 又把过期时间设成了 TimerSystemConstants.MaxTime。三层都没有兜底 ⇒ 流送一旦永久不完成,整条场景加载链永久停住, 连带 灯交接 从未执行,一分钟后地形卸载,玩家坠落。
治本方向是给这个等待加超时放行:等到上限就往下走, 再配合贴地预检通过后主动做一次灯交接作为双保险。
5. 子关卡 LayerTasks:两套集合求差
子关卡是「同一张图上可动态开关的一片内容」,比如某个任务专属的建筑。 它由两个来源各维护一套集合:
| 集合 | 来源 | 触发时机 |
|---|---|---|
| ClientLayerTasks | LevelPlayConfig.ClientLayerTaskSettings.Slots:
遍历 slot,第一个 VisibleCondition 通过的 slot 胜出(break),
没配条件视为直接通过 | 客户端条件变化时 UpdateClientLayerTasks() |
| ServerLayerTasks | 协议 WorldSetLayerTaskSCNotify 的 layerTaskNameList | 服务器推送 |
private SetLayerTasks(type: ESetLayerTasksType, newLayerTasks: Set<string>): void {
const oldLayerTasks = type === Client ? this.ClientLayerTasks : this.ServerLayerTasks;
// 卸载旧的:在 old 不在 new
for (const dl of oldLayerTasks) {
if (!newLayerTasks.has(dl)) { this.SetWorldPartitionDataLayerState(`DataLayerRuntime_SubMap_${dl}`, false); }
}
// 加载新的:在 new 不在 old
for (const dl of newLayerTasks) {
if (!oldLayerTasks.has(dl)) { this.SetWorldPartitionDataLayerState(`DataLayerRuntime_SubMap_${dl}`, true); }
}
...
}
凡是「用新状态替换旧状态」的地方都适用这条:算差集,只动真正变化的部分。
SetLayerTasks,
但最终操作的是同一批 DataLayer。
同一个名字同时出现在两套里时,后一次调用的状态生效。所以 流送等待里把两套都加进等待列表——不能只等一套。
每次 SetWorldPartitionDataLayerState 都会 Emit OnLayerTasksChanged,
表现层据此刷新。注意这个事件是逐个 DataLayer 发的,一次批量变更会发多条。
6. 预加载与「移交」
SceneLoadingController 提供一组 DataLayer 预加载接口, 让目标关卡的内容在正式切场景之前就开始加载。
| 接口 | 作用 |
|---|---|
| PreloadWpDataLayers(position, labels, useVisibleState?) | 核心实现。useVisibleState 决定目标状态是
Activated 还是 Loaded(默认后者:加载但不显示) |
| PreloadWpLevel(position, levelId, useVisibleState?) | 便捷封装,等价于预加载 DataLayerRuntime_EntityLevel_{levelId} |
| CancelPreloadWpDataLayer(transferLevelId?, reason?) | 取消。带 transferLevelId 时语义变成「移交」,见下 |
6.1 「移交」而不是「取消再重来」
预加载和正式加载可能要的是同一批 DataLayer。 如果正式加载开始时把预加载单纯取消掉(还原 DataLayer 状态), 那些刚加载好的内容会被卸载,然后立刻又被加载一遍——预加载完全白做。
所以取消时判断:
DataLayerRuntime_EntityLevel_{transferLevelId}Activated,把它提升到 ActivatedPromote WP DataLayer For Scene Loading。预加载时是 Loaded(不可见),正式加载要可见preserveDataLayerLabel 把它排除在还原之外,日志 Preserve WP DataLayer State。
其它层照常还原RestorePreloadDataLayers 只在当前状态仍等于预加载时设的目标状态时才还原,
否则跳过并打 Restore WP DataLayer Skipped。理由:如果状态已经被别人改过,说明有另一条逻辑正在管这一层, 这时候还原成「预加载之前的样子」就是踩别人。 「只回滚自己造成的改变」是回滚逻辑的通用要求。
7. 调试工具清单
7.1 GM 命令
| 命令 | 参数 | 作用 |
|---|---|---|
| DebugPrintDataLayerInfo | 无 | 输出全部 DataLayer 的开关状态(分成已激活/未激活两段打) |
| DebugSetDataLayerState | DataLayer 名, 0/1 | 手动开关任意 DataLayer,之后自动打一遍全量状态 |
| DebugSetBaimoDesignerDataLayerState | 0/1 | 开关策划白盒层 DataLayerEditor_BaimoDesigner |
| WhereAmI | 无 | 排查流送问题的第一条命令:先打全量 DataLayer 状态, 再输出地图 Id / 完整地图路径 / 配置出生点 / 关联流程 Id / 玩家 Actor 的位置与朝向(含欧拉角) |
7.2 代码内调试接口
| 接口 | 输出 |
|---|---|
| WorldController.DebugPrintAllDataLayerStatus() | 全量 DataLayer 状态,用分隔线包住便于日志里定位 |
| TransientStreamingSourceManager.DebugDump() | 活跃实例数、NextHandle,以及每个句柄的 Valid / 位置 / 流送是否完成 |
| TransientStreamingSourceManager.GetActiveCount() | 活跃临时流送源数量。怀疑泄漏时看这个——数字只增不减就是没释放 |
7.3 引擎控制台
wp.Runtime.StopLoadingLevelStreamingCells 1 // 冻结 cell 加载
wp.Runtime.StopLoadingLevelStreamingCells 0 // 恢复
这两行在 SetStreamingLockStateAndPosition 里以注释形式保留着——
曾经用它配合流送锁定来复现「地形不加载」,现在没有启用。
需要手动构造流送假死场景时,这是最直接的手段。
7.4 日志关键字
| 要查什么 | grep 什么 |
|---|---|
| 流送等待的起止 | 等待WP场景流送完成(有「开始」「结束」「失败」三种) |
| 等待的查询参数 | [CheckTargetStreamingCompleted] 检测参数——能看到实际等的 DataLayer |
| 灯交接 | 开始移动到新场景 / 移动到新场景完成 |
| 流送源创建 | 创建streaming source |
| DataLayer 预加载 | WP DataLayers Preload(Start / Cancel / Transferred) |
| 子关卡变化 | ClientLayerTasks / ServerLayerTasks |
| 贴地预检 | 传送-解锁前贴地预检 |
8. 排查速查表
| 现象 | 先查什么 |
|---|---|
| 传送后立刻穿地下坠 | 流送假阳性。grep 贴地预检 看九点检测的结果 |
| 传送后一分钟左右突然掉下去 | 灯交接没执行。grep 移动到新场景完成,
没有这条就往上找卡在哪一步 |
| 卡在「等待WP场景流送完成(开始)」 | 只有「开始」没有「结束」= 流送永不完成。
看 检测参数 里等的 DataLayer 对不对 |
| 某片内容该出现没出现 | WhereAmI 看那一层 DataLayer 的开关状态;
是子关卡就看它在不在 Client/Server 两套集合里 |
| 内容闪一下又出现 | 差集逻辑被绕过,变成了「全关再全开」 |
| 玩家走远后脚下地形消失 | 流送锁定没解,灯还钉在旧位置 |
| 某个 DataLayer 状态时有时无 | 名字没落在六种前缀里,两个重置函数都不管它 |
| 过场镜头飞过去一片空白 | 没建临时流送源,或者 Outer 提前失效被自动销毁了 |
| 怀疑流送源泄漏 | GetActiveCount() / DebugDump() |
9. 面试怎么讲
WorldPartitionStreamingSourceComponent 的 Actor,
可以理解成一盏灯,照到哪块地形那块就存在。我们维护两盏常驻灯:一盏每帧跟随玩家,保证脚下永远有地形; 一盏在传送时锁定落点,让落点地形提前烤好。玩家搬过去之后做一次灯交接, 把落点地形的持有权从临时灯移给跟随灯,然后关掉临时灯。
第三种是句柄式的临时流送源,用于过场镜头飞到玩家没去过的地方, 生命周期绑在一个 Outer Actor 上,Outer 销毁就自动回收。
表面看像是「等流送」这一步没生效,但日志显示它确实返回了完成。 根因是读错了引擎 API 的语义:
IsStreamingCompleted(Activated, ...) 断言的是「cell 的 Level 已经 AddToWorld、进入可见状态」,
而组件注册和物理刚体创建是引擎分帧摊平的增量工作——可见 ≠ 碰撞已 build 完。
再叠上查询口径的边界(只等 70 米半径内、只等空间哈希的 cell、只等列进去的那几个 DataLayer)
和 HLOD 代理「看着有地其实没碰撞」,三者一起就导致稳定假阳性。解法不是去修等待逻辑,而是换一个能直接证明目标的检查: 以落点为中心做 3×3 向下射线,九条全中才解锁。间距取角色胶囊体直径, 让九条射线正好铺满落脚面积;用九点而不是一点,是为了排除命中临时占位 Actor 的巧合。
还有一个取舍上的反转:早期是预检失败也要放行(预检只能提前发现问题、不能解决问题, 卡着不放人比穿地更糟)。2026-09-21 起改掉了这个判断——放行等于地形没好就解锁移动、撤遮罩、并发 LoadDone, 穿地骗的是玩家,假 LoadDone 骗的是服务器。现在预检持续轮询到就绪,没有次数上限, 并作为解锁与上报的准入门槛;卡住时靠每 100 秒一条红色告警暴露。
立刻掉是流送判定假阳性——说完成了其实没完成。
过一会儿掉是流送永远不说完成,导致整条加载链停在等待那一步, 后面的灯交接从未执行。落点地形只由那盏临时灯持有,临时灯到期,没有源持有那些 cell, 地形被回收,玩家开始下坠。症状在一分钟后,根因在第一秒。
后者能发生是因为这条等待链上三层都没有超时: 轮询用
TimerSystem.Forever;等待命令只在 catch 里失败,而「一直返回 false」不抛异常;
外层 executor 的过期时间被设成了最大值。治本是给等待加超时放行,再拿贴地预检通过后主动交接一次灯作为双保险。
对外只给一个
number,调用方拿不到内部实例,就不可能持有已销毁对象的野引用;
所有操作都要过管理器,无效句柄只是打一条 Warn,不会崩。更关键的是生命周期绑在 Outer Actor 上:Tick 里发现 Outer 失效就自毁并从表里移除。 调用方忘记 Destroy 也不会泄漏——判断依据从「有没有人记得释放」换成了「借主还在不在」。
配套还有几个细节:初始化失败立刻标记已销毁,不留「句柄有效但底层没有」的半死状态; TickAll 先收集待删句柄再统一删,避免遍历中改容器; DestroyAll 明确不重置句柄计数器,防止旧句柄撞上新分配的实例。