首页 / 编辑器专栏 / 实体模板 / 边界检查重生

边界检查重生

策划在关卡编辑器里摆范围盒,玩家离开任务区域 → 弹倒计时警告 → 时间到强制拉回。 另有一种「坠落区域」:一进就直接重生,不倒计时。
本模块不自己做位移,位移全部委托给 ③ 坠落重生。 它负责的是判定时机、文案、黑屏编排——难点全在这三件事的时序上。

ECS 组件 范围盒 overlap 双层黑屏 多语言脏数据 601 行

1. 模块定位

1.1 两种触发条件,一套组件

边界倒计时 BoundaryCountdownCondition坠落区域 FallZoneCondition
语义「你快出界了,N 秒内回来」「这里是虚空,进来就送回去」
触发进/出范围 → 倒计时 → 归零重生进范围 → 立即重生
UI小 Tip 每秒刷新剩余秒数全屏 Tip + 低层黑屏 2.5s 预览
可取消✅ 离开范围就 ResetCountdown❌ 不可取消
重生规则 IdFallingRebornRuleId = 1FallZoneRebornRuleId = 1

两者都是可选配置,一个实体可以只配一种,也可以两种都配。

1.2 编辑器配置结构

export interface IBoundaryRespawnComponent extends IComponentBase {
    /** 倒计时判断条件(可选配置) */
    BoundaryCountdownCondition?: IBoundaryRespawnCondition;
    /** 坠落区域判断条件(可选配置) */
    FallZoneCondition?: IFallZoneCondition;
}

export interface IBoundaryRespawnCondition {
    /** 倒计时(秒),默认15秒 */
    CountdownTime: number;
    EdTidWarningText?: string;      // 预警文本 · 编辑器显示文本(策划填的中文)
    TidWarningText?: string;        // 预警文本 · 多语言 ID
    EdTidRespawnText?: string;      // 重生文本 · 编辑器显示文本
    TidRespawnText?: string;        // 重生文本 · 多语言 ID
    /** 边界范围实体数组 */
    BoundaryRangeEntityIds: number[];
    /** 取反触发:true 表示在边界范围"外"才倒计时(区域内为安全区),默认 false */
    TriggerOnLeave?: boolean;
}

1.3 TriggerOnLeave:一个字段搞定两种语义

策划有两种摆法:「摆危险区」(进去就倒计时)和 「摆安全区」(出来才倒计时)。 代码用一个布尔翻转来统一:

private readonly HandleOnEntityEnterBoundaryRange = (otherCreatureDataId: number): void => {
    if (this.IsHidden()) { return; }
    if (this.DataComp?.BoundaryCountdownCondition?.TriggerOnLeave) {
        this.OnLeaveBoundaryRange();      // 翻转:物理"进入" → 逻辑"离开危险"
    } else {
        this.OnEnterBoundaryRange();
    }
};

private readonly HandleOnEntityLeaveBoundaryRange = (otherCreatureDataId: number): void => {
    if (this.IsHidden()) { return; }
    if (this.DataComp?.BoundaryCountdownCondition?.TriggerOnLeave) {
        this.OnEnterBoundaryRange();      // 翻转:物理"离开" → 逻辑"进入危险"
    } else {
        this.OnLeaveBoundaryRange();
    }
};
这个设计值得学 把「物理事件」和「逻辑语义」解耦在事件 handler 这一层, 下游的 OnEnterBoundaryRange / OnLeaveBoundaryRange 只处理逻辑语义, 完全不知道 TriggerOnLeave 的存在。
反面写法是把 if (TriggerOnLeave) 散布到倒计时、Tip、重生各处,那样必然漏改。

2. 核心难点一:范围盒还没创建怎么办

这是本模块最容易踩、也最难查的坑 组件 OnStart 时,配置里引用的范围实体可能还没创建出来 (实体创建顺序不确定)。此时 GetEntityHandleByLevelEntityInstanceId 返回 undefined, 事件挂不上,玩家走进去什么都不会发生。

2.1 Pending 补注册机制

/** 组件启动时范围实体还没创建出来,先记下 PrefabDataId,等 EntityInitialized 事件再补注册 */
private readonly PendingBoundaryRangeIds: Set<number> = new Set();
private readonly PendingFallZoneIds: Set<number> = new Set();

/** 是否已挂上 EntityInitialized 监听(只在还有待补注册的实体时挂) */
private IsWaitingEntityInitialized = false;

private RegisterRangeEvents(): void {
    const boundaryIds = this.DataComp?.BoundaryCountdownCondition?.BoundaryRangeEntityIds;
    if (boundaryIds) {
        for (const prefabDataId of boundaryIds) {
            if (!this.TryRegisterBoundaryRange(prefabDataId)) {
                this.PendingBoundaryRangeIds.add(prefabDataId);   // 挂不上 → 进待办
            }
        }
    }
    // FallZone 同理 ...
    this.RefreshEntityInitializedListener();
}

2.2 为什么必须用 EntityInitialized 而不是 EntityCreated

/** 按 Pending 是否为空决定挂/摘 EntityInitialized 监听。
 *  用 Initialized 不用 Created:Created 时范围盒的 OnStart 还没跑,
 *  碰撞体和 overlap 委托都不存在,这时挂监听收不到进入事件。 */
private RefreshEntityInitializedListener(): void {
    const needListen = this.PendingBoundaryRangeIds.size > 0
                     || this.PendingFallZoneIds.size > 0;
    if (needListen === this.IsWaitingEntityInitialized) { return; }   // 状态没变不动
    this.IsWaitingEntityInitialized = needListen;
    if (needListen) {
        EventSystem.TryAdd(EEventName.EntityInitialized, this.HandleEntityInitialized);
    } else {
        EventSystem.TryRemove(EEventName.EntityInitialized, this.HandleEntityInitialized);
    }
}
范围盒补注册后收不到进入事件
现象
部分关卡的边界重生完全不生效,玩家走出去毫无反应;重进一次关卡有时又好了
排查
日志显示「范围实体创建完成,补注册成功」,说明注册路径走到了,但 OnEntityEnterClientRange 从来没触发
根因
用的是 EntityCreated 事件。这个事件在实体对象刚 new 出来时就发,此时范围盒实体的 OnStart 还没执行,EtRangeComponent 的碰撞体和 overlap 委托都不存在。挂上去的监听挂在了一个空壳上
修法
改用 EntityInitialized——它在实体 Activate 完成、组件 OnStart 全跑完之后才发
教训
「实体创建」在 ECS 里至少有三个时间点:对象构造、组件 OnInit、组件 OnStart(碰撞体就绪)。跨实体依赖必须问清楚依赖的是哪个时间点的状态,不能看到名字带 Created 就用

2.3 补注册时玩家已经在里面了

this.BoundaryRangeEntityHandles.add(entityHandle);
// 挂监听时玩家可能已站在区域内(overlap begin 早已发过且不会补发),补判一次
const inRangeCreatureDataId = this.GetLocalPlayerCreatureDataIdInRange(entityHandle);
if (inRangeCreatureDataId !== undefined) {
    Log.Debug(..., '重生组件-注册时玩家已在边界范围内,补触发进入', ['PrefabDataId', prefabDataId]);
    this.HandleOnEntityEnterBoundaryRange(inRangeCreatureDataId);
}
/** 本地玩家已在该范围实体的客户端范围内则返回其 CreatureDataId,否则返回 undefined */
private GetLocalPlayerCreatureDataIdInRange(rangeEntityHandle: EntityHandle): number | undefined {
    const rangeComp = rangeEntityHandle.GetComponent(EComponent.EtRangeComponent);
    if (!rangeComp) { return undefined; }
    const localHandle = ActorUtils.GetEntityHandleByActor(Global.BaseCharacter);
    if (!localHandle) { return undefined; }
    const creatureDataId = localHandle.CreatureDataId;
    return rangeComp.IsCreatureInClientRange(creatureDataId) ? creatureDataId : undefined;
}
「事件不补发」是所有 overlap 系统的通病 UE 的 overlap begin 是边沿触发,不是电平。晚挂的监听永远收不到「已经在里面了」这个状态。 凡是延迟挂载 overlap 监听的场景,都必须在挂载后主动查一次当前状态并补触发。 这个模式在项目里叫「挂载补判」,同样适用于 传送点解锁事件。

3. 倒计时流程

1
StartCountdown · 立即显示初始秒数
CurrentCountdown = CountdownTime,解析警告文本, 先 Show 一次再启动 Timer——否则第一次 tick 会先自减,玩家看到的是 14 而不是 15
2
TimerSystem.Forever(..., 1000) 每秒 tick
tick 里三重检查:隐藏态 → reset;已离开边界 → reset;否则自减 + 刷 Tip
3
归零 → TriggerCountdownRespawn
IsRespawning 加锁 → 停 Timer → 隐藏 Tip → 解析重生文本 → DoSimpleRespawn
private StartCountdown(): void {
    if (this.IsRespawning || !this.DataComp?.BoundaryCountdownCondition) { return; }
    this.CurrentCountdown = this.DataComp.BoundaryCountdownCondition.CountdownTime;
    this.CachedWarningText = this.ResolveText(TidWarningText, EdTidWarningText);

    // 立即显示初始秒数,避免第一次tick先自减导致首帧少显示1秒
    const initTipText = `${this.CachedWarningText} ${this.CurrentCountdown}秒`;
    ControllerHolder.BoundaryRespawnTipsController.Show(initTipText);

    this.StopCountdown();                                   // 防重复启动
    this.CountdownTimer = TimerSystem.Forever(() => {
        this.OnCountdownTick();
    }, 1000);
}
「文本在 Start 时解析一次并缓存」 CachedWarningText 在 StartCountdown 时解析,tick 里只做字符串拼接。 每秒都去查多语言表是纯浪费——多语言查询涉及 FlatBuffers 反序列化。

4. 核心难点二:多语言文本的三层兜底

/** 取多语言文本:有翻译用翻译,无翻译用 EdTid 编辑器里策划填的中文兜底;
 *  都为空则返回空串由上层兜底 */
private ResolveText(tid: string | undefined, edTid: string | undefined): string {
    if (tid) {
        const text = this.SanitizeText(configMultiTextLang.GetLocalTextNew(tid) || '');
        if (text) { return text; }
    }
    return edTid || '';
}

/** 净化取词结果:剥掉测试地图导出的 test/ 前缀,
 *  并把"查不到"占位串(text not found|...)视为空,交由上层兜底 */
private SanitizeText(raw: string): string {
    if (!raw || raw.startsWith('text not found')) { return ''; }
    return raw.startsWith('test/') ? raw.slice('test/'.length) : raw;
}
层来源失效场景
① 多语言表GetLocalTextNew(tid)翻译还没录入 → 返回 text not found|xxx 占位串
② 编辑器文本EdTidRespawnText策划没填 / 老存档存的空串
③ 硬编码兜底FallbackRespawnText = '不要离开任务区域'永不失效
边界重生提示显示 test/请不要离开任务区域
现象
QA 截图里提示文本前面挂着一个 test/ 前缀
根因
测试地图导出多语言表时,工具会给 key 加 test/ 命名空间前缀,翻译内容也带了出来。这是导表工具的行为,不是代码 bug,但玩家看到就是 bug
修法
客户端 SanitizeText 主动剥前缀。同时把「查不到」占位串 text not found 也识别为空,走下一层兜底
教训
不要指望上游数据是干净的。文本这类直接进玩家眼睛的数据,客户端做一层 sanitize 成本极低,收益是「脏数据不会变成线上事故」。反过来说,把这类清洗做成静默的也有代价——真正的配置错误被掩盖了,所以清洗层要打 Debug 日志
EdTid 运行时拿不到(兜底成了死代码)
现象
多语言没录入时,玩家看到的是空白,第二层兜底完全没生效
根因
EdTidXxx 是编辑器专用字段,正式导出流程里被剥掉了,运行时读到的永远是 undefined
修法
加第三层硬编码 FallbackRespawnText。第二层保留(编辑器内预览还有用),但不能依赖它

5. 核心难点三:双层黑屏编排

最初版本的 bug:文本被黑屏劈成两段 最早的实现是「弹文字 → 立刻调坠落重生」。坠落重生自己会开黑屏做位移, 结果玩家看到的是:文字闪一下 → 黑屏 → 文字又出现。因为文字的自动关闭计时跨越了整段黑屏。

5.1 倒计时重生(DoSimpleRespawn):等重生完成再弹字

/** 倒计时重生:调坠落重生系统(黑屏→传送→取消黑屏),重生完成后再弹一次 Tip。
 *  Tip 必须等重生完成(黑屏已撤)才显示,否则会被中途黑屏劈成"触发前 + 重生后"两段。 */
private DoSimpleRespawn(respawnText: string): void {
    this.CleanupRebornCallback();

    this.OnRebornCompletedCallback = (): void => {
        this.CleanupRebornCallback();       // 一次性回调:自摘
        this.IsRespawning = false;
        this.IsInBoundary = false;
        // 重生完成、黑屏已撤,此时才弹一次文本,避免被黑屏劈成两段
        if (respawnText) {
            ControllerHolder.BoundaryRespawnFullScreenController.ShowOnce(respawnText);
        }
    };
    EventSystem.Add(EEventName.FallingRebornCompleted, this.OnRebornCompletedCallback);

    ControllerHolder.FallingRebornController.TriggerFallingReborn(
        baseCharacter, BoundaryRespawnTipsConstants.FallingRebornRuleId);
}

5.2 坠落区域(DoFallZoneRespawn):五步黑屏接力

坠落区域必须先让玩家看清文字再传送(因为他不知道自己为什么被送走), 所以要在主黑屏之前先垫一层低层黑屏。方法头注释直接写了完整编排:

1
开低层黑屏(HUD 层)+ 弹 Tip 文字(Float 层)
Float 渲染在低层黑屏之上,所以是「黑底白字」
2
等 Tip prefab 异步加载完成(finishCallback)
才开始 PreTeleportBlackScreenMs = 2500 延时。否则 Tip 还没渲染主黑屏就盖上了
3
延时结束 → 调坠落重生,主黑屏(CG 层)盖住一切,做位移
CG 层在 Float 之上,会把 Tip 和低层黑屏都盖住
4
主黑屏淡出后低层黑屏仍在
两段黑屏中间画面不会断——这是整个编排的关键
5
FallingRebornCompleted → 关低层黑屏
Tip 走自己的 FullScreenAutoCloseMs = 3000 自动关闭计时

5.3 UI 层级关系(必须记住)

层谁在用相对关系
CG主黑屏(FallingRebornController)最上层,盖住一切
Float全屏 Tip 文字在 HUD 之上,CG 之下
HUD低层黑屏(LowLayerBlackScreenController)最下层
常量注释里已经写清了这个约束
/** 触发位移前的黑屏+文字预览时长(毫秒)。
 *  注意:Tip 是 Float 层,FallingRebornController 的主黑屏挂在 CG 层会盖住 Float,
 *  所以玩家只能在"主黑屏起来前"的这段窗口里看到文字,窗口要足够长。 */
public static readonly PreTeleportBlackScreenMs = 2500;
2500ms 是逆推出来的:中文提示语约 10 字,正常阅读速度 5 字/秒 → 2 秒, 加 0.5 秒余量。不是拍脑袋。

6. 清理路径:三个资源必须成对释放

protected override OnEnd(): boolean {
    this.StopCountdown();                          // ① 倒计时 Timer
    ControllerHolder.BoundaryRespawnTipsController.Hide();
    this.ClearPreTeleportTimer();                  // ② 预览延时 Timer
    this.CloseLowLayerBlackScreenIfNeeded();       // ③ 低层黑屏(最危险)
    this.CleanupRebornCallback();                  // ④ 全局事件回调
    this.UnregisterRangeEvents();                  // ⑤ 所有范围盒事件 + Pending + EntityInitialized
    this.UnregisterStateChangeEvent();
    return true;
}
③ 低层黑屏漏关 = 玩家永久黑屏 低层黑屏是全局单例控制器,组件销毁不会自动关它。 如果玩家在坠落区域重生过程中被传送到别的关卡(组件 OnEnd), 低层黑屏就永远留在屏幕上,玩家只能重启客户端。
所以用了 IsLowLayerBlackScreenOpen 布尔跟踪自己有没有开过, CloseLowLayerBlackScreenIfNeeded 幂等关闭。
这与 结算 VisibilityLock 残留 是同一类问题: 向全局单例申请了资源,就必须在自己所有退出路径上释放。

6.1 UnregisterRangeEvents 的完整性

private UnregisterRangeEvents(): void {
    if (this.IsWaitingEntityInitialized) {              // 全局监听
        this.IsWaitingEntityInitialized = false;
        EventSystem.TryRemove(EEventName.EntityInitialized, this.HandleEntityInitialized);
    }
    this.PendingBoundaryRangeIds.clear();               // 待办集合
    this.PendingFallZoneIds.clear();

    for (const entityHandle of this.BoundaryRangeEntityHandles) {   // 已注册的 handle
        EventSystem.TryRemoveWithTarget(entityHandle, OnEntityEnterClientRange, ...);
        EventSystem.TryRemoveWithTarget(entityHandle, OnEntityLeaveClientRange, ...);
    }
    this.BoundaryRangeEntityHandles.clear();
    // FallZone 同理 ...
}

注意为什么要自己维护 BoundaryRangeEntityHandles 这个 Set: 配置里的是 PrefabDataId,取消注册需要的是 EntityHandle。 如果 OnEnd 时再去 GetEntityHandleByLevelEntityInstanceId 反查, 实体可能已经被销毁 → 拿不到 handle → 监听泄漏。 所以注册时就存下 handle 引用。

7. 隐藏态处理

/** 当前实体是否处于隐藏态。隐藏态时整个重生组件功能关闭 */
private IsHidden(): boolean {
    return this.SceneItemCompInternal?.SceneItemCurrentState === entityHiddenTagName;
}

private readonly HandleOnSceneItemStateChange = (oldTag, newTag): void => {
    if (newTag?.TagNameString === entityHiddenTagName) {
        this.ResetCountdown();          // 立即停倒计时 + 收 Tip
        this.IsInBoundary = false;
    }
};

IsHidden() 在 5 个地方被检查:4 个 overlap handler + 每次 tick。 为什么不只靠状态变更事件?因为防御式编程: 状态变更事件可能因为注册时机问题漏收,每次 tick 再查一遍代价可忽略。

8. 常见修改场景

8.1 改倒计时时长

不改代码。策划在编辑器里改 CountdownTime。

8.2 加一种新的重生条件(比如「血量低于 X」)

1
接口加字段
ILevelGameplayComponents.ts 的 IBoundaryRespawnComponent 加可选 HpCondition?
2
Scheme 加声明
BoundaryRespawnComponentScheme.ts。注意校验 scheme 与编辑 scheme 必须一致,否则报「非法字段」(同类 bug)
3
组件加监听 + 复用 DoSimpleRespawn
重生执行路径不用重写,只加触发判定
4
编辑器与运行时是两条独立流水线
Scheme 属编辑器侧,组件属运行时侧,各自构建后才都生效

8.3 排查「边界重生不触发」

步骤看什么结论
1grep 重生组件-边界重生组件启动没有 → 组件没挂上 / OnInit 返回 false(拿不到 CreatureData 或 entityData)
2grep 范围实体尚未创建,等待创建后补注册有 → 走了 Pending 路径,看下一步
3grep 范围实体创建完成,补注册成功没有 → 范围实体 PrefabDataId 配错或该实体压根没在这张图
4grep 玩家进入边界范围没有 → 范围盒碰撞体没配 / 没勾客户端范围检测
5grep 实体进入隐藏态有 → 实体被关卡逻辑隐藏了,功能是被有意关闭的

9. 面试怎么讲

范围盒事件注册有什么坑难点
两个,都是时序问题:
① 实体创建顺序不确定。组件启动时引用的范围实体可能还没创建, 事件挂不上。做法是记进 Pending 集合,监听全局实体事件补注册。 关键细节是必须用 EntityInitialized 而不是 EntityCreated—— Created 时范围盒的 OnStart 还没跑,碰撞体和 overlap 委托都不存在,挂上去也收不到事件。 这个 bug 表现是「有时生效有时不生效」,非常难查。
② overlap 是边沿触发不补发。补注册成功时玩家可能已经站在区域里了, overlap begin 早就发过了。所以注册后必须主动查一次当前状态并手动补触发进入。
为什么要两层黑屏设计
因为提示文字和位移黑屏在不同 UI 层,且生命周期不同步。
坠落区域重生要先让玩家看清「为什么被送走」,所以需要 2.5 秒的文字预览窗口。 但位移本身要黑屏遮丑,主黑屏在 CG 层会盖住 Float 层的文字。
解法是:先在 HUD 层开一层低层黑屏当底,Float 层弹文字 → 玩家看 2.5 秒 → CG 层主黑屏盖上做位移 → 主黑屏淡出后低层黑屏还在,画面不会突然亮一下 → 最后关低层黑屏。
额外一个细节:预览延时不是位移触发后开始算,而是等 Tip 的 prefab 异步加载完成回调之后才开始, 否则 prefab 加载慢的机器上文字还没渲染出来主黑屏就盖上了。
多语言文本为什么要三层兜底工程
每层都有真实失效场景:
① 多语言表——翻译没录入时返回的不是空串,是 text not found|xxx 占位串, 直接显示就是事故;另外测试地图导出的文本会带 test/ 命名空间前缀,要剥掉。
② 编辑器文本 EdTid——后来发现它运行时根本拿不到, 正式导出会剥掉编辑器字段。所以这层实际是死代码,但保留着因为编辑器内预览还有用。
③ 硬编码 '不要离开任务区域'——这才是真正的兜底。
更一般的教训:不要假设上游数据干净。直接进玩家眼睛的文本, 客户端做一层 sanitize 成本极低。但清洗层要打日志,否则真配错了也发现不了。
TriggerOnLeave 这个设计的取舍在哪设计
策划有两种摆法:摆「危险区」(进去倒计时)和摆「安全区」(出来倒计时)。 最直接的做法是做两个组件,或者在每个用到区域状态的地方判一次 TriggerOnLeave。
实际采用的是在事件 handler 这一层就把物理事件翻译成逻辑语义: 物理「进入」在 TriggerOnLeave 时调 OnLeaveBoundaryRange。 这样下游的倒计时、Tip、重生逻辑完全不知道这个字段存在。
好处是加新的下游逻辑时不会漏——如果把判断散布出去, 后来的人加一个新分支很容易忘了处理翻转。