《Cocos 游戏开发入门》4.3 暂停、时间缩放与慢动作

暂停不是把循环停掉,而是让 dt 归零。本节给《方块冲刺》引入 timeScale:讲清它作用在缩放后的 dt 上、为什么 UI 计时必须不受缩放影响,并用慢动作与命中停顿两个玩法验证整套时间管线,最后对照 Cocos Creator 3.8 的暂停与时间缩放 API。

4.3 暂停、时间缩放与慢动作

前两节我们让物理稳定地跑在固定步长上,并用插值把画面抹平。但游戏世界里的「时间」并不总是以正常速度流动的:打开背包要暂停、进入子弹时间要慢放、格斗游戏命中瞬间要「顿一下」。这些需求看似零散,其实都指向同一个开关——时间缩放(time scale)。

这一节把 timeScale 引入《方块冲刺》的时间管线,并重点解决两个最容易做错的地方:它作用在哪一层 dt 上,以及为什么 UI 计时必须绕开它。

本节是第 4 章收尾。你会看到一个被反复问的问题的答案:「暂停时到底该不该把 requestAnimationFrame 停掉?」结论是——不该,暂停是「让时间走得慢到 0」,而不是「让循环停转」。这样暂停期间 UI、动画、输入都还能正常响应。

4.3.1 三种「停」:暂停、降速、冻结单帧

先把需求拆清楚,它们对时间的要求并不一样:

需求效果时间缩放值典型场景
暂停游戏世界完全静止0打开菜单、切后台
慢动作世界变慢,但仍在推进0.2 ~ 0.5子弹时间、残血演出
命中停顿冻结极短一段时间0(持续 ~0.1s)打击感、暴击反馈
加速世界变快> 1回放、快进

四者的实现其实是同一个机制:timeScale 取不同的值,持续不同的时长。暂停是「timeScale 长期为 0」,命中停顿是「timeScale 短暂为 0」,慢动作是「timeScale 长期小于 1」。把它们统一起来,代码就只有一份。

4.3.2 timeScale 作用在缩放后的 dt 上

最容易错的一步是:timeScale 到底乘在哪?

正确的做法是在累加之前,把真实 dt 缩放掉,再喂给累加器:

const raw = (now - last) / 1000;      // 真实经过的秒数
const scaled = raw * this.timeScale;  // 缩放后的 dt
this.accumulatorUs += scaled * US_PER_SECOND;   // 只有缩放后的时间进累加器

为什么强调「缩放后的 dt」?因为累加器和固定步长模拟是一条完整的链路。如果你把 timeScale 乘在别处——比如乘在 step 内部、或者乘在渲染插值上——会出现两类 bug:

  • 乘在 step 内部:固定步长就不再固定了,物理又变回可变步长,4.2 节的努力全部作废。
  • 乘在渲染上:逻辑按真实时间跑(方块已经在飞),但画面被减速,逻辑与表现脱节,命中判定和看到的位置对不上。

所以 timeScale 只能有一个作用点:真实 dt 与累加器之间。缩放后的时间进入累加器后,整个「累加 → 固定步 → 插值」链路自然全部跟着变慢,逻辑和表现始终同步。

4.3.3 暂停就是把 timeScale 设成 0

按上面的设计,暂停的实现就非常干净了:

pause(): void {
  this.timeScale = 0;
}

resume(): void {
  this.timeScale = 1;
}

当 timeScale = 0 时,scaled = 0,累加器不再增长,while 循环一步都不跑,alpha 停在原地。世界静止。

注意这里没有做的一件事:没有把 requestAnimationFrame 停掉。循环仍在每帧运行,只是逻辑步数为 0。这样做的好处:

  • 暂停菜单里的按钮动画、悬停高亮、输入响应都还活着——因为它们不依赖被缩放的游戏时间。
  • 恢复时不会因为「循环停了很久」而积累一大坨待补的 dt。
  • 不需要在恢复时重新对齐时间基准(lastMs 一直是新的)。

如果把循环真的停掉再重启,now - last 会是一个巨大的值,你不得不在恢复时额外处理——这正是很多「暂停恢复后方块瞬移」bug 的来源。

4.3.4 UI 计时不受缩放影响

现在看第二个坑:不是所有东西都该跟着变慢。

《方块冲刺》里有两类计时:

  • 游戏世界时间:方块的位置、金币的旋转、敌人的巡逻。这些必须受 timeScale 影响。
  • UI 与元时间:暂停菜单的淡入、倒计时 HUD、广告冷却、真实的「本局已玩多久」。这些必须不受影响——否则暂停时菜单会冻住,玩家以为游戏卡了。

所以 Clock 要暴露两个时间口径:

/** 游戏世界时间:受 timeScale 影响,用于逻辑与玩法。 */
get worldTime(): number { return this.worldElapsed; }

/** 真实时间:不受缩放影响,用于 UI、倒计时、统计。 */
get realTime(): number { return this.realElapsed; }

worldElapsed 累加的是 scaled,realElapsed 累加的是 raw。UI 层拿 realTime,玩法层拿 worldTime。这条分界线画清楚了,「暂停后 UI 也停了」和「暂停后倒计时还在走」这两类 bug 就都不会出现。

4.3.5 慢动作与命中停顿

有了统一机制,玩法层只需要调 timeScale 的值和持续时间。

慢动作:击杀最后一个敌人时进入 0.3 倍速演出:

// 进入慢动作,持续 1.5 秒真实时间
function enterSlowMotion(clock: Clock, realSeconds = 1.5): void {
  clock.timeScale = 0.3;
  clock.scheduleReal(realSeconds, () => {
    clock.timeScale = 1;
  });
}

这里用的是 scheduleReal——一个按真实时间触发的调度,而不是按游戏时间。如果用游戏时间调度,0.3 倍速下 1.5 秒游戏时间需要 5 秒真实时间才走完,演出会比预期长得多。计时器该用哪种时间,取决于「你想让这段演出在玩家感受里持续多久」——答案是真实时间。

命中停顿(hitstop):格斗游戏里,攻击命中的瞬间整个世界冻结 80ms,然后恢复。它和慢动作的唯一区别是 timeScale 取 0、持续极短:

// 命中时冻结 80ms 真实时间,制造打击感
function hitStop(clock: Clock, ms = 80): void {
  clock.timeScale = 0;
  clock.scheduleReal(ms / 1000, () => {
    clock.timeScale = 1;
  });
}

4.3.6 给 Clock 加 timeScale

把上面的设计落到 mini2d 的 Clock 上,改动其实很小——只在累加前多一步缩放,并记录真实时间:

export class Clock {
  timeScale = 1;              // 1 = 正常,0 = 暂停,<1 = 慢动作
  private worldElapsed = 0;   // 游戏世界累计秒数(受缩放)
  private realElapsed = 0;    // 真实累计秒数(不受缩放)

  tick(step: () => void): StepStats {
    const nowMs = this.time.now();
    if (this.lastMs === null) {
      this.lastMs = nowMs;
      return { steps: 0, alpha: 0 };
    }

    const rawMs = Math.max(0, nowMs - this.lastMs);
    this.lastMs = nowMs;
    this.realElapsed += rawMs / 1000;

    // 关键:缩放发生在进累加器之前
    const scaledMs = rawMs * this.timeScale;
    this.worldElapsed += scaledMs / 1000;
    const elapsedUs = Math.round(scaledMs * 1000);

    const maxElapsedUs = this.fixedStepUs * this.maxSubSteps;
    this.accumulatorUs += Math.min(elapsedUs, maxElapsedUs);

    let steps = 0;
    while (this.accumulatorUs >= this.fixedStepUs) {
      step();
      this.accumulatorUs -= this.fixedStepUs;
      steps += 1;
    }

    return { steps, alpha: this.accumulatorUs / this.fixedStepUs };
  }
}

一个容易忽略的细节:缩放后仍要对增量取整。scaledMs * 1000 取整,保证累加器始终是整数微秒。如果先取整再缩放(Math.round(rawMs * 1000) * timeScale),在 timeScale = 0.3 这种值下会引入系统性偏差,慢动作的时长就不准了。

配合一个假时钟测试,验证暂停时步数为 0:

it('timeScale = 0 时不再步进,但仍能恢复', () => {
  const time = new FakeTime();
  const game = new Game({ time });
  time.advance(STEP_MS);
  game.frame();

  game.clock.timeScale = 0;
  for (let i = 0; i < 10; i += 1) {
    time.advance(STEP_MS);
    expect(game.frame()).toBe(0);   // 暂停:一步都不跑
  }

  game.clock.timeScale = 1;
  time.advance(STEP_MS);
  expect(game.frame()).toBe(1);     // 恢复:正常步进
});

4.3.7 Cocos Creator 3.8 对照

Cocos 3.8 把「暂停」和「时间缩放」分成了两个不同层级的 API,用之前必须分清它们各自影响什么:

API影响范围对应 mini2d
director.pause() / director.resume()只暂停游戏逻辑timeScale = 0(逻辑层)
game.pause() / game.resume()逻辑 + 渲染 + 输入 + 音频更强的暂停
director.getScheduler().setTimeScale(n)所有调度定时器慢动作 / 快进
tween(target).timeScale(n)单个缓动单个动画的独立缩放

要点:

  • director.pause() 和 game.pause() 的差别,cc.d.ts 里写得很清楚:game.pause() 会暂停「游戏逻辑、渲染、输入事件派发」,而 director.pause() 只暂停游戏逻辑。想让暂停菜单还能响应点击,用 director.pause();想彻底冻结(比如切后台),用 game.pause()。
  • 做慢动作/快进用 Scheduler.setTimeScale(n),它的文档原文就是「create a ‘slow motion’ or ‘fast forward’ effect」,1.0 正常、< 1.0 慢放、> 1.0 快进。注意它影响的是该 Scheduler 下管理的所有定时器,所以 UI 倒计时如果也用同一个 Scheduler,就会一起被缩放——这正是 4.3.4 要单独分开真实时间的原因。
  • 单个动画想独立缩放,用 tween(target).timeScale(scale)。Tween.getTimeScale() 能读回当前缩放;注意 tween.duration 返回的是设计总时长,不随 timeScale 变化。
import { _decorator, Component, director, game, tween, Vec3 } from 'cc';
const { ccclass } = _decorator;

@ccclass('TimeControl')
export class TimeControl extends Component {
  openMenu(): void {
    director.pause();               // 只停逻辑,菜单 UI 仍可交互
  }

  closeMenu(): void {
    director.resume();
  }

  enterBulletTime(): void {
    director.getScheduler().setTimeScale(0.3);   // 全局慢动作
  }

  coinPop(): void {
    // 单个缓动独立缩放,不影响世界
    tween(this.node)
      .to(0.2, { scale: new Vec3(1.2, 1.2, 1) })
      .timeScale(0.5)
      .start();
  }
}

本节 Cocos 代码为 L2 片段,director / game / Scheduler / tween 等符号均已在 cc.d.ts(Cocos Creator 3.8.8)中核对存在;它不参与 mini2d 的测试。Vec3 的具体用法会在第 3 章相关小节展开。

时间这条线到这里就完整了:从「乘 dt」的直觉,到固定步长累加器,再到 timeScale 统一暂停与慢动作。第 4 章的全部代码都能在假时钟下确定性复现。下一章转向另一个「和帧同频」的输入源——触摸与键盘事件,以及它和这套时间管线之间最容易出的坐标错位问题。

阅读导航:上一节:4.2 固定步长物理与渲染插值 · 下一节:5.1 输入事件模型与坐标换算 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「cocos」更多文章

  1. 《Cocos 游戏联机实战》9.3 延迟补偿与体验权衡
  2. 《Cocos 游戏联机实战》9.2 网络状态 UI 与提示
  3. 《Cocos 游戏联机实战》9.1 同步表现与插值动画