3.2 命中、暴击与随机性

命中与暴击让伤害从确定值变成分布:85% 命中、25% 暴击、180% 暴击倍率,解析期望恰为 204。用自实现确定性 PRNG(mulberry32,Python 与 TypeScript 同算法同种子)跑 20 万次,实测均值 203.961、偏差仅 0.01%,并说明暴击真正的代价是方差而不是均值。

3.1 的伤害公式是纯确定的:给定攻防,伤害就是一个整数。但真实的战斗会有「打空」和「暴击」,伤害于是从一个确定值变成一个分布。本节要处理的就是这个分布:怎么把命中与暴击叠进 3.1 的公式、怎么算它的期望、以及一个比期望更容易被忽略的东西——方差。

先说清楚本节不做什么。站内那篇伤害公式与平衡工具 讨论的是单机/动作游戏里「暴击曲线怎么调才好玩」,那是手感问题。本节是 SLG 兵种与回合口径:暴击作用在「整堆部队每回合的总伤害」上,玩家看不到单次飘字,只能看到一支部队几个回合打空对方。所以本节关心的是统计量(期望、方差、跨语言可复现),不是手感。另外,伪随机的保底(pity)机制留到第 5 章,本节只做最朴素的独立掷点。

随机项必须「自实现」,否则跨语言一定漂

一个很自然的想法是直接用语言自带的随机数:Python 用 random,TypeScript 用 Math.random()。但对 bastion 这种「一份配置、两处消费」的工程,这是致命的:两个语言的随机数实现不同,同一份配置跑出来的伤害分布就对不上,3.1 好不容易保证的「逐位一致」当场失效。

所以本节用自实现的确定性 PRNG:mulberry32。它只有一个 32 位状态,每步做一次固定算术,输出一个 [0,1) 的浮点。关键是它没有隐藏状态、不依赖系统时间、不依赖运行时实现——给定种子,序列就唯一确定。Python 与 TypeScript 各写一份同算法实现,前几个随机数逐位相同,跨语言对照才有意义。

命中与暴击的掷点规则也很朴素:先掷一次命中,命中后再掷一次暴击。

if roll() < hitChancePct / 100:      # 命中
    if roll() < critChancePct / 100: # 暴击
        dmg = base * critMultiplierPct // 100
    else:
        dmg = base
else:                                 # 打空
    dmg = 0

下面这段是 S 层仿真脚本,读 config/n03_battle.json 的 random 段,用自实现的 mulberry32 跑 20 万次,把实测命中率、暴击率、伤害均值与解析期望一起算出来。

# file: sim/n03_random.py
# tier: S
# verify: python3 sim/n03_random.py --config config/ --out out/
"""命中、暴击与随机性:用自实现确定性 PRNG(mulberry32)跑伤害分布,对照解析期望。

随机不吃运行时实现:Python 与 proto/src/n03_round.ts 同算法同种子,序列逐位一致。
产出:out/n03_random.json + out/n03_random.csv + out/n03_random.svg
"""

from __future__ import annotations

import argparse
import csv
import json
import os
import xml.etree.ElementTree as ET

MASK = 0xFFFFFFFF
SVG_NS = "http://www.w3.org/2000/svg"


def imul(a: int, b: int) -> int:
    """Math.imul 的 Python 等价:低 32 位乘积。"""
    return ((a & MASK) * (b & MASK)) & MASK


def mulberry32(seed: int):
    """与 proto 侧同算法同种子,逐位一致。"""
    a = seed & MASK

    def nxt() -> float:
        nonlocal a
        a = (a + 0x6D2B79F5) & MASK
        t = imul(a ^ (a >> 15), a | 1)
        t = (t ^ ((t + imul(t ^ (t >> 7), t | 61)) & MASK)) & MASK
        return ((t ^ (t >> 14)) & MASK) / 4294967296.0

    return nxt


def simulate(seed, trials, hit_pct, crit_pct, crit_mult_pct, base):
    rnd = mulberry32(seed)
    total = total_sq = hits = crits = 0
    series, step = [], max(trials // 60, 1)
    for i in range(trials):
        if int(rnd() * 100) < hit_pct:
            hits += 1
            if int(rnd() * 100) < crit_pct:
                crits += 1
                dmg = base * crit_mult_pct // 100
            else:
                dmg = base
        else:
            dmg = 0
        total += dmg
        total_sq += dmg * dmg
        if (i + 1) % step == 0:
            series.append(total / (i + 1))
    return {"trials": trials, "hits": hits, "crits": crits,
            "total": total, "totalSq": total_sq, "series": series}


def analytic_mean_milli(hit_pct, crit_pct, crit_mult_pct, base) -> int:
    """解析期望(单位 0.001 伤害):base * hit * [(100-crit)*100 + crit*mult] / 1000。"""
    crit_mix = (100 - crit_pct) * 100 + crit_pct * crit_mult_pct
    return base * hit_pct * crit_mix // 1000


def draw_svg(path, series, analytic) -> None:
    W, H, (pl, pr, pt, pb) = 680, 360, (60, 24, 28, 40)
    lo, hi = min(min(series), analytic), max(max(series), analytic)
    span, n = (hi - lo) or 1.0, max(len(series) - 1, 1)
    pts = [f"{pl + (W - pl - pr) * i / n:.2f},{pt + (H - pt - pb) * (1 - (v - lo) / span):.2f}"
           for i, v in enumerate(series)]
    svg = ET.Element("svg", {"xmlns": SVG_NS, "viewBox": f"0 0 {W} {H}", "width": str(W), "height": str(H)})
    ET.SubElement(svg, "rect", {"width": str(W), "height": str(H), "fill": "#ffffff"})
    ET.SubElement(svg, "polyline", {"points": " ".join(pts), "fill": "none", "stroke": "#1f6feb", "stroke-width": "2"})
    ay = pt + (H - pt - pb) * (1 - (analytic - lo) / span)
    ET.SubElement(svg, "line", {"x1": str(pl), "y1": f"{ay:.2f}", "x2": str(W - pr), "y2": f"{ay:.2f}",
                                "stroke": "#cf222e", "stroke-width": "1.5", "stroke-dasharray": "5 4"})
    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()
    rc = json.load(open(os.path.join(args.config, "n03_battle.json"), encoding="utf-8"))["random"]
    r = simulate(rc["seed"], rc["trials"], rc["hitChancePct"], rc["critChancePct"],
                 rc["critMultiplierPct"], rc["baseDamage"])
    n = r["trials"]
    sim_mean = r["total"] / n
    ana_milli = analytic_mean_milli(rc["hitChancePct"], rc["critChancePct"],
                                    rc["critMultiplierPct"], rc["baseDamage"])
    ana_mean = ana_milli / 1000.0
    report = {
        "version": 1, "seed": rc["seed"], "trials": n,
        "hitChancePct": rc["hitChancePct"], "critChancePct": rc["critChancePct"],
        "critMultiplierPct": rc["critMultiplierPct"], "baseDamage": rc["baseDamage"],
        "observedHitRateBp": r["hits"] * 10000 // n,
        "observedCritRateBp": r["crits"] * 10000 // r["hits"] if r["hits"] else 0,
        "simMeanMilli": int(round(sim_mean * 1000)), "analyticMeanMilli": ana_milli,
        "simStdMilli": int(round(((r["totalSq"] / n - sim_mean * sim_mean) ** 0.5) * 1000)),
        "deviationBp": int(abs(sim_mean - ana_mean) / ana_mean * 10000),
    }
    rnd = mulberry32(rc["seed"])
    report["firstRolls"] = [round(rnd(), 12) for _ in range(5)]
    os.makedirs(args.out, exist_ok=True)
    with open(os.path.join(args.out, "n03_random.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    with open(os.path.join(args.out, "n03_random.csv"), "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f)
        w.writerow(["metric", "value"])
        for k in ("trials", "observedHitRateBp", "observedCritRateBp", "simMeanMilli",
                  "analyticMeanMilli", "simStdMilli", "deviationBp"):
            w.writerow([k, report[k]])
    draw_svg(os.path.join(args.out, "n03_random.svg"), r["series"], ana_mean)
    print(json.dumps(report, ensure_ascii=False))
    return 0


if __name__ == "__main__":
    raise SystemExit(main())

在本机跑它,输出是这样的(同机相对量级,绝对数值不可移植):

{"version": 1, "seed": 20261012, "trials": 200000, "hitChancePct": 85, "critChancePct": 25,
 "critMultiplierPct": 180, "baseDamage": 200, "observedHitRateBp": 8499, "observedCritRateBp": 2497,
 "simMeanMilli": 203961, "analyticMeanMilli": 204000, "simStdMilli": 106857, "deviationBp": 1,
 "firstRolls": [0.341123865684, 0.017803263851, 0.364578908775, 0.008977568476, 0.681191388052]}

解析期望:一个能心算的对照

20 万次跑下来,实测命中率 8499bp(84.99%)、暴击率 2497bp(24.97%),都贴着配置里的 85% 与 25%。伤害均值 203.961,而解析期望是 204.000,偏差只有 1bp(0.01%)。这个 204 可以直接手算:

E = base × hit × [ (1 − crit) × 1 + crit × mult ]
  = 200 × 0.85 × [ 0.75 × 1 + 0.25 × 1.8 ]
  = 200 × 0.85 × 1.2
  = 204

三个因子分别是「基础伤害」「命中率」「暴击的加权倍率」:不暴击时贡献 0.75 × 1,暴击时贡献 0.25 × 1.8,合起来 1.2。所以暴击把 200 的伤害期望抬到 200 × 0.85 × 1.2 = 204。模拟值能贴到 1bp 以内,说明确定性 PRNG 的分布是对的——不是「看起来随机」,而是「统计量和公式对得上」。

暴击真正的代价是方差,不是均值

如果只盯着期望,会觉得暴击是「白送 4 点伤害」(204 vs 200)。但报告里还有个数:simStdMilli: 106857——标准差约 106.9。作为对照,如果关掉暴击(倍率设为 100%),均值会降到 200 × 0.85 = 170,标准差则降到约 71.4(√(0.85 × 0.15 × 200²) = √5100)。也就是说,暴击把均值从 170 抬到 204 的同时,把标准差从 71 抬到 107——方差涨了约一半。

方差在 SLG 里是真实成本:同样的期望,方差越大,单场战斗的结果越不可预测,「以弱胜强」和「阴沟翻船」都更常见,玩家越容易把一次败仗归因于「运气」而不是「配队」。所以调暴击时,期望和方差要一起看:想要「稳定强」,就得压低倍率、提高触发率;想要「有爆发」,就得接受方差。

B 基准:把「期望 vs 模拟」接进软告警

模拟和解析对得上,这件事应该被自动检查,而不是靠人看。下面这段是 B 层基准脚本,读 out/n03_random.json,把实测命中率、暴击率、伤害均值分别和解析值比,偏差超过 benchToleranceBp(本卷 100bp,即 1%)就软告警。它是软告警:无论报几条,进程一律返回 0——阈值是可调的团队约定,不是硬门禁。

# file: sim/n03_random_bench.py
# tier: B
# verify: python3 sim/n03_random.py --config config/ --out out/ && python3 sim/n03_random_bench.py
"""B 基准:把「确定性 PRNG 模拟」的伤害均值对照「解析期望」,输出偏差软告警 JSON。

只读 out/n03_random.json,不重算公式。软告警:无论偏差多大,进程一律返回 0。
偏差阈值 benchToleranceBp 是可调的团队约定,不是硬门禁。
"""

from __future__ import annotations

import json
import os

HERE = os.path.dirname(os.path.abspath(__file__))
OUT = os.path.join(HERE, "..", "out")


def main() -> int:
    with open(os.path.join(OUT, "n03_random.json"), encoding="utf-8") as f:
        res = json.load(f)
    with open(os.path.join(HERE, "..", "config", "n03_battle.json"), encoding="utf-8") as f:
        tol = json.load(f)["random"]["benchToleranceBp"]

    sim = res["simMeanMilli"]
    ana = res["analyticMeanMilli"]
    dev = res["deviationBp"]
    checks = [
        {"name": "hitRate", "observedBp": res["observedHitRateBp"],
         "expectedBp": res["hitChancePct"] * 100, "devBp": abs(res["observedHitRateBp"] - res["hitChancePct"] * 100)},
        {"name": "critRate", "observedBp": res["observedCritRateBp"],
         "expectedBp": res["critChancePct"] * 100, "devBp": abs(res["observedCritRateBp"] - res["critChancePct"] * 100)},
        {"name": "damageMean", "observedBp": sim, "expectedBp": ana, "devBp": dev},
    ]
    warns = [c["name"] for c in checks if c["devBp"] > tol]
    report = {
        "toleranceBp": tol, "simMeanMilli": sim, "analyticMeanMilli": ana,
        "deviationBp": dev, "checks": checks,
        "warnCount": len(warns), "warnings": warns,
        "verdict": "soft-warn" if warns else "within-tolerance",
    }
    with open(os.path.join(OUT, "n03_random_bench.json"), "w", encoding="utf-8") as f:
        json.dump(report, f, ensure_ascii=False, indent=2)
    print(json.dumps(report, ensure_ascii=False))
    return 0  # 软告警:不硬失败


if __name__ == "__main__":
    raise SystemExit(main())

跑出来三项偏差分别是 1bp、3bp、1bp,全部落在 100bp 容差内,verdict 为 within-tolerance:

{"toleranceBp": 100, "simMeanMilli": 203961, "analyticMeanMilli": 204000, "deviationBp": 1,
 "checks": [{"name": "hitRate", "observedBp": 8499, "expectedBp": 8500, "devBp": 1},
            {"name": "critRate", "observedBp": 2497, "expectedBp": 2500, "devBp": 3},
            {"name": "damageMean", "observedBp": 203961, "expectedBp": 204000, "devBp": 1}],
 "warnCount": 0, "warnings": [], "verdict": "within-tolerance"}

跨语言:同一串随机数

本节的随机数不是「跑起来差不多」,而是逐位相同。Python 侧与 TypeScript 侧用同一份 mulberry32、同一个种子 20261012,前五个随机数是 0.341123865684, 0.017803263851, 0.364578908775, 0.008977568476, 0.681191388052——两边 toFixed(12) 后完全一致。TypeScript 侧的实现放在 proto/src/n03_round.ts,3.3 的测试会断言这五个数。有了这个前提,「命中暴击的随机」才能和「伤害公式」「回合结算」一样,做到一份配置、两语言对齐。

随机项处理完,3.1 的确定伤害与 3.2 的分布就齐了。剩下的问题只有一个:把这两样装进一个回合循环——一支部队怎么按回合消耗另一支部队、什么时候判定胜负、同一场仗能不能回放出一模一样的结果。这是 3.3 的主题。

阅读导航:上一节:3.1 伤害公式与减伤模型 · 下一节:3.3 确定性战斗结算 。

继续阅读

探索更多技术文章

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

全部文章 返回首页

「slg」更多文章

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