独立游戏核心循环与手感设计 用整整一节讲「数值平衡」,但讲法是散文式的经验判断——哪里给多了、哪里给少了,靠手感与试玩。《小游戏开发实战》卷一《市场品类与产品设计》 关心的是立项与变现:这批资源该不该做成付费点。本节两样都不做,只做一件更靠前的事:把「资源」定义清楚,然后把一局的账记平——收入多少、支出多少、余额多少,一个整数都不许对不上。
1.2 资源与目标
上一节的状态表里,有些分量会被花掉,有些不会。会被花掉的四种就是资源;而目标就是这四种资源的期末余额必须落在哪个区间里。
| 资源 | 回合初 | 每回合收入 | 支出 | 边界 | 清零规则 |
|---|---|---|---|---|---|
能量 energy | 重置为 energyMax=3 | +3(重置) | −card.cost(打牌) | [0, 3] | 回合开始重置,余额作废 |
手牌 hand | 起手 5 张 | 回合末补牌至 handSize=5 | −1(打出一张) | ≤ 5 | 抽牌堆空则补不满 |
生命 playerHp | 60 | 无 | −(enemyAttack − 吸收)(回合末) | ≥ 0 | 无恢复机制 |
格挡 block | 0 | +card.block(打牌) | −min(block, 6)(回合末吸收) | ≥ 0 | 回合开始全清 |
敌方血 enemyHp | 50 | 无 | −min(card.damage, enemyHp)(打牌) | ≥ 0 | 无 |
「回合开始全清」这一行是《灰烬牌库》最容易被忽略的规则:格挡只在本回合内有效,回合结束时它先吸收敌人攻击,剩下的余量在下一回合开始时被抹掉。这条规则让格挡的收支出现损耗,也是下一节「反馈空档」的成因。
三个目标则是三种不同性质的边界:
| 目标 | 表达式 | 本次实跑 | 性质 |
|---|---|---|---|
| 主目标 | enemyHp<=0 | enemyHp=0 ✓ | 达成即胜 |
| 次目标 | playerHp>0 | playerHp=48 ✓ | 被破坏即败 |
| 限时 | turn<=turnLimit(20) | turn=4 ✓ | 超时判负 |
S 层:把一局记成一册收支账
sim/gd_mech_01_resources.py 逐回合记收入与支出,跑完用 ashlib.digest 与官方引擎的摘要对拍:账本若与引擎有半点分歧,两个 digest 立刻不相等。这不是形式主义——下面这段回放是脚本自带的,与 ashlib.run_spec 逐条对应,对拍就是它俩的一致性证明。
#!/usr/bin/env python3
# file: sim/gd_mech_01_resources.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_mech_01_resources.py
"""卷一 1.2 资源与目标(S 层):把一局《灰烬牌库》记成一册逐回合的资源收支账。
四种资源:能量 energyMax、手牌 handSize、生命 playerHp、格挡 block。每回合分记收入与支出,
再把三个目标(enemyHp<=0 / playerHp>0 / turn<=turnLimit)落成期末余额。
本脚本**自带一份与 ashlib.run_spec 逐条对应的回放**,跑完用 ashlib.digest 与官方引擎的摘要对拍——
账本若与引擎有半点分歧,digest 立刻不相等。全整数、无浮点、无随机。产出 out/mech01_resources.csv。
"""
from __future__ import annotations
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")
HEADER = ["turn", "energyIn", "energyOut", "energyEnd", "blockIn", "blockAbsorbed",
"blockDecayed", "handIn", "handOut", "hpLoss", "dealtTurn", "dealtCum",
"playerHpEnd", "enemyHpEnd", "rejects"]
def fresh(p: dict) -> dict:
"""一回合的流水起点:能量在回合开始时重置为 energyMax,其余归零。"""
return {"energyIn": p["energyMax"], "energyOut": 0, "blockIn": 0, "blockAbsorbed": 0,
"blockDecayed": 0, "handIn": 0, "handOut": 0, "hpLoss": 0,
"dealtTurn": 0, "rejects": 0}
def replay(spec: dict) -> tuple[list[list[int]], dict]:
"""逐回合记账的回放;规则与 ashlib.run_spec 逐条一致。返回 (账本行, 末态快照)。"""
p = spec["params"]
deck = []
for c in spec["cards"]:
deck.extend([c] * c["count"])
deck = ashlib.shuffle_deck(deck, spec["seed"])
hand, draw, discard = deck[: p["handSize"]], deck[p["handSize"]:], []
turn, energy = 1, p["energyMax"]
php, ehp, block = p["playerHp"], p["enemyHp"], 0
dealt = played = 0
over = won = False
cur, rows = fresh(p), []
def close() -> None:
rows.append([turn, cur["energyIn"], cur["energyOut"], energy, cur["blockIn"],
cur["blockAbsorbed"], cur["blockDecayed"], cur["handIn"],
cur["handOut"], cur["hpLoss"], cur["dealtTurn"], dealt,
php, ehp, cur["rejects"]])
for cmd in spec["strategy"]:
if over:
break
if turn > p["turnLimit"]:
over = True
break
if cmd.startswith("p"):
i = int(cmd[1:])
if i >= len(hand) or hand[i]["cost"] > energy: # R1 费用前置:被拒 = 意图没变成状态
cur["rejects"] += 1
continue
c = hand.pop(i)
discard.append(c)
energy -= c["cost"]
block += c["block"]
cur["energyOut"] += c["cost"]
cur["blockIn"] += c["block"]
cur["handOut"] += 1
d = min(c["damage"], ehp) # R4 伤害截断:溢出部分不记账
ehp -= d
dealt += d
cur["dealtTurn"] += d
played += 1
if ehp <= 0:
over = won = True
break
elif cmd == "e":
absorbed = min(block, p["enemyAttack"])
block -= absorbed
php = max(0, php - (p["enemyAttack"] - absorbed))
cur["blockAbsorbed"] += absorbed
cur["hpLoss"] += p["enemyAttack"] - absorbed
cur["blockDecayed"] = block # 回合开始清零:这点格挡再也换不成血
if php <= 0: # 败北:引擎在此 break,不补牌
close()
over = True
break
while len(hand) < p["handSize"] and draw: # 补牌算本回合的收入
hand.append(draw.pop(0))
cur["handIn"] += 1
close()
turn += 1
block = 0
energy = p["energyMax"]
cur = fresh(p)
if not rows or rows[-1][0] != turn: # 最后一回合没走完 endTurn,补记一行
close()
snap = {"turn": turn, "energy": energy, "playerHp": php, "enemyHp": ehp, "block": block,
"dealt": dealt, "played": played, "over": over, "won": won,
"hand": [c["id"] for c in hand], "cardCount": len(hand) + len(draw) + len(discard),
"damageConserved": dealt == p["enemyHp"] - ehp}
return rows, snap
def main() -> int:
spec = ashlib.load_spec()
p = spec["params"]
rows, snap = replay(spec)
official = ashlib.run_spec(spec)
def tot(i: int) -> int:
return sum(r[i] for r in rows)
energy_in, energy_out, energy_end = tot(1), tot(2), tot(3)
block_in, absorbed, decayed = tot(4), tot(5), tot(6)
hand_in, hand_out, hp_loss, dealt_sum = tot(7), tot(8), tot(9), tot(10)
rejects = tot(14)
print("== 逐回合资源收支账 ==")
print(f"{'回合':<5}{'能量入':>7}{'能量出':>7}{'能量余':>7}{'格挡入':>7}{'吸收':>5}{'格挡弃':>7}"
f"{'抽牌':>5}{'出牌':>5}{'掉血':>5}{'造成':>5}{'累计':>6}{'敌血':>5}{'被拒':>5}")
for r in rows:
print(f"{r[0]:<5}{r[1]:>7}{r[2]:>7}{r[3]:>7}{r[4]:>7}{r[5]:>5}{r[6]:>7}"
f"{r[7]:>5}{r[8]:>5}{r[9]:>5}{r[10]:>5}{r[11]:>6}{r[13]:>5}{r[14]:>5}")
print(f"\n能量:入 {energy_in} = 出 {energy_out} + 余 {energy_end}(每回合预算 {p['energyMax']})")
print(f"格挡:产 {block_in} = 吸收 {absorbed} + 回合开始弃 {decayed}"
f"(利用率 {absorbed * 1000 // block_in}‰)")
print(f"手牌:起手 {p['handSize']} + 抽 {hand_in} − 出 {hand_out} = 末态 {len(snap['hand'])} 张"
f"(牌堆守恒 {snap['cardCount']})")
print(f"生命:{p['playerHp']} → {snap['playerHp']}(掉 {hp_loss},敌人每回合固定 {p['enemyAttack']})")
print(f"目标:主 {spec['goals']['primary']} → enemyHp={snap['enemyHp']};"
f"次 {spec['goals']['secondary']} → playerHp={snap['playerHp']};"
f"限时 {spec['goals']['limit']} → turn={snap['turn']}")
print(f"伤害:逐回合合计 {dealt_sum} = 敌方血量 {p['enemyHp']}(溢出截断已扣除)")
print(f"被拒 {rejects} 条(费用不足),占出牌尝试 {rejects * 1000 // (hand_out + rejects)}‰")
print(f"账本末态 digest = {ashlib.digest(snap)}")
print(f"引擎末态 digest = {ashlib.digest(official)}")
print(f"对拍一致 = {ashlib.digest(snap) == ashlib.digest(official)} won={snap['won']}")
assert energy_in == energy_out + energy_end, "能量账不平"
assert block_in == absorbed + decayed, "格挡账不平"
assert dealt_sum == p["enemyHp"] - snap["enemyHp"], "伤害账不平"
assert hand_out == snap["played"], "出牌数对不上"
assert p["handSize"] + hand_in - hand_out == len(snap["hand"]), "手牌账不平"
assert ashlib.digest(snap) == ashlib.digest(official), "与引擎摘要不一致"
n = ashlib.write_csv(os.path.join(OUT, "mech01_resources.csv"), HEADER, rows)
print(f"wrote out/mech01_resources.csv({n} 行 + 表头)")
return 0
if __name__ == "__main__":
sys.exit(main())
本机实跑 /opt/homebrew/bin/python3 sim/gd_mech_01_resources.py:
== 逐回合资源收支账 ==
回合 能量入 能量出 能量余 格挡入 吸收 格挡弃 抽牌 出牌 掉血 造成 累计 敌血 被拒
1 3 3 0 0 0 0 3 3 6 23 23 27 1
2 3 3 0 10 6 4 3 3 0 6 29 21 1
3 3 2 1 0 0 0 1 2 6 12 41 9 2
4 3 2 1 0 0 0 0 1 0 9 50 0 0
能量:入 12 = 出 10 + 余 2(每回合预算 3)
格挡:产 10 = 吸收 6 + 回合开始弃 4(利用率 600‰)
手牌:起手 5 + 抽 7 − 出 9 = 末态 3 张(牌堆守恒 12)
生命:60 → 48(掉 12,敌人每回合固定 6)
目标:主 enemyHp<=0 → enemyHp=0;次 playerHp>0 → playerHp=48;限时 turn<=turnLimit → turn=4
伤害:逐回合合计 50 = 敌方血量 50(溢出截断已扣除)
被拒 4 条(费用不足),占出牌尝试 307‰
账本末态 digest = {"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,"hand":["strike","jab","heavy"],"over":true,"played":9,"playerHp":48,"turn":4,"won":true}
引擎末态 digest = {"block":0,"cardCount":12,"damageConserved":true,"dealt":50,"enemyHp":0,"energy":1,"hand":["strike","jab","heavy"],"over":true,"played":9,"playerHp":48,"turn":4,"won":true}
对拍一致 = True won=True
wrote out/mech01_resources.csv(4 行 + 表头)
判读:四笔账里各有一个洞
能量账:入 12(4 回合 × 3)= 出 10 + 余 2。余下的 2 点没有任何补偿机制——回合 3 剩 1 点、回合 4 剩 1 点,全部随回合重置作废。能量预算的利用率是 833‰。
格挡账:产 10 = 吸收 6 + 回合开始弃 4,利用率只有 600‰。回合 2 打了 2 张 guard 拿到 10 点格挡,敌人只打 6 点,剩下的 4 点在回合 3 开始被抹掉。格挡的产出不是免费的:那 2 张牌占掉了 2 点能量与 2 张手牌,换回来的有效防御只有 6 点。
手牌账:起手 5 + 抽 7 − 出 9 = 末态 3 张,抽牌堆在回合 3 被抽空。手牌这条资源的约束不是「有多少」,而是「补牌速度」:handSize=5 是容量,draw 堆是库存,库存见底时容量就是假的。
伤害账:逐回合合计 50 = 敌方血量 50,但这是截断之后的数。回合 4 打出的 heavy 名义伤害 14,实际只造成 9——min(card.damage, enemyHp) 把溢出的 5 点丢掉了。这条规则保护了「伤害守恒」不变量(dealt == enemyHp初 − enemyHp末),代价是溢出伤害不结转:打 14 点与打 9 点在结果上完全一样。
还有一笔不在这张表里的账:4 条指令被静默拒绝(费用不足),占出牌尝试的 307‰。它们既没有花掉能量,也没有产生伤害——机制上什么都没发生。这是 1.3 节要处理的接口缺口。
D 档:资源与目标的验收清单
| 检查项 | 合格标准 | 本局 |
|---|---|---|
| 每种资源都有收支两侧 | 收入与支出都不为空,或显式声明「无」 | 5 种资源全过 |
| 每种资源都有边界 | 上界或下界至少一个,且为整数 | energy∈[0,3]、其余 ≥0 |
| 账目闭合 | 入 = 出 + 余(逐资源、逐回合) | 能量 12=10+2、格挡 10=6+4 |
| 与引擎对拍 | 账本末态 digest 与 ashlib.run_spec 逐字一致 | 一致 |
| 目标可判定 | 三个目标都是布尔表达式,不留「尽量」 | 主 / 次 / 限时三条 |
| 溢出显式 | 被截断的量单独记账,不混进总伤害 | 回合 4 溢出 5 点 |
账记平之后,四种资源的变动就成了玩家能看见的东西——或者说,只有一部分能被看见。下一节问:状态里有哪几个分量被呈现到了屏幕上?
阅读导航:上一节:1.1 机制的最小构成:动词、规则、状态 · 下一节:1.3 反馈:机制与玩家的接口 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。