如果你做过 RPG 或塔防,站内那篇伤害公式与平衡工具 已经把「伤害公式不是越复杂越专业」讲得很透:属性、护甲递减、暴击曲线,目标都是能解释「为什么这一刀是 137」。那是通用口径,面向单机与动作游戏。本节换一条口径:SLG 的兵种与回合——一次交锋是两支部队互相消耗,伤害按整堆结算,回合数由双方的血量池与每回合伤害共同决定。所以本节不讨论暴击曲线怎么调,只回答一个更靠前的问题:一支部队的伤害,怎么从攻防属性里算出来,减伤到底是不是「按比例扣」。
先划边界。本节只处理单个兵种对单个兵种的伤害与减伤,用的是 bastion 里已经跑通的 damage_per_unit 公式(sim/bastion_core.py 与 proto/src/core.ts 逐位一致)。兵种克制矩阵——枪克骑、骑克弓、弓克枪这张三角——留到第 4 章系统化展开;本节公式里虽然已经带了克制倍率这一项,但只把它当成公式里的一个乘数,不做矩阵。而随机项(命中、暴击)留到 3.2,本节先看纯确定性的那一半。
伤害是一行减法,不是一次百分比乘法
很多人的第一反应是把伤害写成「攻击力 × (1 − 护甲减伤率)」——护甲越高,减伤率越大,看起来符合直觉。但 SLG 的部队堆叠里,攻防数值区间很窄(units.json 里 atk 在 180–260、def 在 120–220),用百分比减伤会导致「堆一点护甲就接近免伤」,曲线极陡,几乎没法调。bastion 采用的是线性减法模型:
pct = 克制成立 ? counterMultiplierPct : 100
raw = atk * pct - def * defenseFactorPct
raw = raw * (100 + terrainPct) // 100
dmg = max(minDamage, raw // 100)
拆开看只有三段。第一段是攻击侧:攻击力乘一个倍率(克制时 150%,否则 100%)。第二段是防御侧:减去「防御力 × 系数」,本卷 battle.json 里 defenseFactorPct 取 50,意思是每 1 点防御抵消 0.5 点伤害。第三段是保底:无论怎么减,每回合至少打 minDamage(1 点)。所有数字都是整数,最后一步地板除,保证 Python 与 TypeScript 算出逐位一致的结果——这是「一份配置、两处消费」能成立的前提。
于是当没有克制、没有地形时,伤害简化成一个特别好记的式子:伤害 ≈ 攻击 − 防御 ÷ 2。比如攻击 260、防御 120,就是 260 − 60 = 200。这就是为什么 SLG 的伤害表可以手算——它是减法,不是乘法。
减伤是线性的,所以「多一点防御」永远等值
线性减法有一个百分比模型没有的性质:每一点防御抵消的伤害是恒定的。防御从 0 加到 60,伤害恒定降 30;从 240 加到 300,伤害还是恒定降 30。减伤率会随防御升高而上升(因为分母——未减伤的伤害——没变),但减伤的绝对量是常数。
这带来一个直接的设计推论:护甲的收益是线性的,不会递减。玩家堆防御永远「线性划算」,直到撞到某个边界。下面这份配置定义了本节要扫描的参数:攻击 5 档、防御 6 档、地形 3 档,外加一个「减伤上限」。它是 D 层(静态层)产物,本身不产生数字,但可以被脚本消费。
{
"version": 1,
"note": "第 3 章战斗数值:伤害/减伤扫描参数、命中暴击参数、B 基准阈值。全整数,无浮点。防御系数与最小伤害仍读共享 config/battle.json。",
"damage": {
"reductionCapPct": 70,
"atkList": [120, 180, 260, 340, 460],
"defList": [0, 60, 120, 180, 240, 300],
"terrainList": [0, 15, 30]
},
"random": {
"seed": 20261012,
"trials": 200000,
"hitChancePct": 85,
"critChancePct": 25,
"critMultiplierPct": 180,
"baseDamage": 200,
"benchToleranceBp": 100
}
}
damage 段里 reductionCapPct: 70 是后文要用的「减伤上限」;random 段是 3.2 用的命中暴击参数,本节不碰。下面这段是 S 层仿真脚本,读 config/ 里的扫描参数,调用 bastion_core 的 damage_per_unit 把整张攻防地形表算完,并对比「有上限」与「无上限」两套减伤。
# file: sim/n03_damage.py
# tier: S
# verify: python3 sim/n03_damage.py --config config/ --out out/
"""伤害公式与减伤模型:扫描攻击/防御/地形,复现 core 的线性减法护甲模型,并对比「减伤上限」修正。
复用 sim/bastion_core.py 的 damage_per_unit(与 proto/src/core.ts 逐位一致),不另写公式。
产出:out/n03_damage.json + out/n03_damage.csv + out/n03_damage.svg
"""
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)
import bastion_core as core # noqa: E402
SVG_NS = "http://www.w3.org/2000/svg"
def raw_damage(atk: int, defense: int, units_cfg: dict, rules: dict, terrain_pct: int) -> int:
"""线性减法护甲模型:走 core 的公式,pct 固定 100(无克制)。"""
atk_unit = {"id": "synthetic_atk", "atk": atk}
def_unit = {"id": "synthetic_def", "def": defense}
return core.damage_per_unit(atk_unit, def_unit, units_cfg, rules, terrain_pct)
def capped_damage(raw: int, base: int, cap_pct: int, min_damage: int) -> int:
"""减伤上限:伤害不得低于「同地形无甲伤害 base」的 (100-cap_pct)%。"""
floor_dmg = base * (100 - cap_pct) // 100
return max(min_damage, raw, floor_dmg)
def polyline(points, w=680, h=380, pad=(60, 24, 28, 44)):
pl, pr, pt, pb = pad
xs = [p[0] for p in points]
ys = [p[1] for p in points]
xlo, xhi = min(xs), max(xs)
ylo, yhi = min(ys), max(ys)
xspan = (xhi - xlo) or 1
yspan = (yhi - ylo) or 1
out = []
for x, y in points:
px = pl + (w - pl - pr) * (x - xlo) / xspan
py = pt + (h - pt - pb) * (1 - (y - ylo) / yspan)
out.append(f"{px:.2f},{py:.2f}")
return " ".join(out)
def draw_svg(path: str, series: dict[str, list[tuple[int, int]]]) -> None:
svg = ET.Element("svg", {"xmlns": SVG_NS, "viewBox": "0 0 680 380", "width": "680", "height": "380"})
ET.SubElement(svg, "rect", {"width": "680", "height": "380", "fill": "#ffffff"})
colors = ["#1f6feb", "#d29922", "#2da44e", "#cf222e", "#8250df"]
for i, (label, pts) in enumerate(series.items()):
ET.SubElement(svg, "polyline", {
"points": polyline(pts), "fill": "none",
"stroke": colors[i % len(colors)], "stroke-width": "2",
})
ET.SubElement(svg, "text", {"x": "60", "y": str(30 + i * 16), "font-size": "12",
"fill": colors[i % len(colors)]}).text = label
ET.ElementTree(svg).write(path, encoding="utf-8", xml_declaration=True)
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("--config", default="config/")
ap.add_argument("--out", default="out/")
args = ap.parse_args()
units_cfg = core.load_config("units")
rules = core.load_config("battle")["rules"]
nb = json.load(open(os.path.join(args.config, "n03_battle.json"), encoding="utf-8"))
dmg_cfg = nb["damage"]
cap = dmg_cfg["reductionCapPct"]
min_damage = rules["minDamage"]
rows = []
for atk in dmg_cfg["atkList"]:
for terrain in dmg_cfg["terrainList"]:
base = raw_damage(atk, 0, units_cfg, rules, terrain)
for defense in dmg_cfg["defList"]:
raw = raw_damage(atk, defense, units_cfg, rules, terrain)
capped = capped_damage(raw, base, cap, min_damage)
red_raw_pct = (base - raw) * 100 // base if base else 0
red_cap_pct = (base - capped) * 100 // base if base else 0
rows.append({
"atk": atk, "def": defense, "terrainPct": terrain,
"raw": raw, "capped": capped,
"reductionRawPct": red_raw_pct, "reductionCappedPct": red_cap_pct,
})
# 关键观测量:低攻高防是否触到 minDamage 地板
floor_hits = [r for r in rows if r["raw"] <= min_damage]
max_red_raw = max(r["reductionRawPct"] for r in rows)
max_red_capped = max(r["reductionCappedPct"] for r in rows)
report = {
"version": 1,
"defenseFactorPct": rules["defenseFactorPct"],
"minDamage": min_damage,
"reductionCapPct": cap,
"rows": len(rows),
"maxReductionRawPct": max_red_raw,
"maxReductionCappedPct": max_red_capped,
"minDamageFloorHits": len(floor_hits),
"sample": [r for r in rows if r["atk"] == 260 and r["terrainPct"] == 0],
}
os.makedirs(args.out, exist_ok=True)
with open(os.path.join(args.out, "n03_damage.json"), "w", encoding="utf-8") as f:
json.dump(report, f, ensure_ascii=False, indent=2)
with open(os.path.join(args.out, "n03_damage.csv"), "w", encoding="utf-8", newline="") as f:
w = csv.writer(f)
w.writerow(["atk", "def", "terrainPct", "raw", "capped", "reductionRawPct", "reductionCappedPct"])
for r in rows:
w.writerow([r["atk"], r["def"], r["terrainPct"], r["raw"], r["capped"],
r["reductionRawPct"], r["reductionCappedPct"]])
series = {}
for atk in dmg_cfg["atkList"]:
series[f"atk={atk} terrain=0"] = [(r["def"], r["raw"]) for r in rows
if r["atk"] == atk and r["terrainPct"] == 0]
draw_svg(os.path.join(args.out, "n03_damage.svg"), series)
print(json.dumps(report, ensure_ascii=False))
return 0
if __name__ == "__main__":
raise SystemExit(main())
在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):
{"version": 1, "defenseFactorPct": 50, "minDamage": 1, "reductionCapPct": 70,
"rows": 90, "maxReductionRawPct": 99, "maxReductionCappedPct": 70,
"minDamageFloorHits": 6, "sample": [{"atk": 260, "def": 0, "terrainPct": 0, "raw": 260, "capped": 260, "reductionRawPct": 0, "reductionCappedPct": 0}, {"atk": 260, "def": 60, "terrainPct": 0, "raw": 230, "capped": 230, "reductionRawPct": 11, "reductionCappedPct": 11}, {"atk": 260, "def": 120, "terrainPct": 0, "raw": 200, "capped": 200, "reductionRawPct": 23, "reductionCappedPct": 23}, {"atk": 260, "def": 180, "terrainPct": 0, "raw": 170, "capped": 170, "reductionRawPct": 34, "reductionCappedPct": 34}, {"atk": 260, "def": 240, "terrainPct": 0, "raw": 140, "capped": 140, "reductionRawPct": 46, "reductionCappedPct": 46}, {"atk": 260, "def": 300, "terrainPct": 0, "raw": 110, "capped": 110, "reductionRawPct": 57, "reductionCappedPct": 57}]}
读表:一个数字序列里的四个设计问题
把 sample 这一段摊开,就是攻击 260 打不同防御时的伤害表:
| 防御 | 伤害 | 减伤率 |
|---|---|---|
| 0 | 260 | 0% |
| 60 | 230 | 11% |
| 120 | 200 | 23% |
| 180 | 170 | 34% |
| 240 | 140 | 46% |
| 300 | 110 | 57% |
这张表回答四个问题。第一,绝对减伤是常数吗? 是——每 60 点防御恒定减 30 点伤害,从 0→60 和 240→300 一模一样。第二,减伤率会饱和吗? 会——减伤率从 0% 涨到 57%,但每 60 点防御带来的增量(11%、12%、11%、12%、11%)几乎不变,所以减伤率会缓慢逼近但永远到不了 100%。第三,地形怎么叠? 地形加成作用在减法之后:raw × (100+terrain)//100,即地形 15% 会把 260 变成 299。第四,什么时候会出事? 当攻击很低、防御很高时,raw 会变成负数——这就是下一个问题。
低攻高防会撞上「1 点伤害地板」
报告里有一个数字叫 minDamageFloorHits: 6——在 90 个组合里,有 6 个组合的 raw 已经被压到 minDamage(1 点)的地板上。它们全部来自攻击最低的 120 那一档:攻击 120、防御 300 时,120 − 150 = −30,被保底截成 1。maxReductionRawPct: 99 说的就是这件事——减伤率可以逼近 99%,逼近「无敌」。
这在数值上是危险的:一个高防低攻的「铁乌龟」单位,会让所有攻击低于某个阈值的敌人每回合只能打 1 点,战斗被拖进 maxRounds 上限、变成平局。保底 minDamage 只保证「不会 0 伤害」,它不保证「不会接近免伤」。要真正封住这个洞,得给减伤装一个上限。
减伤上限:给线性减法装一个刹车
capped_damage 做的事很简单:伤害不得低于「同地形无甲伤害」的 (100 − reductionCapPct)%。本卷取上限 70%,意思是任何防御最多只能吃掉 70% 的伤害,剩下的 30% 一定打得进去。对照结果很干净:无上限时 maxReductionRawPct 高达 99,加上限后 maxReductionCappedPct 恰好等于配置的 70。攻击 120 打防御 300 时,伤害从 1 抬到 120 × 30% = 36,铁乌龟不再无敌。
这里有个容易被忽略的细节:上限的地板要按同地形的无甲伤害算,而不是按裸攻击力算。否则地形加成会把「无甲伤害」抬高,但上限地板没跟着抬,导致实际减伤率超过设定的上限。本脚本把 base(同地形无甲伤害)传进 capped_damage,正是为了封住这个偏差——这也是为什么 maxReductionCappedPct 能精确停在 70 而不是 76。
为什么是减法而不是乘法:一个可算的反例
如果把同样的攻防换成百分比减伤(比如 dmg = atk × (1 − def/(def+K))),会得到一条完全不同的曲线:减伤率随防御单调上升且没有天花板,防御堆到上千时减伤率逼近 100%,单位直接免伤。线性减法用「一个常数」换来了「可预测」——策划看一眼攻防差就能估出伤害,不需要反解一个分式。代价是必须自己补一个减伤上限(上一节刚补过),而百分比模型天然自带饱和。两套模型没有对错,只有「你要可预测、还是要自带饱和」的取舍;SLG 的攻防区间窄、需要手算表,所以选了减法。
把公式接进回归
伤害公式的价值不在跑一次,而在每次改配置都跑。n03_damage.py 的输出里有两个可以当断言的数:minDamageFloorHits(有多少组合撞了地板)与 maxReductionCappedPct(加上限后的最大减伤率)。把它们写进回归:只要有人调了 defenseFactorPct 或 reductionCapPct,导致某个组合撞地板、或减伤率越过上限,这两个数立刻变化,CI 就能红。这样,「减伤不会逼近无敌」就从一句口头共识,变成一个有守卫的不变量。
到这里,伤害公式的确定性部分就完整了:攻击侧、防御侧、地形、保底、上限,五个旋钮都能在配置里调,跑一遍脚本就能看到整张表。但真实的战斗不是纯确定的——会有「打空」「暴击」。这些随机项怎么加、怎么保证两个语言算出的随机序列一模一样,是 3.2 要解决的问题。
阅读导航:上一节:目录 · 下一节:3.2 命中、暴击与随机性 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。