12.2 打击感、粒子与反馈

把「打击感」拆成可量化的三要素:屏幕震动(幅度按几何比衰减、方向交替)、命中停顿帧、缩放脉冲。三者全用整数、无随机,同一权重与同一时刻必得同一结果,最后用单文件 HTML 原型配 Playwright 断言「命中→震动→衰减到静止」的完整数值序列。

站内已有一整卷 Phaser 引擎专书 ——那 127 篇是 Phaser 的工程实操(含它的动画与粒子系统),讲一套具体引擎的 API 怎么用。本节讲的是与引擎无关的手感原理:打击感由哪些要素构成、每个要素怎么量化、为什么「抖动 + 停顿 + 放大」叠起来才像打中。换成任何引擎都成立。

上一节把动效拆成曲线与调度,解决的是「一个值怎么优雅地走过去」。但玩家真正兴奋的瞬间——合成成功、暴击、升级——不是某一个值在动,而是好几个反馈同时炸开。这一节把这一瞬间拆开。

打击感是叠加出来的

「打击感」听起来玄,拆开其实只有三个可量化的要素:屏幕震动(画面在抖,制造冲击)、命中停顿(画面冻一两帧,制造重量)、缩放脉冲(被击中的方块瞬间放大,制造弹性)。三者各自简单,叠在一起才产生「打中了」的错觉。

粒子在这里的角色是视觉糖霜:碎片、光点、飘字都是同一套「发射 → 生命衰减 → 消失」的循环,本质是把震动幅度换成「粒子的位置与透明度」,用同一种衰减模型驱动。所以本节不单独讲粒子系统,而是把可复现、可断言的那部分——震动、停顿、脉冲——做成整数内核;粒子只需复用同样的衰减曲线。

屏幕震动:几何衰减 + 方向交替

震动最容易做错的是「幅度」。线性衰减(每帧减固定值)收得太生硬;正确做法是几何衰减:每过一个 freqMs 周期,幅度乘以 decayNum/decayDen。以配置的 7/10 为例,重击 12px 起步,依次是 12 → 8 → 5 → 4 → 2 → 2 → 1 → 0,约 420ms 归零。这个「先猛后柔」的包络正是冲击感的来源。

方向则每周期翻转一次(+12 → −8 → +5 → −4 …),让画面左右抖动而不是单向平移。这里有两个坑:一是整数幂必须用 BigInt,因为 7^k 在 k 大时会越过 2^53;二是幅度跌破 minAmpPx 要归零,否则会一直抖着个 1px 的尾。两者都写进了测试。

命中停顿与缩放脉冲

命中停顿(hitstop)是「画面冻住」的帧数:重击 5 帧、轻击 2 帧(按 60fps)。它不改变任何数值,只是把这一小段时间内的推进暂停,让大脑觉得「这一下很重」。帧数必须整、必须短——超过 6 帧就从「有力」变成「卡顿」。

缩放脉冲在命中瞬间把方块冲到峰值(重击 1300‰、轻击 1120‰),再在 recoverMs 内线性回落到 1000‰。注意它和震动的时间尺度不同:震动约 420ms 才停,脉冲 140ms 就回正——所以玩家看到的是「方块先弹一下,然后画面还在余震」。

P 层:打击感内核

下面这段是 P 层(内核层)产物 core/src/e12/impact.ts:屏幕震动、命中停顿、缩放脉冲三要素,全部整数、无 Math.random(),复用上一节的 ediv / eclamp。参数来自同目录的 D 层配置 config/e12_impact.json(下表)。

{
  "version": 1,
  "game": "merge-island",
  "seed": 20261012,
  "note": "打击感参数:屏幕震动幅度(像素整数)与几何衰减、命中停顿帧数、缩放脉冲千分比,全整数禁浮点。",
  "shake": { "ampPx": 12, "decayNum": 7, "decayDen": 10, "freqMs": 60, "minAmpPx": 1 },
  "hitstop": { "lightFrames": 2, "heavyFrames": 5, "fps": 60 },
  "pulse": { "lightMilli": 1120, "heavyMilli": 1300, "recoverMs": 140 }
}
// file: core/src/e12/impact.ts
// tier: P
// verify: node --test 'core/test/e12_*.test.ts'
//
// 《合成小岛》打击感内核:屏幕震动(幅度几何衰减 + 方向交替)、命中停顿帧、缩放脉冲。
// 三要素全整数、无 Math.random(),同一 weight 与 elapsedMs 必得同一结果。

import { ediv, eclamp } from "./easing.ts";

/** 整数幂:用 BigInt 计算避免中间量超出 2^53,再转回安全整数。 */
export function powInt(base: number, exp: number): number {
  let acc = 1n;
  const b = BigInt(base);
  for (let i = 0; i < exp; i += 1) acc *= b;
  return Number(acc);
}

export interface ImpactConfig {
  version: number;
  seed: number;
  shake: { ampPx: number; decayNum: number; decayDen: number; freqMs: number; minAmpPx: number };
  hitstop: { lightFrames: number; heavyFrames: number; fps: number };
  pulse: { lightMilli: number; heavyMilli: number; recoverMs: number };
}
export type ImpactWeight = "light" | "heavy";
export interface ImpactSample {
  weight: ImpactWeight;
  elapsedMs: number;
  shakePx: number;
  scaleMilli: number;
  hitstopFrames: number;
  resting: boolean;
}

const MAX_K = 12; // 衰减次数上限:超过后振幅必为 0,同时避免整数幂溢出

/** 震动的基准幅度:重击用满幅,轻击减半。 */
export function baseAmpPx(weight: ImpactWeight, cfg: ImpactConfig): number {
  return weight === "heavy" ? cfg.shake.ampPx : ediv(cfg.shake.ampPx, 2);
}

/** 震动幅度(像素,非负):每 freqMs 按 decayNum/decayDen 几何衰减,衰减到 minAmpPx 以下归零。 */
export function shakeAmpPx(weight: ImpactWeight, elapsedMs: number, cfg: ImpactConfig): number {
  const k = Math.min(ediv(elapsedMs, cfg.shake.freqMs), MAX_K);
  const amp = ediv(baseAmpPx(weight, cfg) * powInt(cfg.shake.decayNum, k), powInt(cfg.shake.decayDen, k));
  return amp < cfg.shake.minAmpPx ? 0 : amp;
}

/** 震动位移(像素,带正负):每 freqMs 换一次方向。 */
export function shakeOffsetPx(weight: ImpactWeight, elapsedMs: number, cfg: ImpactConfig): number {
  const k = ediv(elapsedMs, cfg.shake.freqMs);
  const sign = k % 2 === 0 ? 1 : -1;
  return sign * shakeAmpPx(weight, elapsedMs, cfg);
}

/** 命中停顿帧数:重击更长。 */
export function hitstopFrames(weight: ImpactWeight, cfg: ImpactConfig): number {
  return weight === "heavy" ? cfg.hitstop.heavyFrames : cfg.hitstop.lightFrames;
}

/** 缩放脉冲(千分比):命中瞬间冲到峰值,recoverMs 内线性回落到 1000。 */
export function scalePulseMilli(weight: ImpactWeight, elapsedMs: number, cfg: ImpactConfig): number {
  const peak = weight === "heavy" ? cfg.pulse.heavyMilli : cfg.pulse.lightMilli;
  if (elapsedMs >= cfg.pulse.recoverMs) return 1000;
  const e = eclamp(elapsedMs, 0, cfg.pulse.recoverMs);
  return 1000 + ediv((peak - 1000) * (cfg.pulse.recoverMs - e), cfg.pulse.recoverMs);
}

/** 一次命中在 elapsedMs 时刻的三要素快照。 */
export function impactAt(weight: ImpactWeight, elapsedMs: number, cfg: ImpactConfig): ImpactSample {
  return {
    weight,
    elapsedMs,
    shakePx: shakeOffsetPx(weight, elapsedMs, cfg),
    scaleMilli: scalePulseMilli(weight, elapsedMs, cfg),
    hitstopFrames: hitstopFrames(weight, cfg),
    resting: shakeAmpPx(weight, elapsedMs, cfg) === 0,
  };
}

/** 从命中到震动完全静止所需的毫秒数(用于排期与断言)。 */
export function restAfterMs(weight: ImpactWeight, cfg: ImpactConfig): number {
  for (let k = 0; k <= MAX_K; k += 1) {
    if (shakeAmpPx(weight, k * cfg.shake.freqMs, cfg) === 0) return k * cfg.shake.freqMs;
  }
  return MAX_K * cfg.shake.freqMs;
}

注意 shakeAmpPx 与 scalePulseMilli 都是纯函数:给定权重与 elapsedMs,输出唯一确定,没有内部计时器、没有随机。这让「打击感」第一次变得可断言。下面是 P 层测试 core/test/e12_impact.test.ts 的实跑输出(同机相对量级,耗时不可移植):

✔ 整数幂用 BigInt:powInt 结果正确且安全
✔ 基准幅度:重击满幅、轻击减半
✔ 震动幅度几何衰减并归零
✔ 震动幅度单调不增
✔ 震动方向每 freqMs 交替
✔ 命中停顿:重击长于轻击
✔ 缩放脉冲:峰值在命中瞬间,recoverMs 内回落 1000
✔ 从命中到静止的时长
✔ 快照一致且可复现(无隐藏随机)
ℹ pass 9 / fail 0

「单调不增」那条从 0 到 600ms 逐毫秒比对,确保振幅不会中途回升;「从命中到静止的时长」直接断言重击 420ms、轻击 360ms——把上文的衰减表变成了机器可验证的契约。

W 层:可玩原型与验证

下面这段是 W 层(网页层)的构建脚本 web/build_e12_impact.mjs:它把 easing.ts 与 impact.ts 一起内联进单文件 HTML,页面上是一块会被「打」的方块。同样 import { bundle, page, writePage, inlineConfig } from "./pack.mjs" 复用主控的擦除逻辑。

#!/usr/bin/env node
// file: web/build_e12_impact.mjs
// tier: W
// verify: node web/build_e12_impact.mjs
// 把 core/src/e12/{easing,impact}.ts 的缓动与打击感内核打成单文件 HTML 原型。
// 必须复用 web/pack.mjs 的擦除逻辑与页面外壳,产物 web/dist/e12_impact.html。

import { readFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { bundle, page, writePage, inlineConfig } from "./pack.mjs";

const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const cfg = JSON.parse(readFileSync(join(ROOT, "config", "e12_impact.json"), "utf8"));
const js = bundle(ROOT, ["core/src/e12/easing.ts", "core/src/e12/impact.ts"]);

const script = `const CFG = ${inlineConfig(cfg)};
${js}
const cv = document.getElementById("board"), g = cv.getContext("2d");
const W = cv.width, H = cv.height, CX = W / 2, CY = H / 2, S = 64;
let clock = 0, startMs = 0, weight = "heavy", hitCount = 0;
function sample() { return impactAt(weight, clock - startMs, CFG); }
function draw() {
  g.clearRect(0, 0, W, H);
  const s = sample();
  const half = Math.max(8, Math.floor(S * s.scaleMilli / 1000 / 2));
  g.strokeStyle = "#334155"; g.lineWidth = 1; g.strokeRect(CX - S / 2, CY - S / 2, S, S);
  g.fillStyle = s.resting ? "#475569" : "#38bdf8";
  g.fillRect(CX + s.shakePx - half, CY - half, half * 2, half * 2);
  document.getElementById("shk").textContent = String(s.shakePx);
  document.getElementById("scl").textContent = String(s.scaleMilli);
  document.getElementById("hit").textContent = String(hitCount);
  globalThis.__pebble = { clock, shakePx: s.shakePx, ampPx: shakeAmpPx(weight, clock - startMs, CFG),
    scaleMilli: s.scaleMilli, resting: s.resting, hitCount, canvasDrawn: true, api };
}
const api = {
  hit(w) { weight = w || "heavy"; startMs = clock; hitCount += 1; draw();
    return { clock, elapsedMs: 0, shakePx: sample().shakePx, ampPx: shakeAmpPx(weight, 0, CFG), scaleMilli: sample().scaleMilli }; },
  advance(ms) { clock += ms; draw(); const e = clock - startMs;
    return { clock, elapsedMs: e, shakePx: sample().shakePx, ampPx: shakeAmpPx(weight, e, CFG), scaleMilli: sample().scaleMilli, resting: sample().resting }; },
  sample() { const e = clock - startMs; return { clock, elapsedMs: e, shakePx: sample().shakePx, ampPx: shakeAmpPx(weight, e, CFG), scaleMilli: sample().scaleMilli, resting: sample().resting }; },
  reset() { clock = 0; startMs = 0; weight = "heavy"; hitCount = 0; draw(); return { clock }; },
};
draw();
`;
const body = `  <h1>《合成小岛》· 打击感原型</h1>
  <div class="sub">屏幕震动 + 命中停顿 + 缩放脉冲来自 core/src/e12/impact.ts:全整数、无 Math.random</div>
  <div class="card row"><div><div class="k">震动 (px)</div><div class="big" id="shk">0</div></div><div><div class="k">缩放 (‰)</div><div class="big" id="scl">1000</div></div><div><div class="k">命中次数</div><div class="big" id="hit">0</div></div></div>
  <div class="card"><canvas id="board" width="480" height="240"></canvas></div>
  <div class="sub">命中瞬间:方块先被放大并左右震动,随后几何衰减到静止</div>`;
const css = "#board { width: 480px; height: 240px; }";
const bytes = writePage(join(ROOT, "web/dist/e12_impact.html"), page({ title: "《合成小岛》打击感原型 · e12", body, script, css }));
console.log(JSON.stringify({ html: "web/dist/e12_impact.html", htmlBytes: bytes, bundleLines: js.split("\n").length }, null, 2));

下面这段是 W 层验证脚本 web/verify_e12_impact.mjs:Playwright 打开页面,触发一次命中,然后每 60ms 采样一次幅度与缩放,直到静止,断言「命中 → 震动 → 衰减到静止」的完整序列、方向交替、缩放回落,并读回 canvas 非透明像素数证明画面真的画出来了,最后截图存档。

开头那个预热页不是多余的:无头 Chromium 冷启动后 GPU 光栅化路径要等到第二个页面才就绪,同一个 browser 的第一个页面读 canvas 像素会恒为空白(页面本身绘制正常,截图也有内容)。先开一个预热页跑一次完整导航,后续页面读像素才稳定。这是本机 40 轮冷启动实测出来的结论,不是想当然。

#!/usr/bin/env node
// file: web/verify_e12_impact.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_e12_impact.mjs web/dist/e12_impact.html
// W 层验收:Playwright 无头打开单文件 HTML,触发一次命中并推进时间,
// 断言「命中 → 震动 → 衰减到静止」的幅度序列与缩放脉冲回落,并截图存档。

import { createRequire } from "node:module";
import { pathToFileURL, fileURLToPath } from "node:url";
import { dirname, join, resolve } from "node:path";
import { mkdirSync } from "node:fs";
import assert from "node:assert/strict";

const { chromium } = createRequire(import.meta.url)("playwright");
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const url = pathToFileURL(resolve(ROOT, process.argv[2] ?? "web/dist/e12_impact.html")).href;
mkdirSync(join(ROOT, "out/shots"), { recursive: true });
const browser = await chromium.launch();
const warm = await browser.newPage(); await warm.goto(url); await warm.close(); // 预热:冷启动首个 page 读 canvas 像素恒为空白
const errors = [];
const open = async () => {
  const p = await browser.newPage({ viewport: { width: 800, height: 900 } });
  p.on("pageerror", (e) => errors.push(String(e)));
  await p.goto(url);
  await p.waitForFunction(() => globalThis.__pebble?.canvasDrawn === true, null, { timeout: 10000 });
  return p;
};

// 命中一次,然后每 60ms 采样幅度与缩放,直到静止,最后读回 canvas 非透明像素数:
// 一次性把「这一次打击的完整快照」取回来。
const strike = (page, weight) => page.evaluate((w) => {
  const a = globalThis.__pebble.api;
  a.reset();
  const first = a.hit(w);
  const amps = [first.ampPx], offsets = [first.shakePx], scales = [first.scaleMilli];
  for (let i = 0; i < 12; i += 1) {
    const r = a.advance(60);
    amps.push(r.ampPx); offsets.push(r.shakePx); scales.push(r.scaleMilli);
    if (r.resting) break;
  }
  const cv = document.getElementById("board");
  const d = cv.getContext("2d").getImageData(0, 0, cv.width, cv.height).data;
  let painted = 0;
  for (let i = 0; i < d.length; i += 4) if (d[i + 3] > 0) painted += 1;
  const s = a.sample();
  return { amps, offsets, scales, resting: s.resting, finalScale: s.scaleMilli, painted };
}, weight);

const page = await open();
const heavy = await strike(page, "heavy");
await page.screenshot({ path: join(ROOT, "out/shots/e12_impact.png"), fullPage: true });
await page.close();

const page2 = await open();
const light = await strike(page2, "light");
const again = await strike(page2, "heavy");
await page2.close();
await browser.close();

assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.equal(heavy.amps[0], 12, `命中瞬间重击幅度应为 12,实测 ${heavy.amps[0]}`);
for (let i = 1; i < heavy.amps.length; i += 1) {
  assert.ok(heavy.amps[i] <= heavy.amps[i - 1], `幅度应非增:${heavy.amps[i - 1]} -> ${heavy.amps[i]}`);
}
assert.equal(heavy.amps[heavy.amps.length - 1], 0, "震动应衰减到 0");
assert.equal(heavy.resting, true, "末尾应处于静止");
assert.equal(heavy.scales[0], 1300, "重击缩放峰值应为 1300");
assert.equal(heavy.finalScale, 1000, "缩放应回落到 1000");
assert.equal(light.amps[0], 6, `命中瞬间轻击幅度应为 6,实测 ${light.amps[0]}`);
assert.equal(light.scales[0], 1120, "轻击缩放峰值应为 1120");
assert.ok(heavy.offsets[0] > 0 && heavy.offsets[1] < 0, "震动方向应交替");
assert.ok(heavy.painted > 500, `canvas 应有绘制内容,实测 ${heavy.painted} px`);
assert.deepEqual(again, heavy, "同一 weight 与时间线应可复现");

console.log(JSON.stringify({
  page: "web/dist/e12_impact.html", heavyAmps: heavy.amps, heavyOffsets: heavy.offsets,
  heavyScales: heavy.scales, lightAmps: light.amps, lightScalePeak: light.scales[0],
  resting: heavy.resting, canvasPaintedPx: heavy.painted, rerunMatch: JSON.stringify(again) === JSON.stringify(heavy),
  screenshot: "out/shots/e12_impact.png",
}, null, 2));
console.log("SMOKE-W12-IMPACT: PASS");

先构建、再验证,本机实跑输出如下(同机相对量级,绝对数值不可移植):

$ node web/build_e12_impact.mjs
{ "html": "web/dist/e12_impact.html", "htmlBytes": 11765, "bundleLines": 158 }
$ NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_e12_impact.mjs web/dist/e12_impact.html
{
  "heavyAmps":    [12, 8, 5, 4, 2, 2, 1, 0],
  "heavyOffsets": [12, -8, 5, -4, 2, -2, 1, 0],
  "heavyScales":  [1300, 1171, 1042, 1000, 1000, 1000, 1000, 1000],
  "lightAmps":    [6, 4, 2, 2, 1, 1, 0],
  "lightScalePeak": 1120, "resting": true,
  "canvasPaintedPx": 4356, "rerunMatch": true
}
SMOKE-W12-IMPACT: PASS

这串数字说明什么

看 heavyAmps:12 → 8 → 5 → 4 → 2 → 2 → 1 → 0,正是几何比 7/10 逐周期取整的结果,且单调不增、末尾精确归零。heavyOffsets 则是同样幅度带上交替的正负号,所以方块是左右抖而不是单向平移。heavyScales 前三个点 1300 → 1171 → 1042 在 140ms 内线性落到 1000,之后一直保持 1000——脉冲结束、方块复位。

resting=true 表示采样循环走到了静止(震幅为 0),canvasPaintedPx=4356 说明画布真的被画了(不是空帧),rerunMatch=true 说明同样的权重与时间线两遍得到逐点相同的结果。这些断言合起来,等于给「打击感」写了一份回归测试:以后谁改了衰减比,测试会立刻指出手感变了。

小结与下一步

这一节把「打中那一下」拆成三个可量化的整数要素:震动(几何衰减 + 方向交替)、停顿(帧数)、脉冲(峰值 + 回落),全部是纯函数,因此可断言、可回归。粒子则是复用同一套衰减曲线的视觉糖霜,不改变手感模型。

但到目前为止,反馈都还停留在画面。玩家打中一个方块时,还应该听到「啵」的一声、手心感到一下轻震——声音与震动,是同一套反馈模型的另外两个通道。下一节就把它们补上,并处理「静音、关闭震动、同一事件连点」这些真实边界。

阅读导航:上一节:缓动函数与时间轴 · 下一节:音效与震动反馈 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

  1. 附录 F:全书公式、参数与数据集索引
  2. 9.3 同意管理与数据出境
  3. 9.2 COPPA 与儿童向游戏的限制