3.1 伤害公式与减伤模型

把 SLG 的伤害从「按比例扣血」还原成一行可判定的整数公式:攻击乘倍率、减去防御、再取保底。用配置驱动的《王朝纪元》护甲扫描(5 档攻击 × 6 档防御 × 3 档地形)复现线性减法模型,演示低攻高防撞上 1 点伤害地板、以及减伤上限为什么必要。

如果你做过 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 打不同防御时的伤害表:

防御伤害减伤率
02600%
6023011%
12020023%
18017034%
24014046%
30011057%

这张表回答四个问题。第一,绝对减伤是常数吗? 是——每 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 命中、暴击与随机性 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

  1. 12.3 留存与付费的联合分析
  2. 12.2 流失预警与召回
  3. 附录 C:全书公式、参数与数据集索引