首页 / 入职工作清单 / 副本结算

副本结算

0 到 1:通关后黑屏 → 流送结算点位 → MVP 特写 → 全家福合影 → 结算 UI。 后续迭代成三阶段相机流程 + 按队伍站位动态计算相机距离(1 人到 4 人自动构图)。 这是「表现层做到极致」的模块——没有网络同步难题,难的是时序与穿帮。

LevelSequence 三阶段 FOV 包围盒构图 1229 行 Controller 穿帮帧防御

1. 工作清单

内容性质
副本结算 0 到 1:结算流程搭建、MVP 演出、结算 UI、数据展示从 0 建
三阶段相机升级:黑屏备场 → Seq2 MVP 特写 → Seq3 全家福;相机距离按站位动态计算表现升级

2. 理论:三阶段相机编排

1
阶段一 · 黑屏备场
黑屏内完成结算点 WP 流送 + 队伍站位 + MVP 换装。玩家看到的第一帧就是成品。
2
阶段二 · Seq2 MVP 特写
LevelSequence 播 MVP 角色特写动画,播完销毁 3D proxy。此时结算 UI 还没开。
3
阶段三 · Seq3 全家福 + UI
全员站位合影,相机距离按包围盒动态算,同屏弹结算 UI。
「先流送后开 UI」不是随手写的顺序 反过来(先 OpenView 再等流送)会先弹出带跳过按钮的空 UI,玩家莫名干等几秒。 异步工作的排序原则:让玩家看到的东西和按钮同时就绪,黑屏是最好的遮挡施工围挡。

3. 理论:相机距离的 FOV 数学

全家福要「恰好装下所有人」。固定距离在 1 人时太空、4 人时贴脸,所以按站位实时算:

步骤公式 / 做法
取包围盒GetActorArrayBounds 得到原点和半尺寸(extent)
水平半宽extent 投影到 rightDir + 固定身位余量(余量是固定值,不随人数放大)
两半 FOV垂直半 FOV = atan(tan(水平半FOV) / 宽高比)(UE 的 FOV 是水平的)
取距离max(halfWidth/tan(水平半FOV), halfHeight/tan(垂直半FOV)),再 Clamp 到 [120, 2500]

4. 代码片段

4.1 相机调参常量组:每个数字都有注释和来历

Src\Game\Module\Dungeon\LevelSettlement\DungeonSettlementController.ts:49-83

class DungeonSettlementControllerConstants {
    /** 角色体型半径兜底值(CharacterBodyRadius 配置缺省时使用) */
    public static readonly DefaultBodyRadius = 50;
    /** 站位点 Z 轴抬高偏移,避免角色入地 */
    public static readonly SettlementZOffset = 10;
    /** 相机距离计算失败时的兜底值 */
    public static readonly CameraFallbackDistance = 900;
    /** 相机视野角度(FOV,度) */
    public static readonly CameraFov = 60;
    /** 相机距离最小值(防止贴脸) */
    public static readonly CameraMinDistance = 120;
    /** 相机距离最大值(防止过远) */
    public static readonly CameraMaxDistance = 2500
    /** 全家福相机注视点高度(角色身体中部,cm),相机同高实现平视正面合照 */
    public static readonly Seq3LookAtHeight = 100;
    /** 全家福角色正脸朝向修正 Yaw:由实测反推——偏移0时角色偏出相机+90°,故-90可正对相机 */
    public static readonly Seq3FaceYawOffset = -90;
}
把「手感」写成本可读的数字 贴脸、入地、脸偏 90 度——美术验收提的所有问题都被参数化成具名常量。 尤其 Seq3FaceYawOffset = -90 注释里写明「由实测反推」: 面试讲这个数字的来历,比讲十行公式更能证明你真的调过。

4.2 相机距离动态计算(核心算法)

Src\Game\Module\Dungeon\LevelSettlement\DungeonSettlementController.ts:685-724

const originRef = $ref<UE.Vector>(new UE.Vector(0, 0, 0));
const extentRef = $ref<UE.Vector>(new UE.Vector(0, 0, 0));
UE.GameplayStatics.GetActorArrayBounds(actorArray, false, originRef, extentRef);
const origin = $unref(originRef);
const extent = $unref(extentRef);

// 包围盒在水平轴(rightDir)上的投影半宽;左右各留一个身位的余量——
// 余量是固定值,不会随人数增多、总宽度变大而被同步放大
const halfWidth = Math.abs(extent.X * rightDir.X) + Math.abs(extent.Y * rightDir.Y) + horizontalMargin;
const halfHeight = extent.Z;

const halfHorizontalFovRad = MathUtils.DegToRad * (Constants.CameraFov * 0.5);
const halfVerticalFovRad = Math.atan(
    Math.tan(halfHorizontalFovRad) / Constants.CameraAspectRatio,
);
const distanceByWidth = halfWidth / Math.tan(halfHorizontalFovRad);
const distanceByHeight = halfHeight / Math.tan(halfVerticalFovRad);
const distance = MathUtils.Clamp(
    Math.max(distanceByWidth, distanceByHeight),
    Constants.CameraMinDistance,
    Constants.CameraMaxDistance,
);
两个细节面试可以主动讲 ① UE 相机 FOV 是水平的,垂直半 FOV 要用宽高比换算(atan(tan(h)/aspect))—— 只算水平距离会切头。② 余量是固定身位而非比例:按比例放大距离, 4 人队 2 米宽站位会把 20cm 余量放大成半米,画面边缘留白难看。

4.3 Play+Pause 定格首帧 + 换装后才关黑屏

Src\Game\Module\Dungeon\LevelSettlement\DungeonSettlementController.ts:386-428(节选)

if (player) {
    if (!this.Gm) {
        // PlaybackSettings 是结构体,必须读-改-写回,直接改字段不会生效
        const playbackSettings = player.PlaybackSettings;
        playbackSettings.bPauseAtEnd = true;
        player.PlaybackSettings = playbackSettings;
    }
    player.OnFinished.Add(() => {
        // Seq2 结束 = 阶段2 结束,进入阶段3;销毁 3D proxy 避免 UI 阶段穿帮
        this.ClearProxyActors();
    });
}

// 换装完成(或超时/GM 跳过)后才算准备好,避免关黑屏后出现"先默认皮、后换装"的穿帮帧
this.ApplyMvpAvatarToSeq2(this.LevelSequenceActor2, () => {
    if (!this.LevelSequenceActor2 || !ObjectUtils.IsValid(this.LevelSequenceActor2)) {
        finishOnce();   // 换装/超时兜底可能在结算已结束后才触发,此时 Seq2 已销毁
        return;
    }
    // Play+Pause 让序列绑定生效并定格首帧,相机同步就位,画面在黑屏下备好
    this.LevelSequencePlayer2?.Play();
    this.LevelSequencePlayer2?.Pause();
    const cameraActor = this.GetBindingPositionByTag('DungeonSettlementCamera2');
    if (cameraActor) {
        CameraManager.SwitchCamera(cameraActor, ECameraMode.Level, '副本结算-切换相机2');
    }
    this.Seq2Ready = true;
});
三处引擎/时序坑 ① PlaybackSettings 结构体读-改-写回:puerts 里直接改字段不生效,值类型语义; ② Play+Pause:让序列的轨道绑定与相机在黑屏下就位定格,玩家见到的第一帧就是对的; ③ 换装回调可能晚于结算结束(玩家秒退),finishOnce 幂等 + Actor 有效性检查双保险。

5. 面试怎么讲

结算演出流程怎么设计的设计
三阶段相机:黑屏备场 → MVP 特写 → 全家福+UI。 第一阶段把流送、站位、换装全部藏在黑屏里做完;第二阶段 LevelSequence 播 MVP 特写, 播完销毁 3D proxy 防穿帮;第三阶段全员合影,相机距离按站位包围盒动态算,同时弹结算 UI。
关键取舍是先流送后开 UI——反过来会先弹空 UI 让玩家干等。 演出类功能的排序原则:让玩家看到的东西和能点的按钮同时就绪。
相机距离是怎么算的算法
取全员 Actor 的包围盒,算水平半宽和垂直半高,分别除以对应半 FOV 的正切值, 得到「恰好装下」的两个距离取 max,再 Clamp 到 [120, 2500]cm。
两个易错点:UE 相机 FOV 是水平的,垂直半 FOV 要按宽高比换算,只算水平会切头; 边距用固定身位而不是比例,比例放大在 4 人队会留出夸张空白。 注视点取包围盒中心上方 100cm(身体中部),相机与注视点同高实现平视合照。
讲一个表现层踩过的坑难点
「先默认皮肤、后换装」穿帮帧。MVP 换装是异步拉取外观数据,起初黑屏一关就开始播特写, 换装完成时画面里角色已经换了套衣服闪一下。
修法把时序反过来:换装完成回调(带超时兜底)之后才允许关黑屏, 并用 Play+Pause 把序列定格在首帧等相机就位。还有一个 puerts 引擎坑: LevelSequence 的 PlaybackSettings 是结构体,必须整体读出来改完再写回, 直接改字段静默不生效,当时排查了很久。