5.1 规格内联进单文件 HTML

用 web/pack.mjs 的 bundle() 把 core/src/ashdeck/ 内核按手工拓扑序擦类型,再与 play_spec.json 一起内联进一个 9448 字节的 HTML:无 CDN、无 import、离线双击可跑。页面画敌方血条、我方血条与手牌,并暴露 globalThis.__ashdeck 的 run/snapshot 接口,附构建脚本与实跑输出。

5.1 规格内联进单文件 HTML

第 4 章的 4.3 无死锁与可解性 已经能在命令行上判定一份规格自不自洽;但一份只能靠命令行跑的规格,还不是原型——策划打不开它,评审会上也演示不了。本章把规格推进到 W 层:一个 HTML 文件,双击就能玩。先说清分工:《小游戏开发实战》卷一的最小可玩原型 讲的是产品口径——原型要做多大、验证什么假设、什么时候止损;本节讲的是可断言原型的工程做法——规格怎么内联、状态怎么被程序读到。至于渲染管线、资源加载、引擎 API,那是 《引擎实现与体验打磨》 的地盘;本套只做逻辑内核,页面上的 canvas 只是状态的可见投影,不是游戏引擎。

内联这件事由 web/pack.mjs 的两个函数完成,本章一行擦除逻辑都不自己写:

  • bundle(root, modules):按手工拓扑序读入模块,删掉跨文件 import 与顶层 export,再用 Node 内置的 stripTypeScriptTypes 擦掉类型,拼成一段可直接执行的 JS。
  • inlineConfig(value):把规格对象序列化成 JSON 并转义 <,安全地塞进页面脚本。

拓扑序不是随便排的:bundle() 去掉 import 之后,所有模块落在同一个作用域,所以「先声明后使用」必须由调用方保证,且模块之间不得重名。本章的序是 types.ts → rng.ts → cards.ts → combat.ts → digest.ts:类型文件擦完是空的;rng 提供 rngNext / shuffleDeck;cards 提供 buildDeck;combat 用前两者;digest 最后只依赖快照的形状。五个模块恰好没有同名导出,所以不需要加前缀命名空间。

下面这段就是构建脚本,它把 core/src/ashdeck/ 的内核与 config/play_spec.json 一起写进 out/mech05.html:

// file: web/build_mech05.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/build_mech05.mjs
//
// 5.1 规格内联进单文件 HTML:把 core/src/ashdeck/ 的确定性内核与 config/play_spec.json
// 一起塞进**一个** HTML。产物零外部依赖:无 CDN、无 import、无网络、离线双击可跑。
// 页面暴露 globalThis.__ashdeck = { run(seq?) -> digest, snapshot(), canvasDrawn }。
// 擦类型复用 web/pack.mjs 的 bundle(),本章不重写任何擦除逻辑。

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

const HERE = dirname(fileURLToPath(import.meta.url));
const ROOT = join(HERE, ".."); // $HOME/.gdbook/ashdeck

// 手工拓扑序:先声明后使用。去掉跨文件 import 后同一作用域内不得重名。
const MODULES = [
  "core/src/ashdeck/types.ts",
  "core/src/ashdeck/rng.ts",
  "core/src/ashdeck/cards.ts",
  "core/src/ashdeck/combat.ts",
  "core/src/ashdeck/digest.ts",
];

const spec = JSON.parse(readFileSync(join(ROOT, "config/play_spec.json"), "utf8"));
const core = bundle(ROOT, MODULES);
const OUT = join(ROOT, "out", "mech05.html");

const script = core + `

const SPEC = ` + inlineConfig(spec) + `;
const cv = document.getElementById("cv");
const ctx = cv.getContext("2d");
let snap = null;
let frames = 0;

function bar(x, y, w, h, num, den, color) {
  ctx.fillStyle = "#334155";
  ctx.fillRect(x, y, w, h);
  const fw = den === 0 ? 0 : Math.floor((w * num) / den);
  ctx.fillStyle = color;
  ctx.fillRect(x, y, fw, h);
}

function render(s) {
  const W = cv.width;
  const H = cv.height;
  ctx.fillStyle = "#111827";
  ctx.fillRect(0, 0, W, H);
  bar(20, 22, W - 40, 16, s.enemyHp, SPEC.params.enemyHp, "#f87171");
  ctx.fillStyle = "#e2e8f0";
  ctx.font = "13px system-ui, sans-serif";
  ctx.fillText("敌方 HP " + s.enemyHp + " / " + SPEC.params.enemyHp, 20, 58);
  const n = s.hand.length;
  const cw = 92;
  const ch = 118;
  const gap = 12;
  const x0 = Math.floor((W - (n * cw + (n - 1) * gap)) / 2);
  for (let i = 0; i < n; i += 1) {
    const x = x0 + i * (cw + gap);
    const y = 92;
    ctx.fillStyle = "#1e293b";
    ctx.fillRect(x, y, cw, ch);
    ctx.strokeStyle = "#38bdf8";
    ctx.strokeRect(x + 0.5, y + 0.5, cw - 1, ch - 1);
    ctx.fillStyle = "#e2e8f0";
    ctx.fillText(s.hand[i], x + 10, y + 26);
    ctx.fillStyle = "#94a3b8";
    ctx.fillText("第 " + i + " 张", x + 10, y + ch - 12);
  }
  bar(20, H - 38, W - 40, 16, s.playerHp, SPEC.params.playerHp, "#34d399");
  ctx.fillStyle = "#e2e8f0";
  ctx.fillText("我方 HP " + s.playerHp + " / " + SPEC.params.playerHp + "   回合 " + s.turn + "   能量 " + s.energy, 20, H - 46);
  document.getElementById("out").textContent = digestSnap(s);
  document.getElementById("turn").textContent = String(s.turn);
  document.getElementById("php").textContent = String(s.playerHp);
  document.getElementById("ehp").textContent = String(s.enemyHp);
  frames += 1;
}

globalThis.__ashdeck = {
  spec: SPEC,
  run(seq) {
    const sp = JSON.parse(JSON.stringify(SPEC));
    if (seq) sp.strategy = seq;
    snap = runSpec(sp);
    render(snap);
    return digestSnap(snap);
  },
  snapshot() {
    return snap;
  },
  frames() {
    return frames;
  },
  canvasDrawn: false,
};

globalThis.__ashdeck.run();
globalThis.__ashdeck.canvasDrawn = true;
`;

const body = `<h1>《灰烬牌库》· 单文件可玩原型</h1>
<div class="sub">内核与规格都内联在这一个 HTML 里:无 CDN、无外部模块、离线可跑</div>
<canvas id="cv" width="640" height="360"></canvas>
<div class="row">
  <div class="card"><div class="k">回合</div><div class="big" id="turn">—</div></div>
  <div class="card"><div class="k">我方 HP</div><div class="big" id="php">—</div></div>
  <div class="card"><div class="k">敌方 HP</div><div class="big" id="ehp">—</div></div>
</div>
<div class="card"><div class="k">digest(应与 S / P 层逐字相同)</div><div id="out" class="k">—</div></div>`;

const html = page({
  title: "《灰烬牌库》· 第 5 章 W 层可玩原型",
  subtitle: "一份规格,四处消费:这是 W 层",
  body,
  script,
});

const bytes = writePage(OUT, html);
console.log("modules=" + MODULES.length + " kernelChars=" + core.length);
console.log("built " + OUT + "  " + bytes + " B");

实跑 NODE_PATH=/opt/homebrew/lib/node_modules node web/build_mech05.mjs:

modules=5 kernelChars=4123
built /Users/bingrong.yan/.gdbook/ashdeck/out/mech05.html  9448 B

Node 会在 stderr 上多打一行 ExperimentalWarning: stripTypeScriptTypes is an experimental feature;它不影响产物,正文只记 stdout。

读出来:5 个模块擦完类型后共 4123 个字符的内核,连同 8 项参数、4 种卡与 25 步策略序列,整页落盘 9448 字节。这个数字里没有一行是外部引用——没有 CDN、没有 <script src>、没有 import。把它发给任何人,双击就是可玩原型。

页面画了什么

canvas 是 640×360,画三样东西:顶部一条敌方血条(红)、底部一条我方血条(绿)、中间一行手牌(每张牌一个矩形,写上卡 id 与序号)。三个大数字(回合 / 我方 HP / 敌方 HP)与 digest 文本走 DOM,便于直接读。血条宽度用 Math.floor((w * num) / den) 算,保证同一快照在任何机器上画出同一像素数。

页面同时暴露一个给自动化用的接口 globalThis.__ashdeck:

成员作用
run(seq?)用给定策略序列跑一局(省略则用规格自带的 strategy),重绘并返回 digest 字符串
snapshot()返回最后一次运行的快照对象(回合、HP、手牌、dealt、cardCount 等)
frames()已渲染帧数,用来区分「跑过」与「只加载过」
canvasDrawn首帧画完后置 true;Playwright 靠它等待页面就绪

这个接口是本章的关键约定:页面不是给人看的 demo,而是一个可被程序驱动的确定性函数。下一节的断言全部建立在 run(seq) 返回的 digest 上。

为什么要内联

维度单文件内联外链 CDN / npm 依赖
分享发一个 .html发目录 + 说明怎么装依赖
构建一条 node 命令需要 bundler 配置与产物路径
离线双击即跑断网即白屏
版本漂移内核与规格同一次构建,不可能错位页面可能加载到旧内核
体积9.4 KB几十 KB 起,还没算依赖

四条里最容易被低估的是版本漂移。原型最怕的不是跑不起来,而是跑起来的结果没人信:页面用的内核是上周的、规格是今天的,那这个 digest 到底证明了什么?内联把「内核 + 规格 + 页面」压成同一次构建的同一个产物,这个疑问就不存在了。

从规格到页面:四步

  1. 读 config/play_spec.json(唯一真源,不抄常量)。
  2. bundle() 按拓扑序擦类型拼接内核。
  3. inlineConfig() 把规格安全地序列化进脚本。
  4. writePage() 写出单文件 HTML。

四步里只有第 2 步有技术含量,而它被 pack.mjs 封装掉了——本章的 build_mech05.mjs 里没有任何 stripTypeScriptTypes 调用。这是刻意的:擦类型逻辑一旦分散到各章,就会出现「有的章擦对了、有的章擦漏了」的漂移,而那正是本章要消灭的东西。共享底座只暴露 bundle / page / writePage / inlineConfig 四个函数,各章只负责决定「装哪些模块、画什么、暴露什么接口」。

内联的代价

内联不是万能的,它有明确的反面。三种情况下不要内联:

场景为什么不该内联该怎么做
资产体量大(贴图、音频、字体)base64 内联会把文件撑到几十 MB,打开就卡资产外置,只内联内核与规格
需要多页面 / 多关卡切换每页都要重内联一次,改一处要重建全部抽一个共享 kernel.js,页面各自引入
需要热更新或在线调参改参数要重建 HTML,迭代手感变差规格走 URL 参数或 localStorage,内核仍可内联

反过来,只要满足「内核小、规格小、目标是被自动断言或离线演示」,内联就是最优解——本章正好满足这三条。判断标准可以简化成一句话:你要分享的是「一个文件」还是「一套工程」。

一个常见误解:内联等于不可维护

有人担心内联会把内核复制成第二份,从此两处要同步。恰恰相反:内联是构建步骤,不是手工复制。out/mech05.html 是从 core/ 生成的产物,改内核只改 core/,重跑一条命令即可;产物本身不进版本库、不参与评审,随时可以删掉重建。真正会带来「两份实现」的是手写第二份内核——那正是内联要避免的。

对照一下另外两条路:如果页面用 <script src="../core/src/ashdeck/combat.ts"> 直接引用源码,浏览器既不认识 .ts、也不认识裸的 import,必须上构建工具;如果引用编译产物,就多出一份要同步的 dist/。内联把「编译」与「部署」压成一步,代价是产物不可增量更新——对原型来说这个代价可以接受。

产物长什么样

out/mech05.html 的骨架只有三块:<style>、<canvas> 与三个数字卡片、以及最后那段 <script type="module">。脚本前半是内联的内核,后半是页面自己的 render() 与 globalThis.__ashdeck 定义。内联的边界就在这里——内核部分逐字来自 core/,页面部分逐字来自 build_mech05.mjs,两者同处一个 <script> 但来源清晰可分:

页面区块来源是否随规格变
<style>pack.mjs 的 BASE_CSS否
<canvas> 与数字卡片build_mech05.mjs 的 body否
内联内核core/src/ashdeck/*.ts 擦类型后内核改则变
const SPEC = {…}config/play_spec.json是(改参数即变)

本节实跑清单

命令 / 对象产物实测
node web/build_mech05.mjsout/mech05.html9448 字节
内核擦类型后的 JS 长度内联进页面的脚本4123 字符
页面 canvas640 × 360230400 像素(下一节断言)

与「最小可玩原型」的分工

《小游戏开发实战》卷一的最小可玩原型 回答的是该不该做、做多大:目标是什么、范围切到哪里、什么信号出现就止损。本节回答的是做出来的东西怎么被程序读:规格内联的拓扑序、页面暴露的接口、产物里能不能有一行外部依赖。两者不冲突——那一卷决定「做不做」,本节决定「做出来的这个文件,能不能被自动断言」。所以本节刻意不写范围与止损,也不谈美术与手感;那些在 《引擎实现与体验打磨》 与卷三的品类专章里。

页面有了,下一个问题是:谁来玩、怎么断言。下一节用 Playwright 把整段策略序列注入进去,逐字比对浏览器与 Node 的结果。

阅读导航:上一节:4.3 无死锁与可解性 · 下一节:5.2 Playwright 真玩:注入策略、断言状态 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

  1. 附录 A 品类参数表、机制距离矩阵与数据集索引
  2. 9.3 自走棋的涌现
  3. 9.2 自走棋的经济与平衡