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