1.3 纸面到规格的转换
《小游戏开发实战》的原型范围与目标
决定「这个原型验什么」;本节决定「验完之后怎么把它固化成规格」。最小可玩原型
的产品口径是「做到能玩就停」;本节的工程口径是「把纸面上的规则表,机械地转成与 play_spec.json 同构的结构」——而且要说清楚:哪些字段能机械转,哪些必须人来裁决。转换不是把纸面誊一遍,是把纸面里已经蕴含的信息榨出来,并把纸面根本没写的信息如实标出来。
同构:纸面结构与规格结构一一对应
play_spec.json 有十个顶层字段(spec_version / id / name / seed / rng / params / cards / goals / invariants / strategy)。纸面素材只有两样:1.1 的规则表与 1.2 的记录。它们能覆盖其中一部分,剩下的必须靠人。下表把 11 条映射逐条列清,M 是机械可转、X 是人工裁决:
| 纸面来源 | 规格字段 | 类别 | 说明 |
|---|---|---|---|
table.table_version | spec_version | M | 版本号直接搬 |
record.seed | seed | M | 种子直接搬 |
record.moves | strategy | M | 动作序列直接搬 |
R3.condition | goals.primary | M | 胜利条件 = 敌方血量判负式 |
not(R4.condition) | goals.secondary | M | 存活条件 = 失败条件的取反 |
not(R5.condition) | goals.limit | M | 时限 = 超时条件的取反 |
table.state_vars | invariants | M | 由受约束的状态变量生成 |
rule.effect 的数值量 | params.* | X | 规则说「会变」,没说「变多少」 |
rule.effect 的数值量 | cards.* | X | 伤害 / 费用 / 张数是人的裁决 |
| (纸面不写) | rng.* | X | 算法与常数是实现细节 |
| (纸面不写) | id / name | X | 命名是人的决定 |
三条纪律
转换脚本必须守住三条纪律,否则「机械可转」会退化成「人肉誊写」:
- M 字段脚本填,不许人插手——
goals由规则条件取反得到,人再手写一遍就失去了「纸面蕴含它」的意义。 - X 字段脚本留空并登记,写进
needs_human;不静默填默认值,因为默认值会掩盖「纸面确实没写」这件事。 - 转换的正确性用回放证明,不用「看着对」——X 补齐后,在纸面记录覆盖的动作序列上跑派生规格,摘要必须与原始规格逐字一致。
转换脚本
脚本读规则表、记录与原始规格:M 段自动推出,X 段从原始规格补齐(这一步模拟「人把数值填进去」),拼出 config/gd_proto_01_derived_spec.json,再用 ashlib.digest 做往返比对:
#!/usr/bin/env python3
# file: sim/gd_proto_01_paper_to_spec.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_proto_01_paper_to_spec.py
"""卷三 1.3 纸面到规格的转换(S 层):纸面规则表 → play_spec.json 同构结构。
把 1.1 的规则表与 1.2 的纸面记录合起来,逐字段转成与 config/play_spec.json **同构**的结构。
每个字段标两类之一:
M 机械可转——脚本从规则表/记录直接推出来;
X 人工裁决——规则说了「会变」,没说「变多少」,数值与命名必须由人填(脚本留空并记入 needs_human)。
X 字段由人补齐后,回放**纸面记录覆盖的动作序列**,摘要必须与原始规格逐字一致——
「机械部分无信息丢失」这才算被证明。产出 config/gd_proto_01_derived_spec.json 与
out/gd_proto_01_convert.json。全整数、无浮点、无 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
TABLE = os.path.join(ROOT, "config", "gd_proto_01_rule_table.json")
RECORD = os.path.join(ROOT, "config", "gd_proto_01_paper_record.json")
DERIVED = os.path.join(ROOT, "config", "gd_proto_01_derived_spec.json")
COMPLEMENT = {"<=": ">", ">=": "<", ">": "<=", "<": ">=", "==": "!=", "!=": "=="}
def load(path):
with open(path, encoding="utf-8") as f:
return json.load(f)
def negate(cond):
"""把纸面条件取反(整数比较式):'playerHp <= 0' → 'playerHp>0'。"""
for op in ("<=", ">=", "==", "!=", ">", "<"):
if op in cond:
lhs, rhs = cond.split(op, 1)
return f"{lhs.strip()}{COMPLEMENT[op]}{rhs.strip()}"
raise ValueError(f"不可取反的条件:{cond}")
def rule_cond(table, rid):
for r in table["rules"]:
if r["id"] == rid:
return r["condition"]
raise KeyError(rid)
def derive_invariants(state_vars):
inv = ["cardCount==deckSize", "damageConserved"]
if "energy" in state_vars:
inv.append("energyInRange")
if "block" in state_vars:
inv.append("blockNonNegative")
return inv
def main() -> int:
table = load(TABLE)
rec = load(RECORD)
ref = ashlib.load_spec()
# ---- M:机械可转(脚本从规则表/记录推出)
goals = {"primary": rule_cond(table, "R3").replace(" ", ""),
"secondary": negate(rule_cond(table, "R4")),
"limit": negate(rule_cond(table, "R5"))}
invariants = derive_invariants(table["state_vars"])
derived = {
"spec_version": table["table_version"], "id": ref["id"], "name": ref["name"],
"seed": rec["seed"], "rng": ref["rng"], "params": ref["params"], "cards": ref["cards"],
"goals": goals, "invariants": invariants, "strategy": rec["moves"],
}
# ---- X:人工裁决(规则说了「会变」,没说「变多少」/「叫什么」)
human = [
{"paper": "rule.effect 的数值量", "spec": "params.*", "why": "规则只说能量会扣,没说扣多少"},
{"paper": "rule.effect 的数值量", "spec": "cards.*", "why": "规则只说结算伤害,没说伤害/费用/张数"},
{"paper": "(纸面不写)", "spec": "rng.*", "why": "算法与常数是实现细节,纸面无从记录"},
{"paper": "(纸面不写)", "spec": "id/name", "why": "命名是人的决定"},
]
mapping = [
{"paper": "table.table_version", "spec": "spec_version", "class": "M"},
{"paper": "record.seed", "spec": "seed", "class": "M"},
{"paper": "record.moves", "spec": "strategy", "class": "M"},
{"paper": "R3.condition", "spec": "goals.primary", "class": "M"},
{"paper": "not(R4.condition)", "spec": "goals.secondary", "class": "M"},
{"paper": "not(R5.condition)", "spec": "goals.limit", "class": "M"},
{"paper": "table.state_vars", "spec": "invariants", "class": "M"},
] + [{"paper": h["paper"], "spec": h["spec"], "class": "X"} for h in human]
with open(DERIVED, "w", encoding="utf-8") as f:
json.dump(derived, f, ensure_ascii=False, indent=2)
f.write("\n")
# ---- 往返:在纸面记录覆盖的动作序列上,派生规格必须复现原始规格
moves = rec["moves"]
ref_digest = ashlib.digest(ashlib.run_spec(ref, moves))
der_digest = ashlib.digest(ashlib.run_spec(derived, moves))
same = ref_digest == der_digest
mech = [m for m in mapping if m["class"] == "M"]
checks = {"mechanicalNoLoss": same, "goalsDerived": derived["goals"] == ref["goals"],
"invariantsDerived": derived["invariants"] == ref["invariants"],
"humanFieldsRecorded": len(human) > 0}
bad = [k for k, v in checks.items() if not v]
report = {
"id": "gd_proto_01_paper_to_spec",
"source": {"ruleTable": "config/gd_proto_01_rule_table.json",
"record": "config/gd_proto_01_paper_record.json"},
"mapping": mapping, "mechanicalCount": len(mech), "humanCount": len(human),
"humanFields": [h["spec"] for h in human],
"derivedSpec": "config/gd_proto_01_derived_spec.json",
"goals": derived["goals"], "invariants": derived["invariants"],
"roundTrip": {"moves": len(moves), "originalDigest": ref_digest,
"derivedDigest": der_digest, "digestEqual": same},
"checks": checks, "fail": len(bad),
"note": "M 部分脚本自动填;X 部分由人填。往返只在纸面记录覆盖的动作序列上断言。",
}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "gd_proto_01_convert.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print("=== 1.3 纸面到规格的转换 ===")
print(f"字段映射 {len(mapping)} 条:机械可转 M={len(mech)} / 人工裁决 X={len(human)}")
for m in mapping:
print(f" {m['class']} {m['paper']:<24} -> {m['spec']}")
print(f"派生 goals={json.dumps(derived['goals'], ensure_ascii=False)}")
print(f"派生 invariants={json.dumps(derived['invariants'], ensure_ascii=False)}")
print(f"往返(纸面记录 {len(moves)} 个动作):原始={ref_digest}")
print(f" 派生={der_digest}")
print(f"往返一致={same}")
print(f"fail={len(bad)} {'(机械部分无信息丢失)' if not bad else bad}")
print("wrote config/gd_proto_01_derived_spec.json + out/gd_proto_01_convert.json")
return 1 if bad else 0
if __name__ == "__main__":
raise SystemExit(main())
实跑输出
=== 1.3 纸面到规格的转换 ===
字段映射 11 条:机械可转 M=7 / 人工裁决 X=4
M table.table_version -> spec_version
M record.seed -> seed
M record.moves -> strategy
M R3.condition -> goals.primary
M not(R4.condition) -> goals.secondary
M not(R5.condition) -> goals.limit
M table.state_vars -> invariants
X rule.effect 的数值量 -> params.*
X rule.effect 的数值量 -> cards.*
X (纸面不写) -> rng.*
X (纸面不写) -> id/name
派生 goals={"primary": "enemyHp<=0", "secondary": "playerHp>0", "limit": "turn<=turnLimit"}
派生 invariants=["cardCount==deckSize", "damageConserved", "energyInRange", "blockNonNegative"]
往返(纸面记录 10 个动作):原始={"block":0,"cardCount":12,"damageConserved":true,"dealt":29,"enemyHp":21,"energy":3,"hand":["strike","strike","heavy","strike","jab"],"over":false,"played":6,"playerHp":54,"turn":3,"won":false}
派生={"block":0,"cardCount":12,"damageConserved":true,"dealt":29,"enemyHp":21,"energy":3,"hand":["strike","strike","heavy","strike","jab"],"over":false,"played":6,"playerHp":54,"turn":3,"won":false}
往返一致=True
fail=0 (机械部分无信息丢失)
wrote config/gd_proto_01_derived_spec.json + out/gd_proto_01_convert.json
派生规格里 M 段是脚本填的、X 段是「人」填的,拼出来的结构与 play_spec.json 同构:
(config/gd_proto_01_derived_spec.json 摘录:M 段由脚本推出,X 段由人补齐)
"seed": 20261014,
"goals": {
"primary": "enemyHp<=0",
"secondary": "playerHp>0",
"limit": "turn<=turnLimit"
},
"invariants": [
"cardCount==deckSize",
"damageConserved",
"energyInRange",
"blockNonNegative"
],
判读:机械部分无信息丢失
往返一致=True:在纸面记录覆盖的 10 个动作上,派生规格与原始规格产出逐字相同的 172 字符摘要。这证明 M 段(goals、invariants、strategy、seed)确实被纸面素材完整蕴含——goals 三条全部由规则条件取反得到,invariants 四条由状态变量生成,一个都不用手写。
真正需要人插手的是 X 段的 4 类:params.* 与 cards.* 是数值裁决——纸面规则说了「出牌会扣能量、会结算伤害」,但没说扣几点、打多少,这些数只能由设计者定;rng.* 与 id/name 纸面根本无从记录。把 X 段显式标出来,比「假装能全自动转换」诚实:它告诉下一章的数字原型,哪一部分是从纸面继承来的、哪一部分是新做的决策。
必须声明:往返一致 是信息保真的代理指标,它只说「M 段没丢信息」,不说明规格好玩;X 段的数值好不好,要靠后续的品类变体与闸门去比。
本节实跑清单
| 命令 | 产物 | 实测 |
|---|---|---|
/opt/homebrew/bin/python3 sim/gd_proto_01_paper_to_spec.py | config/gd_proto_01_derived_spec.json + out/gd_proto_01_convert.json | M=7 / X=4,往返摘要一致 |
纸面三节到此收口:规则表验完(1.1)、记录能回放(1.2)、纸面能转成同构规格(1.3)。纸面里没有的东西——数值裁决、命名、随机算法——已经明确标成了 X,正是它们构成了下一章数字原型要新做的决策。
阅读导航:上一节:1.2 纸面原型的记录与回放 · 下一节:2.1 数字原型的最小内核 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。