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 确定性战斗结算 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。