站内的独立游戏核心循环与手感设计
(约 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)显式标出,不假装它们已生效 |
三张表立起来之后,下一节把其中的状态按「会不会被花掉」再分一次:能量、手牌、生命、格挡是四种资源,而目标就是这四种资源的期末余额。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。