3.1 可玩原型的规格内联

把 config/play_spec.json 与 core/src/ashdeck/ 的五个确定性内核模块塞进一个自包含 HTML:无构建工具、无网络、无外部 script,拷走一个文件就能玩。本节给出构建脚本与产物级断言——页面里内联的规格必须与磁盘上的规格逐字段相等,任何外部引用都会让「可离线、可分享」失效。

3.1 可玩原型的规格内联

《引擎实现与体验打磨》 那套解决的是引擎怎么实现——渲染管线、资源加载、帧循环、平台适配;本节解决的是另一个问题:同一份规格怎么变成一个能玩的东西。《小游戏开发实战》的最小可玩原型 讲的是产品口径的原型——做多大、验什么、什么时候砍;本节讲工程口径——怎么让这个原型不依赖任何构建工具、任何网络、任何宿主。

做法只有一句话:把 config/play_spec.json 与 core/src/ashdeck/ 的五个确定性内核模块,一起内联进一个 HTML 文件。

为什么必须内联

三个理由,每个都能被证伪。

其一,无构建工具。站内既有的引擎文章默认有 bundler、有依赖目录、有资源管线;但一个可玩原型的目标读者可能是策划、是发行、是三天后回来的自己。内联之后,「跑起来」只剩一个动作:双击。

其二,可离线。页面里任何一个外部引用(<script src>、CDN 字体、远程贴图)都会让「拷走一个文件就能玩」失效——断网、内网、邮件附件预览,全都会挂。内联把这个风险降到零,而且可验证:数一下页面里有几个外部引用,答案必须是 0。

其三,可分享。3.3 会把「种子 + 策略序列」编码进链接片段;如果页面本身还要联网取资源,那分享出去的就不是一个文件,而是一套部署。

内联的对象不是「代码」,是三样东西,各自有各自的契约:

内联对象来源契约
规格config/play_spec.json逐字段相等,不是「大致一样」
内核core/src/ashdeck/ 五个模块手工拓扑序拼接,擦类型后无重名
链接契约config/gd_proto_03_share_link.json前缀与分隔符,编解码双方共用

W 层:构建脚本

web/build_proto03.mjs 只做三件事:读规格、bundle() 内核、writePage() 出页面。它 import 的是 web/pack.mjs 的 bundle / page / writePage / inlineConfig——擦类型逻辑是共享的,本节一行都不重写:

// file: web/build_proto03.mjs
// tier: W
// verify: NODE_PATH=/opt/homebrew/lib/node_modules node web/build_proto03.mjs
//
// 3.1 规格内联 / 3.3 可回放:把 config/play_spec.json 与 core/src/ashdeck/ 的五个确定性内核
// 模块塞进**一个**自包含 HTML(out/proto03.html)——无构建工具、无网络、无 <script src>,
// 拷走一个文件就能玩。页面暴露 globalThis.__ashdeck:
//   run(seq, override) -> digest(与 S 层 Python / P 层 TS 逐字相同)
//   replay()           -> 写回 location.hash 再读回来,逐字重现同一局,返回 digest
//   shareLink()        -> 链接片段 #s=<seed>&seq=<cmd-cmd-...>;snapshot();canvasDrawn
// 擦类型复用 web/pack.mjs 的 bundle(),本章不重写任何擦除逻辑;构建期顺手在 Node 侧跑一遍
// 内核,把 baseline / share 两条 digest 与链接片段落进 out/proto03_build.json。

import { bundle, page, writePage, inlineConfig } from "./pack.mjs";
import { readFileSync, writeFileSync, mkdirSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { dirname, join } from "node:path";
import { runSpec } from "../core/src/ashdeck/combat.ts";
import { digestSnap } from "../core/src/ashdeck/digest.ts";

const ROOT = join(dirname(fileURLToPath(import.meta.url)), ".."); // $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 LINK = JSON.parse(readFileSync(join(ROOT, "config/gd_proto_03_share_link.json"), "utf8"));
const core = bundle(ROOT, MODULES);
const OUT = join(ROOT, "out", "proto03.html");

const script = core + `

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

// 链接片段:#s=<seed>&seq=<cmd-cmd-...>。分隔符取自 config,编码/解码全整数、无浮点。
function parseHash(h) {
  const parts = {};
  for (const kv of String(h || "").replace(LINK.hashPrefix, "").split(LINK.pair)) {
    const i = kv.indexOf("=");
    if (i > 0) parts[kv.slice(0, i)] = kv.slice(i + 1);
  }
  return parts;
}
function shareLink(seed, seq) {
  return LINK.hashPrefix + "s=" + seed + LINK.pair + "seq=" + seq.join(LINK.sep);
}
function specFromHash(h) {
  const p = parseHash(h), sp = JSON.parse(JSON.stringify(SPEC));
  if (p.s) sp.seed = Number(p.s);
  if (p.seq) sp.strategy = String(p.seq).split(LINK.sep);
  return sp;
}
function bar(x, y, w, h, num, den, color) {
  ctx.fillStyle = "#334155";
  ctx.fillRect(x, y, w, h);
  ctx.fillStyle = color;
  ctx.fillRect(x, y, Math.floor((w * num) / den), h);
}
function render(s, tag) {
  const W = cv.width, H = cv.height, p = SPEC.params;
  ctx.fillStyle = "#111827";
  ctx.fillRect(0, 0, W, H);
  ctx.font = "13px system-ui, sans-serif";
  bar(20, 24, W - 40, 16, s.enemyHp, p.enemyHp, "#f87171");
  bar(20, H - 40, W - 40, 16, s.playerHp, p.playerHp, "#34d399");
  ctx.fillStyle = "#e2e8f0";
  ctx.fillText("敌方 HP " + s.enemyHp + " / " + p.enemyHp, 20, 60);
  ctx.fillText("手牌 " + s.hand.join(" · ") + "   回合 " + s.turn + "   能量 " + s.energy, 20, 92);
  ctx.fillText("我方 HP " + s.playerHp + " / " + p.playerHp, 20, H - 48);
  ctx.fillText(tag + "   " + (s.won ? "胜" : "未胜"), 20, H - 16);
  document.getElementById("out").textContent = digestSnap(s);
  document.getElementById("link").textContent = shareLink(SPEC.seed, SPEC.strategy);
}

globalThis.__ashdeck = {
  spec: SPEC,
  run(seq, override) {
    const sp = JSON.parse(JSON.stringify(SPEC));
    if (seq) sp.strategy = seq;
    if (override) for (const k of Object.keys(override)) sp.params[k] = override[k];
    snap = runSpec(sp);
    render(snap, "默认局");
    return digestSnap(snap);
  },
  replay() {
    location.hash = shareLink(LINK.seed, SPEC.strategy);
    snap = runSpec(specFromHash(location.hash));
    render(snap, "回放局 s=" + LINK.seed);
    return digestSnap(snap);
  },
  shareLink() { return shareLink(LINK.seed, SPEC.strategy); },
  snapshot() { return snap; },
  canvasDrawn: false,
};

globalThis.__ashdeck.run();
// 冷启动首个 page 的 canvas 在首帧合成前读像素恒为空白;推迟到两次 rAF 之后再置真。
requestAnimationFrame(() => requestAnimationFrame(() => {
  globalThis.__ashdeck.canvasDrawn = true;
}));
`;

const body = `<h1>《灰烬牌库》· 可玩原型</h1>
<div class="sub">一个自包含 HTML:规格 + 内核内联,无构建工具、无网络、无 &lt;script src&gt;</div>
<canvas id="cv" width="640" height="360"></canvas>
<div class="card"><div class="k">digest(应与 S 层 Python / P 层 TS 逐字相同)</div><div id="out" class="k">—</div></div>
<div class="card"><div class="k">可回放链接片段(种子 + 整段策略序列)</div><div id="link" class="k">—</div></div>`;

const html = page({ title: "《灰烬牌库》· 第 3 章可玩原型",
  subtitle: "规格内联 + 无头真玩 + 可回放链接", body, script });
const bytes = writePage(OUT, html);

const shareSpec = JSON.parse(JSON.stringify(spec));
shareSpec.seed = LINK.seed;
const shareLinkStr = LINK.hashPrefix + "s=" + LINK.seed + LINK.pair + "seq=" + spec.strategy.join(LINK.sep);
const nodeDigest = digestSnap(runSpec(spec));
const nodeShareDigest = digestSnap(runSpec(shareSpec));
mkdirSync(join(ROOT, "out"), { recursive: true });
writeFileSync(join(ROOT, "out", "proto03_build.json"),
  JSON.stringify({ spec: spec.id, modules: MODULES.length, kernelChars: core.length,
    page: "out/proto03.html", pageBytes: bytes, seed: LINK.seed,
    strategyLength: spec.strategy.length, shareLink: shareLinkStr,
    nodeDigest, nodeShareDigest }, null, 2) + "\n");

console.log("modules=" + MODULES.length + " kernelChars=" + core.length);
console.log("built " + OUT + "  " + bytes + " B");
console.log("SHARE_LINK=" + shareLinkStr);
console.log("NODE_DIGEST=" + nodeDigest);
console.log("NODE_SHARE_DIGEST=" + nodeShareDigest);

构建实跑输出

modules=5 kernelChars=4123
built /Users/bingrong.yan/.gdbook/ashdeck/out/proto03.html  10068 B
SHARE_LINK=#s=301&seq=p0-p0-p0-p0-e-p0-p0-p0-p0-e-p0-p0-p0-p0-e-p0-p0-p0-p0-e-p0-p0-p0-p0-e
NODE_DIGEST={"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,"hand":["strike","jab","heavy"],"over":true,"played":9,"playerHp":48,"turn":4,"won":true}
NODE_SHARE_DIGEST={"block":5,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":0,"hand":["jab","strike","jab"],"over":true,"played":7,"playerHp":48,"turn":3,"won":true}

kernelChars=4123 是内联规模的直接读数:4123 个字符的内核 + 一份规格 + 一层页面外壳,总共 10068 B。这个体积意味着它可以当邮件附件、可以塞进 issue、可以贴在聊天窗口里——原型分享的摩擦成本几乎为零。注意构建期顺手在 Node 侧跑出的两条 digest:它们不是装饰,3.2 和 3.3 会用它们做跨宿主断言。

S 层:产物级断言

「已经内联了」不能靠肉眼看代码。sim/gd_proto_03_inline.py 把产物当输入:从 HTML 里把 const SPEC = {...}; 与 const LINK = {...}; 抠出来解析,与磁盘上的两份配置比对;再统计外部引用数量。

#!/usr/bin/env python3
# file: sim/gd_proto_03_inline.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_proto_03_inline.py
"""3.1 可玩原型的规格内联(S 层):证明 out/proto03.html 真的把规格与内核内联了。

三条断言,全部对着**产物**而不是源码:
  1) 页面里 `const SPEC = {...}` 解析出来必须与 config/play_spec.json **逐字段相等**;
  2) 页面里 `const LINK = {...}` 解析出来必须与 config/gd_proto_03_share_link.json 相等;
  3) 页面自包含:恰好 1 个 <script> 且无 src、无 http(s):// 外链、无 <link rel=stylesheet>。

第 3 条是「无构建工具、可离线、可分享」的可验证形态——只要页面里出现任何一个外部引用,
拷走这一个文件就不再能玩。另统计五个内核模块的函数符号是否都在页面里出现。
产出 out/gd_proto_03_inline.json。全整数、无浮点、无 random。
"""
from __future__ import annotations

import json
import os
import re
import sys

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
PAGE = os.path.join(OUT, "proto03.html")

# 五个内核模块各自必须贡献的符号(内联后应全部出现在页面里)
KERNEL_SYMBOLS = ["rngNext", "shuffleDeck", "buildDeck", "runSpec", "digestSnap"]


def extract(html: str, name: str):
    """抓 `const <name> = {...};` 这一行(inlineConfig 输出是单行 JSON)。"""
    m = re.search(rf"^const {name} = (\{{.*\}});$", html, re.M)
    return json.loads(m.group(1)) if m else None


def main() -> int:
    if not os.path.exists(PAGE):
        print("缺 out/proto03.html:请先跑 web/build_proto03.mjs", file=sys.stderr)
        return 2
    with open(PAGE, encoding="utf-8") as f:
        html = f.read()
    spec = json.load(open(os.path.join(ROOT, "config", "play_spec.json"), encoding="utf-8"))
    link = json.load(open(os.path.join(ROOT, "config", "gd_proto_03_share_link.json"), encoding="utf-8"))

    inlined_spec = extract(html, "SPEC")
    inlined_link = extract(html, "LINK")
    symbols = {s: (s in html) for s in KERNEL_SYMBOLS}
    script_tags = len(re.findall(r"<script\b", html))
    script_src = len(re.findall(r"<script[^>]*\bsrc=", html))
    stylesheet_links = len(re.findall(r"<link\b", html))
    external = len(re.findall(r"https?://", html))

    checks = {
        "specInlined": inlined_spec == spec,
        "linkInlined": inlined_link == link,
        "allKernelSymbols": all(symbols.values()),
        "singleInlineScript": script_tags == 1,
        "noScriptSrc": script_src == 0,
        "noStylesheetLink": stylesheet_links == 0,
        "noExternalUrl": external == 0,
        "exposesApi": "globalThis.__ashdeck" in html and "canvasDrawn" in html,
    }
    bad = [k for k, v in checks.items() if not v]
    report = {
        "id": "gd_proto_03_inline", "page": "out/proto03.html",
        "pageBytes": os.path.getsize(PAGE),
        "specFields": sorted(spec.keys()), "specId": spec["id"],
        "strategyLength": len(spec["strategy"]),
        "kernelSymbols": symbols, "scriptTags": script_tags, "scriptSrc": script_src,
        "stylesheetLinks": stylesheet_links, "externalUrls": external,
        "checks": checks, "fail": len(bad),
        "note": "内联的意义是可离线与可分享:页面里任何一个外部引用都会让「拷走一个文件就能玩」失效。",
    }
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "gd_proto_03_inline.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")

    print("=== 3.1 规格内联的产物级断言 ===")
    print(f"页面 {report['page']}  {report['pageBytes']} B")
    print(f"内联 SPEC 与 config/play_spec.json 逐字段相等:{checks['specInlined']}({len(spec)} 个顶层字段)")
    print(f"内联 LINK 与 config/gd_proto_03_share_link.json 相等:{checks['linkInlined']}")
    print(f"内核符号齐全 {sum(symbols.values())}/{len(symbols)}:{','.join(s for s, v in symbols.items() if v)}")
    print(f"<script> {script_tags} 个 / 带 src {script_src} 个 / <link> {stylesheet_links} 个 / 外链 {external} 个")
    print(f"fail={len(bad)}" + ("" if not bad else f"  {bad}"))
    print("wrote out/gd_proto_03_inline.json")
    return 1 if bad else 0


if __name__ == "__main__":
    raise SystemExit(main())

S 实跑输出

=== 3.1 规格内联的产物级断言 ===
页面 out/proto03.html  10068 B
内联 SPEC 与 config/play_spec.json 逐字段相等:True(10 个顶层字段)
内联 LINK 与 config/gd_proto_03_share_link.json 相等:True
内核符号齐全 5/5:rngNext,shuffleDeck,buildDeck,runSpec,digestSnap
<script> 1 个 / 带 src 0 个 / <link> 0 个 / 外链 0 个
fail=0
wrote out/gd_proto_03_inline.json

判读:八条断言里最容易被忽略的一条

八条断言全绿,但真正撑住「可分享」这个主张的是第 3 组:<script> 恰好 1 个、带 src 的 0 个、<link> 0 个、http(s):// 出现 0 次。前两组(规格与链接契约逐字段相等)证明的是「内联的内容是对的」;第 3 组证明的是「内联这件事真的发生了」——一个 import 忘了去掉、一个 CDN 字体没删,pageBytes 只涨几百字节,肉眼根本看不出来,但文件已经不再自包含。

另一条值得说明的是 内联 SPEC ... 逐字段相等:True(10 个顶层字段):这里比的是解析后的对象,不是字符串。也就是说,即使 JSON.stringify 的键顺序、或者 inlineConfig 对尖括号做的转义(把 < 写成它的 Unicode 转义形式,免得页面里出现真的标签起始符)让两边文本不完全一样,语义仍然必须相等——这正是「规格是数据,不是文档」的口径。

本节的可玩性数字只有体积与引用计数,不涉及任何体验量;下一节会用到可玩性指标,那里必须声明:可玩性数字是代理指标(proxy),只做相对比较,禁绝对化。

本节实跑清单

命令产物实测
NODE_PATH=… node web/build_proto03.mjsout/proto03.html + out/proto03_build.json5 模块内联,10068 B
/opt/homebrew/bin/python3 sim/gd_proto_03_inline.pyout/gd_proto_03_inline.json8 断言全绿,外链 0

页面已经能打开、能玩、能分享。但「能玩」目前只是肉眼判断——下一节把它变成断言。

阅读导航:上一节:2.3 原型的范围与止损 · 下一节:3.2 浏览器里的真玩与断言 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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