10.3 震动、顿帧与打击感
到 10.2 为止,《方块冲刺》的反馈已经有了视觉和听觉两条线。但很多玩家会反馈同一个词:「软」。撞上障碍、吃到金币,该有的特效和音效都在,就是不够「爽」。
问题不在「有没有反馈」,而在反馈的节奏。真实世界的碰撞是有重量的:拳头打中东西的那一瞬间,画面会「卡」一下,屏幕会「震」一下,手机(手柄)会「嗡」一下。这三件事——顿帧、屏幕位移、触觉震动——合起来就是打击感。
10.3.1 打击感的三要素
| 要素 | 效果 | 作用时长 | 实现手段 |
|---|---|---|---|
| 顿帧(hitstop) | 画面「卡」一瞬 | 30~80 ms | 时间缩放 / 冻结 |
| 屏幕震动 | 画面整体位移 | 100~300 ms | 节点位移 + tween |
| 触觉震动 | 手机「嗡」一下 | 15~40 ms | 平台震动 API |
三者的共同点是短暂。顿帧超过 100ms 就从「有力量」变成「卡顿」;屏幕震动太久会让人晕;触觉震动连续触发会让人烦。打击感的调参,本质是在「够短」和「能感知」之间找那个窗口。
10.3.2 跨端震动 API
手机震动没有统一标准,三端三套 API:
| 平台 | API | 备注 |
|---|---|---|
| Web | navigator.vibrate(ms) | 仅 Android 浏览器支持,iOS Safari 不支持 |
| 微信小游戏 | wx.vibrateShort({ type }) | type 为 'light' / 'medium' / 'heavy' |
| 原生 | 走 JSB 桥 | Cocos 未直接暴露,通常用平台插件 |
Cocos 没有把震动抽象成 cc 的 API,所以要自己封装一层。下面这个类可以过 tsc --noEmit:
import { _decorator, Component, sys } from 'cc';
const { ccclass } = _decorator;
@ccclass('Haptics')
export class Haptics extends Component {
/** 轻震动:拾取、按钮点击 */
static light(): void {
Haptics.vibrate(15);
}
/** 重震动:受击、撞击、失败 */
static heavy(): void {
Haptics.vibrate(40);
}
private static vibrate(ms: number): void {
// 微信小游戏:走 wx.vibrateShort,用 type 表达轻重
if (sys.platform === sys.Platform.WECHAT_GAME) {
const wxApi = (globalThis as any).wx;
if (wxApi && typeof wxApi.vibrateShort === 'function') {
wxApi.vibrateShort({ type: ms > 25 ? 'heavy' : 'light' });
}
return;
}
// Web:走 navigator.vibrate,iOS 会静默忽略
const nav = (globalThis as any).navigator;
if (nav && typeof nav.vibrate === 'function') {
nav.vibrate(ms);
}
}
}
三个工程上的注意点:
(globalThis as any).wx而不是直接写wx。TS 不认识wx这个全局对象,直接写会编译报错;用as any拿到后再做typeof检查,既不报错也不会在非微信端炸。- iOS 上震动无效。
navigator.vibrate在 iOS Safari 里根本不存在,typeof检查会直接跳过。所以震动只能是「锦上添花」,不能承载关键信息。 - 别在每帧里调。震动是稀缺资源,连续震动会让玩家关掉系统触觉。只在「事件发生的那一帧」调一次。
10.3.3 顿帧:让世界卡一瞬
顿帧的原理是:碰撞发生后的几十毫秒里,把游戏的时间放慢到接近静止,但渲染继续。玩家的主观感受是「打中了,有重量」。
实现顿帧有两种思路:
思路 A:手动时间缩放(推荐)。维护一个 timeScale,玩法代码用它缩放 dt。顿帧时把 timeScale 设到 0.05,几十毫秒后恢复 1。这套逻辑与引擎无关,可以单测——正是卷一 4.3 讲的「暂停、时间缩放」在引擎里的落地。
思路 B:引擎定时器缩放。director.getScheduler().setTimeScale(x) 能创建慢动作效果。引擎文档的原话是「它影响该 Scheduler 下管理的所有定时器与动作」。它的好处是不用改玩法代码,坏处是你必须记得恢复,否则整个游戏会一直慢下去——这是很难定位的 bug。
下面这个调度器是思路 A 的实现,与引擎无关,可直接单测:
export interface HitStopConfig {
duration: number; // 冻结时长(秒,真实时间)
scale: number; // 冻结期间的时间缩放,通常 0 ~ 0.1
}
/** 维护一个时间缩放,到点自动恢复;多次触发取「更强 + 更长」 */
export class GameClock {
private scale = 1;
private freezeLeft = 0;
private freezeScale = 1;
/** 触发一次顿帧 */
hitStop(cfg: HitStopConfig): void {
this.freezeScale = Math.min(this.freezeScale, cfg.scale);
this.freezeLeft = Math.max(this.freezeLeft, cfg.duration);
this.scale = this.freezeScale;
}
/** 每帧用真实 dt 推进,返回本帧应使用的时间增量 */
tick(realDt: number): number {
if (this.freezeLeft > 0) {
this.freezeLeft -= realDt;
if (this.freezeLeft <= 0) {
this.freezeLeft = 0;
this.freezeScale = 1;
this.scale = 1;
}
}
return realDt * this.scale;
}
get timeScale(): number {
return this.scale;
}
}
设计要点:
tick收的是真实 dt,返回缩放后的 dt。调用方(玩法系统)用返回值,顿帧调度本身用真实时间推进——这样冻结时长不会被自己缩放,避免了「越冻越久」。- 多次触发取「更强 + 更长」:连续受击时,
scale取更小的、duration取更长的,不会因为第二次触发而提前解冻。 - 到点恢复
scale = 1:这是最容易漏的一步,也是思路 B 里那个「游戏一直慢」bug 的根源。
用 vitest 断言:hitStop({ duration: 0.05, scale: 0.05 }) 后 timeScale 应等于 0.05;连续 tick(0.06) 之后 timeScale 应恢复为 1;在冻结期间再调一次 hitStop,timeScale 应保持 0.05 而不是被拉回 1。
10.3.4 屏幕震动:节点位移 + tween
屏幕震动就是让相机(或整个游戏根节点)在一小段时间里随机抖动,幅度随时间衰减。用 9.2 的 tween 就能做:
import { _decorator, Component, Node, Vec3, tween } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('ScreenShake')
export class ScreenShake extends Component {
@property(Node)
private target: Node | null = null;
/** 震一下:amplitude 像素,duration 秒 */
shake(amplitude = 8, duration = 0.25): void {
const node = this.target ?? this.node;
const origin = node.position.clone();
const stepTime = 0.02;
const steps = Math.max(2, Math.round(duration / stepTime));
let t = tween(node);
for (let i = 0; i < steps; i += 1) {
const decay = 1 - i / steps; // 幅度线性衰减
const dx = (Math.random() * 2 - 1) * amplitude * decay;
const dy = (Math.random() * 2 - 1) * amplitude * decay;
t = t.to(stepTime, { position: new Vec3(origin.x + dx, origin.y + dy, origin.z) });
}
// 最后一帧回到原位,避免残留偏移
t.to(stepTime, { position: origin }).start();
}
}
三个关键细节:
- 先记录
origin:node.position.clone()存下震前位置。忘了这一步,震完节点会漂移——因为每段 tween 的终点都是随机偏移,不是原点。 - 幅度衰减:
decay = 1 - i / steps,越往后抖动越小。不衰减的震动看起来像「故障」而不是「撞击」。 - 最后归位:循环结束后再补一段
to(origin),确保节点精确回到原点。
10.3.5 白闪与挤压:把「命中」画出来
屏幕震动之外,还有两个廉价但有效的打击感手段。
白闪:受击瞬间整个屏幕蒙一层白色,快速淡出。做法是在 UI 最上层放一个铺满屏幕的白色 Sprite,初始 opacity 为 0,命中时把它顶到 180 再淡回 0:
import { _decorator, Component, UIOpacity, tween } from 'cc';
const { ccclass, property } = _decorator;
@ccclass('HitFlash')
export class HitFlash extends Component {
@property(UIOpacity)
private flash: UIOpacity | null = null;
play(): void {
if (!this.flash) {
return;
}
this.flash.opacity = 180;
tween(this.flash)
.to(0.15, { opacity: 0 }, { easing: 'quadOut' })
.start();
}
}
注意 UIOpacity 挂在白色覆盖节点上,不是相机。它的 opacity 是 0~255 的整数,180 大约是 70% 不透明度——全白 255 太刺眼,180 更耐看。
挤压与拉伸(squash & stretch):角色落地或受击时,让它在垂直方向压扁、水平方向拉宽,几十毫秒后弹回。这正是 9.2.3 里那个「压扁—弹回」的 tween,参数换一下就是打击感的组成部分:
tween(this.node)
.to(0.05, { scale: new Vec3(1.25, 0.75, 1) }) // 受击压扁
.to(0.12, { scale: new Vec3(1, 1, 1) }, { easing: 'backOut' }) // 回弹
.start();
10.3.6 什么时候不该用打击感
打击感是调味料,不是主菜。滥用会让游戏变得吵闹、晕眩、廉价。以下场合应该克制:
| 场合 | 该不该用 | 原因 |
|---|---|---|
| 拾取金币 | 只轻震动 + 缩放 | 高频事件,重反馈会烦 |
| 撞击障碍 | 全套(顿帧 + 震动 + 白闪) | 低频、关键事件 |
| 按钮点击 | 只音效 | 震动太频繁 |
| 连续受击(无敌帧内) | 只第一次 | 否则屏幕一直在震 |
| 设置界面 | 都不用 | 非游戏场景 |
一条经验法则:事件的频率越低、越关键,反馈就越重;越高频,就越轻。金币拾取一局几十次,只配一声「叮」加一点缩放;撞障碍一局几次,才值得顿帧和震动。
10.3.7 工程视角的收口
这一节做完,工程里多了:
assets/scripts/core/game-clock.ts:顿帧时间缩放调度器,有单测;assets/scripts/platform/haptics.ts:跨端震动封装;assets/scripts/ui/screen-shake.ts:屏幕震动;assets/scripts/ui/hit-flash.ts:白闪;- 一个「命中反馈」编排函数,把震动、顿帧、白闪、挤压按事件类型组合起来。
到这里,第 10 章的反馈闭环完成:视觉(第 9 章)、听觉(10.1)、跨端限制(10.2)、触觉与节奏(10.3)。下一章进入另一条主线——存档与本地化:怎么用 sys.localStorage 存进度、怎么做存档版本迁移、怎么处理多语言与字体。那是一条和「表现」完全不同的线,但同样是「能发布的产品」的必要条件。
阅读导航:上一节:10.2 多端音频限制 · 下一节:11.1 本地存档与版本迁移 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。