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