边界检查重生
策划在关卡编辑器里摆范围盒,玩家离开任务区域 → 弹倒计时警告 → 时间到强制拉回。
另有一种「坠落区域」:一进就直接重生,不倒计时。
本模块不自己做位移,位移全部委托给 ③ 坠落重生。
它负责的是判定时机、文案、黑屏编排——难点全在这三件事的时序上。
ECS 组件
范围盒 overlap
双层黑屏
多语言脏数据
601 行
1. 模块定位
1.1 两种触发条件,一套组件
边界倒计时 BoundaryCountdownCondition | 坠落区域 FallZoneCondition | |
|---|---|---|
| 语义 | 「你快出界了,N 秒内回来」 | 「这里是虚空,进来就送回去」 |
| 触发 | 进/出范围 → 倒计时 → 归零重生 | 进范围 → 立即重生 |
| UI | 小 Tip 每秒刷新剩余秒数 | 全屏 Tip + 低层黑屏 2.5s 预览 |
| 可取消 | ✅ 离开范围就 ResetCountdown | ❌ 不可取消 |
| 重生规则 Id | FallingRebornRuleId = 1 | FallZoneRebornRuleId = 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
归零 →
TriggerCountdownRespawnIsRespawning 加锁 → 停 Timer → 隐藏 Tip → 解析重生文本 → DoSimpleRespawnprivate 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),
低层黑屏就永远留在屏幕上,玩家只能重启客户端。
所以用了
这与 结算 VisibilityLock 残留 是同一类问题: 向全局单例申请了资源,就必须在自己所有退出路径上释放。
所以用了
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
接口加字段
2
Scheme 加声明
BoundaryRespawnComponentScheme.ts。注意校验 scheme 与编辑 scheme 必须一致,否则报「非法字段」(同类 bug)
3
组件加监听 + 复用 DoSimpleRespawn
重生执行路径不用重写,只加触发判定
4
编辑器与运行时是两条独立流水线
Scheme 属编辑器侧,组件属运行时侧,各自构建后才都生效
8.3 排查「边界重生不触发」
| 步骤 | 看什么 | 结论 |
|---|---|---|
| 1 | grep 重生组件-边界重生组件启动 | 没有 → 组件没挂上 / OnInit 返回 false(拿不到 CreatureData 或 entityData) |
| 2 | grep 范围实体尚未创建,等待创建后补注册 | 有 → 走了 Pending 路径,看下一步 |
| 3 | grep 范围实体创建完成,补注册成功 | 没有 → 范围实体 PrefabDataId 配错或该实体压根没在这张图 |
| 4 | grep 玩家进入边界范围 | 没有 → 范围盒碰撞体没配 / 没勾客户端范围检测 |
| 5 | grep 实体进入隐藏态 | 有 → 实体被关卡逻辑隐藏了,功能是被有意关闭的 |
9. 面试怎么讲
范围盒事件注册有什么坑难点
两个,都是时序问题:
① 实体创建顺序不确定。组件启动时引用的范围实体可能还没创建, 事件挂不上。做法是记进 Pending 集合,监听全局实体事件补注册。 关键细节是必须用
② overlap 是边沿触发不补发。补注册成功时玩家可能已经站在区域里了, overlap begin 早就发过了。所以注册后必须主动查一次当前状态并手动补触发进入。
① 实体创建顺序不确定。组件启动时引用的范围实体可能还没创建, 事件挂不上。做法是记进 Pending 集合,监听全局实体事件补注册。 关键细节是必须用
EntityInitialized 而不是 EntityCreated——
Created 时范围盒的 OnStart 还没跑,碰撞体和 overlap 委托都不存在,挂上去也收不到事件。
这个 bug 表现是「有时生效有时不生效」,非常难查。② overlap 是边沿触发不补发。补注册成功时玩家可能已经站在区域里了, overlap begin 早就发过了。所以注册后必须主动查一次当前状态并手动补触发进入。
为什么要两层黑屏设计
因为提示文字和位移黑屏在不同 UI 层,且生命周期不同步。
坠落区域重生要先让玩家看清「为什么被送走」,所以需要 2.5 秒的文字预览窗口。 但位移本身要黑屏遮丑,主黑屏在 CG 层会盖住 Float 层的文字。
解法是:先在 HUD 层开一层低层黑屏当底,Float 层弹文字 → 玩家看 2.5 秒 → CG 层主黑屏盖上做位移 → 主黑屏淡出后低层黑屏还在,画面不会突然亮一下 → 最后关低层黑屏。
额外一个细节:预览延时不是位移触发后开始算,而是等 Tip 的 prefab 异步加载完成回调之后才开始, 否则 prefab 加载慢的机器上文字还没渲染出来主黑屏就盖上了。
坠落区域重生要先让玩家看清「为什么被送走」,所以需要 2.5 秒的文字预览窗口。 但位移本身要黑屏遮丑,主黑屏在 CG 层会盖住 Float 层的文字。
解法是:先在 HUD 层开一层低层黑屏当底,Float 层弹文字 → 玩家看 2.5 秒 → CG 层主黑屏盖上做位移 → 主黑屏淡出后低层黑屏还在,画面不会突然亮一下 → 最后关低层黑屏。
额外一个细节:预览延时不是位移触发后开始算,而是等 Tip 的 prefab 异步加载完成回调之后才开始, 否则 prefab 加载慢的机器上文字还没渲染出来主黑屏就盖上了。
多语言文本为什么要三层兜底工程
每层都有真实失效场景:
① 多语言表——翻译没录入时返回的不是空串,是
② 编辑器文本
③ 硬编码
更一般的教训:不要假设上游数据干净。直接进玩家眼睛的文本, 客户端做一层 sanitize 成本极低。但清洗层要打日志,否则真配错了也发现不了。
① 多语言表——翻译没录入时返回的不是空串,是
text not found|xxx 占位串,
直接显示就是事故;另外测试地图导出的文本会带 test/ 命名空间前缀,要剥掉。② 编辑器文本
EdTid——后来发现它运行时根本拿不到,
正式导出会剥掉编辑器字段。所以这层实际是死代码,但保留着因为编辑器内预览还有用。③ 硬编码
'不要离开任务区域'——这才是真正的兜底。更一般的教训:不要假设上游数据干净。直接进玩家眼睛的文本, 客户端做一层 sanitize 成本极低。但清洗层要打日志,否则真配错了也发现不了。
TriggerOnLeave 这个设计的取舍在哪设计
策划有两种摆法:摆「危险区」(进去倒计时)和摆「安全区」(出来倒计时)。
最直接的做法是做两个组件,或者在每个用到区域状态的地方判一次
实际采用的是在事件 handler 这一层就把物理事件翻译成逻辑语义: 物理「进入」在
好处是加新的下游逻辑时不会漏——如果把判断散布出去, 后来的人加一个新分支很容易忘了处理翻转。
TriggerOnLeave。实际采用的是在事件 handler 这一层就把物理事件翻译成逻辑语义: 物理「进入」在
TriggerOnLeave 时调 OnLeaveBoundaryRange。
这样下游的倒计时、Tip、重生逻辑完全不知道这个字段存在。好处是加新的下游逻辑时不会漏——如果把判断散布出去, 后来的人加一个新分支很容易忘了处理翻转。