3.3 原型的可分享与可回放
《引擎实现与体验打磨》 那套交付的是能跑起来的引擎——构建产物、平台包、性能预算;本节交付的是能贴出去的一局:一段链接。《小游戏开发实战》的最小可玩原型 讨论过原型该做多小;本节补上它没回答的那一半——原型做小之后,怎么把它准确地递给别人。
3.2 里我们已经能断言一局的结果,但那个结果还在本机。截图能证明「我看过这一局」,证明不了「你也能看到同一局」。把种子与整段策略序列编码进链接片段,才能让「同一局」变成可传递的输入。
分享的是链接,不是截图
一张截图包含的信息是不可复现的:它记录了某一帧的样子,却没有记录到达那一帧的路径。别人看到截图只能问「你怎么打的」,而答案通常说不清。链接片段记录的是输入——种子决定洗牌,序列决定每一步操作;有了输入,同一份内核必然给出同一个局面。这就是 3.1 为什么必须内联、3.2 为什么必须确定性:没有确定性,回放无从谈起。
链接片段的契约
契约落在 config/gd_proto_03_share_link.json,页面与 S 层脚本共用同一份——它只声明三样东西:hashPrefix 是 #、pair 是 &、sep 是 -,外加一个默认种子 301。
| 片段成分 | 含义 | 例 |
|---|---|---|
# | 前缀,走 hash 不触发导航 | # |
s=<seed> | 洗牌种子(整数) | s=301 |
& | 键值对分隔符 | & |
seq=<cmd-cmd-...> | 整段策略序列,- 连接 | seq=p0-p0-p0-p0-e-... |
完整片段 80 字符,形如 #s=301&seq=p0-p0-p0-p0-e-p0-...-e。选 hash 而不是查询串,是因为 hash 变化不会触发页面重新加载——回放时页面不会闪一下。
页面侧:replay() 的三步
页面里的 replay() 故意绕远路:先把自己生成的链接写回 location.hash,再从 location.hash 读回来。如果只在内存里传对象,编解码里的 bug 永远不会暴露;绕这一圈,等于每次回放都顺带做一次「分享—接收」的端到端往返。
三步是:shareLink(seed, seq) 编码 → 赋给 location.hash → specFromHash(location.hash) 解码 → runSpec 跑一局 → 返回 digest。回放逻辑与 3.1 的构建脚本是同一份文件(同一个 file: 清单,本节关注 replay() 与 specFromHash() 那两段):
// 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:规格 + 内核内联,无构建工具、无网络、无 <script src></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);
W 实跑:让验证器走回放路径
共享验证器支持 --run <方法名>,让它调用 replay() 而不是 run();期望值取 3.1 构建期落下的 NODE_SHARE_DIGEST:
# snippet: 验证器两次独立加载都走 replay(),期望值取自构建期的 NODE_SHARE_DIGEST
NODE_PATH=/opt/homebrew/lib/node_modules node web/verify_mech.mjs \
--page out/proto03.html --run replay \
--expect-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}'
=== 通用断言 ===
ok 两遍独立加载 digest 逐字一致(确定性)
ok canvas 已绘制(230400 px >= 1)
ok digest 与 --expect-digest 一致
PAGE_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}
OK W 层通过
PAGE_DIGEST 与构建期的 NODE_SHARE_DIGEST 一字不差。注意它和 3.2 的默认局 digest 不同(turn 4→3、played 9→7、block 0→5)——种子从 20261014 换成 301,洗牌顺序变了,同一段 25 条命令走上了另一条路径,仍然打赢。这说明链接片段真的在起作用,而不是碰巧复现了默认局。
S 层:回放一致性的三条断言
sim/gd_proto_03_replay.py 在 Python 侧独立解码同一段链接,重跑一遍,并对编解码本身做无损检查:
#!/usr/bin/env python3
# file: sim/gd_proto_03_replay.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_proto_03_replay.py
"""3.3 原型的可分享与可回放(S 层):链接片段 `#s=<seed>&seq=<cmd-cmd-...>` 的逐字重现。
分享一段原型,分享的不是截图,是**一段可回放的链接**。断言三条:
1) **可解码**:从 out/proto03_build.json 取页面生成的链接片段,按契约解出 seed 与整段策略序列;
2) **可重现**:用解出来的 (seed, 序列) 在 Python 侧重跑,digest 必须与构建期 Node 侧落下的
nodeShareDigest **逐字相同**(同算法同种子的自实现 PRNG 是唯一保证);
3) **可再编码**:把解出来的值重新编码,必须与原字符串**逐字相同**(编解码无损,否则分享出去的
链接会漂移)。
契约取自 config/gd_proto_03_share_link.json:hashPrefix / pair / sep。全整数、无浮点、无 random。
产出 out/gd_proto_03_replay.json。
"""
from __future__ import annotations
import json
import os
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
def parse_hash(fragment: str, link: dict) -> dict:
"""镜像页面的 parseHash:去掉前缀、按 pair 切键值、按 sep 切策略序列。"""
parts: dict = {}
for kv in fragment.replace(link["hashPrefix"], "").split(link["pair"]):
i = kv.find("=")
if i > 0:
parts[kv[:i]] = kv[i + 1:]
if "seq" in parts:
parts["seq"] = parts["seq"].split(link["sep"])
return parts
def encode_hash(seed: int, seq: list, link: dict) -> str:
return (link["hashPrefix"] + "s=" + str(seed) + link["pair"]
+ "seq=" + link["sep"].join(seq))
def main() -> int:
bpath = os.path.join(OUT, "proto03_build.json")
if not os.path.exists(bpath):
print("缺 out/proto03_build.json:请先跑 web/build_proto03.mjs", file=sys.stderr)
return 2
with open(bpath, encoding="utf-8") as f:
build = json.load(f)
link = json.load(open(os.path.join(ROOT, "config", "gd_proto_03_share_link.json"), encoding="utf-8"))
fragment = build["shareLink"]
parts = parse_hash(fragment, link)
seed = int(parts["s"])
seq = list(parts["seq"])
spec = ashlib.load_spec()
replay_spec = json.loads(json.dumps(spec))
replay_spec["seed"] = seed
replay_spec["strategy"] = seq
py_digest = ashlib.digest(ashlib.run_spec(replay_spec, seq))
reencoded = encode_hash(seed, seq, link)
checks = {
"decoded": "s" in parts and "seq" in parts,
"seedMatchesConfig": seed == link["seed"],
"seqLengthMatches": len(seq) == build["strategyLength"],
"replayDigestMatchesNode": py_digest == build["nodeShareDigest"],
"reencodeLossless": reencoded == fragment,
}
bad = [k for k, v in checks.items() if not v]
report = {
"id": "gd_proto_03_replay", "fragment": fragment, "fragmentChars": len(fragment),
"seed": seed, "strategyLength": len(seq), "commands": seq,
"pythonDigest": py_digest, "nodeShareDigest": build["nodeShareDigest"],
"reencoded": reencoded, "contract": {"hashPrefix": link["hashPrefix"],
"pair": link["pair"], "sep": link["sep"]},
"checks": checks, "fail": len(bad),
"note": "回放一致性的判据是逐字相同;不一致说明编解码或 PRNG 有一侧漂了。",
}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "gd_proto_03_replay.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print("=== 3.3 链接片段的回放一致性断言 ===")
print(f"片段({report['fragmentChars']} 字符):{fragment}")
print(f"解出 seed={seed} 序列长度={len(seq)} 前 6 步={'-'.join(seq[:6])}")
print(f"Python 回放 digest {py_digest}")
print(f"Node 构建期 share digest {build['nodeShareDigest']}")
print(f"逐字相同={checks['replayDigestMatchesNode']} 再编码无损={checks['reencodeLossless']}")
print(f"fail={len(bad)}" + ("" if not bad else f" {bad}"))
print("wrote out/gd_proto_03_replay.json")
return 1 if bad else 0
if __name__ == "__main__":
raise SystemExit(main())
=== 3.3 链接片段的回放一致性断言 ===
片段(80 字符):#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
解出 seed=301 序列长度=25 前 6 步=p0-p0-p0-p0-e-p0
Python 回放 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}
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}
逐字相同=True 再编码无损=True
fail=0
wrote out/gd_proto_03_replay.json
判读:三条断言各自的失效信号
| 断言 | 验的是 | 失效意味着 |
|---|---|---|
| 可解码 | 契约两侧一致 | 页面改了分隔符而脚本没跟,链接在接收端解不开 |
| 可重现 | PRNG 与规则同源 | 同一段链接在不同语言下跑出不同局面,回放失效 |
| 再编码无损 | 编解码互为逆运算 | 链接转发几次就漂了,分享链条会断 |
第三条最容易被忽略:它不检查任何游戏逻辑,只检查「解出来再编回去还是不是原来那串」。少了它,一个把空序列解成 [""] 的小 bug 可以长期潜伏——因为回放当前这一局看起来完全正常。80 字符的片段短到可以直接贴在聊天窗口里,也正因为它短,任何一处漂移都会立刻被发现。口径再声明一次:本节比较的都是代理指标(proxy)——回合数、出牌数、格挡值这些从快照里读出来的整数,只用于相对比较(默认局 vs 回放局),禁绝对化;「同一段链接在任何宿主上都重现同一局」是可断言的机制事实,而「这一局更好玩」不是本节能说的话。
本节实跑清单
| 命令 | 产物 | 实测 |
|---|---|---|
NODE_PATH=… node web/verify_mech.mjs --page out/proto03.html --run replay --expect-digest '…' | 回放断言 | 3 断言全绿 |
/opt/homebrew/bin/python3 sim/gd_proto_03_replay.py | out/gd_proto_03_replay.json | 5 断言全绿,再编码无损 |
第 3 章到此收口:规格内联成一个文件(3.1)、在无头浏览器里真玩并断言(3.2)、把一局编码成可回放的链接(3.3)。下一章换一个方向——不再问「原型怎么跑」,而是问「先验什么假设、采多少样本、拿什么做决策」。
阅读导航:上一节:3.2 浏览器里的真玩与断言 · 下一节:4.1 测试的目标与假设 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。