1.2 资源与目标

上一节的三张表里,状态分量按「会不会被花掉」再分一次,就得到四种资源:能量、手牌、生命、格挡;而目标就是这四种资源的期末余额。本节给一张每回合的收入-支出表,用《灰烬牌库》跑一局账:能量入 12 出 10 余 2、格挡产 10 只吸收 6、伤害 50 有 5 点被溢出截断,账本末态与引擎摘要逐字对拍一致。

独立游戏核心循环与手感设计 用整整一节讲「数值平衡」,但讲法是散文式的经验判断——哪里给多了、哪里给少了,靠手感与试玩。《小游戏开发实战》卷一《市场品类与产品设计》 关心的是立项与变现:这批资源该不该做成付费点。本节两样都不做,只做一件更靠前的事:把「资源」定义清楚,然后把一局的账记平——收入多少、支出多少、余额多少,一个整数都不许对不上。

1.2 资源与目标

上一节的状态表里,有些分量会被花掉,有些不会。会被花掉的四种就是资源;而目标就是这四种资源的期末余额必须落在哪个区间里。

资源回合初每回合收入支出边界清零规则
能量 energy重置为 energyMax=3+3(重置)−card.cost(打牌)[0, 3]回合开始重置,余额作废
手牌 hand起手 5 张回合末补牌至 handSize=5−1(打出一张)≤ 5抽牌堆空则补不满
生命 playerHp60无−(enemyAttack − 吸收)(回合末)≥ 0无恢复机制
格挡 block0+card.block(打牌)−min(block, 6)(回合末吸收)≥ 0回合开始全清
敌方血 enemyHp50无−min(card.damage, enemyHp)(打牌)≥ 0无

「回合开始全清」这一行是《灰烬牌库》最容易被忽略的规则:格挡只在本回合内有效,回合结束时它先吸收敌人攻击,剩下的余量在下一回合开始时被抹掉。这条规则让格挡的收支出现损耗,也是下一节「反馈空档」的成因。

三个目标则是三种不同性质的边界:

目标表达式本次实跑性质
主目标enemyHp<=0enemyHp=0 ✓达成即胜
次目标playerHp>0playerHp=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 反馈:机制与玩家的接口 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「gamedesign」更多文章

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