坠落重生
玩家掉出世界(穿地、跳崖、地形没加载出来)时的兜底救援系统。 设计核心是「持续记录一个可信的安全点,出事时传回去」。 难点全在什么算「可信」——记错了点,救援反而把玩家送进另一个坑。
ControllerBase
协议 WorldFallingRebornCSReq
3×3 射线检测
双层安全校验
609 行
1. 模块定位
1.1 两条触发路径
| 路径 | 触发条件 | 入口 |
|---|---|---|
| Z 轴保底检测 | 玩家 Z 坐标低于关卡允许的最小 Z 值 | CheckPlayerUnderZMin 定时器 |
| 主动调用 | 关卡逻辑 / 边界重生 / 其它系统调 | FallingRebornAction(entityHandle, configId) |
1.2 常量表
class FallingRebornControllerConstants {
/** 更新坠落重生点的时间间隔 */
public static readonly UpdateRebornTimeInterval = 30 * TimeUtil.InverseMillisecond;
/** 坠落重生黑屏时间 */
public static readonly BlackScreenTime = 0.5 * TimeUtil.InverseMillisecond;
/** 坠落重生安全检查深度 */
public static readonly SafeDepth = 2000;
/** 玩家坠落保底Z坐标检查的CD时间(毫秒) */
public static readonly FallingRebornZCheckIntervalMs = 500;
/** 玩家坠落保底默认Z值 */
public static readonly DefaultLevelPositionMinZ = -10000;
}
| 常量 | 值 | 为什么是这个值 |
|---|---|---|
| UpdateRebornTimeInterval | 30s | 慢速刷新重生点。玩家正常跑图时不需要频繁更新,30s 够了 |
| BlackScreenTime | 0.5s | 黑屏遮丑时长。太短遮不住位移,太长玩家觉得卡 |
| SafeDepth | 2000 | 重生点向下探 20 米。超过这个深度还没地面,认为这个点悬空不可用 |
| FallingRebornZCheckIntervalMs | 500 | Z 轴保底检测每 0.5s 一次。够快(掉出世界 0.5s 内会被抓到)又不费性能 |
| DefaultLevelPositionMinZ | -10000 | 关卡没配 minZ 时的兜底值。-100 米,正常地图不会到这么低 |
2. 核心机制:怎么记录一个「可信的」重生点
这是本模块的设计精华
不是「玩家在哪就记哪」。必须满足三个条件才更新重生点:
① 距上次记录点移动够远(省性能);② 脚下 3×3 全部命中静态地形;③ 不在重生流程中。
2.1 UpdateFallingReborn 的三道闸
private UpdateFallingReborn(): boolean {
if (this.IsInRebornProcess) { return false; } // 闸①:重生中不记
const playerEntity = Global.PlayerEntity;
if (!playerEntity) { return false; }
const characterActorComp = playerEntity.GetComponent(EComponent.CharacterActorComponent);
if (!characterActorComp) { return false; }
const centerLocation = characterActorComp.ActorLocationProxy;
// 闸②:距离变化太小,为节省性能,不更新坐标
const distSquared = centerLocation
.Subtraction(this.FallingRebornLocation, this.TempVector).SizeSquared();
const minUpdateDist = 352;
if (distSquared < minUpdateDist * minUpdateDist) { return true; }
// 闸③:在规格为[-n, n]的方形中进行地形检查,只有可站立的静态地形,才更新坠落重生点
const horizontalDist = 132;
const verticalDist = 176;
const n = 1; // n=1 → i,j ∈ {-1,0,1} → 3x3 = 9 条射线
for (let i = -n; i <= n; i++) {
for (let j = -n; j <= n; j++) {
offset.Set(horizontalDist * i, horizontalDist * j, 0);
// start = center + offset ;end = start - (0,0,176)
if (!TraceElementCommon.LineTrace(this.TraceElement, '[坠落重生]')) {
// 没有命中物体,说明角色下方处于浮空,不更新坠落重生点
return false;
}
}
}
// 通过所有检查,更新坠落重生点
this.FallingRebornLocation.DeepCopy(characterActorComp.ActorLocation);
this.FallingRebornRotation.Yaw = characterActorComp.ActorRotation.Yaw;
this.RefreshFallingRebornLookAtLocation();
return true;
}
3×3 射线:与传送模块同源的思路
传送的贴地预检用的是同一套逻辑,参数也几乎一样
(
132 这个数字约等于角色胶囊体直径,保证 3×3 覆盖玩家实际占地面积。 单条中心射线会在栏杆、狭窄桥面、平台边缘假阳性。
horizontalDist=132)。区别是垂直距离:这里 176(只要脚下有地就行),
传送那边 200(落点可能略高于地面)。132 这个数字约等于角色胶囊体直径,保证 3×3 覆盖玩家实际占地面积。 单条中心射线会在栏杆、狭窄桥面、平台边缘假阳性。
2.2 双速刷新
| 模式 | 触发 | 用途 |
|---|---|---|
SlowUpdateFallingReborn | 每 30s | 常态。玩家正常跑图 |
QuickUpdateFallingReborn | 进入 BP_UpdateFallingRebornArea_C 范围盒 | 关卡设计师标记的「这里要密集记点」区域,比如跳跃关卡 |
范围盒用栈管理(PushUpdateRebornArea / PopUpdateRebornArea),
支持嵌套。栈顶决定当前行为。
2.3 LookAtLocation:重生后看向哪
/**
* 根据栈顶 BP_UpdateFallingRebornArea_C 的 ShouldLookAtLocation 决定是否记录 LookAtLocation 的位置。
* 为 true 时,记录其 LookAtLocation 组件的世界坐标;否则清空记录。
*/
private RefreshFallingRebornLookAtLocation(): void {
const top = this.UpdateRebornAreaStack[this.UpdateRebornAreaStack.length - 1];
if (!top || !top.ShouldLookAtLocation || !top.LookAtLocation) {
this.FallingRebornLookAtLocation = undefined;
return;
}
this.FallingRebornLookAtLocationInternal
.FromUeVector(top.LookAtLocation.K2_GetComponentLocation());
this.FallingRebornLookAtLocation = this.FallingRebornLookAtLocationInternal;
}
用途:跳跃关卡里玩家掉下去后,重生时应该面朝前进方向,
而不是面朝掉下去时的乱七八糟朝向。策划在范围盒上摆一个 LookAtLocation 组件指方向。
3. Z 轴保底检测
private readonly CheckPlayerUnderZMin = (): void => {
// 重生过程中无需检测
if (this.IsInRebornProcess) { return; }
// 加载过程中,由于地形未生成,角色会无限坠落,此时不要触发坠落重生
if (ModelManager.GameModeModel?.IsLoadingAnyScene) { return; }
const playerEntity = Global.PlayerEntity;
if (!playerEntity) { return; }
const characterActorComp = playerEntity.GetComponent(EComponent.CharacterActorComponent);
if (!characterActorComp) { return; }
if (characterActorComp.ActorLocationProxy.Z >= this.FallingRebornZMin) { return; }
Log.Info(ELogModule.Level, ELogAuthor.TJH,
'[坠落重生] 玩家Z坐标低于关卡允许的最小Z值,触发坠落重生',
['当前Z', characterActorComp.ActorLocationProxy.Z],
['最小Z', this.FallingRebornZMin]);
this.FallingRebornAction(playerEntity, 0);
};
第二个前置判断(
这也解释了为什么 传送模块 要做贴地预检—— 两个模块在同一个问题的两端:传送负责「别把人放到没地的地方」, 坠落重生负责「真掉下去了捞回来」。
IsLoadingAnyScene)是血泪教训
这个判断不加会出灾难性 bug:
场景加载时地形还没流送出来,角色本来就在无限坠落中,
此时触发坠落重生 → 传到重生点 → 重生点地形也没加载 → 继续坠落 → 再次触发……
无限重生循环,玩家永远进不了游戏。这也解释了为什么 传送模块 要做贴地预检—— 两个模块在同一个问题的两端:传送负责「别把人放到没地的地方」, 坠落重生负责「真掉下去了捞回来」。
4. 三步执行流程
Step 1 · FallingRebornAction — 加锁 + 请求服务器
public FallingRebornAction(entityHandle: EntityHandle, fallingRebornConfigId: number): void {
// 重生过程中不能再次重生
if (this.IsInRebornProcess) { return; }
this.IsInRebornProcess = true; // 重入锁
this.CurrentRebornEntityHandle = entityHandle;
const message = new Nami.Protocol.WorldFallingRebornCSReq();
message.ruleId = fallingRebornConfigId;
Net.Call(ERequestMessageId.WorldFallingRebornCSReq, message, (response) => {
void this.FallingRebornAction_Step2(response);
});
}
IsInRebornProcess 是全模块最重要的一个标志位,
被 4 处前置判断(函数开头「条件不满足就直接 return」的挡门语句)引用。
Step 2 · 确定重生点(最复杂的一步)
1
默认用客户端记录的点
rebornLocation = this.FallingRebornLocation2
服务器个人复活点覆盖 · 全零要拒绝
仅当
RebornLocationRule === 1 且 position 存在时才考虑覆盖,
并且必须校验非全零
3
LookAtLocation 覆盖 Yaw
只覆盖 Yaw,保留 Pitch/Roll
4
禁输入 + 黑屏淡入(await)
await AddBlackScreenAsync(Start, '[坠落重生]')5
流送地形
BuildSceneTeleportCommand原始位置不安全,先认为地形没流送,主动流送
6
流送完成后二次校验
CalculateSafeRebornLocation向下探
SafeDepth=2000。安全 → Step3;不安全 → 下一帧流送到关卡出生点再 Step3全零坐标防护(真实 bug 的修复)
if (fallingRebornConfig?.RebornLocationRule === 1 && response.position !== undefined) {
const position = response.position;
// 服务器没有记录个人复活点时会下发全零坐标,此时不能采用,否则会把玩家传到世界原点
if (position.x !== 0 || position.y !== 0 || position.z !== 0) {
this.TempOverrideRebornLocation.Set(position.x, position.y, position.z);
rebornLocation = this.TempOverrideRebornLocation;
this.TempOverrideRebornRotation.Set(0, response.yaw ?? 0, 0);
rebornRotation = this.TempOverrideRebornRotation;
} else {
Log.Warn(ELogModule.Level, ELogAuthor.ZZB,
'[坠落重生] 服务器个人复活点为全零坐标,回退使用客户端记录的坠落重生点');
}
}
坠落重生把玩家传到世界原点
现象
部分玩家坠落重生后出现在地图 (0,0,0),通常是虚空
根因
服务器没有该玩家的个人复活点记录时,下发的是
position = {0,0,0} 而不是不下发字段。客户端 position !== undefined 判断通过,直接采用了全零修法
显式校验 xyz 非全零,全零时回退客户端记录点并打 Warn
教训
protobuf 的「零值」与「未设置」不可区分(proto3 默认省略零值字段)。凡是接收坐标/Id/数量这类字段,
!== undefined 不够,必须做语义校验。这个坑在整个项目里反复出现二次安全校验 CalculateSafeRebornLocation
public CalculateSafeRebornLocation(
originLocation: Readonly<Vector>, zOffsetStart: number, zOffsetEnd: number,
): [Readonly<Vector>, boolean] {
// 从重生点向下发打射线,如果下方悬空,把重生点更新到安全位置
if (Global.BaseCharacter && this.TraceElement) {
// start = origin + zOffsetStart ;end = origin + zOffsetEnd(-2000)
if (!TraceElementCommon.LineTrace(this.TraceElement, '[坠落重生]')) {
// 没有命中物体,下方悬空,把重生点更新到安全位置(g.关卡.xlsx LevelBronPosition)
const levelConfig = configLevelConfigById.GetConfig(ModelManager.GameModeModel!.LevelId);
if (levelConfig) {
const safeLocation = Vector.Create(
levelConfig.LevelBornPosition.X,
levelConfig.LevelBornPosition.Y,
levelConfig.LevelBornPosition.Z
+ Global.BaseCharacter.CapsuleComponent!.GetScaledCapsuleHalfHeight(),
);
return [safeLocation, false]; // false = 原点不安全
}
}
}
return [originLocation, true]; // true = 原点安全
}
| 细节 | 说明 |
|---|---|
返回元组 [Vector, boolean] | 同时给出「用哪个点」和「原点是否安全」,调用方按 bool 决定日志措辞 |
回退到 LevelBornPosition | 关卡表 g.关卡.xlsx 配的出生点。这是最后的安全网 |
加 CapsuleHalfHeight | 出生点配的是脚底坐标,Actor 位置在胶囊体中心,不加会半截入地 |
为什么不安全时要 TimerSystem.Next 延后一帧
第二次
BuildSceneTeleportCommand 不能在第一次的回调里同帧发起——
流送系统的状态机还没退出上一条命令,同帧再发会被吞掉或者产生竞态。
延后一帧是最省事的隔离手段。
Step 3 · 执行位移 + 收尾
private FallingRebornAction_Step3(location: Readonly<Vector>, rotation: Readonly<Rotator>): void {
const moveSyncComp = this.CurrentRebornEntityHandle?.GetComponent(EComponent.BaseMoveSyncComponent);
moveSyncComp?.SafeMoveTeleport(location, rotation, '[坠落重生]');
// 重置相机
ControllerHolder.CameraController.FightCamera?.LogicComponent?.ResetInitialCameraRotation();
this.BlackScreenTimerId = TimerSystem.Delay(() => {
this.BlackScreenTimerId = undefined;
void this.EndFallingReborn();
}, FallingRebornControllerConstants.BlackScreenTime); // 0.5s
this.IsInRebornProcess = false; // 解锁
}
注意 IsInRebornProcess 在这里就解锁了,不等黑屏结束
SafeMoveTeleport 一返回,位移就已经完成,重生流程实质上结束了;
黑屏只是还要再淡出 0.5 秒。这里提前解锁是有意的:IsInRebornProcess 为 true 时,
每 500ms 跑一次的 Z 轴检测(CheckPlayerUnderZMin)会直接 return 不做任何事。
所以如果把解锁推迟到黑屏淡出之后,就会出现一个 0.5 秒的检测盲区——
万一这次救援的落点本身也悬空(地图改版、地形没流送出来),
玩家在黑屏期间继续往下掉,这一轮检测被锁挡住,
要再等 0.5 秒之后的下一次检测才会发起第二次救援,
玩家能明显感到「黑屏结束后又掉了一下」。
5. 边界情况处理
| 场景 | 处理 | 在哪 |
|---|---|---|
| 切关卡 | 重置重生点 + 重设 FallingRebornZMin | HandleOnEnterLevel |
| 断线重连 | 重新初始化检测定时器与重生点 | HandleOnReconnectCompleted |
| 场景加载中 | 跳过 Z 轴检测(防无限重生循环) | CheckPlayerUnderZMin |
| 重生进行中 | 4 处前置判断全部 return | IsInRebornProcess |
| Debug 可视化 | IsDebugShowFallingReborn 开启后射线画出来 | SetDrawDebugTrace |
6. 性能优化点
| 手段 | 效果 |
|---|---|
TempVector / TempVector2 / TempVector3 复用 | 每帧不 new Vector。puerts 下 UE 对象的创建开销很高,这是全项目通用写法 |
| 距离阈值 352(平方比较) | 玩家原地小幅移动时跳过整个 9 条射线检测 |
SizeSquared() 而不是 Size() | 省一次开方 |
| 双速刷新(30s / 快速) | 常态低频,危险区域才高频 |
| Z 检测 500ms 一次 | 不放 Tick 里 |
7. 面试怎么讲
坠落重生怎么保证不会把玩家救到另一个坑里设计
做了三层校验:
① 记录时校验:只有在脚下 3×3 共 9 条射线全部命中静态地形时才更新重生点。 单条中心射线在栏杆、狭窄桥面上会假阳性,3×3 覆盖胶囊体直径能挡住。
② 使用时校验:流送完成后再向下探 20 米确认地形真的存在 (因为记录点也可能因为地图改版失效)。
③ 最后兜底:还是不安全就传到关卡表配的出生点,并且要加上胶囊体半高, 因为配的是脚底坐标。
① 记录时校验:只有在脚下 3×3 共 9 条射线全部命中静态地形时才更新重生点。 单条中心射线在栏杆、狭窄桥面上会假阳性,3×3 覆盖胶囊体直径能挡住。
② 使用时校验:流送完成后再向下探 20 米确认地形真的存在 (因为记录点也可能因为地图改版失效)。
③ 最后兜底:还是不安全就传到关卡表配的出生点,并且要加上胶囊体半高, 因为配的是脚底坐标。
讲一个这个模块里印象深的坑难点
两个:
① 无限重生循环。场景加载时地形没流送出来,角色本来就在无限坠落, 如果不判断
② 服务器全零坐标。服务器没有玩家个人复活点记录时下发
① 无限重生循环。场景加载时地形没流送出来,角色本来就在无限坠落, 如果不判断
IsLoadingAnyScene 就触发重生,会传到重生点→重生点也没地形→继续坠落→再触发,
玩家永远进不了游戏。这个判断看起来只有一行,却是灾难性 bug 的唯一防线。② 服务器全零坐标。服务器没有玩家个人复活点记录时下发
{0,0,0},
客户端 !== undefined 判断通过就用了,把玩家传到世界原点。
根因是 proto3 无法区分「零值」和「未设置」。
教训是接收坐标/Id 这类字段必须做语义校验,不能只判 undefined。这个坑在项目里反复出现。
为什么位移完成就解锁,不等黑屏结束细节
因为锁的语义是「防止重生流程重入」,而位移一完成流程就结束了,黑屏只是收尾动画。
更关键的是这把锁还有个副作用:
更一般的原则:互斥锁的持有期应该等于临界区,不要顺手覆盖到收尾动画。
更关键的是这把锁还有个副作用:
IsInRebornProcess 为 true 期间,
每 500ms 一次的 Z 轴检测会整个跳过。
所以推迟解锁 = 多出 0.5 秒的检测盲区——
万一这次的落点本身也悬空,玩家在黑屏里继续下坠,
要等下一次检测才会发起第二次救援,表现是「黑屏结束后又掉了一下」。更一般的原则:互斥锁的持有期应该等于临界区,不要顺手覆盖到收尾动画。