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 到底证明了什么?内联把「内核 + 规格 + 页面」压成同一次构建的同一个产物,这个疑问就不存在了。
从规格到页面:四步
- 读
config/play_spec.json(唯一真源,不抄常量)。 bundle()按拓扑序擦类型拼接内核。inlineConfig()把规格安全地序列化进脚本。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.mjs | out/mech05.html | 9448 字节 |
| 内核擦类型后的 JS 长度 | 内联进页面的脚本 | 4123 字符 |
| 页面 canvas | 640 × 360 | 230400 像素(下一节断言) |
与「最小可玩原型」的分工
《小游戏开发实战》卷一的最小可玩原型 回答的是该不该做、做多大:目标是什么、范围切到哪里、什么信号出现就止损。本节回答的是做出来的东西怎么被程序读:规格内联的拓扑序、页面暴露的接口、产物里能不能有一行外部依赖。两者不冲突——那一卷决定「做不做」,本节决定「做出来的这个文件,能不能被自动断言」。所以本节刻意不写范围与止损,也不谈美术与手感;那些在 《引擎实现与体验打磨》 与卷三的品类专章里。
页面有了,下一个问题是:谁来玩、怎么断言。下一节用 Playwright 把整段策略序列注入进去,逐字比对浏览器与 Node 的结果。
阅读导航:上一节:4.3 无死锁与可解性 · 下一节:5.2 Playwright 真玩:注入策略、断言状态 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。