副本结算流程
副本打完之后那段「镜头推进 → MVP 特写 → 全家福 → 数据面板」的完整演出。 八个模块里代码量最大、异步编排最复杂的一个:单 Controller 1435 行, 横跨 LevelSequence、程序相机、ECS 实体隐藏、UI 展台 Proxy、换装异步回调、DataTable 配置六个子系统。 本页把三个阶段拆开讲透,并把源码注释里沉淀下来的十来个坑逐个还原。
1. 模块定位与三阶段总览
1.1 三个阶段
DungeonMvpView 的黑屏内完成「加载 → 换装 → Play+Pause 定格首帧 → 切相机」,
黑屏揭开后才真正 Play。UI 侧同时把 3D 站位投影成飘字。DungeonSettlementView 显示伤害/DPS/评价/点赞。1.2 文件职责表
| 文件 | 行数 | 职责 |
|---|---|---|
| LevelSettlement/DungeonSettlementController.ts | 1435 | 核心编排:三阶段调度、相机、Seq、Proxy、实体隐藏 |
| DungeonSettlementData.ts | 417 | 结算数据模型:成员数据、MVP 判定、派生指标、点赞索引 |
| View/EndGame/DungeonMvpView.ts | 352 | 阶段2 UI:黑屏 await、倒计时、长按跳过、3D→UI 投影 |
| View/EndGame/DungeonSettlementView.ts | 301 | 阶段3 UI:成员列表、点赞、离开副本倒计时 |
| View/EndGame/DungeonSettlementDataItem.ts | 327 | 单条数据行(伤害占比条、评价图标) |
| View/EndGame/DungeonSettlementItem.ts | 148 | 3D 角色头顶飘的名字/称号条目 |
| LevelSettlement/DungeonSettlementDefine.ts | 26 | 纯常量:点位上限、资源路径、特效路径 |
| SequenceEventDungeonSettlementSeq2ShowButton.ts | 9 | Seq 轨道事件 → Emit 一个事件,让 UI 显示按钮 |
1.3 事件名汇总
| 事件 | 发出方 | 接收方 / 作用 |
|---|---|---|
| DungeonSettlementTeamActive | DungeonSettlementView | Controller:进/出阶段3,控制 proxy 可见 + 切 Camera3 + 实体隐藏反向 |
| DungeonSettlementMvpCloseUp | Controller.PlayMvpCloseUp | 广播「Seq2 开播了」 |
| DungeonSettlementMvpCloseUpEnd | Controller | 阶段2 结束 |
| DungeonSettlementSeq2ShowButton | Seq 轨道事件(9 行的那个文件) | MvpView:到点显示「结算」按钮 |
| DungeonSettlementShowNameplate | Controller | 显示 3D 角色头顶名牌 |
| DungeonSettlementHideNameplate | Controller | 隐藏名牌 |
| DungeonClickLike | SettlementView | Controller:给被赞的 proxy 挂流光材质特效 |
| StartLeaveLevel | 离开副本流程 | 清理 |
2. 入口 OnStart:三道校验与 SettlementReady 语义
/** OnStart 校验通过(本副本有结算表现)才为 true,
false 时 MVP 界面只走倒计时不做任何 3D 表现 */
private SettlementReady = false;
Log.Error + return false。副本 Id 都查不到是数据事故Log.Debug「结算模版ID无效」+ return false。
这是正常业务分支——很多副本压根没配结算表现,所以用 Debug 不用 ErrorLog.Error + return false。配了模版 Id 却查不到点位表 = 配表漏行SettlementReady 是唯一的门禁标记,为 false 时
DungeonMvpView 依然会正常开、正常倒计时、正常关,只是不做任何 3D 表现——
降级而不是崩掉,是这套编排里贯穿始终的思路。
3. 阶段1:相机 Tween 推镜(已关闭 / 代码保留)
SpawnCharactersAtMarkers() 与 StartCameraTween() 的调用被注释掉,
函数体加了 // @ts-expect-error 阶段1临时关闭未调用。
源码里的原话:「阶段1(相机 Tween 推镜)已关闭,阶段2 用 Seq2 序列自带角色,不再 SpawnCharactersAtMarkers(否则那排 marker 角色会在 MVP 特写时穿帮显示)。」
3.1 数据结构
interface ISettlementCameraSegment {
CamTarget: UE.Vector; // 该段终点位置(世界偏移)
TargetRot: UE.Rotator; // 该段终点旋转
Duration: number; // 段时长(秒)
CamCurve: UE.CurveVector | undefined; // 三轴各自的缓动曲线
}
配置来源是 DataTable,行结构 SSettlementCameraConfig,按角色 Id 取行:
readonly RoleId: number /* @cpp:int */;
readonly CameraOffset: Vector;
readonly CameraRotationOffset: Rotator;
readonly TweenDuration: number /* @cpp:float */;
readonly CameraPosCurve: UE.CurveVector | undefined;
readonly Seq2: ISoftObjectPtr<UE.LevelSequence>;
readonly MvpShowButtonTime: number /* @cpp:float */;
DungeonModel.GetDungeonSettlementAllData() 遍历
WorldConfig.GetDungeonSettlementDataTable() 的 RowNames,
按 info.RoleId 建 Map 缓存,OnInit 预热、OnClear 置 undefined。
源码注释:「相机配置按角色 ID 取行,新结构无 Skill 字段,直接用 RoleId 作 key」。
3.2 倒序推镜
// 相机初始落在最后一段,从 lastIndex - 1 逐段倒推到第 0 段(MVP)
this.TweenSegmentIndex = lastIndex - 1;
3.3 逐帧采样
private OnCameraTweenTick(deltaMs: number): void {
if (this.TweenHolding) { /* 段末定格 TweenHoldDuration 秒 */ }
const t = Math.min(elapsed / segment.Duration, 1);
// CurveVector 三轴各取一条曲线,按轴分别插值 —— X/Y/Z 可以有不同缓动
const camProgress = segment.CamCurve.GetVectorValue(t);
loc.X = DungeonSettlementController.Lerp(prev.X, target.X, camProgress.X);
// ... Y / Z 同理
const rot = UE.KismetMathLibrary.RLerp(prevRot, targetRot, t, true);
actor.K2_SetActorLocationAndRotation(loc, rot, false, ..., false);
}
private static Lerp(from: number, to: number, alpha: number): number {
return from + (to - from) * alpha;
}
| 细节 | 为什么这么写 |
|---|---|
| 位置用 CurveVector,旋转用 RLerp 线性 | 位置需要美术调缓动曲线;旋转做非线性容易出现视觉抖动,且 RLerp(shortestPath=true) 自动走最短弧 |
| 三轴分开插值 | 常见需求「Z 先抬起来再水平推近」,单一 alpha 做不到 |
TweenHoldDuration = 0 缺省 | 公共参数未配时不定格,直接进下一段 |
DefaultTweenDuration = 1 | 配置 TweenDuration <= 0 时的兜底,避免除零 |
3.4 一个容易想歪的取舍:不叠加站位朝向
// 美术按角色默认朝向调好机位并复制相对值,这里直接当世界偏移/世界旋转用,
// 不叠加站位朝向,避免站位 Yaw 非0 时把正面机位转成侧面(所见即所得)
这类取舍在配置驱动的表现系统里非常典型——对齐美术的心智模型,而不是对齐数学。
4. 阶段2:Seq2 MVP 特写(黑屏内异步编排)
4.1 全流程
/**
* MVP 特写准备:隐藏实体 → 加载 Seq2 → 换装 → 定格首帧 → 切相机,
* 全程在 MvpView 的黑屏内完成。
* 由 DungeonMvpView.OnBeforeStartAsync await,完成后才关黑屏,再由 PlayMvpCloseUp 播放。
*/
public async PrepareMvpCloseUpAsync(): Promise<void> { ... }
StartBlackScreen,黑屏在 StartAsync 结束后才关。
所有准备工作塞进这个窗口bPauseAtEnd = trueSetRoleAvatarBindingsWithData(actor, roleId, clothes, Seq2AvatarActor, 'Player', true, cb),
10s 超时兜底Play() 立刻 Pause()——序列停在首帧,但所有绑定已生效。
4.2 双层超时兜底
/** MVP 准备阶段的一次性结束回调,OnEnd 提前结束时调用,
避免界面永远 await 在黑屏里 */
private MvpPrepareFinish: (() => void) | undefined = undefined;
| 兜底层 | 常量 | 触发后的行为 |
|---|---|---|
| 换装回调超时 | ApplyAvatarTimeout = 10000 | 不等换装了,继续走后面的定格+切相机,Seq2 照播(可能没换装,但不卡) |
| 整体准备超时 | MvpPrepareTimeout = 15000 | 强制 finishOnce(),关黑屏,避免界面永远全黑 |
| OnEnd 提前结束 | MvpPrepareFinish?.() | 玩家在准备期间就退了,主动放行还在 await 的界面 |
| UI 侧最终保底 | MaxShowTime = 60s | MvpView 自己的 Tick 保底,没收到 CloseUp 事件也强行结算 |
finishOnce 内部第一件事就是把 this.MvpPrepareFinish = undefined,
三条路径(正常完成 / 15s 超时 / OnEnd)谁先到谁生效,后到的看到 undefined 直接跳过。
比布尔标记多一个好处:顺手断掉了闭包引用,不会因为持有回调导致 View 泄漏。
4.3 PlayMvpCloseUp 与 proxy 隐藏时机
if (this.Seq2Ready && actor valid) {
this.LevelSequencePlayer2?.Play();
// Seq2 真正 Play 之后再隐藏 proxy,避免出现"无角色"空窗导致描边特效闪断
for (const proxy of this.ProxyActors) { proxy.SetRoleVisible(false); }
} else if (this.Gm) {
this.OnDungeonSettlementCloseUpEnd(); // GM 模式没 Seq2,直接跳阶段3
}
EventSystem.Emit(EEventName.DungeonSettlementMvpCloseUp);
顺序是 先 Play 再隐藏,差一帧就会出现「旧 proxy 已消失、Seq2 角色还没显示」的空窗, 描边(CustomDepth)后处理会闪断一下。
4.4 Seq2 结束的收尾
player2.OnFinished.Add(() => {
// Seq2 结束 = 阶段2 结束,进入阶段3(全屏 UI);销毁 3D proxy 避免 UI 阶段穿帮
this.ClearProxyActors();
if (this.Gm) { this.OnDungeonSettlementCloseUpEnd(); }
});
5. 阶段3:Seq3 全家福
5.1 基准点:X/Y 与 Z 分别取
// X/Y 取 MVP 站位作排中心,Z 取结算基准地面(避免叠加 marker 抬高导致浮空)
const mvpLoc = this.MarkerActors.get(0)?.GetLocation() ?? this.GetSettlementSpawnLocation();
const groundZ = this.GetSettlementSpawnLocation().Z;
const baseLocation = new UE.Vector(mvpLoc.X, mvpLoc.Y, groundZ);
marker 的 Z 已经加过 SettlementZOffset = 10(防入地),直接整个复用会二次抬高。
所以只借 X/Y、Z 回到原始基准。
5.2 站位算法
// 1. 按体型半径累加中心距
centers[i] = centers[i - 1] + radii[i - 1] + radii[i] + formationSpace;
// formationSpace = configCommonParamById.GetFloatConfig('team_formation_space')
// radii 缺省时用 DefaultBodyRadius = 50
// 2. 整体居中
// 3. 首尾沿 forward 反方向后压 radii[index],避免一字平直
radii[i-1] + radii[i] + space 保证任意两人边缘间距恒等于 space。首尾后压一个身位是纯美术需求——一字排开太呆板,微弧形更像合影。
5.3 GetSettlementPositionOrder:站位序 ↔ 成员序
// 排序键:CharacterBodyType 升序 → Id → a.Index - b.Index
// 最后一个键的作用:同角色同体型时按成员序定序,保证排序稳定、站位与成员一一对应
// 再从中间往两边铺:
let left = Math.floor(n / 2) - 1;
let right = Math.floor(n / 2);
// 偶数下标往右、奇数往左 → 体型小的站中间,大的分居两侧
这正是阶段3 名字与模型错位 bug 的修复要点—— 双人带同一个角色时,roleId 无法区分是谁。
5.4 贴地射线 TraceGroundZ
private TraceGroundZ(x: number, y: number, baseZ: number): number {
// 从 baseZ + Seq3GroundTraceUp(300) 向下打 Seq3GroundTraceDown(1000)
// 命中 → ImpactPoint.Z + SettlementZOffset(10)
// 未命中 → baseZ + SettlementZOffset(10)
}
结算点位是配表填的固定坐标,但地形可能有坡。每个人独立打一条射线贴地, 避免一排人有的埋进地里有的浮空。射线起点抬高 300 是为了兼容站位点略低于地面的情况。
5.5 相机:从「配置半径估算」到「真实包围盒」的四连改
CameraFallbackDistance = 900 —— 人多了装不下CameraDistanceOffset(180) + 满 4 人时 CameraFourPeopleOffset(80) —— 体型差异没考虑UE.GameplayStatics.GetActorArrayBounds(actorArray, false, originRef, extentRef);
// 把包围盒 extent 投影到相机右方向上,得到画面里的真实半宽
const halfWidth = Math.abs(extent.X * rightDir.X) + Math.abs(extent.Y * rightDir.Y) + horizontalMargin;
const halfHeight = extent.Z;
// CameraFov(60) 是水平 FOV,垂直 FOV 要按 16:9 换算
const halfHorizontalFovRad = MathUtils.DegToRad * (CameraFov * 0.5);
const halfVerticalFovRad = Math.atan(Math.tan(halfHorizontalFovRad) / CameraAspectRatio);
const distanceByWidth = halfWidth / Math.tan(halfHorizontalFovRad);
const distanceByHeight = halfHeight / Math.tan(halfVerticalFovRad);
const distance = MathUtils.Clamp(Math.max(distanceByWidth, distanceByHeight),
CameraMinDistance(120), CameraMaxDistance(2500));
① 横竖都要算,取大者。只算横向的话,一个 8 米高的机甲会被切头。
② 水平 FOV → 垂直 FOV 的换算:
tan(vFov/2) = tan(hFov/2) / aspect。
UE 的 FieldOfView 语义是水平角,直接拿来算高度会算错。③ 半宽要投影到相机右方向,不能直接用 extent.X。 包围盒是轴对齐的(AABB),相机可能斜着看,投影才是画面里的真实宽度。
5.6 留白用「一个身位」而不是比例系数
const avgBodyRadius = radii.reduce((a, b) => a + b, 0) / radii.length;
// 留白按"一个身位"取平均体型半径,避免用比例系数——
// 人数越多总宽度越大,比例留白会被同步放大导致人多时镜头明显偏远(8人小体型实测过远)
比例留白(halfWidth *= 1.2)看起来更「自适应」,实际是错的:
8 个小角色总宽 800,20% 留白就是 160;2 个角色总宽 200,20% 只有 40。
观感上人越多画面越空。绝对留白才符合直觉。
5.7 两个相机细节
rot = UE.KismetMathLibrary.FindLookAtRotation(camLoc, lookAtLoc);
cameraComponent.FieldOfView = 60;
cameraComponent.bConstrainAspectRatio = false;
// CameraActor 默认按 16:9 约束画幅,宽屏下会加左右黑边,这里关掉铺满视口
| 常量 | 值 | 含义 |
|---|---|---|
| Seq3LookAtHeight | 100 | 注视点抬到角色身体中部(cm),相机同高 → 平视正面合照 |
| Seq3FaceYawOffset | -90 | 正脸朝向修正。注释写明是实测反推:偏移 0 时角色偏出相机 +90° |
// 修正朝向 就完事,而是写了「由实测反推——偏移0时角色偏出相机+90°,故-90可正对相机」。
后来人看到这条注释,就知道这不是拍脑袋,也知道该怎么复现验证(改成 0 看看是不是偏 90°)。
5.8 SwitchCamera3 三级兜底
Seq_DungeonSettlement_20100201_3 资产缺失,
第 1、2 级都拿不到相机,于是相机根本没切。
QA 报的现象是「SetActorVisible=true 了但角色不显示」——
实际角色好好站着,只是镜头还停在别处。教训:静默失败的兜底一定要打 Warn,否则表现层的锅会甩到逻辑层。
6. 数据层 DungeonSettlementData
6.1 MVP 判定
public IsMvp(): boolean {
return cfg.DataType === EDungeonEvaluationDataType.None;
}
不是「伤害最高」,而是服务器下发的评价条目里带了一条 DataType === None 的称号。
评价体系是配置化的(最高输出 / 最抗揍 / 最佳辅助…),MVP 是其中特殊的一条:它没有对应的数值维度。
6.2 排序
@profiled()
public AnalyzeData(): void {
// 累加 Total / Max(伤害、承伤、治疗)
// MembersData[0] 一定是 MVP
// 其余按加入时间倒序(因为站位是倒着站的)
if (!hasMvp) { Log.Warn(..., ELogAuthor.QCJ, '没有mvp数据'); }
}
构造 DungeonSettlementMemberData 时还会对 evaluationIdList
按 EvaluationType → EvaluationLevel 排序,保证 UI 上称号顺序稳定。
派生指标 GetDamagePercent / GetDps / GetDtps / GetHps 都在这层算好,View 只负责显示。
6.3 点赞双向索引
ClickLike: Map<number, Set<number>>; // 被赞 uid → 点赞者 uid 集合
GetAllPlayerUidExceptSelf(); // 排除自己(不能给自己点赞)
用 Map<number, Set<number>> 而不是数组,一次 has 就能判断「我赞过没」,
避免每帧 UI 刷新时做 O(n) 查找。
7. 实体隐藏与恢复:HiddenEntities 自维护集合
/**
* 结算期间被本控制器隐藏的实体集合。
* 现用现拿(hide/show 各调一次 GetAllEntities)会出现
* "hide 时存在、show 时已不在列表"的实体永远卡在隐藏状态,
* 因此自己维护这个集合,恢复时只遍历它,不再二次依赖 GetAllEntities。
*/
private readonly HiddenEntities: Set<Readonly<EntityHandle>> = new Set();
@profiled('SetActiveAllPlayers.GetAllEntities')
public SetActiveAllPlayers(enable: boolean): void {
if (this.EntityActiveState === enable) { return; } // 幂等
if (!enable) {
// 隐藏:取一次 GetAllEntities 全集 → SetActorVisible(false) → 存进集合
// 同时挂 EntityCreated / EntityRemoved 两个监听
} else {
// 显示:先摘监听 → 只遍历 HiddenEntities → clear
}
}
| 监听 | 处理 | 为什么必须有 |
|---|---|---|
| EntityCreated | 新实体也隐藏并入集合 | 结算演出期间服务器可能还在下发实体(延迟到达的召唤物、掉落物),不处理会突然冒出来穿帮 |
| EntityRemoved | 从集合移除 | 注释原文:「避免 show 阶段在已销毁的 handle 上做无效 SetActorVisible(销毁路径会调用 SetActorVisible(false) 隐藏,留在集合里会 stale)」 |
8. 外观获取:快照 vs 实时的双层回退
// 组队信息是进副本时的快照,副本内换装不刷新它,优先读实体上的实时外观
private GetAvatarInfo(index: number) {
const snapshot = FashionUtils.ConvertToAvatarInfo(memberData.PlayerInfo.roleActiveFashion);
return this.GetLiveAvatarInfo(this.GetMemberUid(index), snapshot[0]) ?? snapshot;
}
private GetLiveAvatarInfo(uid: number, snapshotRoleId: number) {
for (const entity of EntityInterfaceHelper.GetAllEntities()) {
if (entity.OwnerPlayerId !== uid) { continue; }
const comp = entity.GetComponent(CreatureDataComponent)
?.ServerCompData.Get('roleFashionComp');
// 同一玩家的召唤物等实体没有外观组件,继续找角色实体
if (!comp) { continue; }
// 副本内可能切过角色,优先取与快照同角色的那份,避免把 MVP 换成别的角色
return comp.roleActiveFashionList?.find(f => f.roleConfigId === snapshotRoleId)
?? comp.roleActiveFashion;
}
}
!comp → continue:同一个 uid 名下有多个实体(角色本体、召唤物、载具),
只有角色本体带 roleFashionComp。第一个匹配 uid 的不一定是角色。②
find(roleConfigId === snapshotRoleId):多角色队伍在副本里可以换人,
roleActiveFashionList 里有多套。不按 roleId 筛就可能把 MVP 换成队里的另一个角色。
9. UiSceneRoleActorProxy 的两个坑
9.1 SetModelStateNoTween
// 结算角色 spawn 在副本世界(非UI展台场景),走 SetModelStateNoTween 跳过相机Tween,
// 避免刷 "找不到对应的Actor(Tag=RoleCase/SceneCase/CameraTween)" 报错;相机由本控制器程序控制。
actorProxy.SetModelStateNoTween(UiModelStateNames.DungeonSettlement);
UiSceneRoleActorProxy 本来是给 UI 展台(角色界面、抽卡)用的,
默认会去场景里找 RoleCase / CameraTween 这些 Tag 的 Actor。
结算是在副本世界里 spawn,这些 Actor 不存在 → 每帧刷错误日志。
NoTween 版本只设模型状态、不碰相机。
9.2 ProxyActorByUid
/** 玩家 uid → proxy:ProxyActors 是站位序,UI 是成员序,两边只能靠 uid 对齐 */
private readonly ProxyActorByUid: Map<number, UiSceneRoleActorProxy> = new Map();
点赞特效 GetProxyActorByUid(tarUid)?.AddMaterialControllerEffect(FxFlowLight, ContextFlowLight)、
UI 飘字定位 GetProxyActorLocationByUid(uid) 都走这张表。
10. 两个 UI 界面
10.1 DungeonMvpView(阶段2)
protected MvpShowButtonTime = 3 * SecondToMillisecond; // 3s 后出「结算」按钮
protected SkipPressTime = 2 * SecondToMillisecond; // 长按 2s 跳过
// MVP动画保底时长,暂定60秒,防止没有 DungeonSettlementMvpCloseUp 事件,导致卡流程
protected MaxShowTime = 60 * SecondToMillisecond;
// 是否已结算过:CloseMe 为异步关闭,界面真正销毁前仍会继续 OnTick,
// 没有这个标记会导致保底逻辑在关闭中的界面上反复重触发,无限重复结算
protected HasSettled = false;
| 回调 | 做什么 |
|---|---|
| OnBeforeStartAsync | await PrepareMvpCloseUpAsync()。注释:「本界面配了 StartBlackScreen,黑屏在 StartAsync 结束后才关;把 Seq2 加载+换装放在这里 await,保证黑屏揭开时角色已换好装,不出穿帮帧」 |
| OnBeforeShow | 读 configCommonParamById.GetIntConfig('dungeon_mvp_timeout')(默认 15s);末尾 PlayMvpCloseUp() |
| OnTick | 跨过 MvpShowButtonTime → 显按钮;超 MaxShowTime 且未结算 → Warn + 保底;每帧 LguiUtil.ProjectWorldToUiLocation(GetMarkerLocation(index)) 把 3D 站位投成 UI 坐标 |
| OnClickBtnSettle | HasSettled 幂等 → 清两个 Timer → OnDungeonSettlementCloseUpEnd() → OpenView(DungeonSettlementView) → CloseMe() |
| ESC | 倒计时已开始则直接结算 |
10.2 DungeonSettlementView(阶段3)
OnBeforeStartAsync: EventSystem.Emit(EEventName.DungeonSettlementTeamActive, true);
OnBeforeDestroy: EventSystem.Emit(EEventName.DungeonSettlementTeamActive, false);
Controller 侧对这个事件的响应:
private OnDungeonSettlementTeamActive(active: boolean): void {
for (const proxy of this.ProxyActors) { proxy.SetRoleVisible(active); }
if (active) { this.SwitchCamera3(); }
else { this.LevelSequencePlayer2?.Stop(); CameraManager.ReleaseCamera(...); }
this.SetActiveAllPlayers(!active); // 注意取反
}
3D 侧:按体型从中间往两边铺。
两套顺序完全不同,所以
UpdateSettleItemsLocation() 里写着:「3D 站位是按体型重排的,与这里的成员顺序不同,只能按 uid 取模型位置」 →
GetProxyActorLocationByUid(memberData.GetPlayerUid())
其它要点:
OnDungeonClickLike→GetProxyActorByUid(tarUid)?.AddMaterialControllerEffect(FxFlowLight, ContextFlowLight)OnClickBtnClose→RequestLeaveCurrentDungeon()StartTipsTimer用info.countdown.stateEndTimeMs算剩余,LguiUtil.SetLocalTextNewNamed(TxtTips, 'SettlementRemainTimeTips', {...})CloseMe里有被注释掉的DungeonEndGameTipsView,注释「TODO: 策划要求先暂时隐藏该窗口 2026-07-28」
11. OnEnd 清理十一步
| # | 动作 | 备注 |
|---|---|---|
| 1 | Gm = false | |
| 2 | DungeonId = 0 | |
| 3 | DungeonCfg = undefined | |
| 4 | SettlementReady = false | |
| 5 | MvpPrepareFinish?.() | 放行还在 await 的界面,否则黑屏永远不关 |
| 6 | Seq2Ready = false | |
| 7 | StopCameraTween + 销毁 CameraTweenActor + 清 Tween 状态 | |
| 8 | LevelSequencePlayer2?.Stop() | |
| 9 | ReleaseSeq2Avatar() | 销毁 Seq2 前先释放换装 Actor,避免野指针 |
| 10 | 销毁 Seq3CameraActor + MarkerActors.clear | |
| 11 | SetActiveAllPlayers(true) + ReleaseCamera + ClearProxyActors() | 恢复世界 |
12. 常量速查表
全部在 DungeonSettlementControllerConstants,每条都带中文 JSDoc。
| 常量 | 值 | 含义 |
|---|---|---|
| DefaultBodyRadius | 50 | 角色体型半径兜底值(CharacterBodyRadius 缺省时) |
| SettlementZOffset | 10 | 站位点 Z 抬高,避免入地 |
| CameraFallbackDistance | 900 | 相机距离计算失败的兜底 |
| CameraFov | 60 | 视野角度(水平 FOV,度) |
| CameraDistanceOffset | 180 | 相机基础距离回退量(留白) |
| CameraFourPeopleOffset | 80 | 满 4 人时额外回退 |
| CameraMinDistance | 120 | 防贴脸 |
| CameraMaxDistance | 2500 | 防过远 |
| CameraAspectRatio | 16/9 | 按垂直 FOV 校验高度是否入镜 |
| RenderCustomDepthStencil | 2 | 自定义深度模板值(描边后处理) |
| GmRoleIdList | 1013 × 8 | GM 测试用角色 Id 列表 |
| DefaultTweenDuration | 1 | Tween 段时长兜底(秒) |
| TweenHoldDuration | 0 | 段末定格时长(秒)缺省值 |
| Seq3LookAtHeight | 100 | 全家福注视点高度(cm) |
| Seq3FaceYawOffset | -90 | 正脸朝向修正 Yaw(实测反推) |
| Seq3GroundTraceUp | 300 | 贴地射线起点抬高(cm) |
| Seq3GroundTraceDown | 1000 | 贴地射线向下距离(cm) |
| ApplyAvatarTimeout | 10000 | 换装回调超时(ms) |
| MvpPrepareTimeout | 15000 | MVP 准备整体超时(ms) |
另有 DungeonSettlementDefineConstants:
MarkerSpawnMax = 8; // 结算点位数量
DungeonSettlementModelId = 20100201; // 结算关卡ID
PathDungeonSettlementSeq = '/Game/Aki/Scene/InteractionLevel/Prefab/DungeonSettlement/';
FxFlowLight = '.../DA_FX_MCTRL_Group_FlowLight_EndGame';
ContextFlowLight = 'FlowLightEndGameFx';
13. Bug 卡集合
iProxyActorByUid,UI 侧改用 GetProxyActorLocationByUid(uid);同时 GetSettlementPositionOrder 改成返回成员序索引而不是 roleIdGetAllEntities。hide 时存在、show 时已不在列表的实体,没人给它 SetActorVisible(true)HiddenEntities 集合 + EntityCreated/EntityRemoved 双监听OnBeforeStartAsync 的 await 里,黑屏成为天然的遮丑窗口;Play()+Pause() 定格首帧确保绑定已生效Play() 之后再 SetRoleVisible(false)GetActorArrayBounds 取真实包围盒 + 横竖两方向各算距离取大者 + 留白改绝对值(平均体型半径)SetActorVisible=true 但角色不显示」Seq_DungeonSettlement_20100201_3 资产缺失,三级兜底全落空,相机根本没切,角色其实好好站着CameraActor 默认 bConstrainAspectRatio = true,按 16:9 约束画幅SetModelStateNoTweenCloseMe 是异步关闭,界面销毁前 OnTick 还在跑,60s 保底逻辑反复触发HasSettled 布尔幂等OnEnd 里 ReleaseSeq2Avatar() 排在销毁 Seq2 之后、销毁相机之前,顺序固化PrepareMvpCloseUpAsync 的 Promise 永远不 resolveMvpPrepareFinish 一次性回调,OnEnd 主动调用放行;外加 15s 超时14. 常见修改场景
14.1 给某个角色配 MVP 特写
DungeonSettlementCamera2)和角色 ActorSSettlementCameraConfig,填 RoleId + Seq2 软引用 + MvpShowButtonTimeSequenceEventDungeonSettlementSeq2ShowButton 显示按钮14.2 重新启用阶段1 相机 Tween
去掉 OnStart 里两行注释、删掉两个函数上的 @ts-expect-error。
但必须同时解决角色穿帮——阶段1 spawn 的那排 marker 角色会在 Seq2 特写时出现在画面里。
两个方向:① 阶段2 开始前 ClearProxyActors();
② 阶段1 复用 Seq2 的角色,不单独 spawn。
14.3 加一个新的结算数据维度(比如「治疗量」)
GetHps)放这里,不要放 View14.4 排查表
| 现象 | 先查 |
|---|---|
| 完全没有结算演出 | SettlementReady。grep Debug 日志「结算模版ID无效」——大概率是 DungeonSettlementModel 没配 |
| 卡在全黑 | MvpPrepareFinish 有没有被调用。15s 超时兜底触发了吗?grep MvpPrepareTimeout |
| MVP 换装不对 | GetLiveAvatarInfo 的两层 continue/find。是不是匹配到了召唤物,或者匹配到了另一个角色 |
| 角色不显示 | 先确认相机切没切(Camera3 三级兜底的 Warn),不要一上来就查 SetActorVisible |
| 名字飘错人头上 | 有没有走 GetProxyActorLocationByUid,还是又用了下标 |
| 结算完实体不见了 | HiddenEntities 有没有正常 clear;EntityActiveState 幂等是不是把 show 吞了 |
| 相机拍不全 | 打印 GetActorArrayBounds 的 extent。Proxy 还没 LoadAvatar 完成时包围盒是零 |
15. 面试怎么讲
关键设计有三点:
① 把黑屏当作天然的遮丑窗口。界面框架有个
StartBlackScreen 配置,
黑屏在 OnBeforeStartAsync 返回后才关。整套准备塞进这个 await 里,
黑屏揭开时画面已经完全就绪,一帧穿帮都没有。②
Play() 紧接 Pause() 预热。
LevelSequence 的绑定是第一次 Play 才解算的,不 Play 拿不到相机位置、换装也挂不上。
Play 完立刻 Pause,序列停在首帧但绑定已生效。③ 三层超时兜底:换装 10s、整体准备 15s、UI 侧 60s。 每一层超时后都是降级继续而不是报错中断—— 玩家宁可看到没换装的角色,也不能卡在黑屏里。
终态做法:
GetActorArrayBounds 拿全员的合并包围盒,
把 extent 投影到相机右方向算出画面真实半宽,然后横竖两个方向各算一次距离取大者——
只算横向的话,一个 8 米高的机甲会被切头。一个容易错的点是 UE 的
FieldOfView 是水平角,
算高度必须先换算:tan(vFov/2) = tan(hFov/2) / aspect。还有个反直觉的结论:留白要用绝对值不能用比例。 比例留白在人多时会被放大,8 个小角色的画面反而显得空。 最后改成「一个平均身位」的绝对留白。
前三版失败的共同原因是:拿配置参数当渲染尺寸用。 配置里的
CharacterBodyRadius 语义是站位间距,不是模型大小。
根治方式是引入稳定业务主键:建一张
ProxyActorByUid 映射,
所有跨列表访问一律走 uid。中间还踩了一步:一开始想用 roleId 当 key, 但队伍里可能有两个人带同一个角色,roleId 不唯一。 所以站位序表最终存的是「成员序索引」,源码注释也专门写清了这个原因。
更一般的原则:只要两个集合的排序规则可能不同,就绝不能用下标关联, 哪怕当前它们碰巧一致。
正确做法是记账:hide 时把动过的实体存进
HiddenEntities,
show 时只遍历这个集合。同时挂两个监听——
EntityCreated 把新来的也隐藏并入账,
EntityRemoved 把销毁的移出账本(否则 show 阶段会在野 handle 上操作)。这是个通用模式:凡是「A 时刻批量做、B 时刻批量撤」的场景,都要记账而不是重查。
处理方式是:注释掉调用点并写明原因,函数体上加
@ts-expect-error 阶段1临时关闭未调用
让编译器接受「已定义未使用」。这样死代码是显式标记的,
不会有人以为是忘了删,也不会被 lint 规则误伤。反面例子是同一个工程里的
LevelEventDestroyEnvBullet——
写完了但因为该行为是 ImplementType: 'Server' 根本不会被客户端注册,
没有任何标记,看代码的人得翻到 Action.ts 的配置表才知道它是死的。