首页 / 关卡侧系统支持 / 副本结算

副本结算流程

副本打完之后那段「镜头推进 → MVP 特写 → 全家福 → 数据面板」的完整演出。 八个模块里代码量最大、异步编排最复杂的一个:单 Controller 1435 行, 横跨 LevelSequence、程序相机、ECS 实体隐藏、UI 展台 Proxy、换装异步回调、DataTable 配置六个子系统。 本页把三个阶段拆开讲透,并把源码注释里沉淀下来的十来个坑逐个还原。

1435 行 Controller LevelSequence 程序相机 UiSceneRoleActorProxy 异步编排 11 类典型 Bug

1. 模块定位与三阶段总览

1.1 三个阶段

1
阶段1 · 相机 Tween 推镜 已关闭
在结算点位上 Spawn 一排角色,相机倒序从最后一名逐段推到 MVP。 代码完整保留但调用被注释掉,因为阶段2 换成了 Seq2 自带角色,两套角色会互相穿帮。
2
阶段2 · Seq2 MVP 特写
按 MVP 的角色 Id 取一条专属 LevelSequence, 在 DungeonMvpView 的黑屏内完成「加载 → 换装 → Play+Pause 定格首帧 → 切相机」, 黑屏揭开后才真正 Play。UI 侧同时把 3D 站位投影成飘字。
3
阶段3 · Seq3 全家福 + 数据面板
全员按体型排队站好,程序算一台能装下所有人的相机, 切到 DungeonSettlementView 显示伤害/DPS/评价/点赞。

1.2 文件职责表

文件行数职责
LevelSettlement/DungeonSettlementController.ts1435核心编排:三阶段调度、相机、Seq、Proxy、实体隐藏
DungeonSettlementData.ts417结算数据模型:成员数据、MVP 判定、派生指标、点赞索引
View/EndGame/DungeonMvpView.ts352阶段2 UI:黑屏 await、倒计时、长按跳过、3D→UI 投影
View/EndGame/DungeonSettlementView.ts301阶段3 UI:成员列表、点赞、离开副本倒计时
View/EndGame/DungeonSettlementDataItem.ts327单条数据行(伤害占比条、评价图标)
View/EndGame/DungeonSettlementItem.ts1483D 角色头顶飘的名字/称号条目
LevelSettlement/DungeonSettlementDefine.ts26纯常量:点位上限、资源路径、特效路径
SequenceEventDungeonSettlementSeq2ShowButton.ts9Seq 轨道事件 → Emit 一个事件,让 UI 显示按钮

1.3 事件名汇总

事件发出方接收方 / 作用
DungeonSettlementTeamActiveDungeonSettlementViewController:进/出阶段3,控制 proxy 可见 + 切 Camera3 + 实体隐藏反向
DungeonSettlementMvpCloseUpController.PlayMvpCloseUp广播「Seq2 开播了」
DungeonSettlementMvpCloseUpEndController阶段2 结束
DungeonSettlementSeq2ShowButtonSeq 轨道事件(9 行的那个文件)MvpView:到点显示「结算」按钮
DungeonSettlementShowNameplateController显示 3D 角色头顶名牌
DungeonSettlementHideNameplateController隐藏名牌
DungeonClickLikeSettlementViewController:给被赞的 proxy 挂流光材质特效
StartLeaveLevel离开副本流程清理

2. 入口 OnStart:三道校验与 SettlementReady 语义

/** OnStart 校验通过(本副本有结算表现)才为 true,
    false 时 MVP 界面只走倒计时不做任何 3D 表现 */
private SettlementReady = false;
1
GetDungeonConfig(dungeonId) 拿不到
Log.Error + return false。副本 Id 都查不到是数据事故
2
DungeonCfg.DungeonSettlementModel === 0
Log.Debug「结算模版ID无效」+ return false。 这是正常业务分支——很多副本压根没配结算表现,所以用 Debug 不用 Error
3
GetDungeonSettlementModelConfig 拿不到点位
Log.Error + return false。配了模版 Id 却查不到点位表 = 配表漏行
「不做表现」和「出错」要分开 三道校验只有第二道用 Debug。这个区分很实用:线上 grep Error 时不会被 「这个副本本来就没结算演出」淹没。
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;
为什么倒着推 演出意图是「从队尾一路扫到 MVP,最后停在 C 位」。 所以段数组仍按站位序 0..n 生成(0 号是 MVP),但播放时从末尾往前走, 终点自然落在 MVP 身上。数据顺序和播放顺序解耦,改站位算法不影响演出方向。

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 时把正面机位转成侧面(所见即所得)
「所见即所得」优先于「数学上正确」 直觉上相对偏移应该乘站位旋转矩阵。但实际工作流是:美术在编辑器里摆好一个角色(默认朝向 0), 调好相机,复制相对坐标填表。如果运行时再叠一次站位 Yaw, 美术调的正面机位就变成了侧面,表填得再对也「看着不对」。
这类取舍在配置驱动的表现系统里非常典型——对齐美术的心智模型,而不是对齐数学。

4. 阶段2:Seq2 MVP 特写(黑屏内异步编排)

4.1 全流程

/**
 * MVP 特写准备:隐藏实体 → 加载 Seq2 → 换装 → 定格首帧 → 切相机,
 * 全程在 MvpView 的黑屏内完成。
 * 由 DungeonMvpView.OnBeforeStartAsync await,完成后才关黑屏,再由 PlayMvpCloseUp 播放。
 */
public async PrepareMvpCloseUpAsync(): Promise<void> { ... }
1
MvpView.OnBeforeStartAsync 里 await
界面配了 StartBlackScreen,黑屏在 StartAsync 结束后才关。 所有准备工作塞进这个窗口
2
SetActiveAllPlayers(false)
把副本里所有实体藏起来,只留结算演出
3
加载 Seq2 + bPauseAtEnd = true
非 GM 时设置末帧暂停,否则播完会跳回首帧闪一下
4
ApplyMvpAvatarToSeq2 换装
SetRoleAvatarBindingsWithData(actor, roleId, clothes, Seq2AvatarActor, 'Player', true, cb), 10s 超时兜底
5
Play() 紧接 Pause()
源码注释:「让序列绑定生效并定格首帧,相机同步就位,画面在黑屏下备好」
6
GetBindingPositionByTag('DungeonSettlementCamera2') → SwitchCamera
Seq2Ready = true,finishOnce() 放行界面
7
黑屏关闭 → OnBeforeShow 末尾 PlayMvpCloseUp()
此时才真正 Play。第一帧就是换好装的正确画面
Play() + Pause() 这个组合值得记住 LevelSequence 的绑定解算发生在第一次 Play 时,不 Play 就拿不到相机的实际位置、 换装 Actor 也不会真正挂上去。所以「想要预热但不想播」的标准做法就是 Play() 立刻 Pause()——序列停在首帧,但所有绑定已生效。

4.2 双层超时兜底

/** MVP 准备阶段的一次性结束回调,OnEnd 提前结束时调用,
    避免界面永远 await 在黑屏里 */
private MvpPrepareFinish: (() => void) | undefined = undefined;
兜底层常量触发后的行为
换装回调超时ApplyAvatarTimeout = 10000不等换装了,继续走后面的定格+切相机,Seq2 照播(可能没换装,但不卡)
整体准备超时MvpPrepareTimeout = 15000强制 finishOnce(),关黑屏,避免界面永远全黑
OnEnd 提前结束MvpPrepareFinish?.()玩家在准备期间就退了,主动放行还在 await 的界面
UI 侧最终保底MaxShowTime = 60sMvpView 自己的 Tick 保底,没收到 CloseUp 事件也强行结算
幂等靠置 undefined 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);
// 偶数下标往右、奇数往左 → 体型小的站中间,大的分居两侧
返回的是成员序索引,不是 roleId 源码注释:「list[站位序] = 成员序索引,不能直接存 roleId:队伍里有重复角色时无法反查是哪个成员」。
这正是阶段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 相机:从「配置半径估算」到「真实包围盒」的四连改

演进过程
v1
写死距离 CameraFallbackDistance = 900 —— 人多了装不下
v2
按人数加偏移:CameraDistanceOffset(180) + 满 4 人时 CameraFourPeopleOffset(80) —— 体型差异没考虑
v3
按配置体型半径估算总宽 —— 单人时退化成「半宽 = 自身半径」,遇到超高/超大体型(巨型机甲)相机贴脸拍不全
v4 · 终态
直接取真实渲染包围盒,横竖两个方向各算一次距离取大者
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 约束画幅,宽屏下会加左右黑边,这里关掉铺满视口
常量值含义
Seq3LookAtHeight100注视点抬到角色身体中部(cm),相机同高 → 平视正面合照
Seq3FaceYawOffset-90正脸朝向修正。注释写明是实测反推:偏移 0 时角色偏出相机 +90°
-90 这种魔数怎么写才负责 源码没有写 // 修正朝向 就完事,而是写了「由实测反推——偏移0时角色偏出相机+90°,故-90可正对相机」。 后来人看到这条注释,就知道这不是拍脑袋,也知道该怎么复现验证(改成 0 看看是不是偏 90°)。

5.8 SwitchCamera3 三级兜底

1
程序生成的 Seq3CameraActor
正常路径
2
GetBindingPositionByTag('DungeonSettlementCamera3')
回退到 Seq 里绑的机位
3
Log.Warn
两个都没有 → 相机不切,画面停在上一台
这条兜底暴露过一个真实事故 副本 20100201 的 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)」
这是一个通用模式:成对操作要记账,不要重新查询 凡是「A 时刻批量做了什么、B 时刻要撤销」的场景, 不要在 B 时刻重新查一次全集——两次查询之间集合一定会变。 正确做法是 A 时刻把动过的对象记下来,B 时刻只处理账本。 同样的坑在传送的实体清理、 VisibilityLock 残留里都出现过。

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;
回调做什么
OnBeforeStartAsyncawait PrepareMvpCloseUpAsync()。注释:「本界面配了 StartBlackScreen,黑屏在 StartAsync 结束后才关;把 Seq2 加载+换装放在这里 await,保证黑屏揭开时角色已换好装,不出穿帮帧」
OnBeforeShow读 configCommonParamById.GetIntConfig('dungeon_mvp_timeout')(默认 15s);末尾 PlayMvpCloseUp()
OnTick跨过 MvpShowButtonTime → 显按钮;超 MaxShowTime 且未结算 → Warn + 保底;每帧 LguiUtil.ProjectWorldToUiLocation(GetMarkerLocation(index)) 把 3D 站位投成 UI 坐标
OnClickBtnSettleHasSettled 幂等 → 清两个 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);   // 注意取反
}
成员排序:UI 序 ≠ 3D 站位序 UI 侧:本地玩家排第一(中间位),其余按加入时间正序。
3D 侧:按体型从中间往两边铺。
两套顺序完全不同,所以 UpdateSettleItemsLocation() 里写着:
「3D 站位是按体型重排的,与这里的成员顺序不同,只能按 uid 取模型位置」 → GetProxyActorLocationByUid(memberData.GetPlayerUid())

其它要点:

11. OnEnd 清理十一步

顺序有讲究,尤其是第 9 步
#动作备注
1Gm = false
2DungeonId = 0
3DungeonCfg = undefined
4SettlementReady = false
5MvpPrepareFinish?.()放行还在 await 的界面,否则黑屏永远不关
6Seq2Ready = false
7StopCameraTween + 销毁 CameraTweenActor + 清 Tween 状态
8LevelSequencePlayer2?.Stop()
9ReleaseSeq2Avatar()销毁 Seq2 前先释放换装 Actor,避免野指针
10销毁 Seq3CameraActor + MarkerActors.clear
11SetActiveAllPlayers(true) + ReleaseCamera + ClearProxyActors()恢复世界

12. 常量速查表

全部在 DungeonSettlementControllerConstants,每条都带中文 JSDoc。

常量值含义
DefaultBodyRadius50角色体型半径兜底值(CharacterBodyRadius 缺省时)
SettlementZOffset10站位点 Z 抬高,避免入地
CameraFallbackDistance900相机距离计算失败的兜底
CameraFov60视野角度(水平 FOV,度)
CameraDistanceOffset180相机基础距离回退量(留白)
CameraFourPeopleOffset80满 4 人时额外回退
CameraMinDistance120防贴脸
CameraMaxDistance2500防过远
CameraAspectRatio16/9按垂直 FOV 校验高度是否入镜
RenderCustomDepthStencil2自定义深度模板值(描边后处理)
GmRoleIdList1013 × 8GM 测试用角色 Id 列表
DefaultTweenDuration1Tween 段时长兜底(秒)
TweenHoldDuration0段末定格时长(秒)缺省值
Seq3LookAtHeight100全家福注视点高度(cm)
Seq3FaceYawOffset-90正脸朝向修正 Yaw(实测反推)
Seq3GroundTraceUp300贴地射线起点抬高(cm)
Seq3GroundTraceDown1000贴地射线向下距离(cm)
ApplyAvatarTimeout10000换装回调超时(ms)
MvpPrepareTimeout15000MVP 准备整体超时(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 卡集合

① 阶段3 名字与模型错位
现象
全家福里 A 的名字飘在 B 头上
根因
ProxyActors 是站位序(按体型重排),UI 成员列表是成员序(本地玩家优先),两边共用同一个循环下标 i
修复
建 ProxyActorByUid,UI 侧改用 GetProxyActorLocationByUid(uid);同时 GetSettlementPositionOrder 改成返回成员序索引而不是 roleId
教训
两个列表只要排序规则不同,就不能共用下标,必须有稳定业务主键(这里是 uid)
② 实体永久卡在隐藏状态
现象
结算结束回到世界,部分实体再也不显示
根因
hide/show 各调一次 GetAllEntities。hide 时存在、show 时已不在列表的实体,没人给它 SetActorVisible(true)
修复
自维护 HiddenEntities 集合 + EntityCreated/EntityRemoved 双监听
③ 黑屏揭开第一帧未换装穿帮
现象
MVP 特写开头闪一帧默认外观
根因
换装是异步的,黑屏关闭时还没回调完
修复
整套准备挪进 OnBeforeStartAsync 的 await 里,黑屏成为天然的遮丑窗口;Play()+Pause() 定格首帧确保绑定已生效
④ 描边特效闪断
根因
先隐藏 proxy 再 Play Seq2,中间有一帧「无角色」空窗
修复
调换顺序:Play() 之后再 SetRoleVisible(false)
⑤ 全家福相机拍不全 · 四连改
现象
单人超大体型(巨型机甲)时相机贴脸;8 人小体型时镜头过远
根因
用配置体型半径估算画面宽度;留白用比例系数
修复
GetActorArrayBounds 取真实包围盒 + 横竖两方向各算距离取大者 + 留白改绝对值(平均体型半径)
教训
「配置里的半径」是站位间距参数,不是渲染尺寸,别拿来当包围盒用
⑥ Camera3 资源缺失 → 静默失败 资产问题
现象
QA 报「SetActorVisible=true 但角色不显示」
真因
副本 20100201 的 Seq_DungeonSettlement_20100201_3 资产缺失,三级兜底全落空,相机根本没切,角色其实好好站着
修复
补资产;代码侧兜底已有 Log.Warn
⑦ 宽屏左右黑边
根因
CameraActor 默认 bConstrainAspectRatio = true,按 16:9 约束画幅
修复
程序生成的相机显式关掉
⑧ 每帧刷「找不到对应的Actor(Tag=RoleCase/…)」
根因
Proxy 默认走 UI 展台流程,去找场景里不存在的 Tag Actor
修复
改用 SetModelStateNoTween
⑨ 无限重复结算
根因
CloseMe 是异步关闭,界面销毁前 OnTick 还在跑,60s 保底逻辑反复触发
修复
HasSettled 布尔幂等
⑩ Seq2 换装 Actor 野指针
根因
先销毁 Seq2 再释放换装 Actor,Actor 还被序列绑定引用着
修复
OnEnd 里 ReleaseSeq2Avatar() 排在销毁 Seq2 之后、销毁相机之前,顺序固化
⑪ 界面永远卡黑屏
根因
玩家在 MVP 准备期间退出,PrepareMvpCloseUpAsync 的 Promise 永远不 resolve
修复
MvpPrepareFinish 一次性回调,OnEnd 主动调用放行;外加 15s 超时

14. 常见修改场景

14.1 给某个角色配 MVP 特写

1
做一条 LevelSequence
里面绑好相机(Tag 打 DungeonSettlementCamera2)和角色 Actor
2
在 DataTable 里加一行
RowStruct = SSettlementCameraConfig,填 RoleId + Seq2 软引用 + MvpShowButtonTime
3
Seq 里加事件轨道
到点触发 SequenceEventDungeonSettlementSeq2ShowButton 显示按钮
4
TS 零改动
配置驱动

14.2 重新启用阶段1 相机 Tween

去掉 OnStart 里两行注释、删掉两个函数上的 @ts-expect-error。 但必须同时解决角色穿帮——阶段1 spawn 的那排 marker 角色会在 Seq2 特写时出现在画面里。 两个方向:① 阶段2 开始前 ClearProxyActors(); ② 阶段1 复用 Seq2 的角色,不单独 spawn。

14.3 加一个新的结算数据维度(比如「治疗量」)

1
协议加字段
服务器下发
2
DungeonSettlementMemberData 加 getter
派生计算(如 GetHps)放这里,不要放 View
3
AnalyzeData 累加 Total/Max
占比条需要 Max
4
DungeonSettlementDataItem 加显示
327 行那个文件

14.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. 面试怎么讲

这里的复杂异步编排是怎么组织的重点
副本结算的 MVP 特写。表面需求很简单:播一段角色专属过场。 难点在于四个异步操作要在一个黑屏窗口内串完: LevelSequence 异步加载 → 换装异步回调 → 序列绑定解算 → 相机切换。
关键设计有三点:
① 把黑屏当作天然的遮丑窗口。界面框架有个 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 语义是站位间距,不是模型大小。
两个列表顺序不一致导致的错位,怎么根治设计
结算这里有两套顺序:3D 站位按体型从中间往两边铺(小的在中间好看), UI 列表按本地玩家优先 + 加入时间。 最初两边共用循环下标,导致名字飘错人头上。
根治方式是引入稳定业务主键:建一张 ProxyActorByUid 映射, 所有跨列表访问一律走 uid。
中间还踩了一步:一开始想用 roleId 当 key, 但队伍里可能有两个人带同一个角色,roleId 不唯一。 所以站位序表最终存的是「成员序索引」,源码注释也专门写清了这个原因。
更一般的原则:只要两个集合的排序规则可能不同,就绝不能用下标关联, 哪怕当前它们碰巧一致。
批量隐藏/恢复实体,为什么不能两次查全集概念
因为两次查询之间集合会变。结算演出持续十几秒,期间实体会被创建(延迟到达的召唤物) 也会被销毁。hide 时存在、show 时已经不在列表的实体,就永远卡在隐藏状态了。
正确做法是记账:hide 时把动过的实体存进 HiddenEntities, show 时只遍历这个集合。同时挂两个监听—— EntityCreated 把新来的也隐藏并入账, EntityRemoved 把销毁的移出账本(否则 show 阶段会在野 handle 上操作)。
这是个通用模式:凡是「A 时刻批量做、B 时刻批量撤」的场景,都要记账而不是重查。
为什么阶段1 的代码留着不删工程
相机 Tween 推镜是完整实现并验证过的功能,只是因为阶段2 改用 Seq 自带角色后两套角色会穿帮, 临时关掉了调用。策划随时可能要回退。
处理方式是:注释掉调用点并写明原因,函数体上加 @ts-expect-error 阶段1临时关闭未调用 让编译器接受「已定义未使用」。这样死代码是显式标记的, 不会有人以为是忘了删,也不会被 lint 规则误伤。
反面例子是同一个工程里的 LevelEventDestroyEnvBullet—— 写完了但因为该行为是 ImplementType: 'Server' 根本不会被客户端注册, 没有任何标记,看代码的人得翻到 Action.ts 的配置表才知道它是死的。