1.1 不写代码先验规则
《小游戏开发实战》的原型范围与目标 回答的是产品问题——这个原型要验什么、做到什么程度就停;本节回答的是机制问题——在写一行代码之前,怎么用一张纸把规则先跑一遍,并证明这张纸本身是完整的。最小可玩原型 讲的是原型的产品口径;本节的纸面原型不是文档,而是一张可记录、可回放的规则表,它的完备性能像代码一样被检查。
纸面原型不是写文档,是写一张能跑的规则表
设计文档写的是「玩家可以出牌,牌有费用,敌人会反击」;纸面规则表写的是「哪一条规则、在什么条件下、把哪些状态变量改成什么」。前者只能读,后者能被执行——人拿着它就能手推,脚本拿着它就能校验。区别在于每条规则都要交代它写哪些状态:一条规则改了状态却不声明,纸面上看不出来,一旦跑起来就是「凭空多出来一个数字」,最后没人说得清那个数字从哪来。
所以纸面原型的第一条纪律是:规则表是一个全函数。每个状态变量至少被一条规则写,每条规则的状态增量都指向已声明的变量。第二条纪律是:数字全是整数——能量、血量、格挡、伤害都是整数,纸面上不写小数,脚本里也就没有浮点误差。第三条纪律是:条件必须可判定,写成整数比较式,而不是「当玩家觉得合适时」。
纸面规则表的五个字段
每条规则固定五字段,缺一不可。这不是排版偏好——缺 condition 的规则是「无条件触发」,缺 state_delta 的规则是「改了什么都不说」,两者都会让纸面推演变成不可复现的即兴表演:
| 字段 | 含义 | 《灰烬牌库》例 |
|---|---|---|
id | 规则标识,供其它规则与日志引用 | R1 |
trigger | 什么时候检查这条规则 | 玩家出牌 p<idx> |
condition | 整数比较式,成立才执行 | idx < len(hand) and hand[idx].cost <= energy |
effect | 做了什么(人读的一句话) | 扣能量、加格挡、结算伤害、牌进弃牌堆 |
state_delta | 写哪些状态变量(脚本据此查完整性) | energy, block, enemyHp, dealt, played, hand, discard |
规则表(D 档)
《灰烬牌库》单场战斗的规则表落成 5 条规则、12 个状态变量。它只描述「什么条件下改哪些状态」,不含任何数值——数值是下一节的事,本节先把结构验完:
{
"table_version": 1,
"game": "ashdeck",
"state_vars": [
"turn",
"energy",
"playerHp",
"enemyHp",
"block",
"dealt",
"played",
"over",
"won",
"hand",
"draw",
"discard"
],
"rules": [
{
"id": "R1",
"trigger": "玩家出牌 p<idx>",
"condition": "idx < len(hand) and hand[idx].cost <= energy",
"effect": "扣能量、加格挡、结算伤害、牌进弃牌堆",
"state_delta": ["energy", "block", "enemyHp", "dealt", "played", "hand", "discard"]
},
{
"id": "R2",
"trigger": "玩家结束回合 e",
"condition": "over == false",
"effect": "敌人攻击(先扣格挡再扣血)、抽满手牌、能量重置、回合加一",
"state_delta": ["playerHp", "block", "turn", "energy", "hand", "draw"]
},
{
"id": "R3",
"trigger": "伤害结算后",
"condition": "enemyHp <= 0",
"effect": "胜利:结束且 won",
"state_delta": ["over", "won"]
},
{
"id": "R4",
"trigger": "敌人攻击后",
"condition": "playerHp <= 0",
"effect": "失败:结束且 not won",
"state_delta": ["over", "won"]
},
{
"id": "R5",
"trigger": "回合开始时",
"condition": "turn > turnLimit",
"effect": "超时失败:结束且 not won",
"state_delta": ["over", "won"]
}
]
}
校验:规则表必须是全函数
光写出来不算数,得让脚本验。校验分四项:字段完整性、引用完整性、全函数性、条件可判定。再加一次负对照——抽掉一条规则,看「全函数性」检查是否真的报出悬空变量;检查能抓到问题才算检查:
#!/usr/bin/env python3
# file: sim/gd_proto_01_validate_rules.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_proto_01_validate_rules.py
"""卷三 1.1 不写代码先验规则(S 层):校验纸面规则表是「可执行的全函数」。
纸面规则表 config/gd_proto_01_rule_table.json 把每条规则写成
{id, trigger, condition, effect, state_delta}。先写代码之前,先证明这张表本身是完整的:
1) 字段完整性:五字段齐全且非空;
2) 引用完整性:state_delta 只能写 state_vars 里声明过的状态变量;
3) 全函数性:每个状态变量至少被一条规则写(否则纸面上它是「悬空」的);
4) 条件可判定:condition 含整数比较运算符,且无浮点字面量、无随机源。
再做一次**负对照**:抽掉一条规则,看「全函数性」检查是否真的报出悬空变量——
检查非空才算检查。
产出 out/gd_proto_01_validate.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")
TABLE = os.path.join(ROOT, "config", "gd_proto_01_rule_table.json")
FIELDS = ["id", "trigger", "condition", "effect", "state_delta"]
CMP = ("<=", ">=", "==", "!=", "<", ">")
FLOAT_LIT = re.compile(r"[0-9]+\.[0-9]+")
RANDOM_TOK = ("random", "rand(", "urandom", "seed(")
def load(path):
with open(path, encoding="utf-8") as f:
return json.load(f)
def check_fields(rules):
"""返回 (缺失列表, 空字段列表)。"""
missing, empty = [], []
for r in rules:
for f in FIELDS:
if f not in r:
missing.append(f"{r.get('id', '?')}.{f}")
elif r[f] in ("", None, []):
empty.append(f"{r.get('id', '?')}.{f}")
return missing, empty
def check_refs(rules, state_vars):
unknown = []
for r in rules:
for v in r["state_delta"]:
if v not in state_vars:
unknown.append(f"{r['id']}:{v}")
return unknown
def coverage(rules, state_vars):
cov = {v: 0 for v in state_vars}
for r in rules:
for v in r["state_delta"]:
if v in cov:
cov[v] += 1
orphans = sorted(v for v, n in cov.items() if n == 0)
return cov, orphans
def check_conditions(rules):
bad = []
for r in rules:
c = r["condition"]
if not any(op in c for op in CMP):
bad.append(f"{r['id']}:无比较运算符")
elif FLOAT_LIT.search(c):
bad.append(f"{r['id']}:含浮点字面量")
elif any(t in c for t in RANDOM_TOK):
bad.append(f"{r['id']}:含随机源")
return bad
def main() -> int:
table = load(TABLE)
rules = table["rules"]
state_vars = table["state_vars"]
missing, empty = check_fields(rules)
unknown = check_refs(rules, state_vars)
cov, orphans = coverage(rules, state_vars)
bad_cond = check_conditions(rules)
# 负对照:抽掉写覆盖最广的规则,全函数性检查必须报出悬空变量
dropped = max(rules, key=lambda r: len(set(r["state_delta"])))["id"]
kept = [r for r in rules if r["id"] != dropped]
_, orphan_after = coverage(kept, state_vars)
neg = {"droppedRule": dropped, "orphanVars": orphan_after,
"detected": len(orphan_after) > 0}
checks = {
"fieldsComplete": not missing and not empty,
"refIntegrity": not unknown,
"totalFunction": not orphans,
"conditionDecidable": not bad_cond,
"negativeControlDetects": neg["detected"],
}
bad = [k for k, v in checks.items() if not v]
report = {
"id": "gd_proto_01_validate_rules", "table": "config/gd_proto_01_rule_table.json",
"tableVersion": table["table_version"], "rules": len(rules),
"stateVars": len(state_vars), "missingFields": missing, "emptyFields": empty,
"unknownDeltaVars": unknown, "orphanVars": orphans, "badConditions": bad_cond,
"coverage": cov, "negativeControl": neg, "checks": checks, "fail": len(bad),
"note": "纸面规则表通过这四项检查,才允许进入写代码阶段;负对照证明检查非空。",
}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "gd_proto_01_validate.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print("=== 1.1 纸面规则表校验 ===")
print(f"规则 {len(rules)} 条 / 状态变量 {len(state_vars)} 个")
print(f"字段完整性={'通过' if checks['fieldsComplete'] else '失败'} "
f"引用完整性={'通过' if checks['refIntegrity'] else '失败'} "
f"全函数性={'通过' if checks['totalFunction'] else '失败'} "
f"条件可判定={'通过' if checks['conditionDecidable'] else '失败'}")
print("每条规则的写覆盖:")
for r in rules:
print(f" {r['id']} trigger={r['trigger']:<12} writes={len(r['state_delta'])}")
print("每个状态变量被几条规则写:")
print(" " + " ".join(f"{v}={cov[v]}" for v in state_vars))
print(f"悬空变量={orphans or '无'}")
print(f"负对照:抽掉 {neg['droppedRule']} 后悬空变量={neg['orphanVars']} "
f"检出={neg['detected']}")
print(f"fail={len(bad)} {'(可进入写代码阶段)' if not bad else bad}")
print("wrote out/gd_proto_01_validate.json")
return 1 if bad else 0
if __name__ == "__main__":
raise SystemExit(main())
实跑输出
=== 1.1 纸面规则表校验 ===
规则 5 条 / 状态变量 12 个
字段完整性=通过 引用完整性=通过 全函数性=通过 条件可判定=通过
每条规则的写覆盖:
R1 trigger=玩家出牌 p<idx> writes=7
R2 trigger=玩家结束回合 e writes=6
R3 trigger=伤害结算后 writes=2
R4 trigger=敌人攻击后 writes=2
R5 trigger=回合开始时 writes=2
每个状态变量被几条规则写:
turn=1 energy=2 playerHp=1 enemyHp=1 block=2 dealt=1 played=1 over=3 won=3 hand=2 draw=1 discard=1
悬空变量=无
负对照:抽掉 R1 后悬空变量=['dealt', 'discard', 'enemyHp', 'played'] 检出=True
fail=0 (可进入写代码阶段)
wrote out/gd_proto_01_validate.json
判读:负对照证明检查非空
四项检查全过,悬空变量=无——12 个状态变量每一个都被至少一条规则写,纸面上没有「来路不明的数字」。energy=2 block=2 hand=2 是规则交叠处(出牌与结束回合都会碰),over=3 won=3 是三条终止规则共用同一对标志位;turn / playerHp / draw 各自只有 R2 写,这正是负对照要打的点。
负对照抽掉写覆盖最广的 R1,dealt / discard / enemyHp / played 四个变量立刻变成悬空——检查报出来了,说明它不是「恒返回通过」的空检查。这条纪律在写代码之前就拦住了一整类 bug:如果你在纸面上说不清一个变量归谁管,写进代码后它会变成最难查的隐式状态。
必须声明:本节的「完整性」是结构性代理指标,只说明规则表自洽,不说明游戏好玩。「规则表全函数」是能证明的,「因此有趣」不能。
本节实跑清单
| 命令 | 产物 | 实测 |
|---|---|---|
/opt/homebrew/bin/python3 sim/gd_proto_01_validate_rules.py | out/gd_proto_01_validate.json | 4 项检查全过,负对照检出 4 个悬空变量 |
规则表验完,只是「结构完整」。它到底能不能跑、跑出来的数对不对,要靠下一节:把一次纸面推演记下来,再让脚本回放它。
阅读导航:上一节:目录 · 下一节:1.2 纸面原型的记录与回放 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。