首页 / 入职工作清单 / 副本集合区域

副本集合区域

评评②做的多人协作引导模块:把散在副本里的玩家聚合到集合区,倒计时归零强制传送。 坑全在「分布式的玩家」上:广播是关卡级(非 AOI 视野), 倒计时用服务器绝对时间戳(客户端时钟无意义), UI 的 OpenView 是异步的——通知先到、控件后就绪,刷新会被丢。

多人协作 关卡级广播非 AOI 服务器绝对时间戳 异步窗口丢刷新防御 两终态统一编码

1. 工作清单

内容性质
副本集合区域 0 到 1:14050 广播协议对接、进关全量初值 + 运行时增量、逐秒倒计时提示 UI、文案多语言与净化兜底 0 建

2. 理论:三个分布式难题与对应决策

2.1 广播范围:为什么不用 AOI

1
集合是关卡级状态
inRangeNum/totalNum/forceTeleportTimeMs 描述整个关卡所有玩家的聚合进度
2
玩家可能离集合区很远
AOI(兴趣域)同步只管视野内实体——集合区实体不在视野内就没数据
3
方案:全局 Controller 驱动
14050 关卡级全员下发,提示 UI 由全局 Controller 持有,不挂在任何实体上
提示类 UI 的宿主选择 实体级 UI(血条、交互提示)挂实体,跟着 AOI 走; 关卡级状态 UI(集合、毒圈提示)必须由全局 Controller 驱动—— 数据本来就是全关卡广播的,宿主选错(挂实体)会得到「玩家跑远提示消失」的灵异 bug。 进关/重连还用 EndClientLoading 事件补刷一次,玩家加载再慢也能看到提示。

2.2 倒计时:服务器绝对时间戳 + 两终态统一编码

forceTeleportTimeMs 值语义客户端行为
0服务器 IDLE(未开始)或 FINISHED(已完成)隐藏 UI、停逐秒刷新
> 0强制传送时刻(服务器绝对毫秒时间戳)remain = ceil((forceMs - Date.now())/1000),逐秒刷新
两个设计决策 ① 绝对时间戳而非剩余秒数:客户端各评倒计时必然漂移, 服务器只发「传送时刻」,剩余数客户端本地算,多人倒计时天然一致(同毒圈关键帧方案); ② IDLE 和 FINISHED 都编码成 0:客户端只需要区分「有倒计时/无倒计时」, 「没开始」和「已结束」的 UI 行为完全一样(隐藏)—— 两终态合一让客户端 switch 永远只有两个分支,协议演进时加状态不破客户端。

2.3 逐秒刷新:单入口 + 不受时间膨胀影响

1
所有刷新走 RefreshTips
14050 通知、EndClientLoading、逐秒 tick 三个入口全部收敛到这一个函数
2
每秒 tick 只是再调 RefreshTips
RefreshTips 内部评判:倒计时 ≤0 就停 tick 隐藏 UI,归零逻辑不用 tick 评己写
3
{ Dilation: 1 }
倒计时定时器不随全局时间膨胀缩放——演出慢动作时集合倒计时照常走
Dilation: 1 这个参数救过一个真实场景 副本里 QTE/技能演出会开全局时间膨胀(世界变 0.5 倍速)。 集合倒计时如果跟着膨胀走,一场演出玩家看到倒计时「卡住 8 秒」—— 真实时间没到,传送却没发生,观感像 bug。 「显示时钟」类 UI 的定时器必须和游戏时间膨胀解耦。

3. 代码片段

3.1 广播接收 + 全量初值补刷

Src\Game\Module\DungeonGather\DungeonGatherController.ts:54-75

/** 收到集合广播(14050):关卡级全员下发,与集合区实体是否在本地视野内无关 */
private readonly OnEntityGatherUpdateSCNotify = (notify: Game.Protocol.WorldEntityGatherUpdateSCNotify): void => {
    const gatherInfo = notify.gatherInfo;
    if (!gatherInfo) { return; }
    Log.Info(ELogModule.Level, ELogAuthor.ZZB, '集合区域-收到集合广播14050',
        ['inRangeNum', gatherInfo.inRangeNum ?? 0],
        ['totalNum', gatherInfo.totalNum ?? 0],
        ['forceTeleportTimeMs', LongIdOps.GetConstrained64BitId(gatherInfo.forceTeleportTimeMs ?? 0)],
    );
    ModelManager.DungeonGatherModel?.SetInfo(gatherInfo);
    this.RefreshTips();
};

/** 进关卡/重连加载完成:用全量集合信息补一次,玩家离集合区再远也能看到提示 */
private readonly OnEndClientLoading = (): void => {
    this.RefreshTips();
};
两类信息源 运行时增量走 14050 广播;进关/重连的全量初值走关卡信息命令(见 3.4)。 重连场景两者都靠:加载完成事件补一次(用全量初值),之后的广播继续增量。 Log.Info 带 inRangeNum/totalNum/forceMs 三字段—— 线上「玩家说没看到提示」时日志能直接还原当时服务器推了什么。

3.2 RefreshTips:单入口 + 两终态隐藏

Src\Game\Module\DungeonGather\DungeonGatherController.ts:77-105

/** 刷新集合提示。forceTeleportTimeMs=0 时隐藏:服务器 IDLE(未开始) 与 FINISHED(已完成) 都下发 0 */
private RefreshTips(): void {
    const info = ModelManager.DungeonGatherModel?.GetInfo();
    const remainSec = this.GetRemainTeleportSec(info);
    // 无集合信息或无倒计时=未开始或已完成,隐藏 UI 并停止刷新
    if (!info || remainSec <= 0) {
        this.StopCountdownTick();
        ControllerHolder.DungeonGatherTipsController.Hide();
        return;
    }
    const inRangeNum = info.inRangeNum ?? 0;
    const totalNum = info.totalNum ?? 0;
    const dataComp = this.GetGatherDataComp(info);
    const template = this.ResolveText(dataComp?.TidTeleportText, dataComp?.EdTidTeleportText);
    const text = StringUtils.Format(template, `${inRangeNum}`, `${totalNum}`, `${remainSec}`);
    const numText = `(${inRangeNum}/${totalNum})`;
    this.StartCountdownTick();
    ControllerHolder.DungeonGatherTipsController.Show(numText, text);
}

/** 剩余传送秒数,完全以服务器强制传送时间戳为准;0 = 无倒计时(集合未开始或已完成) */
private GetRemainTeleportSec(info: Game.Protocol.IEntityGatherInfo | undefined): number {
    const forceMs = LongIdOps.GetConstrained64BitId(info?.forceTeleportTimeMs ?? 0);
    if (forceMs > 0) {
        return Math.max(0, Math.ceil((forceMs - Date.now()) / 1000));
    }
    return 0;
}
「完全以服务器时间为准」的取舍 倒计时显示是 ceil 向上取整——玩家看到 10 就还剩 9.x 秒, 宁可显示多 1 秒不显示少(显示 0 却没传送 = 观感 bug)。 remainSec <= 0 隐藏的判断放在函数头,三个入口(广播/加载完成/tick)都评然走到。

3.3 逐秒 tick:不膨胀 + 已启动不重复

Src\Game\Module\DungeonGather\DungeonGatherController.ts:128-152

/** 启动倒计时逐秒刷新(已启动则不重复) */
private StartCountdownTick(): void {
    if (this.CountdownTimerId !== undefined) {
        return;
    }
    this.CountdownTimerId = this.OwnedTimers.Forever(
        this.OnCountdownTick,
        1000,
        'DungeonGatherController.StartCountdownTick',
        { Dilation: 1 },
    );
}

/** 倒计时逐秒回调:刷新文本;归零由 RefreshTips 内部隐藏并停止 */
private readonly OnCountdownTick = (): void => {
    this.RefreshTips();
};
tick 是「无状态」的 OnCountdownTick 一行——只是再调 RefreshTips。 刷新内容、归零判断、启停逻辑全在 RefreshTips 单入口里, tick 本身零状态,没有「tick 里忘停定时器」这类泄漏路径 (归零 → RefreshTips 头部 → StopCountdownTick)。

3.4 View 异步窗口:PendingText 防丢刷新

Src\Game\Module\Tips\View\DungeonGatherTipsView.ts:23-47

/** 控件就绪前收到的文本,就绪后补刷,避免 OpenView 异步期间的刷新被丢掉 */
private PendingNumText = '';
private PendingText = '';

protected override OnBeforeShow(): void {
    this.TxtNum = this.GetText(EDungeonGatherTipsComponents.TxtNum);
    this.TxtCd = this.GetText(EDungeonGatherTipsComponents.TxtCd);
    const param = this.OpenParam as IDungeonGatherTipsParam | undefined;
    this.RefreshText(this.PendingNumText || (param?.NumText ?? ''), this.PendingText || (param?.Text ?? ''));
}

/** 外部刷新提示文本:numText 上行人数,text 下行配置文案 */
public RefreshText(numText: string, text: string): void {
    this.PendingNumText = numText;
    this.PendingText = text;
    this.TxtNum?.SetText(numText);
    this.TxtCd?.SetText(text);
}
竞态时间线(经典 UI 异步坑) Controller 收到 14050 → Show() 触发 OpenView(异步,控件要等 OnBeforeShow 才就绪)→ 与此同时 tick 每秒调 RefreshText——控件没就绪时 this.TxtNum? 可选链静默跳过, 刷新被丢。等控件就绪,屏幕上显示的是 OpenParam 的旧文本,倒计时不走了。
解法:RefreshText 无条件写 PendingText(两次写入无损耗), OnBeforeShow 就绪时优先用 Pending 覆盖 OpenParam—— 异步窗口里到达的每一拍刷新都被「存起来迟到执行」。

3.5 进关全量初值:与其他世界模块同一入口

Src\Game\World\SceneLoading\Commands\SceneLoadingCmds\ServerLevelCmd\SceneLoadingApplyLevelInfoCmd.ts:26-34(节选)

// 燃值信息
ModelManager.FirePointModel!.SetInfo(levelInfo.firePointInfo);

ModelManager.CombatMessageModel!.SynchronizeSequenceInfo(levelInfo.battleSeqInfo);

ModelManager.PoisonZoneModel!.SetInfo(levelInfo.poisonZoneInfo);

// 副本集合区域全量初值(进关卡时生效的集合区,运行时增量走 14050)
ModelManager.DungeonGatherModel!.SetInfo(levelInfo.gatherInfo);
全量初值 + 运行时增量的双段式 同一个关卡信息命令里批量灌了燃值/毒圈/集合—— 这是本工程世界类模块的标准接入模式: 加载期灌全量(断线重连/切场景的恢复路径),运行时靠广播增量。 新模块照这个模式接,重连时零额外代码。

4. 面试怎么讲

集合区域模块最难的部分是什么难点
不是集合判定(在服务器),是客户端表现的三类边界:
① 广播是关卡级非 AOI——玩家离集合区再远也要看到提示, 所以 UI 宿主是全局 Controller 不是集合区实体,选错会出现「跑远提示消失」的 bug; ② 倒计时用服务器绝对时间戳,客户端各评倒计时必然漂移, 多人看到的归零时刻必须一致; ③ UI OpenView 是异步的——通知先到、控件后就绪, 逐秒刷新会被可选链静默吞掉,用 PendingText 存迟到刷新,控件就绪时补刷。
forceTeleportTimeMs 为什么用 0 编码两种终态设计
IDLE(未开始)和 FINISHED(已完成)对客户端的表现完全一致:隐藏提示。 把两种服务器状态统一编码成 0,客户端只需要两个分支(有倒计时/无倒计时), 「没开始」和「已结束」不区分。
好处是协议演进加状态不破坏老客户端(新状态只要继续下发 0), 坏处是客户端无法区分——但这里根本不需要区分。 协议设计原则:客户端用不到的区分度不要下发。
倒计时定时器为什么要带 Dilation: 1关联
工程的全局定时器默认跟着时间膨胀走(QTE、演出会把世界缩到 0.5 倍速)。 集合倒计时的基准是服务器真实时间——传送时刻是真实时间戳, 如果显示倒计时跟着慢动作走,一场演出里玩家看到倒计时卡住, 真实时间到了传送没发生,观感是 bug。
所以逐秒 tick 挂 { Dilation: 1 } 和膨胀解耦。 「显示时钟类 UI」和「游戏逻辑时钟」分轨,是时间膨胀体系下的必修课。