4.1 兵种三角与克制矩阵
第 3 章把「一次交战怎么算」钉死在伤害公式与确定性结算上,但没有回答「谁克谁」。这一章补上兵种与阵容这一层:4.1 定克制矩阵,4.2 定阵容加成,4.3 做强度评估与平衡迭代。三者合起来回答产品侧最常问的一句话——「这套阵容到底强不强,强多少」。
先划边界。站内 独立游戏平衡与经济系统设计 讲的是单机/独立游戏口径:一次性通关的难度曲线、技能树、DPS 手感,平衡目标是「让玩家在固定流程里爽到且不腻」。本节是 SLG 兵种/阵容的持续运营口径:同一批兵种要陪玩家打几十个赛季,平衡目标是「每个赛季新卡池、新兵种进来后,克制环不能塌」。前者的数值一次定死,后者的数值每个版本都要重新过一遍。这一章的所有工具都是为了「重过一遍」这件事服务的。
4.1.1 一个 150% 的环为什么不够
bastion/config/units.json 已经给了一个克制环:枪兵 → 骑兵 → 弓兵 → 枪兵,克制方伤害 ×150%。听起来够用,但把它放进确定性战斗里跑一遍就会发现,这个环是不均匀的。
三个兵种的基础数值并不对称:骑兵 atk 260,弓兵 def 只有 120,而枪兵 def 220 却只有 atk 180。用 300 对 300、兵力上下抖动 ±60、每对跑 400 次的实测胜率看:
| 攻击方 → 防守方 | 关系 | 150% 环胜率 | 环内极差 |
|---|---|---|---|
| 枪兵 → 骑兵 | 克制 | 86% | 72 |
| 骑兵 → 弓兵 | 克制 | 100% | 100 |
| 弓兵 → 枪兵 | 克制 | 88% | 73 |
| 骑兵 → 枪兵 | 被克 | 14% | 72 |
| 弓兵 → 骑兵 | 被克 | 0% | 100 |
| 枪兵 → 弓兵 | 被克 | 15% | 73 |
骑兵/弓兵这一对直接饱和:骑兵 100% 胜、弓兵 0% 胜。玩家能感知到的信息就没了——「弓兵完全打不过骑兵」不是设计,是数值事故;另两对也偏到 86/14 和 88/15,三个克制关系强度差了一倍。一个健康的三角应该让三对克制强度接近,玩家才愿意为「换兵种」付操作成本。
4.1.2 克制矩阵:从「一个倍率」到「一张调参表」
于是我们不再用一个倍率描述整个环,而是给每一对有序兵种单独一格。新建 config/n04_counter.json,行是攻击方、列是防守方,值是攻击方伤害的整数百分比——沿用卷一的纪律,config 里不出现浮点。
{
"version": 1,
"note": "兵种克制矩阵:行=攻击方,列=防守方,值为攻击方伤害的整数百分比。它是一张逐对调参表,不是简单的『克制 150』:三个兵种的基础数值并不对称(骑兵 atk 260 天然压制弓兵 def 120),单一 150% 环会让骑兵/弓兵这一对直接饱和到 100%/0%,另两对也偏到 86/14、88/15,所以每个格子单独调到目标胜率带内。",
"matrix": {
"spearman": { "spearman": 100, "cavalry": 105, "archer": 85 },
"cavalry": { "spearman": 85, "cavalry": 100, "archer": 65 },
"archer": { "spearman": 110, "cavalry": 80, "archer": 100 }
},
"terrainBonusPct": 15,
"sweep": { "countBase": 300, "countJitter": 60, "samples": 400, "seed": 20261012 },
"band": { "counterMinPct": 60, "counterMaxPct": 85, "counteredMinPct": 15, "counteredMaxPct": 40, "mirrorTolerancePct": 8 }
}
几个刻意的取值。对角线一律 100,镜像对只受随机抖动影响。克制方向里,枪兵→骑兵 105、弓兵→枪兵 110,是略高于中性的加成;骑兵→弓兵却只有 65——因为骑兵的底子已经碾压弓兵,这里反而要压低骑兵的伤害,否则又是 100% 饱和。被克方向都在 100 以下,枪兵被骑兵打 85、弓兵被骑兵打 80。
这带来一个容易踩的坑:矩阵数值本身不再能判断克制方向。matrix["cavalry"]["archer"] = 65 小于 100,但骑兵仍然是克制弓兵的一方。所以脚本里判断「谁是克制方」必须回到 units.json 的克制环,而不是看矩阵里谁大谁小——relation() 函数就是干这个的。这条纪律值得单独记住:配置数值是调参结果,语义要以语义字段为准。
sweep 块给出仿真参数:基准兵力 300、抖动 ±60、每对 400 次、种子固定。band 是验收带:克制对胜率落在 [60, 85],被克对落在 [15, 40],镜像对在 50 ± 8。
4.1.3 胜率矩阵怎么跑
下面这段是 S 层仿真脚本,读 config/n04_counter.json 与 units.json,对 3×3 全部有序对跑带抖动的确定性战斗,产出胜率矩阵 JSON、明细 CSV 和一张 3×3 热力网格 SVG。兵力抖动用自实现的 mulberry32,与原型层同算法同种子。
# file: sim/n04_counter_matrix.py
# tier: S
# verify: python3 sim/n04_counter_matrix.py --config config/
"""兵种三角与克制矩阵:读 config/n04_counter.json 的 3x3 整数百分比矩阵,对每个有序兵种对跑带抖动的确定性战斗,得胜率矩阵。产出 JSON + CSV + SVG(3x3 热力网格)。"""
from __future__ import annotations
import argparse
import csv
import json
import os
import sys
import xml.etree.ElementTree as ET
HERE = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, HERE)
from bastion_core import load_config # noqa: E402 只读复用
OUT = os.path.join(HERE, "..", "out")
NS = "http://www.w3.org/2000/svg"
def mulberry32(seed):
"""自实现确定性 PRNG,与 proto 侧同算法同种子逐位一致。"""
a = seed & 0xFFFFFFFF
def nxt():
nonlocal a
a = (a + 0x6D2B79F5) & 0xFFFFFFFF
t = (a ^ (a >> 15)) & 0xFFFFFFFF
t = (t * ((1 | a) & 0xFFFFFFFF)) & 0xFFFFFFFF
o = t
x = ((o ^ (o >> 7)) & 0xFFFFFFFF)
x = (x * ((61 | o) & 0xFFFFFFFF)) & 0xFFFFFFFF
t = (((o + x) & 0xFFFFFFFF) ^ o) & 0xFFFFFFFF
return ((t ^ (t >> 14)) & 0xFFFFFFFF) / 4294967296.0
return nxt
def rand_int(rng, lo, hi):
return lo + int(rng() * (hi - lo + 1))
def dmg(a, d, m, rules, t=0):
raw = a["atk"] * m[a["id"]][d["id"]] - d["def"] * rules["defenseFactorPct"]
return max(rules["minDamage"], (raw * (100 + t) // 100) // 100)
def alive(hp, h):
return 0 if hp <= 0 else (hp + h - 1) // h
def resolve(a, an, d, dn, m, rules, t=0):
ah, dh, r = an * a["hp"], dn * d["hp"], 0
while ah > 0 and dh > 0 and r < rules["maxRounds"]:
r += 1
ad = alive(ah, a["hp"]) * dmg(a, d, m, rules, t)
dd = alive(dh, d["hp"]) * dmg(d, a, m, rules)
ah -= dd
dh -= ad
return {"winner": "a" if dh <= 0 and ah > 0 else ("d" if ah <= 0 and dh > 0 else "draw"), "rounds": r}
def relation(ring, a, b):
"""克制关系取 units.json 的克制环,不取矩阵数值——矩阵数值是调参结果,可能是 <100 的克制。"""
if a == b:
return "mirror"
if ring.get(a) == b:
return "counter"
if ring.get(b) == a:
return "countered"
return "neutral"
def run(cc, unit_of, rules, ids, terrain, rng):
m, sw = cc["matrix"], cc["sweep"]
win, rounds = {}, {}
for a in ids:
win[a], rounds[a] = {}, {}
for b in ids:
w = r = 0
for _ in range(sw["samples"]):
an = max(1, sw["countBase"] + rand_int(rng, -sw["countJitter"], sw["countJitter"]))
dn = max(1, sw["countBase"] + rand_int(rng, -sw["countJitter"], sw["countJitter"]))
res = resolve(unit_of[a], an, unit_of[b], dn, m, rules, terrain)
w += 1 if res["winner"] == "a" else 0
r += res["rounds"]
win[a][b], rounds[a][b] = w * 100 // sw["samples"], r // sw["samples"]
return win, rounds
def heatmap(path, ids, win):
cell, pad, top = 84, 40, 46
W, H = pad * 2 + len(ids) * cell, top + pad + len(ids) * cell
svg = ET.Element("svg", {"xmlns": NS, "viewBox": f"0 0 {W} {H}", "width": str(W), "height": str(H)})
ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#fff"})
ET.SubElement(svg, "text", {"x": str(pad), "y": "20", "font-size": "13"}).text = "克制矩阵胜率(行=攻击方 列=防守方,红→绿 = 低→高)"
for j, b in enumerate(ids):
ET.SubElement(svg, "text", {"x": str(pad + j * cell + cell // 2), "y": str(top - 8), "font-size": "12", "text-anchor": "middle"}).text = b
for i, a in enumerate(ids):
ET.SubElement(svg, "text", {"x": str(pad - 8), "y": str(top + i * cell + cell // 2 + 4), "font-size": "12", "text-anchor": "end"}).text = a
for j, b in enumerate(ids):
p = win[a][b]
ET.SubElement(svg, "rect", {"x": str(pad + j * cell), "y": str(top + i * cell), "width": str(cell - 4), "height": str(cell - 4), "fill": f"rgb({max(0,255-p*2)},{min(255,p*2)},120)"})
ET.SubElement(svg, "text", {"x": str(pad + j * cell + (cell - 4) // 2), "y": str(top + i * cell + (cell - 4) // 2 + 5), "font-size": "13", "text-anchor": "middle"}).text = f"{p}%"
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
args = ap.parse_args()
cfg_dir = args.config if os.path.isabs(args.config) else os.path.join(HERE, "..", args.config)
cc = json.load(open(os.path.join(cfg_dir, "n04_counter.json"), encoding="utf-8"))
units_cfg = load_config("units")
rules = load_config("battle")["rules"]
unit_of = {u["id"]: u for u in units_cfg["units"]}
ring = units_cfg["counter"]
ids = [u["id"] for u in units_cfg["units"]]
band = cc["band"]
win, rounds = run(cc, unit_of, rules, ids, 0, mulberry32(cc["sweep"]["seed"]))
twin, _ = run(cc, unit_of, rules, ids, cc["terrainBonusPct"], mulberry32(cc["sweep"]["seed"]))
terrain_rows = [{"attacker": a, "defender": b, "baseWinRatePct": win[a][b], "terrainWinRatePct": twin[a][b]}
for a in ids for b in ids if relation(ring, a, b) == "counter"]
checks = []
for a in ids:
for b in ids:
rel, p = relation(ring, a, b), win[a][b]
if rel == "counter":
checks.append({"pair": [a, b], "rel": rel, "winRatePct": p, "ok": band["counterMinPct"] <= p <= band["counterMaxPct"]})
elif rel == "countered":
checks.append({"pair": [a, b], "rel": rel, "winRatePct": p, "ok": band["counteredMinPct"] <= p <= band["counteredMaxPct"]})
elif rel == "mirror":
checks.append({"pair": [a, b], "rel": rel, "winRatePct": p, "ok": abs(p - 50) <= band["mirrorTolerancePct"]})
report = {"units": ids, "matrixPct": cc["matrix"], "winRatePct": win, "roundsAvg": rounds,
"terrainWinRatePct": terrain_rows, "checks": checks, "ok": all(c["ok"] for c in checks)}
os.makedirs(OUT, exist_ok=True)
json.dump(report, open(os.path.join(OUT, "n04_counter_matrix.json"), "w", encoding="utf-8"), ensure_ascii=False, indent=2)
with open(os.path.join(OUT, "n04_counter_matrix.csv"), "w", encoding="utf-8", newline="") as f:
wr = csv.writer(f)
wr.writerow(["attacker", "defender", "relation", "matrixPct", "winRatePct", "roundsAvg"])
for a in ids:
for b in ids:
wr.writerow([a, b, relation(ring, a, b), cc["matrix"][a][b], win[a][b], rounds[a][b]])
heatmap(os.path.join(OUT, "n04_counter_matrix.svg"), ids, win)
print(json.dumps({k: report[k] for k in ("winRatePct", "roundsAvg", "terrainWinRatePct", "ok")}, ensure_ascii=False))
return 0 if report["ok"] else 1
if __name__ == "__main__":
raise SystemExit(main())
脚本结构:resolve 是两军对撞的确定性回合循环,每回合双方按当前存活数各打一次,伤害走 dmg(矩阵百分比);run 把每对有序兵种跑 400 次抖动战斗,胜率取整为百分比;relation 从克制环判定关系;heatmap 画网格。terrain 参数是给下一节留的第二条轴。
4.1.4 实跑结果
本机运行上面脚本,stdout 一行 JSON(便于脚本消费):
{"winRatePct": {"spearman": {"spearman": 46, "cavalry": 65, "archer": 30}, "cavalry": {"spearman": 32, "cavalry": 52, "archer": 71}, "archer": {"spearman": 68, "cavalry": 31, "archer": 49}}, "roundsAvg": {"spearman": {"spearman": 9, "cavalry": 5, "archer": 4}, "cavalry": {"spearman": 5, "cavalry": 3, "archer": 4}, "archer": {"spearman": 4, "cavalry": 4, "archer": 2}}, "terrainWinRatePct": [{"attacker": "spearman", "defender": "cavalry", "baseWinRatePct": 65, "terrainWinRatePct": 81}, {"attacker": "cavalry", "defender": "archer", "baseWinRatePct": 71, "terrainWinRatePct": 83}, {"attacker": "archer", "defender": "spearman", "baseWinRatePct": 68, "terrainWinRatePct": 81}], "ok": true}
整理成表更直观(行=攻击方,列=防守方,括号内为平均回合数):
| 攻击方 \ 防守方 | 枪兵 | 骑兵 | 弓兵 |
|---|---|---|---|
| 枪兵 | 46% (9) | 65% (5) | 30% (4) |
| 骑兵 | 32% (5) | 52% (3) | 71% (4) |
| 弓兵 | 68% (4) | 31% (4) | 49% (2) |
三对克制现在是 65 / 71 / 68,三对被克是 32 / 31 / 30——三角终于均匀了。三个镜像对 46 / 52 / 49 都贴着 50,说明抖动本身没有系统性偏向。ok 为 true,即 9 个 check 全部落在 band 内。
回合数也顺带说明了一件事:枪兵对枪兵的镜像打了 9 回合,是最慢的一场;骑兵对弓兵只要 4 回合。回合数越短,玩家越难在战斗过程中做「换兵种」的操作,所以把克制强度压到 65%~71% 而不是 100%,同时也把平均回合数拉回了可观察的区间。同机相对量级下这些数字稳定,但绝对数值不可移植——换一套 units.json 的基础数值,矩阵要重调。
4.1.5 地形是第二条轴
矩阵只管兵种关系,真实战场还有一条轴:地形。battle.json 里 terrainBonusPct 是 15,规则是只作用于进攻方。把同一批战斗加上地形再跑一遍,克制对的胜率变化如下:
| 攻击方 → 防守方 | 无地形 | 有地形(+15%) |
|---|---|---|
| 枪兵 → 骑兵 | 65% | 81% |
| 骑兵 → 弓兵 | 71% | 83% |
| 弓兵 → 枪兵 | 68% | 81% |
一个 15% 的进攻加成把三对克制从 6571 顶到 8183。这解释了很多 SLG 里「守方地形优势」的设计动机:如果地形只给进攻方,进攻方胜率会被推到 80% 以上,防守就失去意义;反过来,若地形给防守方,就等于给克制关系再叠一层惩罚。所以矩阵和地形要联合调,不能各调各的——这也是 4.3 平衡迭代里要把加成当成一个可调旋钮的原因。
4.1.6 从矩阵到阵容
到这里我们有了一个可复现的克制矩阵:每个格子是一个整数百分比,每个有序对有一个落在验收带内的胜率,还有一条独立的地形轴。但真实战场从来不是「一种兵对一种兵」——玩家出的是阵容:几个兵种各带多少、吃到哪些加成。下一节就把单兵种的克制矩阵扩成多兵种的组合强度,并用 TypeScript 原型把「加成命中 → 攻击分/防御分 → 组合强度」这条链算清楚。
阅读导航:上一节:目录 · 下一节:4.2 阵容加成与组合强度 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。