首页 / 客户端基础能力 / 坠落重生

坠落重生

玩家掉出世界(穿地、跳崖、地形没加载出来)时的兜底救援系统。 设计核心是「持续记录一个可信的安全点,出事时传回去」。 难点全在什么算「可信」——记错了点,救援反而把玩家送进另一个坑。

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;
}
常量值为什么是这个值
UpdateRebornTimeInterval30s慢速刷新重生点。玩家正常跑图时不需要频繁更新,30s 够了
BlackScreenTime0.5s黑屏遮丑时长。太短遮不住位移,太长玩家觉得卡
SafeDepth2000重生点向下探 20 米。超过这个深度还没地面,认为这个点悬空不可用
FallingRebornZCheckIntervalMs500Z 轴保底检测每 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 射线:与传送模块同源的思路 传送的贴地预检用的是同一套逻辑,参数也几乎一样 (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.FallingRebornLocation
2
服务器个人复活点覆盖 · 全零要拒绝
仅当 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. 边界情况处理

场景处理在哪
切关卡重置重生点 + 重设 FallingRebornZMinHandleOnEnterLevel
断线重连重新初始化检测定时器与重生点HandleOnReconnectCompleted
场景加载中跳过 Z 轴检测(防无限重生循环)CheckPlayerUnderZMin
重生进行中4 处前置判断全部 returnIsInRebornProcess
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 米确认地形真的存在 (因为记录点也可能因为地图改版失效)。
③ 最后兜底:还是不安全就传到关卡表配的出生点,并且要加上胶囊体半高, 因为配的是脚底坐标。
讲一个这个模块里印象深的坑难点
两个:
① 无限重生循环。场景加载时地形没流送出来,角色本来就在无限坠落, 如果不判断 IsLoadingAnyScene 就触发重生,会传到重生点→重生点也没地形→继续坠落→再触发, 玩家永远进不了游戏。这个判断看起来只有一行,却是灾难性 bug 的唯一防线。
② 服务器全零坐标。服务器没有玩家个人复活点记录时下发 {0,0,0}, 客户端 !== undefined 判断通过就用了,把玩家传到世界原点。 根因是 proto3 无法区分「零值」和「未设置」。 教训是接收坐标/Id 这类字段必须做语义校验,不能只判 undefined。这个坑在项目里反复出现。
为什么位移完成就解锁,不等黑屏结束细节
因为锁的语义是「防止重生流程重入」,而位移一完成流程就结束了,黑屏只是收尾动画。
更关键的是这把锁还有个副作用:IsInRebornProcess 为 true 期间, 每 500ms 一次的 Z 轴检测会整个跳过。 所以推迟解锁 = 多出 0.5 秒的检测盲区—— 万一这次的落点本身也悬空,玩家在黑屏里继续下坠, 要等下一次检测才会发起第二次救援,表现是「黑屏结束后又掉了一下」。
更一般的原则:互斥锁的持有期应该等于临界区,不要顺手覆盖到收尾动画。