1.2 纸面原型的记录与回放
《小游戏开发实战》的原型范围与目标 管的是验什么、验到什么程度;本节管的是怎么把验的过程记下来。最小可玩原型 的产品口径关心「原型能不能玩」;本节的纸面口径关心「这次推演能不能被另一个人原样重放」。纸面原型如果只留结论(「推了两回合,敌人剩 21 血」),它就是一段无法复现的轶事;如果记下了每一步的状态,它就能被回放、被比对、被证伪——而能被证伪,正是它区别于「写文档」的地方。
记录什么:动作序列 + 每步全量状态
纸面推演记录只有两样东西:你做了什么(动作序列)和做完之后世界是什么样(每步的全量整数状态)。前者是输入,后者是人工抄写的结果;回放就是把输入重新喂给引擎,看它吐出的状态和抄下来的对不对得上。记录格式固定五字段,缺任何一个都会让回放失去意义:
| 字段 | 含义 | 约束 |
|---|---|---|
record_version | 记录格式版本 | 整数;升版时旧记录仍可读 |
seed | 洗牌种子 | 整数;同种子在任何语言下同序 |
moves | 动作序列 | 字符串数组,按发生顺序 |
state_fields | 记录哪些状态字段 | 必须是状态变量集合的子集 |
states | 每步之后的全量状态 | 长度必须等于 moves 的长度 |
一次两回合的纸面推演记录(D 档)
下面是《灰烬牌库》一次真实纸面推演的记录:种子 20261014,玩家打两回合,共 10 个动作。注意第 4 与第 9 个动作是无效动作——能量已经打光,R1 的条件不成立,状态原地不动,所以 states[4] 与 states[3] 逐字相同。纸面记录不删掉无效动作,因为「玩家试了一下但没打出去」也是真实发生的事:
{
"record_version": 1,
"game": "ashdeck",
"seed": 20261014,
"note": "纸面推演记录:人工抄写,两回合共 10 个动作。states[i] 是第 i 个动作执行后的全量整数状态。",
"moves": ["p0", "p0", "p0", "p0", "e", "p0", "p0", "p0", "p0", "e"],
"state_fields": ["turn", "energy", "playerHp", "enemyHp", "block", "played", "hand"],
"states": [
{"turn": 1, "energy": 3, "playerHp": 60, "enemyHp": 47, "block": 0, "played": 1, "hand": ["heavy", "strike", "guard", "guard"]},
{"turn": 1, "energy": 1, "playerHp": 60, "enemyHp": 33, "block": 0, "played": 2, "hand": ["strike", "guard", "guard"]},
{"turn": 1, "energy": 0, "playerHp": 60, "enemyHp": 27, "block": 0, "played": 3, "hand": ["guard", "guard"]},
{"turn": 1, "energy": 0, "playerHp": 60, "enemyHp": 27, "block": 0, "played": 3, "hand": ["guard", "guard"]},
{"turn": 2, "energy": 3, "playerHp": 54, "enemyHp": 27, "block": 0, "played": 3, "hand": ["guard", "guard", "strike", "strike", "strike"]},
{"turn": 2, "energy": 2, "playerHp": 54, "enemyHp": 27, "block": 5, "played": 4, "hand": ["guard", "strike", "strike", "strike"]},
{"turn": 2, "energy": 1, "playerHp": 54, "enemyHp": 27, "block": 10, "played": 5, "hand": ["strike", "strike", "strike"]},
{"turn": 2, "energy": 0, "playerHp": 54, "enemyHp": 21, "block": 10, "played": 6, "hand": ["strike", "strike"]},
{"turn": 2, "energy": 0, "playerHp": 54, "enemyHp": 21, "block": 10, "played": 6, "hand": ["strike", "strike"]},
{"turn": 3, "energy": 3, "playerHp": 54, "enemyHp": 21, "block": 0, "played": 6, "hand": ["strike", "strike", "heavy", "strike", "jab"]}
]
}
回放:逐字段比对 + 每步守恒
回放脚本做两件事。第一,逐字段比对:把 moves 的前 k 个动作喂给引擎,得到第 k 步的快照,与 states[k-1] 的每个字段比,误差必须是 0(整数状态,不设容差)。第二,每步守恒:牌数恒等于牌库大小、伤害守恒、能量落在 [0, energyMax]、格挡非负。再加一次负对照——把第 7 步的 playerHp 抄错 1 点,回放必须精确报出是第 7 步:
#!/usr/bin/env python3
# file: sim/gd_proto_01_record_replay.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_proto_01_record_replay.py
"""卷三 1.2 纸面原型的记录与回放(S 层):把一次纸面推演回放并逐条校验守恒。
纸面推演记录 config/gd_proto_01_paper_record.json 记的是「动作序列 + 每步之后的全量整数状态」。
回放 = 用引擎按同一条动作序列重跑,逐字段比对人工抄写的状态;误差必须是 **0**(全整数,无浮点)。
同时每步校验四条守恒:牌数守恒、伤害守恒、能量在界内、格挡非负。
再做一次**负对照**:把记录里第 7 步的 playerHp 抄错 1 点,回放必须精确报出这一步——
回放检查能定位抄写错误,才算真的在比对。
产出 out/gd_proto_01_replay.json、out/gd_proto_01_trajectory.csv、out/gd_proto_01_replay.svg。
全整数、无浮点、无 random。
"""
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
RECORD = os.path.join(ROOT, "config", "gd_proto_01_paper_record.json")
def load(path):
with open(path, encoding="utf-8") as f:
return json.load(f)
def compare(rec, spec, mutate=None):
"""按动作序列逐步回放,返回 (错配列表, 每步快照列表)。"""
fields = rec["state_fields"]
moves = rec["moves"]
states = json.loads(json.dumps(rec["states"]))
if mutate is not None:
step, key, delta = mutate
states[step - 1][key] += delta
mismatches, snaps = [], []
for k in range(1, len(moves) + 1):
snap = ashlib.run_spec(spec, moves[:k])
snaps.append(snap)
for f in fields:
if snap[f] != states[k - 1][f]:
mismatches.append({"step": k, "move": moves[k - 1], "field": f,
"engine": snap[f], "record": states[k - 1][f]})
return mismatches, snaps
def conservation(spec, snaps):
ds = ashlib.deck_size(spec)
p = spec["params"]
rows = []
for i, s in enumerate(snaps, 1):
rows.append({
"step": i,
"cardCountEqDeck": s["cardCount"] == ds,
"damageConserved": s["damageConserved"] is True,
"energyInRange": 0 <= s["energy"] <= p["energyMax"],
"blockNonNegative": s["block"] >= 0,
})
ok = all(all(v for k, v in r.items() if k != "step") for r in rows)
return rows, ok
def draw_svg(snaps, path):
spec = ashlib.load_spec()
root = ashlib.svg_begin(660, 220)
ashlib.svg_text(root, 16, 24, "纸面推演回放:每步之后的敌方 HP(整数)")
w, gap, x0, base = 52, 10, 24, 40
den = spec["params"]["enemyHp"]
for i, s in enumerate(snaps):
x = x0 + i * (w + gap)
ashlib.svg_bar(root, x, 60, w, 120, s["enemyHp"], den, "#38bdf8")
ashlib.svg_text(root, x + w // 2, 198, str(i + 1), 12, "#94a3b8", "middle")
ashlib.svg_text(root, x + w // 2, 54, str(s["enemyHp"]), 12, "#e2e8f0", "middle")
return ashlib.svg_save(root, path)
def main() -> int:
spec = ashlib.load_spec()
rec = load(RECORD)
mismatches, snaps = compare(rec, spec)
cons_rows, cons_ok = conservation(spec, snaps)
# 负对照:第 7 步 playerHp 抄错 1 点
neg_mm, _ = compare(rec, spec, mutate=(7, "playerHp", -1))
neg = {"step": 7, "field": "playerHp", "delta": -1,
"detectedMismatches": len(neg_mm), "detected": len(neg_mm) == 1,
"locatedStep": neg_mm[0]["step"] if neg_mm else None}
checks = {"replayExact": not mismatches, "conservationHolds": cons_ok,
"negativeControlLocates": neg["detected"]}
bad = [k for k, v in checks.items() if not v]
csv_rows = [[i, rec["moves"][i - 1], s["turn"], s["energy"], s["playerHp"],
s["enemyHp"], s["block"], s["played"]] for i, s in enumerate(snaps, 1)]
n_csv = ashlib.write_csv(os.path.join(OUT, "gd_proto_01_trajectory.csv"),
["step", "move", "turn", "energy", "playerHp",
"enemyHp", "block", "played"], csv_rows)
svg_bytes = draw_svg(snaps, os.path.join(OUT, "gd_proto_01_replay.svg"))
report = {
"id": "gd_proto_01_record_replay", "record": "config/gd_proto_01_paper_record.json",
"seed": rec["seed"], "moves": len(rec["moves"]), "states": len(rec["states"]),
"stateFields": rec["state_fields"], "replayMismatch": len(mismatches),
"mismatches": mismatches, "conservation": {"stepsChecked": len(cons_rows),
"allHold": cons_ok, "rows": cons_rows},
"negativeControl": neg, "checks": checks, "fail": len(bad),
"artifacts": {"csv": "out/gd_proto_01_trajectory.csv", "csvRows": n_csv,
"svg": "out/gd_proto_01_replay.svg", "svgBytes": svg_bytes},
"note": "回放误差必须是 0:全整数状态,逐位比对,不设容差。",
}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "gd_proto_01_replay.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print("=== 1.2 纸面推演回放 ===")
print(f"动作 {len(rec['moves'])} 个 / 记录状态 {len(rec['states'])} 个 / 比对字段 {len(rec['state_fields'])} 个")
print(f"回放错配={len(mismatches)}(误差 0 才通过)")
print("每步守恒(牌数/伤害/能量/格挡):")
for r in cons_rows:
flags = "".join("1" if v else "0" for k, v in sorted(r.items()) if k != "step")
print(f" step {r['step']:>2} {flags}")
print(f"守恒全成立={cons_ok}")
print(f"负对照:第 {neg['step']} 步 playerHp 抄错 {neg['delta']} → "
f"检出错配 {neg['detectedMismatches']} 条,定位 step={neg['locatedStep']}")
print(f"产物:out/gd_proto_01_trajectory.csv({n_csv} 行)、"
f"out/gd_proto_01_replay.svg({svg_bytes} B)")
print(f"fail={len(bad)} {'(回放可复现且守恒)' if not bad else bad}")
print("wrote out/gd_proto_01_replay.json")
return 1 if bad else 0
if __name__ == "__main__":
raise SystemExit(main())
实跑输出
=== 1.2 纸面推演回放 ===
动作 10 个 / 记录状态 10 个 / 比对字段 7 个
回放错配=0(误差 0 才通过)
每步守恒(牌数/伤害/能量/格挡):
step 1 1111
step 2 1111
step 3 1111
step 4 1111
step 5 1111
step 6 1111
step 7 1111
step 8 1111
step 9 1111
step 10 1111
守恒全成立=True
负对照:第 7 步 playerHp 抄错 -1 → 检出错配 1 条,定位 step=7
产物:out/gd_proto_01_trajectory.csv(10 行)、out/gd_proto_01_replay.svg(4224 B)
fail=0 (回放可复现且守恒)
wrote out/gd_proto_01_replay.json
判读:误差 0 与「能定位错」
回放错配=0 说明人工抄写的 7 个字段 × 10 步全部与引擎一致——这次纸面推演是可复现的:换个人拿着同一条 moves 重推,会得到同一串数。十步的守恒位全是 1111,说明推演过程没有出现「凭空多一张牌」或「伤害对不上」的破绽。
负对照是本节的关键:把第 7 步的 playerHp 从 54 抄成 53,回放报出恰好 1 条错配、且定位在 step=7。这说明检查不是「跑完就绿」,它真的在逐格比对。第 4、9 步是无效动作(能量为 0,R1 条件不成立),状态与上一步完全相同——记录里保留它们,正是为了让「玩家尝试但失败」也被回放出来。
必须声明:回放错配=0 是可复现性的代理指标,它只说「这套规则自洽且被正确抄写」,不说明这套规则好玩。自洽能证明,好玩不能。
本节实跑清单
| 命令 | 产物 | 实测 |
|---|---|---|
/opt/homebrew/bin/python3 sim/gd_proto_01_record_replay.py | out/gd_proto_01_replay.json + out/gd_proto_01_trajectory.csv + out/gd_proto_01_replay.svg | 10 步误差 0,守恒全成立,负对照定位 step=7 |
记录能被回放,只证明「这次推演自洽」。下一步要问的是:从这张规则表和这份记录里,能不能机械地抽出与 play_spec.json 同构的规格?哪些字段抽不出来、必须人来裁决?
阅读导航:上一节:1.1 不写代码先验规则 · 下一节:1.3 纸面到规格的转换 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。