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 输入事件模型与坐标换算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。