11.1 战斗的机制解剖:伤害、时序、资源
站内 《输入、帧缓冲与打击感》 讲的是战斗的表现层——输入采样、帧缓冲窗口、命中反馈与镜头震动,属于引擎与手感实现;本节把战斗当作机制:伤害怎么结算、时序由谁决定、资源怎么消耗,三样都能写进规格、能在确定性内核里复现、能被断言。若只关心通用平衡与经济概念,《独立游戏平衡与经济设计》 已从概念层写过;本节不重复,本节问的是「一场战斗由哪几个字段构成,每个字段凭什么可断言」。
案例是这套三卷本的贯穿案例《灰烬牌库》,不需要新建品类内核——它就是案例本体。core/src/ashdeck/ 的五个模块与 config/play_spec.json 的四个卡种、八个参数从卷一用到现在一字未改。战斗在这里被拆成三组字段:伤害结算、时序、资源。
机制不是形容词,是字段
策划案里的「打击感强」「节奏快」「资源紧张」都是形容词,形容词不可断言。可断言的形态是字段:一个字段 = 内核里一段确定性代码 + 一条可检验的性质。把《灰烬牌库》的战斗拆开,恰好三组:
| 机制字段 | 内核位置 | 语义 | 可断言的性质 |
|---|---|---|---|
| 伤害结算 | combat.ts 的 dealt += Math.min(c.damage, enemyHp) | 每次出牌按 min 截断后累加 | dealt 恒等于敌方血量减少量;damageConserved 恒真 |
| 时序 | combat.ts 的回合循环(出牌段 → e 结束段) | 同回合内先结算玩家出牌、再结算敌方攻击 | 出牌在前、攻击在后;格挡只在回合末吸收 |
| 资源 | combat.ts 的 energy 与手牌数组 | 出牌扣能量、手牌进弃牌堆、回合末补牌 | 0 ≤ energy ≤ energyMax;三分区之和恒为牌组规模 |
这张表就是本节「机制解剖」的全部产出:三组字段、三行可断言的性质。11.2 在这张表上量策略空间,11.3 扫时序参数的相变。
伤害结算:min 截断与守恒
一次出牌对敌方造成 damage,但真实入账是 Math.min(damage, enemyHp)——最后一击不会溢出。于是有一条恒等式:累计伤害 dealt 恒等于敌方血量的减少量。这条性质在 S 层是「守恒误差为 0」,在 P 层是一行断言 s.dealt === spec.params.enemyHp - s.enemyHp。
时序:机制的隐藏维度
时序是机制里最容易被忽略的一维——因为它不改任何数值,只改次序。同样的手牌、同样的能量上限,先打哪张决定了后面还付不付得起。实验 A 固定手牌 [heavy, strike, guard]、能量上限 3,只改出牌次序:heavy>strike>guard 打出 20 点伤害,strike>guard>heavy 只打出 6 点——因为 guard 先耗掉 1 点能量后,2 费的 heavy 就付不起了。六个排列给出三个不同的总伤害 {6, 14, 20},极差 14。
时序的第二层是「谁先手」。内核的回合循环固定「出牌段在前、结束段在后」,把命令序列从 [出牌×4, e] 换成 [e, 出牌×4],就等价于让敌方先攻击。两串都是内核的合法输入,不需要新增任何规则——这正是「时序是机制的隐藏维度」的可执行证据:数值不动,次序一动,结果就变。
资源:能量与手牌
资源字段是 energy 与手牌数组。能量每回合重置为 energyMax,出牌按 cost 递减;手牌打出后进弃牌堆,回合末补齐。可断言的性质是区间与守恒:0 ≤ energy ≤ energyMax,且「手牌 + 抽牌堆 + 弃牌堆」三分区之和恒等于牌组规模 12。
S 层:三组实验一起跑
下面这段 S 层脚本把三组字段的实验一次跑完:实验 A 枚举手牌全排列算总伤害,实验 B 对每条策略跑「先手 / 后手」两串序列,ashlib.run_spec 直接把命令序列喂给内核,不新增规则。
#!/usr/bin/env python3
# file: sim/gd_proto_11_1_combat_anatomy.py
# tier: S
# verify: /opt/homebrew/bin/python3 sim/gd_proto_11_1_combat_anatomy.py
"""卷三 11.1 战斗的机制解剖:伤害、时序、资源(S 层)。
三组机制字段(伤害结算 / 时序 / 资源)各对应内核 `core/src/ashdeck/combat.ts` 里一段确定性代码。
本节用两个**只用内核**的整数实验说明「时序是机制的隐藏维度——同样的数值,次序不同结果不同」:
实验 A(结算顺序):同一手牌 [heavy, strike, guard]、能量上限 3,只改**出牌次序**。能量门控
使后续命令可能付不起,6 个排列给出 3 种总伤害 {6, 14, 20}(极差 14)。
实验 B(谁先手):同一条策略,把命令序列从 `[出牌×4, e]` 换成 `[e, 出牌×4]`——每回合先让敌方
攻击、再出牌。两串都直接喂给 `ashlib.run_spec`,不新增任何规则,只换两段的先后。
产出 out/gd_proto_11_anatomy.json。全整数、无浮点、无 random;数字只做相对比较,禁绝对化。
"""
from __future__ import annotations
import copy
import itertools
import json
import os
import sys
import time
HERE = os.path.dirname(os.path.abspath(__file__))
ROOT = os.path.dirname(HERE)
OUT = os.path.join(ROOT, "out")
CONFIG = os.path.join(ROOT, "config/gd_proto_11_combat.json")
sys.path.insert(0, HERE)
import ashlib # noqa: E402
def play_order(cards: list, order: tuple, emax: int) -> dict:
"""手牌级结算模型:按 order 出牌,能量门控与 combat.ts 的 `if (c.cost > energy) continue` 一致。"""
energy, dealt, block, played = emax, 0, 0, 0
for idx in order:
c = cards[idx]
if c["cost"] > energy:
continue
energy -= c["cost"]
block += c["block"]
dealt += c["damage"]
played += 1
return {"dealt": dealt, "block": block, "energy": energy, "played": played}
def seq_player_first(k: int, turns: int, plays: int) -> list:
out = []
for _ in range(turns):
out += ["p" + str(k)] * plays + ["e"]
return out
def seq_enemy_first(k: int, turns: int, plays: int) -> list:
"""每回合「先敌方攻击、再出牌」:命令序列 [e, 出牌×plays] 重复——仍是内核的合法输入。"""
out = ["e"]
for _ in range(turns):
out += ["p" + str(k)] * plays + ["e"]
return out
def main() -> int:
t0 = time.perf_counter_ns()
spec = ashlib.load_spec()
with open(CONFIG, encoding="utf-8") as f:
cfg = json.load(f)
by_id = {c["id"]: c for c in spec["cards"]}
ss = cfg["strategySpace"]
hand = [by_id[i] for i in cfg["orderProbe"]["hand"]]
orders = []
for perm in itertools.permutations(range(len(hand))):
r = play_order(hand, perm, cfg["orderProbe"]["energyMax"])
r["order"] = [hand[i]["id"] for i in perm]
orders.append(r)
dealt_values = sorted({r["dealt"] for r in orders})
seeds = cfg["seeds"]
rows = []
for ea in cfg["turnOrderProbe"]["enemyAttackValues"]:
for k in cfg["turnOrderProbe"]["strategies"]:
sa = seq_player_first(k, ss["turns"], ss["playsPerTurn"])
sb = seq_enemy_first(k, ss["turns"], ss["playsPerTurn"])
wf = we = hpf = hpe = 0
for sd in seeds:
s = copy.deepcopy(spec)
s["seed"] = sd
s["params"]["enemyAttack"] = ea
a, b = ashlib.run_spec(s, sa), ashlib.run_spec(s, sb)
wf += 1 if a["won"] else 0
we += 1 if b["won"] else 0
hpf += a["playerHp"]
hpe += b["playerHp"]
n = len(seeds)
rows.append({"enemyAttack": ea, "strategy": "slot" + str(k), "winFirst": 1000 * wf // n,
"winEnemy": 1000 * we // n, "hpFirst": hpf // n, "hpEnemy": hpe // n})
report = {"id": "gd_proto_11_anatomy", "seeds": len(seeds), "energyMax": cfg["orderProbe"]["energyMax"],
"orderProbe": orders, "distinctDealt": dealt_values,
"orderSpread": dealt_values[-1] - dealt_values[0], "turnOrder": rows,
"elapsedMs": (time.perf_counter_ns() - t0) // 1000000}
os.makedirs(OUT, exist_ok=True)
with open(os.path.join(OUT, "gd_proto_11_anatomy.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2, sort_keys=True)
f.write("\n")
print("=== 实验 A:结算顺序(手牌 %s,能量上限 %d)===" % (cfg["orderProbe"]["hand"], report["energyMax"]))
print(f"{'次序':<26}{'伤害':>5}{'格挡':>5}{'余能量':>7}{'出牌':>5}")
for r in orders:
print(f"{'>'.join(r['order']):<26}{r['dealt']:>5}{r['block']:>5}{r['energy']:>7}{r['played']:>5}")
print(f"同一手牌、同一能量:总伤害取值 {dealt_values},极差 {report['orderSpread']}")
print(f"\n=== 实验 B:谁先手({len(seeds)} 种子,先手=[出牌×4,e],后手=[e,出牌×4])===")
print(f"{'enemyAttack':>12}{'策略':>7}{'先手胜率':>9}{'后手胜率':>9}{'先手均血':>9}{'后手均血':>9}")
for r in rows:
print(f"{r['enemyAttack']:>12}{r['strategy']:>7}{r['winFirst']:>9}{r['winEnemy']:>9}"
f"{r['hpFirst']:>9}{r['hpEnemy']:>9}")
print(f"elapsed={report['elapsedMs']} ms")
print("wrote out/gd_proto_11_anatomy.json")
return 0
if __name__ == "__main__":
sys.exit(main())
实跑输出(/opt/homebrew/bin/python3 sim/gd_proto_11_1_combat_anatomy.py):
=== 实验 A:结算顺序(手牌 ['heavy', 'strike', 'guard'],能量上限 3)===
次序 伤害 格挡 余能量 出牌
heavy>strike>guard 20 0 0 2
heavy>guard>strike 14 5 0 2
strike>heavy>guard 20 0 0 2
strike>guard>heavy 6 5 1 2
guard>heavy>strike 14 5 0 2
guard>strike>heavy 6 5 1 2
同一手牌、同一能量:总伤害取值 [6, 14, 20],极差 14
=== 实验 B:谁先手(16 种子,先手=[出牌×4,e],后手=[e,出牌×4])===
enemyAttack 策略 先手胜率 后手胜率 先手均血 后手均血
6 slot0 1000 1000 48 42
6 slot1 1000 1000 50 44
6 slot3 937 937 44 38
14 slot0 1000 937 25 11
14 slot1 1000 1000 33 19
14 slot3 937 437 17 5
elapsed=102 ms
wrote out/gd_proto_11_anatomy.json
P 层:把三组字段各绑一条断言
P 层把三组字段各绑到一条不变量上:结算守恒、时序的次序敏感、资源的三分区守恒。node --test 'core/test/gd_proto_11_1_order.test.ts' 六条断言全绿。
// snippet: 11.1 P 层节选(core/test/gd_proto_11_1_order.test.ts)
// 手牌级结算模型:按 order 出牌,能量门控与 combat.ts 的 `if (c.cost > energy) continue` 一致。
function playOrder(cards: Mini[], order: number[], energyMax: number):
{ dealt: number; block: number; energy: number; played: number } {
let energy = energyMax, dealt = 0, block = 0, played = 0;
for (const idx of order) {
const c = cards[idx];
if (c.cost > energy) continue;
energy -= c.cost; block += c.block; dealt += c.damage; played += 1;
}
return { dealt, block, energy, played };
}
const HAND: Mini[] = [byId("heavy"), byId("strike"), byId("guard")];
test("时序·结算顺序:同一手牌、同一能量,只改出牌次序,总伤害取值 {6,14,20}", () => {
assert.equal(SPEC.params.energyMax, 3);
const dealt = perms3().map((o) => playOrder(HAND, o, SPEC.params.energyMax).dealt);
assert.deepEqual([...new Set(dealt)].sort((a, b) => a - b), [6, 14, 20]);
assert.equal(Math.max(...dealt) - Math.min(...dealt), 14);
});
test("资源·三分区守恒:出牌数与牌组规模恒定", () => {
const s = runSpec(SPEC);
assert.equal(DECK_SIZE, 12);
assert.equal(s.cardCount, DECK_SIZE);
assert.equal(invariantsHold(SPEC, s), true);
});
实跑输出(节选):
✔ 时序·结算顺序:同一手牌、同一能量,只改出牌次序,总伤害取值 {6,14,20} (1.620542ms)
✔ 时序·能量门控:先打贵的 heavy 与先打便宜的 strike 结果不同 (0.096ms)
✔ 伤害结算:单次伤害按 min(damage, enemyHp) 截断,dealt 与 enemyHp 的减少量守恒 (0.225917ms)
✔ 时序·谁先手:内核固定「出牌在前、敌方攻击在后」,格挡只在回合末吸收 (0.150833ms)
✔ 资源·三分区守恒:出牌数与牌组规模恒定 (0.099041ms)
✔ 确定性回放:同 seed 两遍 digest 逐字一致,且等于基线 (0.124541ms)
ℹ tests 6
ℹ suites 0
ℹ pass 6
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 567.405083
怎么读这些数
下面几个量都是代理指标(proxy),只做相对比较,禁绝对化。
| 量 | 本节取值 | 读法 |
|---|---|---|
| 结算顺序伤害极差 | 14({6, 14, 20}) | 同一手牌同能量的最大差;0 表示次序无关 |
| 后手均血(ea=6, slot0) | 42(先手 48) | 敌方先手一次攻击的净代价 |
| 胜率翻转(ea=14, slot3) | 937 → 437‰ | 敌方攻击提高后,后手直接掉到劣势 |
三个量都只在本案例的参数下有意义:极差 14 依赖 [heavy, strike, guard] 与能量 3,换个手牌就变。它们的作用是相对比较——比较不同次序、比较先手后手,而不是给「战斗好不好玩」下一个绝对结论。
阅读导航:上一节:10.3 放置的动因 · 下一节:11.2 战斗的策略空间与平衡 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。