6.3 最小可玩原型

把核心循环落成一个能跑的最小可玩原型:用 TypeScript 写可播种、无 Math.random 的合成内核,用 node --test 断言不变量,打包成单文件 HTML,再用 Playwright 无头注入输入、采样状态、验证确定性并截图存档。

照例先划边界。站内那篇小游戏开发综述 是引擎选型 / 性能 / 变现的单篇综述,给的是横向的能力清单;本节给的是核心循环的可运行拆解与可玩原型——同一件事,但落到一行行能跑通的代码上。本套是成体系 + 可运行:这里的原型不是「画个界面」,而是能断言、能回放、能自动化验证的工程产物。前两节把《合成小岛》的核心循环拆成了结构(五个动作、12.1 秒)和节奏(1.4 次/秒、心流 62 分),这一节把它们落成一个能跑的东西:浏览器打开就能点、能自动验证、能截图存档。

最小可玩原型要回答什么

「最小可玩原型」最容易跑偏成两件事:一是做成演示 Demo(好看但不能验证),二是做成半个产品(功能多但核心循环没验)。正确的定义只有一条:能验证核心循环假设的最小代码量。它要回答三个问题:核心动作(合成)的规则能否用一段纯逻辑说清楚?这段逻辑能否用断言锁住它的不变量(升级、拒绝、确定性)?玩家在浏览器里点下去,行为是否和逻辑层逐位一致?

D 层:合成内核的配置

先把内核参数固化成配置——它和前面几节一样,是「一份 config、多处消费」的那一份,落在 config/ch06_merge.json。

{
  "version": 1, "game": "merge-island", "seed": 20261012,
  "slots": 16, "gridCols": 4, "maxSpawnTier": 3, "maxTier": 8,
  "goldBase": 3, "goldNum": 145, "goldDen": 100,
  "rngMultiplier": 1664525, "rngIncrement": 1013904223,
  "note": "最小可玩原型的合成内核配置。全部整数:金币奖励用整数比 goldNum/goldDen 递增;随机数用可播种整数 LCG。core/ 内禁用 Math.random()。"
}

seed = 20261012 是整条链的起点:同一个 seed,无论在 Node 测试还是浏览器里,都得到同一串随机物品与同一份终局。maxSpawnTier = 3 表示新产出物品只可能是 1~3 级,maxTier = 8 是合成上限。

P 层:可播种的合成内核

下面是 P 层内核,一份完整、可直接 import 的 TypeScript 模块。它有三个硬约束:无 Math.random()(随机数一律走 createRng)、幂运算用 BigInt(奖励指数增长会溢出 2^53)、除法一律整数地板除。

// file: core/src/ch06/merge.ts
// tier: P
// verify: node --test 'core/test/ch06_*.test.ts'
//
// 《合成小岛》合成内核:可播种随机、全整数、无 Math.random()。
// 只用可擦除语法(禁 enum / namespace / 参数属性 / 装饰器)。

export interface MergeConfig {
  version: number; seed: number; slots: number; gridCols: number;
  maxSpawnTier: number; maxTier: number; goldBase: number;
  goldNum: number; goldDen: number; rngMultiplier: number; rngIncrement: number;
}
export interface Rng { next(): number; state(): number }
export interface MergeResult { ok: boolean; reason: string; tier: number; gold: number }
export interface AutoResult { merges: number; spawns: number; gold: number; steps: number }

/** 可播种整数 LCG:s = (s * mul + inc) mod 2^32。同一 seed 必得同一序列。 */
export function createRng(seed: number, cfg: MergeConfig): Rng {
  let s = seed >>> 0;
  return {
    next() { s = (Math.imul(s, cfg.rngMultiplier) + cfg.rngIncrement) >>> 0; return s; },
    state() { return s; },
  };
}
/** 空棋盘:0 表示空格,正整数表示物品等级。 */
export function emptyBoard(cfg: MergeConfig): number[] {
  return new Array<number>(cfg.slots).fill(0);
}
/** 在第一个空格放一个新物品(等级 1..maxSpawnTier),返回落点;棋盘满返回 -1。 */
export function spawn(board: number[], rng: Rng, cfg: MergeConfig): number {
  const tier = (rng.next() % cfg.maxSpawnTier) + 1;
  for (let i = 0; i < board.length; i += 1) if (board[i] === 0) { board[i] = tier; return i; }
  return -1;
}
/** 等级 tier 的物品合成后给出的金币:(base * num^(t-1)) // den^(t-1),BigInt 幂。 */
export function goldForTier(tier: number, cfg: MergeConfig): number {
  if (tier < 1) return 0;
  const n = BigInt(cfg.goldNum) ** BigInt(tier - 1), d = BigInt(cfg.goldDen) ** BigInt(tier - 1);
  return Number((BigInt(cfg.goldBase) * n) / d);
}
/** 合成两个相同等级物品;校验顺序 空格 → 同格 → 等级不同 → 已到顶。 */
export function mergeAt(board: number[], from: number, to: number, cfg: MergeConfig): MergeResult {
  if (from === to) return { ok: false, reason: "sameSlot", tier: 0, gold: 0 };
  if (board[from] === 0 || board[to] === 0) return { ok: false, reason: "empty", tier: 0, gold: 0 };
  if (board[from] !== board[to]) return { ok: false, reason: "mismatch", tier: 0, gold: 0 };
  if (board[to] >= cfg.maxTier) return { ok: false, reason: "maxTier", tier: 0, gold: 0 };
  const tier = board[to] + 1;
  board[to] = tier; board[from] = 0;
  return { ok: true, reason: "merged", tier, gold: goldForTier(tier, cfg) };
}
/** 第一对可合成的相同等级物品(未到顶),返回 [from, to];没有返回 null。 */
export function findMergeablePair(board: number[], cfg: MergeConfig): number[] | null {
  const first: Record<number, number> = {};
  for (let i = 0; i < board.length; i += 1) {
    const t = board[i];
    if (t === 0 || t >= cfg.maxTier) continue;
    if (first[t] !== undefined) return [first[t], i];
    first[t] = i;
  }
  return null;
}
/** 确定性自动对局:优先合成,没得合就产出;棋盘满则提前停。同 seed 同结果。 */
export function autoPlay(board: number[], rng: Rng, cfg: MergeConfig, steps: number): AutoResult {
  let merges = 0, spawns = 0, gold = 0;
  for (let s = 0; s < steps; s += 1) {
    const pair = findMergeablePair(board, cfg);
    const r = pair === null ? null : mergeAt(board, pair[0], pair[1], cfg);
    if (r !== null && r.ok) { merges += 1; gold += r.gold; continue; }
    if (spawn(board, rng, cfg) < 0) break;
    spawns += 1;
  }
  return { merges, spawns, gold, steps };
}

内核只有八个导出,却覆盖了合成游戏的全部规则:随机产出、同类合成、等级上限、金币奖励、自动对局。注意 autoPlay 是确定性的——它只依赖传入的 rng,不依赖时间、不依赖 Math.random。这是 W 层能自动验证的前提。

P 层:把不变量写成断言

内核写完了,怎么证明它是对的?答案是断言。下面这份测试用 node --test 直跑(Node 26 原生支持 .ts),把内核的不变量一条条锁死;跑 node --test 'core/test/ch06_*.test.ts',输出(同机相对量级,绝对数值不可移植):

// file: core/test/ch06_merge.test.ts
// tier: P
// verify: node --test 'core/test/ch06_*.test.ts'

import { test } from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { createRng, emptyBoard, spawn, goldForTier, mergeAt, findMergeablePair, autoPlay } from "../src/ch06/merge.ts";

const HERE = dirname(fileURLToPath(import.meta.url));
const cfg = JSON.parse(readFileSync(join(HERE, "..", "..", "config", "ch06_merge.json"), "utf-8")) as Parameters<typeof createRng>[1];

test("LCG 可播种:同一 seed 得同一前 5 个输出", () => {
  const rng = createRng(cfg.seed, cfg);
  assert.deepEqual([rng.next(), rng.next(), rng.next(), rng.next(), rng.next()],
    [1891695331, 1516053222, 1468488973, 3014056712, 989151943]);
});
test("合成奖励按整数比 145/100 递增(全整数)", () => {
  assert.deepEqual([1, 2, 3, 4, 5, 6, 7, 8].map((t) => goldForTier(t, cfg)), [3, 4, 6, 9, 13, 19, 27, 40]);
});
test("同等级合成升一级并给金币,原格清空", () => {
  const b = emptyBoard(cfg); b[0] = 1; b[1] = 1;
  assert.deepEqual(mergeAt(b, 0, 1, cfg), { ok: true, reason: "merged", tier: 2, gold: 4 });
  assert.equal(b[1], 2); assert.equal(b[0], 0);
});
test("四类拒绝:不同等级 / 到顶 / 同格 / 空格", () => {
  const b = emptyBoard(cfg); b[0] = 1; b[1] = 2;
  assert.equal(mergeAt(b, 0, 1, cfg).reason, "mismatch");
  const c = emptyBoard(cfg); c[0] = cfg.maxTier; c[1] = cfg.maxTier;
  assert.equal(mergeAt(c, 0, 1, cfg).reason, "maxTier");
  assert.equal(mergeAt(c, 0, 0, cfg).reason, "sameSlot");
  assert.equal(mergeAt(c, 0, 5, cfg).reason, "empty");
});
test("产出等级在 1..maxSpawnTier,填满后返回 -1", () => {
  const b = emptyBoard(cfg); const rng = createRng(cfg.seed, cfg);
  for (let i = 0; i < cfg.slots; i += 1) {
    const idx = spawn(b, rng, cfg);
    assert.ok(idx >= 0 && b[idx] >= 1 && b[idx] <= cfg.maxSpawnTier);
  }
  assert.equal(spawn(b, rng, cfg), -1);
});
test("找得到第一对可合成物品,到顶等级被跳过", () => {
  const b = emptyBoard(cfg); b[2] = 4; b[7] = 4; b[1] = cfg.maxTier; b[3] = cfg.maxTier;
  assert.deepEqual(findMergeablePair(b, cfg), [2, 7]);
});
test("自动对局可复现:同 seed 同 steps 得同一结果与同一终局", () => {
  const b1 = emptyBoard(cfg), b2 = emptyBoard(cfg);
  assert.deepEqual(autoPlay(b1, createRng(cfg.seed, cfg), cfg, 40), autoPlay(b2, createRng(cfg.seed, cfg), cfg, 40));
  assert.deepEqual(b1, b2);
});
test("自动对局 40 步的确定性输出", () => {
  const b = emptyBoard(cfg);
  assert.deepEqual(autoPlay(b, createRng(cfg.seed, cfg), cfg, 40),
    { merges: 18, spawns: 22, gold: 156, steps: 40 });
});
ℹ tests 8
ℹ pass 8
ℹ fail 0

八条断言全过。最有分量的是最后一条:40 步自动对局恰好 18 次合成、22 次产出、累计 156 金币。这个数字被硬编码进断言,任何人改动合成规则或随机数实现,它都会立刻红——核心循环的数值行为因此成了有守卫的不变量。

W 层:打包并验证

P 层逻辑写完了,但「能跑」还不够,要在浏览器里也跑通、且和逻辑层逐位一致。W 层做两件事:先用 web/build_ch06.mjs 把 core/ 的 TS 内联进单文件 HTML(复用 web/pack.mjs 的官方擦除器,产出 web/dist/ch06.html),再用 web/verify_ch06.mjs 做无头验证。两个脚本如下(构建输出 { "html": "web/dist/ch06.html", "htmlBytes": 7041, "bundleLines": 64 }):

#!/usr/bin/env node
// file: web/build_ch06.mjs
// tier: W
// verify: node web/build_ch06.mjs
// 把 core/src/ch06/merge.ts 的合成内核打成单文件 HTML 可玩原型。
// 必须复用 web/pack.mjs 的擦除逻辑与页面外壳,产物 web/dist/ch06.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 config = JSON.parse(readFileSync(join(ROOT, "config", "ch06_merge.json"), "utf8"));
const js = bundle(ROOT, ["core/src/ch06/merge.ts"]);

const script = `const cfg = ${inlineConfig(config)};
${js}
let rng = createRng(cfg.seed, cfg), board = emptyBoard(cfg), merges = 0, spawns = 0, gold = 0;
const cv = document.getElementById("board"), g = cv.getContext("2d"), PAD = 12;
const CELL = Math.floor((cv.width - PAD * (cfg.gridCols + 1)) / cfg.gridCols);
const COLORS = ["#0b1220", "#38bdf8", "#22c55e", "#eab308", "#f97316", "#ef4444", "#a855f7", "#ec4899", "#14b8a6"];
function draw() {
  g.clearRect(0, 0, cv.width, cv.height);
  for (let i = 0; i < cfg.slots; i += 1) {
    const c = i % cfg.gridCols, r = Math.floor(i / cfg.gridCols), t = board[i];
    const x = PAD + c * (CELL + PAD), y = PAD + r * (CELL + PAD);
    g.fillStyle = t === 0 ? "#1e293b" : COLORS[t];
    g.beginPath(); g.roundRect(x, y, CELL, CELL, 10); g.fill();
    if (t > 0) { g.fillStyle = "#0b1220"; g.font = "bold 20px system-ui"; g.textAlign = "center"; g.textBaseline = "middle"; g.fillText(String(t), x + CELL / 2, y + CELL / 2); }
  }
  document.getElementById("gold").textContent = String(gold);
  globalThis.__pebble = { board: board.slice(), merges, spawns, gold, rngState: rng.state(), slots: cfg.slots, api, canvasDrawn: true };
}
const api = {
  spawnOne() { if (spawn(board, rng, cfg) >= 0) spawns += 1; draw(); return spawns; },
  auto(steps) { const r = autoPlay(board, rng, cfg, steps); merges += r.merges; spawns += r.spawns; gold += r.gold; draw(); return r; },
  reset() { rng = createRng(cfg.seed, cfg); board = emptyBoard(cfg); merges = 0; spawns = 0; gold = 0; draw(); },
};
document.getElementById("spawn").addEventListener("click", () => api.spawnOne());
document.getElementById("auto").addEventListener("click", () => api.auto(40));
document.getElementById("reset").addEventListener("click", () => api.reset());
cv.addEventListener("click", () => api.spawnOne());
draw();
`;
const body = `  <h1>《合成小岛》· 合成内核原型</h1>
  <div class="sub">合成内核由 core/src/ch06/merge.ts 提供:可播种、无 Math.random</div>
  <div class="card"><div class="k">金币</div><div class="big" id="gold">0</div></div>
  <div class="card"><canvas id="board" width="360" height="360"></canvas></div>
  <div class="card row"><button id="spawn">产出新物品</button><button id="auto">自动演示 40 步</button><button id="reset">重置</button></div>`;
const bytes = writePage(join(ROOT, "web/dist/ch06.html"), page({ title: "《合成小岛》合成内核原型 · ch06", body, script }));
console.log(JSON.stringify({ html: "web/dist/ch06.html", htmlBytes: bytes, bundleLines: js.split("\n").length }, null, 2));
#!/usr/bin/env node
// file: web/verify_ch06.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_ch06.mjs web/dist/ch06.html
// W 层验收:Playwright 无头打开单文件 HTML,注入输入、采样 globalThis.__pebble、读 DOM、
// 检查 canvas 真画了东西、跑两遍验确定性、截图存档。

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/ch06.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;
};

// 第一遍:真实点击「自动演示 40 步」按钮,采样状态并截图
const page = await open();
await page.click("#auto");
await page.waitForFunction(() => globalThis.__pebble.merges > 0, null, { timeout: 10000 });
const snap = await page.evaluate(() => {
  const s = globalThis.__pebble;
  const d = document.getElementById("board").getContext("2d").getImageData(0, 0, 360, 360).data;
  let painted = 0;
  for (let i = 0; i < d.length; i += 4) if (d[i + 3] > 0 && d[i] < 250) painted += 1;
  return { board: s.board, merges: s.merges, spawns: s.spawns, gold: s.gold, slots: s.slots, rngState: s.rngState, painted };
});
const domGold = (await page.textContent("#gold")).trim();
await page.screenshot({ path: join(ROOT, "out/shots/ch06_prototype.png"), fullPage: true });
await page.close();

// 第二遍:全新加载,调用暴露的 api 再跑一次,验确定性
const page2 = await open();
const again = await page2.evaluate(() => {
  globalThis.__pebble.api.auto(40);
  const s = globalThis.__pebble;
  return { merges: s.merges, gold: s.gold, rngState: s.rngState };
});
await page2.close();
await browser.close();

assert.equal(errors.length, 0, `页面错误: ${errors.join("; ")}`);
assert.equal(snap.slots, 16, "棋盘应为 16 格");
assert.equal(snap.merges, 18, "40 步应发生 18 次合成");
assert.equal(snap.gold, 156, "40 步应累计 156 金币");
assert.equal(domGold, "156", "DOM 金币应显示 156");
assert.ok(snap.painted > 500, `canvas 应有绘制内容,实测 ${snap.painted} px`);
assert.equal(again.merges, snap.merges, "同 seed 重跑合成次数应一致");
assert.equal(again.gold, snap.gold, "同 seed 重跑金币应一致");
assert.equal(again.rngState, snap.rngState, "同 seed 重跑 RNG 状态应一致");

console.log(JSON.stringify({
  page: "web/dist/ch06.html", slots: snap.slots, merges: snap.merges, spawns: snap.spawns,
  gold: snap.gold, domGold, rngState: snap.rngState, canvasPaintedPx: snap.painted,
  deterministicRerun: { merges: again.merges, gold: again.gold }, screenshot: "out/shots/ch06_prototype.png",
}, null, 2));
console.log("SMOKE-W6: PASS");

跑验证(NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_ch06.mjs web/dist/ch06.html),输出(同机相对量级,绝对数值不可移植):

{ "page": "web/dist/ch06.html", "slots": 16, "merges": 18, "spawns": 22, "gold": 156, "domGold": "156", "rngState": 1572248650, "canvasPaintedPx": 88966, "deterministicRerun": { "merges": 18, "gold": 156 }, "screenshot": "out/shots/ch06_prototype.png" }
SMOKE-W6: PASS

为什么最小可玩原型要能自动验证

看这份输出,最有价值的不是「156 金币」,而是浏览器里的 18 / 156 / rngState=1572248650 与 P 层测试里的数字逐位一致:同一份 config/ch06_merge.json 和同一份内核,在 Node 测试与 Chromium 里跑出了同一个世界。这正是本套「一份 config、三种消费」的落点——Python 仿真算曲线,TS 内核算规则,浏览器原型给人玩,三者用同一套数字对齐。

第二、第三个价值是可回放与截图存档:rngState 是 32 位整数,就是这一局的「存档」,记下 seed 与步数,任何时候都能在任意机器上重放出一模一样的对局(用 Math.random() 的原型只能「大概重现」);而 out/shots/ch06_prototype.png 是浏览器真实渲染的产物,评审时连同断言结果一起贴出,「原型能跑」就是一份可核对的证据。这也是本章与第 12 章(玩法原型与可玩性验证)的分工:本章把核心循环翻译成能跑的最小原型,第 12 章把玩家行为接进来,用可玩性指标判断「值不值得继续做」——本章证明它跑得起来,第 12 章判断它该不该往下走。

阅读导航:上一节:循环时长与反馈密度 · 下一节:关卡结构设计 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「minigame」更多文章

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