1.1 机制的最小构成:动词、规则、状态

机制不是一段文字描述,而是三张能逐字段核对的小表:动词(玩家能做什么)、规则(动词受什么约束、状态怎么变)、状态(规则作用在什么上)。本节用《灰烬牌库》的 play_spec.json 把三张表从规格里解析出来——2 个动词、6 个动作符号、13 条规则、12 个状态分量,并查出 0 个孤儿分量与 2 个没人消费的参数。

站内的独立游戏核心循环与手感设计 (约 690 行)是一篇散文式指南:讲核心循环、讲手感参数体系、讲数值平衡,代码是 Unity csharp 片段、跑不起来。本节不重复那些道理,而是把它们形式化成规格字段——「核心循环」被拆成动词与规则,「手感」留到卷三第 12 章变成可断言的输入-反馈机制。《小游戏开发实战》卷一《市场品类与产品设计》 走的是产品口径(品类选择、用户动机、立项预算、变现前置),本卷走的是机制口径:把玩法写成一份能跑的规格,并证明它的性质。

这是全书开篇,先把贯穿三卷的案例讲清楚。《灰烬牌库》(工程名 ashdeck)是一个卡牌构筑 Roguelike 的最小内核:玩家手里有 5 张牌、每回合 3 点能量,打牌扣能量、加格挡、减敌方血,结束回合时敌人固定打 6 点,打光敌方 50 点血就赢。它的全部规则写在只读真源 config/play_spec.json 里,S / P / W 三层读的是同一个文件。

1.1 机制的最小构成:动词、规则、状态

任何机制都能拆成三张表。这不是分类学,而是可核对性的下限:拆不出这三张表,机制就没法写成规格;三张表之间有洞,机制就有洞。

层一句话在 config/play_spec.json 里长在哪
动词玩家能做什么strategy 的动作字母表 + cards[](动词的参数)
规则动词受什么约束、状态怎么变params(约束与结算参数)+ invariants(恒真式)
状态规则作用在什么上引擎快照的 12 个键(run_spec 的返回值)

三张表有一条硬纪律:必须从规格里长出来,不能手写。手写一份「动词表」放进设计文档,它与实现之间迟早会漂移;而只要动词表是从 handSize 算出来的,改 handSize 就会同步改动词表。下面这段 S 层脚本就是这条纪律的落地。

{
  "params": { "playerHp": 60, "enemyHp": 50, "enemyAttack": 6, "energyMax": 3,
              "handSize": 5, "turnLimit": 20, "rewardGold": 12, "punishHp": 4 },
  "cards": [
    { "id": "strike", "cost": 1, "damage": 6,  "block": 0, "count": 5, "rarity": "common" },
    { "id": "heavy",  "cost": 2, "damage": 14, "block": 0, "count": 3, "rarity": "uncommon" },
    { "id": "jab",    "cost": 0, "damage": 3,  "block": 0, "count": 2, "rarity": "common" },
    { "id": "guard",  "cost": 1, "damage": 0,  "block": 5, "count": 2, "rarity": "common" }
  ],
  "goals": { "primary": "enemyHp<=0", "secondary": "playerHp>0", "limit": "turn<=turnLimit" },
  "invariants": ["cardCount==deckSize", "damageConserved", "energyInRange", "blockNonNegative"]
}

S 层:把三张表从规格里解析出来

sim/gd_mech_01_anatomy.py 读真源,输出动词表、规则表、状态表,再做两项覆盖检查:孤儿分量(没有任何规则写过的状态分量)与空动词(基准策略里一次都没出现的动词)。全整数,无浮点,无随机。

#!/usr/bin/env python3
# file: sim/gd_mech_01_anatomy.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_01_anatomy.py
"""卷一 1.1 机制的最小构成(S 层):把一份玩法规格拆成动词 / 规则 / 状态三张表。

三张表都**从规格解析出来**,不手写:动词表由 handSize 长出动作字母表;规则表每条都指回
play_spec.json 的字段;状态表的分量取自**引擎快照的键**(ashlib.run_spec 的返回值)。
再做覆盖检查:状态分量必须至少被一条规则写过(否则是孤儿分量),动词必须被基准策略用过。
全整数、无浮点、无随机。产出 out/mech01_anatomy.json。
"""
from __future__ import annotations

import json
import os
import sys

import ashlib

HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")

# 「机制与玩家的接口」= 状态里哪些分量被呈现。这一栏是**设计声明**,不从规格算;1.3 沿用同一份名单。
SURFACED = ["block", "energy", "enemyHp", "hand", "over", "playerHp", "turn", "won"]
WRITERS = {"turn": "R6/R7", "energy": "R3/R6", "playerHp": "R5", "enemyHp": "R4",
           "block": "R3/R5/R6", "dealt": "R4", "played": "R3", "over": "R7/R9",
           "won": "R9", "hand": "R2/R3/R8", "cardCount": "I1", "damageConserved": "I2"}


def verbs(spec: dict) -> list[dict]:
    """动词表:动作字母表由 handSize 决定,符号与参数都从规格里长出来。"""
    hs = spec["params"]["handSize"]
    return [
        {"verb": "playCard", "arity": 1, "symbols": [f"p{i}" for i in range(hs)],
         "param": f"hand 槽位 i ∈ [0,{hs})", "effect": "扣能量 / 加格挡 / 减敌方血",
         "guarded_by": "R1 费用前置, R2 下标前置"},
        {"verb": "endTurn", "arity": 0, "symbols": ["e"], "param": "无",
         "effect": "敌方攻击 / 回合 +1 / 重置能量 / 补牌", "guarded_by": "R7 时限前置"},
    ]


def rules(spec: dict) -> list[dict]:
    """规则表:前置条件(动词受什么约束)、结算(状态怎么变)、不变量(什么恒真)。"""
    p, cards = spec["params"], spec["cards"]

    def setof(k: str) -> list[int]:
        return sorted({c[k] for c in cards})

    rows = [
        ("R1", "前置", "card.cost <= energy", f"energyMax={p['energyMax']};cost∈{setof('cost')}"),
        ("R2", "前置", "0 <= i < len(hand)", f"handSize={p['handSize']}"),
        ("R3", "结算", "energy -= cost; block += card.block", f"block∈{setof('block')}"),
        ("R4", "结算", "d = min(card.damage, enemyHp); enemyHp -= d",
         f"enemyHp={p['enemyHp']};damage∈{setof('damage')}"),
        ("R5", "结算", "absorbed = min(block, enemyAttack); playerHp -= enemyAttack - absorbed",
         f"enemyAttack={p['enemyAttack']}"),
        ("R6", "结算", "turn += 1; block = 0; energy = energyMax", f"energyMax={p['energyMax']}"),
        ("R7", "前置", "turn <= turnLimit", f"turnLimit={p['turnLimit']}"),
        ("R8", "结算", "while len(hand) < handSize and draw: hand += draw.pop(0)",
         f"handSize={p['handSize']}"),
        ("R9", "结算", "enemyHp <= 0 → won;playerHp <= 0 → lost",
         f"goals={spec['goals']['primary']}/{spec['goals']['secondary']}"),
    ]
    rows += [(f"I{k}", "不变量", inv, "invariants[]")
             for k, inv in enumerate(spec["invariants"], start=1)]
    return [{"id": i, "kind": k, "expr": e, "params": q} for i, k, e, q in rows]


def states(spec: dict) -> list[dict]:
    """状态表:分量取自引擎快照的键,写入者按上面规则表的编号指认。"""
    snap = ashlib.run_spec(spec)
    return [{"key": k, "type": type(snap[k]).__name__, "surfaced": k in SURFACED,
             "written_by": WRITERS[k]} for k in sorted(snap)]


def main() -> int:
    spec = ashlib.load_spec()
    vs, rs, ss = verbs(spec), rules(spec), states(spec)
    alphabet = sorted({c for v in vs for c in v["symbols"]})
    orphans = [s["key"] for s in ss if not s["written_by"]]
    used = set(spec["strategy"])
    empty = [v["verb"] for v in vs if not set(v["symbols"]) & used]
    surfaced = [s["key"] for s in ss if s["surfaced"]]
    hidden = [s["key"] for s in ss if not s["surfaced"]]
    permille = len(surfaced) * 1000 // len(ss)
    kinds = {k: sum(1 for r in rs if r["kind"] == k) for k in ("前置", "结算", "不变量")}

    print("== 动词表 ==")
    for v in vs:
        print(f"{v['verb']:<9} arity={v['arity']} 符号={'/'.join(v['symbols'])} "
              f"参数={v['param']} 受约束={v['guarded_by']}")
    print(f"字母表={len(alphabet)}(handSize {spec['params']['handSize']} + endTurn 1)"
          f": {','.join(alphabet)}")
    print("\n== 规则表 ==")
    for r in rs:
        print(f"{r['id']:<3} {r['kind']:<3} {r['expr']:<62} ← {r['params']}")
    print("\n== 状态表 ==")
    for s in ss:
        print(f"{s['key']:<16} {s['type']:<5} 呈现={'是' if s['surfaced'] else '否'} "
              f"写入={s['written_by']}")
    print(f"\n动词 {len(vs)} 条 / 规则 {len(rs)} 条(前置 {kinds['前置']}、结算 {kinds['结算']}、"
          f"不变量 {kinds['不变量']})/ 状态分量 {len(ss)} 个")
    print(f"孤儿分量={len(orphans)} {orphans}  空动词={len(empty)} {empty}")
    print(f"呈现 {len(surfaced)}/{len(ss)} = {permille}‰  隐藏={hidden}")

    report = {"spec": spec["id"], "verbs": vs, "alphabet": alphabet, "rules": rs,
              "states": ss, "surfaced": surfaced, "hidden": hidden,
              "surfacedPermille": permille, "orphans": orphans}
    os.makedirs(OUT, exist_ok=True)
    with open(os.path.join(OUT, "mech01_anatomy.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
        f.write("\n")
    print("wrote out/mech01_anatomy.json")
    return 0


if __name__ == "__main__":
    sys.exit(main())

本机实跑 /opt/homebrew/bin/python3 sim/gd_mech_01_anatomy.py:

== 动词表 ==
playCard  arity=1 符号=p0/p1/p2/p3/p4 参数=hand 槽位 i ∈ [0,5) 受约束=R1 费用前置, R2 下标前置
endTurn   arity=0 符号=e 参数=无 受约束=R7 时限前置
字母表=6(handSize 5 + endTurn 1): e,p0,p1,p2,p3,p4

== 规则表 ==
R1  前置  card.cost <= energy                                            ← energyMax=3;cost∈[0, 1, 2]
R2  前置  0 <= i < len(hand)                                             ← handSize=5
R3  结算  energy -= cost; block += card.block                            ← block∈[0, 5]
R4  结算  d = min(card.damage, enemyHp); enemyHp -= d                    ← enemyHp=50;damage∈[0, 3, 6, 14]
R5  结算  absorbed = min(block, enemyAttack); playerHp -= enemyAttack - absorbed ← enemyAttack=6
R6  结算  turn += 1; block = 0; energy = energyMax                       ← energyMax=3
R7  前置  turn <= turnLimit                                              ← turnLimit=20
R8  结算  while len(hand) < handSize and draw: hand += draw.pop(0)       ← handSize=5
R9  结算  enemyHp <= 0 → won;playerHp <= 0 → lost                        ← goals=enemyHp<=0/playerHp>0
I1  不变量 cardCount==deckSize                                            ← invariants[]
I2  不变量 damageConserved                                                ← invariants[]
I3  不变量 energyInRange                                                  ← invariants[]
I4  不变量 blockNonNegative                                               ← invariants[]

== 状态表 ==
block            int   呈现=是 写入=R3/R5/R6
cardCount        int   呈现=否 写入=I1
damageConserved  bool  呈现=否 写入=I2
dealt            int   呈现=否 写入=R4
enemyHp          int   呈现=是 写入=R4
energy           int   呈现=是 写入=R3/R6
hand             list  呈现=是 写入=R2/R3/R8
over             bool  呈现=是 写入=R7/R9
played           int   呈现=否 写入=R3
playerHp         int   呈现=是 写入=R5
turn             int   呈现=是 写入=R6/R7
won              bool  呈现=是 写入=R9

动词 2 条 / 规则 13 条(前置 3、结算 6、不变量 4)/ 状态分量 12 个
孤儿分量=0 []  空动词=0 []
呈现 8/12 = 666‰  隐藏=['cardCount', 'damageConserved', 'dealt', 'played']
wrote out/mech01_anatomy.json

判读:三张表各自暴露了什么

动词表说出一件容易被忽略的事:动作字母表的规模不是「有几张牌」,而是 handSize + 1。玩家按下的键是「打第 i 张」(p0…p4,5 个符号)与「结束回合」(e,1 个),共 6 个符号;牌库里的 12 张牌是这些符号的参数,不是符号本身。手牌变多,字母表就变长——这是卷二算策略空间规模的起点。

规则表把 13 条规则分成三类,比例本身就是信息:前置 3 条(R1 费用、R2 下标、R7 时限)、结算 6 条(R3–R6、R8、R9)、不变量 4 条。前置规则决定「哪些指令会被静默丢弃」,这是第 1.3 节反馈缺口的来源;不变量规则不改变状态,只声明什么永远为真,第 4 章会把它们变成 P 层的断言。

状态表的 12 个分量全部有写入者,孤儿分量 0、空动词 0——这张表是闭合的。表里还有一处值得留意:params 里的 rewardGold 与 punishHp 没有任何一条规则读它们。规格里出现但没人消费的字段,是「规格与实现漂移」的第一个信号;它们是第 8 章奖励节奏与惩罚预算的预留位,在那一章之前,它们只是两个占着位置的整数。

D 档:动词-规则-状态字段表

spec 字段归到哪张表作用
strategy动词动作字母表:一局里玩家实际按下的键(本卷统一 25 个符号)
cards[]动词 / 规则动词的参数:费用、伤害、格挡、张数
params.energyMax规则每回合能量预算:R1 的上界、R6 的重置值
params.handSize规则手牌容量:R2 的下标上界、R8 的补牌目标
params.enemyAttack规则R5 的每回合固定伤害
params.turnLimit规则R7 的时限前置
params.playerHp / enemyHp规则 → 状态两条血条的初值
params.rewardGold / punishHp(本内核未消费)第 8 章奖励与惩罚的预留位
invariants[]规则(不变量)I1–I4 四条恒真式
goals规则R9 的胜负判定
快照 12 键状态规则的读写对象

自检表

检查项合格标准
三张表齐全动词 / 规则 / 状态各一张,且都能指回 spec 字段
从规格长出表的内容由 spec 解析得到,不是手写常量
状态闭合孤儿分量 0:每个状态分量至少被一条规则写过
动词非空空动词 0:每个动词在基准策略里真的被按过
说法诚实未消费的字段(rewardGold / punishHp)显式标出,不假装它们已生效

三张表立起来之后,下一节把其中的状态按「会不会被花掉」再分一次:能量、手牌、生命、格挡是四种资源,而目标就是这四种资源的期末余额。

阅读导航:上一节:目录 · 下一节:1.2 资源与目标 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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