计时器玩法
关卡内的倒计时 / 正计时 HUD(内部代号 BombTimer,行为树计时器)。
策划在行为树里起一个计时器,客户端在屏幕上显示 MM:SS:CC,
剩余时间进入紧急值时切红色 + 抖动动画。
核心设计:服务器只在状态变化时下发一次,
中间的每一帧都由客户端按服务器时间戳墙钟自己推算——不吃带宽,也不会累计漂移。
Controller/Model/View 三层
墙钟推进
协议 WorldBehaviorTreeTimerParameterSCNotify
服务器时间推进
487 行
1. 模块定位
1.1 三个文件的职责
| 文件 | 行数 | 职责 |
|---|---|---|
| BombTimerDefine.ts | 36 | 两个枚举 + 常量。EBombTimerType(正/倒计时)、EBombTimerState(四态) |
| BombTimerModel.ts | 487 | 全部业务逻辑。Map 存计时器、状态映射、墙钟推进、进度/紧急判定 |
| BombTimerController.ts | 125 | 薄壳。只做协议注册 + 转发 Model + Emit 事件 |
| LevelUiTimerView.ts | 252 | HUD。50ms 一刷,红/白双套文本,紧急抖动动画 |
这是标准的「胖 Model 瘦 Controller」
Controller 里 10 个 public 方法有 8 个是一行转发(
this.Model?.Xxx() ?? 默认值)。
为什么不让 View 直接调 Model?因为 ControllerHolder 是 View 唯一被允许依赖的入口,
Controller 层承担了「空值兜底」——Model 可能还没创建,转发层统一 ?? BombTimerDefaultValue,
View 就不用到处判空。
1.2 两个枚举
/**
* 行为树计时器类型枚举
* 服务器协议映射:0=正计时, 1=倒计时
*/
export const enum EBombTimerType {
CountUp = 0, // 正计时(从0开始增加)
CountDown = 1, // 倒计时(从目标值开始减少)
}
/**
* 行为树计时器状态枚举
* 服务器协议映射:0=启动, 1=暂停, 2=停止
*/
export const enum EBombTimerState {
Idle = -1, // 空闲状态(初始状态)—— 客户端独有,服务器没有这个值
Running = 0, // 启动
Paused = 1, // 暂停
Stopped = 2, // 停止
}
为什么要做 status ↔ State 的双向映射函数
枚举值和服务器 status 数值其实是一样的(0/1/2),
MapStatusToState / MapStateToStatus 看起来是多余的。
但它们做了一件关键的事:把服务器没有的 Idle = -1 兜进来。
服务器下发了个 status = 7 这种脏值,default 分支返回 Idle,
下游按「未启动」处理,不会崩也不会乱显示。MapStateToStatus 的 default 则返回 0(Running),
两边的 default 不对称——这是有意的防御方向:读取时宁可判为「没在跑」,写入时宁可判为「在跑」。
2. 核心机制:墙钟推进
2.1 为什么不用客户端 Timer 自减
| 做法 | 问题 |
|---|---|
客户端 Timer 每秒 -1000ms | 累计漂移。Timer 精度不保证,跑 5 分钟能差好几秒;卡顿/后台时更严重 |
| 服务器每秒下发一次 | 纯浪费带宽。一个副本 8 人 × 每秒 1 包 × N 个计时器 |
| 基准时间戳 + 墙钟差 | ✅ 服务器只在状态变化时发一次,客户端按 ServerTimeStamp - sendTimeStamp 算 |
2.2 GetDisplayTime 全文
/**
* 获取计时器显示时间(考虑服务器和客户端的延时)
*/
public GetDisplayTime(timerName: string): number {
const timer = this.Timers.get(timerName);
if (!timer) { return BombTimerDefineConstants.BombTimerDefaultValue; }
const currentValue = timer.CurrentValue;
const targetValue = timer.TargetValue;
const timerType = timer.timerType as EBombTimerType;
const sendTimeStamp = timer.SendTimeStamp;
const currentState = this.MapStatusToState(timer.status);
// 仅运行态才按墙钟推进显示值;暂停/停止/未启动时冻结在 currentValue
// 否则未勾"默认开启"的计时器也会被误显示为正在计时
const timeDiff = currentState === EBombTimerState.Running
? Time.ServerTimeStamp - sendTimeStamp
: 0;
let showTimeMs = 0;
if (timerType === EBombTimerType.CountUp) {
showTimeMs = currentValue + timeDiff; // 正计时:往上加
} else {
showTimeMs = currentValue - timeDiff; // 倒计时:往下减
}
if (showTimeMs > targetValue) { showTimeMs = targetValue; } // 上钳
if (showTimeMs < 0) { showTimeMs = 0; } // 下钳
// 检查计时器是否已完成,如果完成则设置为Stopped状态
if (currentState !== EBombTimerState.Stopped) {
if ((timerType === EBombTimerType.CountUp && showTimeMs >= targetValue) ||
(timerType === EBombTimerType.CountDown && showTimeMs <= 0)) {
this.UpdateTimerState(timerName, EBombTimerState.Stopped); // 客户端自封顶
}
}
return showTimeMs;
}
计时器没勾 AutoStart 却在计时
现象
策划在行为树里配了计时器但没勾「自动启动」,进关卡 HUD 上的计时器却已经在跑,而且数字乱跳
排查
grep
行为树计时器-SetInfo,看到 status 字段的实际值。如果服务器下发的确实是 status = 0(Running),那锅在服务器;如果是 1 或 2 而客户端还在跑,锅在客户端根因(客户端侧)
GetDisplayTime 无视 status 一律按墙钟推进:timeDiff = Time.ServerTimeStamp - sendTimeStamp 无条件计算。计时器只要被 SetInfo 一次,sendTimeStamp 就定了,之后每帧算出来的 timeDiff 都在变大,看起来就是在跑根因(第二处)
GetDisplayTimerName 挑显示对象时只过滤 Stopped,没过滤 Paused,暂停的计时器照样被选中显示修法
改成只有 Running 才加 timeDiff,其余状态
timeDiff = 0,冻结在 currentValue教训
凡是「按时间自动推进」的显示逻辑,必须先查状态再算增量,不能算完了再补判。这类 bug 的特征是「表现是活的但数据是死的」,很容易被误判成服务器没下发
这个 bug 的另一半可能在服务器
如果排查后发现服务器对未勾 AutoStart 的计时器下发的就是
status = 0,
那客户端这层改完只是止血——服务器忽略了 AutoStart 配置。
排查时先看日志里 status 的实际值再决定改哪边。
2.3 客户端自封顶:为什么 Model 要主动改状态
GetDisplayTime 里有一段「算完之后如果到点了就 UpdateTimerState(Stopped)」。
这看起来违反直觉——getter 里有副作用。但这是必要的:
| 如果不自封顶 | 后果 |
|---|---|
| 依赖服务器发 Stopped 通知 | 网络延迟期间,玩家看到倒计时停在 00:00:00 但 UI 没消失、紧急动画还在抖 |
| 只钳制显示值不改状态 | GetDisplayTimerName 过滤不掉它,这个已完成的计时器会一直霸占 HUD,新起的计时器显示不出来 |
所以设计上是「客户端乐观地自己判完成,服务器的 Stopped 通知只是确认」。 代价就是 getter 有副作用——这在代码规范上是要打问号的, 但在这个场景里,替代方案(单开一个 tick 去扫所有计时器)成本更高。
3. 显示对象选择
/**
* 获取应该显示的计时器名称
* 如果有多个计时器 showUi=true,返回和当前时间最近的
* 过滤掉 Stopped 状态的计时器
*/
public GetDisplayTimerName(): string | undefined {
let displayTimerName: string | undefined = undefined;
let minTimeDiff = Infinity;
for (const [timerName, timer] of this.Timers) {
if (timer.showUi) {
const timerState = this.MapStatusToState(timer.status);
if (timerState === EBombTimerState.Stopped) { continue; }
const sendTimeStamp = timer.SendTimeStamp;
const timeDiff = Math.abs(Time.ServerTimeStamp - sendTimeStamp);
if (timeDiff < minTimeDiff) { // 取最近下发的那个
minTimeDiff = timeDiff;
displayTimerName = timerName;
}
}
}
return displayTimerName;
}
「最近下发的优先」是一个隐式优先级
关卡里可能同时存在多个
这个规则没有配置项。如果策划需要显式优先级,要加
showUi 的计时器(比如主线倒计时 + 支线倒计时),
HUD 只有一个位置。选择规则是谁最新下发就显示谁——
隐含的假设是「服务器刚推的那个是玩家当前最该关注的」。这个规则没有配置项。如果策划需要显式优先级,要加
priority 字段,
改动落在这个函数和协议结构。
4. 协议与数据结构
4.1 协议结构
interface IBtTimerMsg {
timerName: string; // 唯一键(不是 Id,是名字)
timerTargetValue: (number|Long); // 目标值(毫秒)
timerType: number; // 0=正计时 1=倒计时
showUi: boolean; // 是否上 HUD
emergencyValue: (number|Long); // 紧急阈值(毫秒)
status: number; // 0=启动 1=暂停 2=停止
currentValue: (number|Long); // 下发瞬间的值
sendTimeStamp: (number|Long); // 下发瞬间的服务器时间戳 —— 墙钟基准
}
4.2 Long 转 number:四个字段都要过 LongIdOps
export type TBombTimerWithExtra = Nami.Protocol.IBtTimerMsg & {
TargetValue: number;
SendTimeStamp: number;
EmergencyValue: number;
CurrentValue: number;
};
public SetInfo(info: Nami.Protocol.IBtTimerMsg): void {
if (!info) { return; }
const targetValue = LongIdOps.GetConstrained64BitId(info.timerTargetValue, '行为树计时器-目标值');
const sendTimeStamp = LongIdOps.GetConstrained64BitId(info.sendTimeStamp, '行为树计时器-发送时间戳');
const emergencyValue= LongIdOps.GetConstrained64BitId(info.emergencyValue, '行为树计时器-紧急值');
const currentValue = LongIdOps.GetConstrained64BitId(info.currentValue, '行为树计时器-当前值');
const timerWithExtra: TBombTimerWithExtra = {
...info,
TargetValue: targetValue, SendTimeStamp: sendTimeStamp,
EmergencyValue: emergencyValue, CurrentValue: currentValue,
};
this.Timers.set(info.timerName, timerWithExtra);
}
protobuf 的 int64 在 JS 里是 Long 对象,不能直接算术
所以约定:协议里所有 int64 字段,进 Model 时立刻用
info.currentValue - info.sendTimeStamp 这么写不会报错但结果是 NaN
(Long 对象参与减法会走 valueOf,返回 object)。所以约定:协议里所有 int64 字段,进 Model 时立刻用
LongIdOps.GetConstrained64BitId 转成 number,并存到 PascalCase 的新字段上。
命名上 currentValue(camelCase,协议原样)和 CurrentValue(PascalCase,已转换)
共存于同一对象,一眼能看出哪个是安全的。GetConstrained64BitId 会检查是否超出 JS 安全整数范围,超了打 Log.Error('Id位溢出')。
第二个参数 context 就是为了让这条 Error 能定位到具体字段。
4.3 唯一键是 timerName 字符串
Timers: Map<string, TBombTimerWithExtra>,键是字符串名字不是数字 Id。
好处是策划在行为树里起名就是键,不用维护 Id 表;
代价是拼错名字不会报错,只会静默地建出一个新计时器。
排查「计时器不显示」时要核对名字大小写。
5. HUD 层 LevelUiTimerView
5.1 双驱动:事件 + 50ms 轮询
1
事件驱动 ·
EEventName.BombTimerChanged协议到达时 Emit。触发
Refresh() —— 负责切换显示对象、开关 UI、起轮询2
轮询驱动 ·
TimerSystem.Forever(OnTimerRefresh, 50)50ms 一次。只负责刷数字(
OnUpdate),因为墙钟每毫秒都在变private Refresh(): void {
const displayTimerName = ControllerHolder.BombTimerController.GetDisplayTimerName();
if (!displayTimerName) { // 没有可显示的 → 关 UI 停轮询
this.ClearTimer();
if (this.IsUiActive) { this.SetUiActive(false); this.IsUiActive = false; }
return;
}
if (displayTimerName === this.CurrentDisplayTimerName && TimerSystem.Has(this.RefreshTimer)) {
return; // 同一个计时器且轮询还活着 → 什么都不做,避免重复起 Timer
}
this.CurrentDisplayTimerName = displayTimerName;
this.OnUpdate(displayTimerName);
if (!this.IsUiActive) { void this.PlayAnimation(EUiSequenceType.Start); } // 隐→显才播入场
this.SetUiActive(true);
this.IsUiActive = true;
this.RefreshTimer = TimerSystem.Forever(this.OnTimerRefresh, 50);
}
高亮那段是防重复的关键
判断条件是「名字相同 && Timer 还活着」两个条件都要—— 只判名字的话,Timer 被
BombTimerChanged 事件可能高频到达(多个计时器同时更新)。
没有这个 early return,每次事件都会再起一个 Forever Timer——
旧的没删,Timer 数量随事件数线性增长,最终 50ms 里跑几十次 OnUpdate。判断条件是「名字相同 && Timer 还活着」两个条件都要—— 只判名字的话,Timer 被
ClearTimer 清掉后就永远起不回来了。
5.2 紧急态:两套文本 + 抖动动画
const isEmergency = ControllerHolder.BombTimerController.IsTimerEmergency(displayTimerName);
// 检测紧急状态切换
if (isEmergency && !this.IsLastEmergency) {
void this.PlayAnimation(EUiSequenceType.Shaking); // 只在"刚进入紧急"时播一次
}
// MM:SS:CC 三段拆分
const totalSeconds = Math.floor(timerValue / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
const milliseconds = timerValue % 1000;
const minutesStr = minutes.toString().padStart(2, '0');
const secondsStr = seconds.toString().padStart(2, '0');
const millisecondsStr = Math.floor(milliseconds / 10).toString().padStart(2, '0'); // 取百分秒
if (isEmergency) {
this.TxtLeftRed?.SetText(minutesStr); ... this.TxtLeft?.SetText(''); ...
this.PnlTimerTipsRed?.SetUIActive(true);
this.PnlTimerTips?.SetUIActive(false);
} else { /* 反过来 */ }
this.IsLastEmergency = isEmergency;
| 细节 | 为什么 |
|---|---|
| 红/白两套完整文本节点,不是改颜色 | 美术给的是两套 prefab(字体、描边、发光都不同)。切颜色做不出效果 |
切换时把另一套 SetText('') | 光 SetUIActive(false) 不清文本,某些情况下隐藏节点仍参与布局导致跳动 |
| 抖动只在状态切换沿播 | IsLastEmergency 记住上一帧。不然 50ms 播一次,抖成筛子 |
毫秒 /10 只显示两位 | 三位数字在 HUD 上视觉太乱,百分秒足够传达紧张感 |
5.3 View 层的越界二次判定
const timerType = ModelManager.BombTimerModel?.GetTimerType(displayTimerName);
if (timerType === EBombTimerType.CountUp && timerValue > targetValue) {
this.ClearTimer(); return;
} else if (timerType === EBombTimerType.CountDown && timerValue < 0) {
this.ClearTimer(); return;
}
Model 的 GetDisplayTime 已经钳制过了,这里理论上永远不成立。
保留它是防御性冗余——View 不信任 Model 的钳制,
万一 Model 改了钳制逻辑,UI 也不会显示出负数。
代价是这段是死代码,读代码的人会困惑。取舍见仁见智。
6. 常见修改场景
6.1 加一个「显示优先级」字段
1
协议加
priority服务器 proto 改 + 重新生成 Protocol.d.ts。需要跟服务器同学协调
2
改
GetDisplayTimerName比较顺序改成「先比 priority,相同再比 timeDiff」
3
View 零改动
View 只调
GetDisplayTimerName(),不关心怎么选出来的6.2 支持同屏显示多个计时器
这是大改。当前架构假设「HUD 只有一个」:
CurrentDisplayTimerName 是单值、RefreshTimer 是单个、UI 节点是固定的 6 个 Text。
要改成多个,需要:
GetDisplayTimerName()→GetDisplayTimerNames(): string[]- View 改成列表 + Item 池(参考 爬塔星级 HUD 的
DungeonStarTimeItem做法) - 轮询 Timer 保持单个,一次 tick 刷所有 Item
6.3 排查「计时器不显示 / 显示错了」
| 步骤 | grep 关键字 | 看什么 |
|---|---|---|
| 1 | 行为树计时器-SetInfo | 协议有没有到。没到 → 行为树没起计时器 / 服务器问题 |
| 2 | 同上,看 showUi | false → 策划配了不上 HUD |
| 3 | 同上,看 status | 1/2 → 暂停或停止,不该显示;0 但策划说没勾 AutoStart → 服务器 bug |
| 4 | 同上,看 timerName | 跟策划配的对不对得上(大小写、空格) |
| 5 | 行为树计时器 - OnStart | View 有没有创建出来 |
| 6 | 看 sendTimeStamp 与 Time.ServerTimeStamp | 差值离谱(比如几小时)→ 服务器时间戳单位错了(秒 vs 毫秒) |
7. 面试怎么讲
计时器同步方案怎么设计的设计
用的是基准时间戳 + 客户端墙钟推算。
服务器只在状态变化(启动/暂停/停止/数值被行为树改)时下发一次,
包里带
好处是不吃带宽(对比每秒下发),也不会累计漂移(对比客户端 Timer 自减)—— 每次都是从基准点重算,误差不累积。
客户端还做了乐观自封顶:算出来到点了就自己把状态置 Stopped, 不等服务器通知,避免网络延迟期间 UI 停在 00:00 不消失。
currentValue 和 sendTimeStamp。
客户端每帧算 ServerTimeStamp - sendTimeStamp 得到经过的时间,
正计时就加、倒计时就减。好处是不吃带宽(对比每秒下发),也不会累计漂移(对比客户端 Timer 自减)—— 每次都是从基准点重算,误差不累积。
客户端还做了乐观自封顶:算出来到点了就自己把状态置 Stopped, 不等服务器通知,避免网络延迟期间 UI 停在 00:00 不消失。
讲一个这个模块的 bug难点
「计时器没勾自动启动却在计时」。
根因是
修法是只有 Running 状态才加 timeDiff,其余冻结在 currentValue。
可复用的教训:凡是「按时间自动推进」的显示逻辑, 必须先查状态再算增量。这类 bug 的特征是「表现是活的但数据是死的」, 第一反应容易怀疑服务器没下发,实际是客户端自己在动。
还有一半可能在服务器——如果日志里 status 确实是 0,说明服务器忽略了 AutoStart 配置, 客户端改完只是止血。所以排查时先看日志里 status 的实际值再决定改哪边。
根因是
GetDisplayTime 无视 status 一律按墙钟推进——
只要计时器被下发过一次,sendTimeStamp 就定了,
之后每帧算出的 timeDiff 都在增大,看起来就在跑。
另外 GetDisplayTimerName 挑显示对象时只过滤了 Stopped,漏了 Paused。修法是只有 Running 状态才加 timeDiff,其余冻结在 currentValue。
可复用的教训:凡是「按时间自动推进」的显示逻辑, 必须先查状态再算增量。这类 bug 的特征是「表现是活的但数据是死的」, 第一反应容易怀疑服务器没下发,实际是客户端自己在动。
还有一半可能在服务器——如果日志里 status 确实是 0,说明服务器忽略了 AutoStart 配置, 客户端改完只是止血。所以排查时先看日志里 status 的实际值再决定改哪边。
protobuf 的 int64 在 TS 里怎么处理工程
protobuf 的 int64 在 JS 里是
项目里的约定是:协议对象进 Model 时立刻用
转换函数还会检查是否超出 JS 安全整数范围(2^53),超了打 Error 日志, 并且要求传一个 context 字符串,这样线上报「Id位溢出」时能定位到是哪个字段。
Long 对象,不能直接参与算术——
写 a - b 不报错但结果是 NaN,非常隐蔽。项目里的约定是:协议对象进 Model 时立刻用
LongIdOps.GetConstrained64BitId 转成 number,
存到一个 PascalCase 的新字段上。比如协议的 currentValue 转成 CurrentValue,
两者共存于同一个对象。这样命名就是文档——camelCase 是协议原样(不安全),PascalCase 是已转换(可以算)。转换函数还会检查是否超出 JS 安全整数范围(2^53),超了打 Error 日志, 并且要求传一个 context 字符串,这样线上报「Id位溢出」时能定位到是哪个字段。
50ms 轮询会不会有性能问题细节
单个 Timer 50ms 一次,做的是几次 Map.get + 算术 + SetText,开销可以忽略。
真正的风险是重复起 Timer:
注意这两个条件都要判——只判名字的话, Timer 被
真正的风险是重复起 Timer:
BombTimerChanged 事件可能高频到达,
每次 Refresh() 都起一个 Forever 而不删旧的,Timer 数量就会线性增长。
所以 Refresh() 里有一个 early return:
如果显示对象没变且轮询还活着就直接返回。注意这两个条件都要判——只判名字的话, Timer 被
ClearTimer 清掉之后就永远起不回来,计时器会卡住不动。