《Cocos 游戏开发实战》10.3 震动、顿帧与打击感

声音和特效都有了,为什么撞上障碍还是「软」?缺的是打击感的三要素:震动、顿帧、屏幕位移。本节给出跨端震动 API 的封装、一个可单测的顿帧时间缩放调度器、以及屏幕震动与白闪的可编译实现,并说清什么时候不该用打击感、哪些高频事件必须刻意克制,避免反馈滥用。

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备注
Webnavigator.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);
    }
  }
}

三个工程上的注意点:

  1. (globalThis as any).wx 而不是直接写 wx。TS 不认识 wx 这个全局对象,直接写会编译报错;用 as any 拿到后再做 typeof 检查,既不报错也不会在非微信端炸。
  2. iOS 上震动无效。navigator.vibrate 在 iOS Safari 里根本不存在,typeof 检查会直接跳过。所以震动只能是「锦上添花」,不能承载关键信息。
  3. 别在每帧里调。震动是稀缺资源,连续震动会让玩家关掉系统触觉。只在「事件发生的那一帧」调一次。

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 本地存档与版本迁移 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「cocos」更多文章

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