计时器玩法

关卡内的倒计时 / 正计时 HUD(内部代号 BombTimer,行为树计时器)。 策划在行为树里起一个计时器,客户端在屏幕上显示 MM:SS:CC, 剩余时间进入紧急值时切红色 + 抖动动画。
核心设计:服务器只在状态变化时下发一次, 中间的每一帧都由客户端按服务器时间戳墙钟自己推算——不吃带宽,也不会累计漂移。

Controller/Model/View 三层 墙钟推进 协议 WorldBehaviorTreeTimerParameterSCNotify 服务器时间推进 487 行

1. 模块定位

1.1 三个文件的职责

文件行数职责
BombTimerDefine.ts36两个枚举 + 常量。EBombTimerType(正/倒计时)、EBombTimerState(四态)
BombTimerModel.ts487全部业务逻辑。Map 存计时器、状态映射、墙钟推进、进度/紧急判定
BombTimerController.ts125薄壳。只做协议注册 + 转发 Model + Emit 事件
LevelUiTimerView.ts252HUD。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 = Game.Protocol.IBtTimerMsg & {
    TargetValue: number;
    SendTimeStamp: number;
    EmergencyValue: number;
    CurrentValue: number;
};

public SetInfo(info: Game.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 对象,不能直接算术 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);
}
高亮那段是防重复的关键 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。 要改成多个,需要:

6.3 排查「计时器不显示 / 显示错了」

步骤grep 关键字看什么
1行为树计时器-SetInfo协议有没有到。没到 → 行为树没起计时器 / 服务器问题
2同上,看 showUifalse → 策划配了不上 HUD
3同上,看 status1/2 → 暂停或停止,不该显示;0 但策划说没勾 AutoStart → 服务器 bug
4同上,看 timerName跟策划配的对不对得上(大小写、空格)
5行为树计时器 - OnStartView 有没有创建出来
6看 sendTimeStamp 与 Time.ServerTimeStamp差值离谱(比如几小时)→ 服务器时间戳单位错了(秒 vs 毫秒)

7. 面试怎么讲

计时器同步方案怎么设计的设计
用的是基准时间戳 + 客户端墙钟推算。 服务器只在状态变化(启动/暂停/停止/数值被行为树改)时下发一次, 包里带 currentValue 和 sendTimeStamp。 客户端每帧算 ServerTimeStamp - sendTimeStamp 得到经过的时间, 正计时就加、倒计时就减。
好处是不吃带宽(对比每秒下发),也不会累计漂移(对比客户端 Timer 自减)—— 每次都是从基准点重算,误差不累积。
客户端还做了乐观自封顶:算出来到点了就自己把状态置 Stopped, 不等服务器通知,避免网络延迟期间 UI 停在 00:00 不消失。
讲一个这个模块的 bug难点
「计时器没勾自动启动却在计时」。
根因是 GetDisplayTime 无视 status 一律按墙钟推进—— 只要计时器被下发过一次,sendTimeStamp 就定了, 之后每帧算出的 timeDiff 都在增大,看起来就在跑。 另外 GetDisplayTimerName 挑显示对象时只过滤了 Stopped,漏了 Paused。
修法是只有 Running 状态才加 timeDiff,其余冻结在 currentValue。
可复用的教训:凡是「按时间自动推进」的显示逻辑, 必须先查状态再算增量。这类 bug 的特征是「表现是活的但数据是死的」, 第一反应容易怀疑服务器没下发,实际是客户端自己在动。
还有一半可能在服务器——如果日志里 status 确实是 0,说明服务器忽略了 AutoStart 配置, 客户端改完只是止血。所以排查时先看日志里 status 的实际值再决定改哪边。
protobuf 的 int64 在 TS 里怎么处理工程
protobuf 的 int64 在 JS 里是 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:BombTimerChanged 事件可能高频到达, 每次 Refresh() 都起一个 Forever 而不删旧的,Timer 数量就会线性增长。 所以 Refresh() 里有一个 early return: 如果显示对象没变且轮询还活着就直接返回。
注意这两个条件都要判——只判名字的话, Timer 被 ClearTimer 清掉之后就永远起不回来,计时器会卡住不动。