站内《游戏引擎实现原理》四卷 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 章的端到端回放会把这套「注入 + 真实驱动对拍」扩展到整局游戏。
四个参数怎么调
拿到这套内核,调参就变成了改四个数字、重跑断言:
| 参数 | 值 | 作用 | 调大的后果 |
|---|---|---|---|
gravityPx | 4 | 每帧速度增量 | 落得更快、更「重」 |
dampNum/dampDen | 15/16 | 每帧速度保留比例 | 越接近 1 越飘,越小越「粘」 |
restNum/restDen | 1/2 | 落地后速度保留比例 | 越接近 1 弹得越高、越久 |
restThreshPx | 2 | 反弹速度低于此值即停 | 越大停得越快,过大则「落地即死」 |
有一条经验值得记:弹性比不要超过阻尼比的反面。如果 restNum/restDen 太接近 1(比如 9/10),球每次落地几乎不损失能量,会弹很多次才停;配合 restThreshPx 太小,就会看到「球在地上弹十几下」的廉价格斗感。《合成小岛》用 1/2,是为了让掉落物弹一两次就落定,不打断玩家的合成节奏——这跟第 12 章的缓动审美是同一套:手感要为玩法服务,不是越「物理」越好。
小结与下一步
这一节把物理手感拆成四个整数参数(重力、阻尼、弹性、静止阈值),写成一个固定步长的整数内核,并用单文件 HTML + Playwright 无头做了双路验证:注入步进与真实 rAF 下落结果一致,落点、弹跳次数、静止判定全部锁进断言。到这里,物理这一章就完整了——7.1 分级、7.2 碰撞、7.3 手感。
物理讲完了,接下来要处理一个更「脏」的问题:玩家的进度怎么存。小游戏没有本地文件系统,退出可能被系统随时杀掉,云存档又要对接不同宿主。下一节就来做本地存储与云存档抽象——怎么把「存哪、存什么、怎么迁移」做成一层不依赖平台的抽象。
阅读导航:上一节:轻量碰撞检测与空间划分 · 下一节:本地存储与云存档抽象 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。