1.1 不写代码先验规则

把规则先写在纸面上,不写一行代码就能判断它是否完整:纸面规则表把每条规则写成触发、条件、效果、状态增量,脚本再校验它是「可执行的全函数」——没有悬空状态变量、条件可判定、引用不越界,并用负对照证明检查非空。这是《灰烬牌库》从纸面走向可执行规格的第一步。

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.pyout/gd_proto_01_validate.json4 项检查全过,负对照检出 4 个悬空变量

规则表验完,只是「结构完整」。它到底能不能跑、跑出来的数对不对,要靠下一节:把一次纸面推演记下来,再让脚本回放它。

阅读导航:上一节:目录 · 下一节:1.2 纸面原型的记录与回放 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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