7.3 物理手感调参

把「落地弹得对不对」变成四个整数参数:重力增量、速度阻尼比、落地弹性比、静止阈值。用固定步长的整数物理内核实现下落与弹跳,再用单文件 HTML 原型配 Playwright 无头,先注入固定帧数步进、再走一遍真实 rAF 下落,断言落点、弹跳次数与静止判定并截图。

站内《游戏引擎实现原理》四卷 180 篇 是从零实现引擎内核(含 ECS 与系统调度),那里会把物理求解器做成可扩展的框架;本章讲的是小游戏语境下到底需要多少物理:分级取舍、轻量碰撞与空间划分、手感参数。这一节只做一件事——让一颗球「落得好看」。

前两节解决了「需要多少物理」和「碰到了没有」,但小游戏的手感还有一半没做:碰到之后怎么动。掉落物落到棋盘上是「啪」地弹一下就停,还是软绵绵地陷下去?方块合并时是直接消失,还是先弹一下再消失?这些都由几个物理参数决定,而它们可以像第 6 章的手感参数一样,全写成整数、全可调。

手感 = 重力 / 阻尼 / 弹性 / 静止阈值

一个物体的下落手感,可以被拆成四个具体、可测的量:重力增量(每帧速度加多少)、速度阻尼(每帧速度乘一个小于 1 的比例,模拟空气阻力)、落地弹性(撞地后速度保留多少并反向)、静止阈值(反弹速度低于多少就判定停下,否则会永远抖动)。把这四个量写成配置,手感就从「凭感觉调」变成了「改数字看落点」。

《合成小岛》的掉落物只需要竖直方向的物理——不需要水平摩擦,也不需要旋转。所以这一节的内核是一维竖直物理,比完整刚体引擎小得多,正好对应上一节的 t3 能力里最轻的那部分。下面这份是 D 层(静态层)产物,重力是像素整数,阻尼与弹性用整数比,全程无浮点。

{
  "version": 1,
  "game": "merge-island",
  "seed": 20261012,
  "note": "物理手感参数:重力 / 阻尼 / 弹性全为整数(阻尼与弹性用整数比),固定步长、禁浮点。改参数即改手感。",
  "gravityPx": 4,
  "dampNum": 15,
  "dampDen": 16,
  "restNum": 1,
  "restDen": 2,
  "restThreshPx": 2,
  "floorY": 300
}

dampNum / dampDen = 15/16 表示每帧速度乘 0.9375,用整数比表达避免浮点;restNum / restDen = 1/2 表示撞地后速度减半并反向。这两个比例是手感的旋钮,下面的内核把它们读进来。

P 层:整数物理步进

下面这段是 P 层(内核层)产物:core/src/e07/feel.ts。它是纯函数式的物理内核——输入当前球状态,输出下一帧状态,不碰渲染、不碰事件,因此既能被单元测试直接跑,也能被浏览器原型内联。固定步长、全整数、无 Math.random()。

// file: core/src/e07/feel.ts
// tier: P
// verify: node --test 'core/test/e07_*.test.ts'
//
// 《合成小岛》物理手感内核:重力 + 阻尼 + 落地弹性 + 静止判定,全整数、固定步长。
// 每个参数都能在 config 里改,改完看落点与静止帧数即可。无 Math.random()、只用可擦除语法。

export interface PhysicsConfig {
  version: number; seed: number;
  gravityPx: number;              // 每帧重力增量(px/frame^2)
  dampNum: number; dampDen: number;   // 每帧速度阻尼比(整数比)
  restNum: number; restDen: number;   // 落地弹性系数(整数比)
  restThreshPx: number;           // 反弹速度 <= 此值即判定静止
  floorY: number;                 // 地面 y(整数像素)
}
export interface Ball { x: number; y: number; vy: number; frames: number; asleep: boolean; bounces: number }

function fdiv(a: number, b: number): number { return Math.floor(a / b); }

export function newBall(x: number, y: number, vy: number): Ball {
  return { x, y, vy, frames: 0, asleep: false, bounces: 0 };
}

/**
 * 单帧步进(固定步长,全整数):
 *   1) 速度 += 重力;
 *   2) 速度 = floor(速度 * dampNum / dampDen),阻尼削弱速度;
 *   3) 位置 += 速度;撞地则贴地并反弹 vy = -floor(vy * restNum / restDen);
 *   4) 反弹速度低于阈值即休眠,避免永远抖动。
 */
export function stepBall(b: Ball, cfg: PhysicsConfig): Ball {
  if (b.asleep) return b;
  let vy = b.vy + cfg.gravityPx;
  vy = fdiv(vy * cfg.dampNum, cfg.dampDen);
  let y = b.y + vy;
  let asleep = false, bounces = b.bounces;
  if (y >= cfg.floorY) {
    y = cfg.floorY;
    vy = -fdiv(vy * cfg.restNum, cfg.restDen);
    bounces += 1;
    if (Math.abs(vy) <= cfg.restThreshPx) { vy = 0; asleep = true; }
  }
  return { x: b.x, y, vy, frames: b.frames + 1, asleep, bounces };
}

/** 连跑若干帧直到休眠或帧数用尽,返回终态(用于仿真与断言)。 */
export function simulate(b: Ball, cfg: PhysicsConfig, maxFrames: number): Ball {
  let cur = b;
  for (let i = 0; i < maxFrames; i += 1) {
    if (cur.asleep) return cur;
    cur = stepBall(cur, cfg);
  }
  return cur;
}

单帧步进只有四步,但每一步都有讲究。先加再阻尼(而不是先阻尼再加)是刻意的:如果先阻尼再加,终端速度会变成 gravity / (1 - damp) 的整数近似;先加再阻尼则让终端速度收敛到 gravity * dampDen / (dampDen - dampNum)——在本例里是 4 * 16 = 64,球不会无限加速。反弹用 -floor(vy * restNum / restDen):撞地前 vy 是正的下落速度,反弹后取负变成上升速度,幅度乘弹性比。静止阈值是防抖的关键:没有它,球会在「弹起 1 像素、落下、再弹起 1 像素」之间永远循环,restThreshPx = 2 直接把这个尾巴剪掉。

P 层测试:把每个参数锁进断言

物理内核最容易出的错是「数值对不上但没人发现」,所以测试要把每个参数的行为都断言死。P 层测试 core/test/e07_feel.test.ts 把整条下落轨迹锁进断言。跑它,输出是这样(同机相对量级,耗时不可移植):

✔ 重力:第一帧速度 = floor(gravity * dampNum / dampDen)
✔ 阻尼:每帧速度收敛到终端速度而非无限增长
✔ 落地弹性:撞地后速度反号且幅度按 restNum/restDen 缩小
✔ 静止判定:反弹速度低于阈值即休眠
✔ 休眠后不再变化(幂等)
✔ 同一输入两遍结果一致(无隐藏随机)
ℹ tests 14
ℹ pass 14
ℹ fail 0

(tests 14 是 7.2 与 7.3 两个测试文件的合计。)第三、四行把整条下落轨迹钉死:球在第 16 帧首次落地,弹 3 次,第 27 帧静止。这些数字都是本机实跑的结果——换个参数,这些数字全变,测试会立刻发现。

W 层:单文件 HTML 原型

光看数字还是不够直观,得能在浏览器里真看它落一次。下面这段是 W 层(网页层)的构建脚本 web/build_e07.mjs:它复用 web/pack.mjs 的擦除逻辑,把 feel.ts 内联进一个单文件 HTML,产物是 web/dist/e07.html。注意它没有自己重写 stripTypeScriptTypes,而是 import { bundle, page, writePage, inlineConfig } from "./pack.mjs"——这是本套所有 W 层的统一约定。

#!/usr/bin/env node
// file: web/build_e07.mjs
// tier: W
// verify: node web/build_e07.mjs
// 把 core/src/e07/feel.ts 的物理手感内核打成单文件 HTML:一颗球带重力 / 阻尼 /
// 落地弹性下落并静止。必须复用 web/pack.mjs 的擦除逻辑与页面外壳,产物 web/dist/e07.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 feelCfg = JSON.parse(readFileSync(join(ROOT, "config", "e07_feel.json"), "utf8"));
const js = bundle(ROOT, ["core/src/e07/feel.ts"]);

const script = `const cfg = ${inlineConfig(feelCfg)};
${js}
const cv = document.getElementById("stage"), g = cv.getContext("2d"), R = 18;
let ball = newBall(120, 0, 0), raf = 0, running = false;
function snap() {
  return { x: ball.x, y: ball.y, vy: ball.vy, frames: ball.frames, asleep: ball.asleep,
    bounces: ball.bounces, floorY: cfg.floorY, canvasDrawn: true, api };
}
function draw() {
  g.clearRect(0, 0, cv.width, cv.height);
  g.fillStyle = "#1e293b"; g.fillRect(0, cfg.floorY, cv.width, cv.height - cfg.floorY);
  g.strokeStyle = "#0ea5e9"; g.lineWidth = 2;
  g.beginPath(); g.moveTo(0, cfg.floorY); g.lineTo(cv.width, cfg.floorY); g.stroke();
  g.fillStyle = ball.asleep ? "#22c55e" : "#38bdf8";
  g.beginPath(); g.arc(ball.x, ball.y, R, 0, Math.PI * 2); g.fill();
  document.getElementById("state").textContent = ball.asleep ? "静止" : "下落";
  document.getElementById("vy").textContent = String(ball.vy);
  globalThis.__pebble = snap();
}
function tick() {
  ball = stepBall(ball, cfg); draw();
  if (!ball.asleep) { raf = requestAnimationFrame(tick); } else { running = false; }
}
const api = {
  reset() { ball = newBall(120, 0, 0); draw(); return snap(); },
  step() { ball = stepBall(ball, cfg); draw(); return snap(); },
  stepN(n) { for (let i = 0; i < n; i += 1) ball = stepBall(ball, cfg); draw(); return snap(); },
  run() { cancelAnimationFrame(raf); ball = newBall(120, 0, 0); running = true; draw(); tick(); return snap(); },
  get() { return snap(); },
};
document.getElementById("drop").addEventListener("click", () => api.run());
draw();
`;
const body = `  <h1>《合成小岛》· 物理手感原型</h1>
  <div class="sub">重力 + 阻尼 + 落地弹性来自 core/src/e07/feel.ts:固定步长、全整数、无 Math.random</div>
  <div class="card row"><div><div class="k">状态</div><div class="big" id="state">下落</div></div><div><div class="k">竖直速度 vy</div><div class="big" id="vy">0</div></div></div>
  <div class="card"><canvas id="stage" width="360" height="360"></canvas></div>
  <button id="drop">重新落下</button>`;
const css = "#stage { width: 360px; height: 360px; }";
const bytes = writePage(join(ROOT, "web/dist/e07.html"), page({ title: "《合成小岛》物理手感原型 · e07", body, script, css }));
console.log(JSON.stringify({ html: "web/dist/e07.html", htmlBytes: bytes, bundleLines: js.split("\n").length }, null, 2));

W 层:无头浏览器真跑一次下落

有了 HTML,还得能自动验证它。下面这段是 W 层验证脚本 web/verify_e07.mjs:它用 Playwright 打开单文件 HTML,先注入固定帧数步进(api.stepN(27))断言落点与静止判定,再点按钮走一遍真实 rAF 下落直到休眠,两次结果应当一致,最后截图。注意 playwright 是用 createRequire(import.meta.url) 加载的,因为 NODE_PATH 对 ESM 的 import 无效。

#!/usr/bin/env node
// file: web/verify_e07.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_e07.mjs web/dist/e07.html
// W 层验收:Playwright 无头打开单文件 HTML,先注入固定帧数步进(api.stepN)断言落点与静止,
// 再点按钮触发真实 rAF 下落直到休眠,两次结果应一致;最后截图存档。

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/e07.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: 720, 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;
};

// 第一遍:注入 27 帧固定步进,断言球落到地面并休眠
const page = await open();
const injected = await page.evaluate(() => globalThis.__pebble.api.stepN(27));
const painted = await page.evaluate(() => {
  const d = document.getElementById("stage").getContext("2d").getImageData(0, 0, 360, 360).data;
  let n = 0;
  for (let i = 0; i < d.length; i += 4) if (d[i + 3] > 0) n += 1;
  return n;
});
// 再点按钮走一遍真实 rAF 下落,轮询到休眠
await page.click("#drop");
await page.waitForFunction(() => globalThis.__pebble.asleep === true, null, { timeout: 5000 });
const live = await page.evaluate(() => globalThis.__pebble.api.get());
const domState = (await page.textContent("#state")).trim();
await page.screenshot({ path: join(ROOT, "out/shots/e07_physics.png"), fullPage: true });
await page.close();

// 第二遍:全新加载,同一次注入步进应可复现
const page2 = await open();
const again = await page2.evaluate(() => globalThis.__pebble.api.stepN(27));
await page2.close();
await browser.close();

assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.equal(injected.floorY, 300, "地面应在 y=300");
assert.equal(injected.y, 300, "注入步进后球应落到地面");
assert.equal(injected.asleep, true, "球应判定为静止");
assert.equal(injected.bounces, 3, "落地应弹 3 次后静止");
assert.equal(injected.frames, 27, "注入 27 帧应恰好收敛");
assert.equal(live.y, 300, "rAF 下落终态应同样落到地面");
assert.equal(live.asleep, true, "rAF 下落终态应为静止");
assert.equal(domState, "静止", "DOM 状态应显示静止");
assert.ok(painted > 2000, `canvas 应有绘制内容,实测 ${painted} px`);
assert.deepEqual({ y: again.y, asleep: again.asleep, frames: again.frames, bounces: again.bounces },
  { y: 300, asleep: true, frames: 27, bounces: 3 }, "同一次步进应可复现");

console.log(JSON.stringify({
  page: "web/dist/e07.html",
  injected: { y: injected.y, vy: injected.vy, frames: injected.frames, asleep: injected.asleep, bounces: injected.bounces },
  live: { y: live.y, asleep: live.asleep, frames: live.frames },
  domState, canvasPaintedPx: painted, screenshot: "out/shots/e07_physics.png",
}, null, 2));
console.log("SMOKE-W7: PASS");

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

$ node web/build_e07.mjs
{ "html": "web/dist/e07.html", "htmlBytes": 5232, "bundleLines": 44 }
$ NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_e07.mjs web/dist/e07.html
{
  "page": "web/dist/e07.html",
  "injected": { "y": 300, "vy": 0, "frames": 27, "asleep": true, "bounces": 3 },
  "live": { "y": 300, "asleep": true, "frames": 27 },
  "domState": "静止",
  "canvasPaintedPx": 22459,
  "screenshot": "out/shots/e07_physics.png"
}
SMOKE-W7: PASS

这段验证到底验证了什么

验证分两路,且两路必须对上。第一路是注入步进:api.stepN(27) 一次性推进 27 帧,读到球停在地面(y = 300)、asleep = true、弹了 3 次。这一路是确定性的——不管浏览器快慢,推进 27 帧的结果一定一样。第二路是真实 rAF 下落:脚本点一下按钮,页面用 requestAnimationFrame 每帧调一次 stepBall,脚本轮询到 asleep === true 才继续。这一路走的是真实的浏览器帧循环。

两路的结果完全一致(都是 y = 300、asleep = true、frames = 27),这说明固定步长的内核在两种驱动方式下行为相同:注入步进是「一次跑 27 帧」,rAF 是「分 27 次各跑一帧」,结果一样。这正是第 5 章讲的固定步长确定性——物理手感调参之所以可信,前提就是「同样的输入永远得到同样的输出」。如果这里用了可变步长或 Math.random(),两路结果就会漂移,调参也就失去了意义。

canvasPaintedPx = 22459 是另一个维度的验证:它数了 canvas 里非透明像素的数量,证明画面真的画出来了,而不是「状态对了但白屏」。第 15 章的端到端回放会把这套「注入 + 真实驱动对拍」扩展到整局游戏。

四个参数怎么调

拿到这套内核,调参就变成了改四个数字、重跑断言:

参数值作用调大的后果
gravityPx4每帧速度增量落得更快、更「重」
dampNum/dampDen15/16每帧速度保留比例越接近 1 越飘,越小越「粘」
restNum/restDen1/2落地后速度保留比例越接近 1 弹得越高、越久
restThreshPx2反弹速度低于此值即停越大停得越快,过大则「落地即死」

有一条经验值得记:弹性比不要超过阻尼比的反面。如果 restNum/restDen 太接近 1(比如 9/10),球每次落地几乎不损失能量,会弹很多次才停;配合 restThreshPx 太小,就会看到「球在地上弹十几下」的廉价格斗感。《合成小岛》用 1/2,是为了让掉落物弹一两次就落定,不打断玩家的合成节奏——这跟第 12 章的缓动审美是同一套:手感要为玩法服务,不是越「物理」越好。

小结与下一步

这一节把物理手感拆成四个整数参数(重力、阻尼、弹性、静止阈值),写成一个固定步长的整数内核,并用单文件 HTML + Playwright 无头做了双路验证:注入步进与真实 rAF 下落结果一致,落点、弹跳次数、静止判定全部锁进断言。到这里,物理这一章就完整了——7.1 分级、7.2 碰撞、7.3 手感。

物理讲完了,接下来要处理一个更「脏」的问题:玩家的进度怎么存。小游戏没有本地文件系统,退出可能被系统随时杀掉,云存档又要对接不同宿主。下一节就来做本地存储与云存档抽象——怎么把「存哪、存什么、怎么迁移」做成一层不依赖平台的抽象。

阅读导航:上一节:轻量碰撞检测与空间划分 · 下一节:本地存储与云存档抽象 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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