客户端战斗结算工程 2026:确定性、叠加与判定仲裁
客户端战斗结算真正难的地方不是算一个伤害公式,而是让属性派生、buff 叠加、判定仲裁这三层在可配表、可回放、可解释、跨设备一致的前提下稳定运行。本文给出一套纯客户端侧可落地的工程化方案,覆盖分层求值、脏标记传播、确定性回放基座与数值回归门禁。
约 47 分钟阅读14,005 字3 次阅读博主

客户端战斗结算真正难的地方不是算一个伤害公式,而是让属性派生、buff 叠加、判定仲裁这三层在可配表、可回放、可解释、跨设备一致的前提下稳定运行。本文给出一套纯客户端侧可落地的工程化方案,覆盖分层求值、脏标记传播、确定性回放基座与数值回归门禁。

一句话摘要:客户端战斗结算真正难的地方不是"算一个伤害公式",而是让属性派生、buff 叠加、判定仲裁这三层在可配表、可回放、可解释、跨设备一致的前提下稳定运行——本文给出一套纯客户端侧可落地的工程化方案。
在客户端团队里,战斗结算大概是最容易被低估的一块工作。它看起来只是一个"输入攻击力、目标防御、返回一个伤害数字"的函数,实际交付时却要同时满足六七个互相拉扯的约束。
第一是策划迭代速度。数值策划每周都在改表:某个技能基础伤害从 120 调到 135,某个防御减伤从 30% 改成 25%,某个 buff 从"可叠加两层"改成"可叠加三层且刷新持续时间"。这些改动必须当天进版本,不能等研发排期。
第二是表现层的精确性。玩家看到的血条掉血、伤害飘字、命中闪白、音效触发,必须和结算结果逐帧对齐。结算层晚一帧,表现层就错位一帧,多帧累积起来就是"明明打中了却没掉血"这类经典客诉。
第三是可解释性。玩家会问"为什么我一下只掉了三分之一血"。客服要能从日志里还原出这一次结算的完整推导链条,而不是只看到一个最终数字。
第四是跨设备一致性。同一场战斗在 iPhone 14 和小米 12 上跑,最终血量必须一致,否则玩家会截图说游戏在骗人。
第五是可回放。策划说"上周那场 Boss 战的伤害曲线不对劲",研发必须能精确复现那一局的结算过程,而不是靠"我本地跑一下好像没问题"。
第六是性能。客户端结算每帧可能触发几十到上百次伤害计算,还不能因此掉帧。
第七是配置即代码的风险。数值表就是代码,一行 JSON 写错一个符号,可能让全服玩家的伤害计算变成 NaN。
这七个约束叠在一起,结算层就变成了一个典型的"简单需求、复杂系统"。很多团队的做法是把它写成一个巨大的 CalculateDamage() 函数,然后在里面塞满 if。这个做法短期能跑,长期会变成没人敢改的黑盒。本文要讨论的,就是怎么把它拆成一个可分层、可仲裁、可验证、可观测的客户端工程。
先划清边界:本文只讨论客户端侧的结算工程。服务端权威结算、状态同步协议、匹配与房间服务都不在范围内;渲染管线、动画混合、AI/NPC 决策同样不在范围内。我们讨论的"确定性",指的是同一台设备、同一份配置、同一串输入事件,客户端能稳定复现出完全一致的结算序列——这是客户端预测、结算回放、本地校验的基础,而不是跨端一致性协议本身。
最直觉的伤害公式长这样:
伤害 = 基础伤害 × (1 + 增伤%) × 攻击力 × (1 - 减伤%) × 暴击系数
这个写法在小游戏里完全够用,但它有一个致命问题:乘法的结合律会让顺序变得不可见。(1+a) × (1+b) 和 (1+a+b) 数值不同,而策划配表时往往同时存在"技能增伤"、"装备增伤"、"状态增伤"三类来源,它们该按什么顺序叠加?策划配表员在 Excel 里写 SUM,程序在代码里写 *,两边的结果对不上,而且错得很隐蔽——只有在跨过某个阈值时才会被玩家发现。
更麻烦的是浮点数。(1.0 + 0.1) * 3.0 和 1.0 + 0.1 + 0.1 + 0.1 在 IEEE 754 双精度下结果不同;一旦结算链路上再叠一次 float(单精度)转换,误差会进一步放大。
工程上更稳的做法是把结算拆成固定顺序的若干层,每层内部只做同类型的合并,层与层之间用乘法连接。经典的五层模型是:
最终伤害 = 基础值
× 加法区(基础增伤百分比汇总)
× 暴击区(是否暴击 × 暴击倍率)
× 独立乘区(技能专属乘区、目标易伤乘区等)
× 减伤区(目标防御减伤汇总)
各层的职责必须语义正交:
BaseDamage,不允许任何其他来源直接改它。+20% 技能增伤 和 +30% 装备增伤 在这一层合并成 +50%。关键约定:加法区里的每一项都是"加法区自己加",不允许在这一层做乘法。这一层用 (1 + sum) 一次性乘上去。(1 + x),多个乘区之间连乘。之所以不并入加法区,是因为乘区之间不可交换——技能易伤乘区 和 目标易伤乘区 谁先算会得出不同结果,必须在代码里固定顺序,并且把这个顺序写进策划文档。(1 - sum),且必须钳制下界(减伤总和不超过某个上限,例如 90%),否则会出现负伤害或"打不动"的极端情况。加法区求和之后必须钳制。一个常见的坑是"增伤 + 增伤 + 减伤"混在一起求和,如果三类都进了加法区,+200% 的技能增伤和 -300% 的减伤会互相抵消到负数。
正确的做法是:加法区只允许同号贡献,或者至少要在求和前对每一项做符号归一化,并且在求和后钳制到 [min, max] 区间。这个区间不该硬编码,而应该来自配表——数值策划往往希望某个赛季的增伤上限是 300%,而不是代码里写死的 200%。
def clamp(v, lo, hi):
return lo if v < lo else (hi if v > hi else v)
def additive_zone(mods, cap):
# mods: [{value: float, cap: float|None}]
total = 0.0
for m in mods:
total += m["value"]
return 1.0 + clamp(total, -cap["down"], cap["up"])
分层模型的真正价值在于:层的顺序是一份可以被测试、被审计、被写进文档的契约。
如果以后要插入新的一层(比如"破防层"),你必须显式决定它插在哪一层之间、影响哪些既有层,并补一批回归用例。这比往一个 300 行的 CalculateDamage 里插一行 damage *= 1.2f 要安全得多——后者会让所有既有测试用例在没有预警的情况下全部偏移,而策划根本不知道偏移了多少。
在代码层面,我建议把层定义成一个显式的静态表,而不是一串手写的语句:
SETTLEMENT_LAYERS = [
("base", "base_value"), # 基础值
("additive", "damage_bonus"), # 加法区
("crit", "critical"), # 暴击区
("multi", "final_multiplier"),# 独立乘区(内部顺序固定)
("reduction","target_reduction"),# 减伤区
]
def settle(ctx):
acc = ctx.base_value
for layer_name, _ in SETTLEMENT_LAYERS:
acc = LAYER_IMPL[layer_name](acc, ctx)
ctx.snapshot(layer_name, acc) # 每层落一次快照,用于回放与解释
return acc
ctx.snapshot(layer_name, acc) 这一行是后面第九节遥测和第七节回放的唯一数据源。结算层必须"自己把推导过程记下来",而不是等出问题再去加日志——等到出问题再补日志时,你已经拿不到当时的中间值了。
新手数值系统往往写成"角色 → 属性 → 派生属性"的树形结构:
基础攻击力 = 100
攻击力 = 基础攻击力 × (1 + 武器加成)
暴击率 = 装备暴击 + 天赋暴击
暴击伤害 = 基础暴伤 + 等级加成
期望 DPS = 攻击力 × 暴击率 × 暴击伤害 / 攻击间隔
看起来像树。但只要策划加了"护甲穿透提高暴击率"或者" buff 状态下攻击力提升进而影响暴击伤害",就会变成真正的图。而且是可能有环的图:a 依赖 b,b 又依赖 a。
这不是理论风险。绝大多数线上数值事故都来自意外的属性依赖环,症状是数值突然变成 NaN 或某个值疯狂震荡。
正确做法是让依赖关系显式声明在数据里,而不是藏在代码的求值顺序中:
ATTR_GRAPH = {
"base_atk": [],
"atk": ["base_atk", "weapon_bonus", "buff_atk_bonus"],
"crit_rate": ["equip_crit", "talent_crit", "armor_pierce_crit"],
"crit_damage": ["base_crit_dmg", "level_bonus"],
"attack_speed": ["base_speed", "buff_speed_bonus"],
"expected_dps": ["atk", "crit_rate", "crit_damage", "attack_speed"],
}
有了这张表,你可以做很多之前做不到的事:
拓扑排序 + 环检测。用 Kahn 算法对依赖图做拓扑排序,如果排不完说明存在环,直接在启动时(或编辑器里)报错并打印环路径,而不是等到战斗结算时得到 NaN。环检测应该在编辑器侧跑,因为一个环在启动阶段暴露的代价远低于在玩家第 3 场战斗时暴露。
求值顺序固定。拓扑排序给出一个确定的求值顺序,把这个顺序固化下来,就避免了"这次 A 先算那次 B 先算"导致浮点结果漂移。
增量重算。有了 DAG,"哪些属性需要重算"就是从脏节点出发的可达集。
当一个 buff 上身时,你不需要重算所有派生属性。正确的传播策略是:
weapon_bonus)def mark_dirty(graph, roots):
dirty = set()
stack = list(roots)
while stack:
n = stack.pop()
if n in dirty:
continue
dirty.add(n)
for dep in GRAPH_REVERSE.get(n, []): # 反向边:谁依赖我
stack.append(dep)
return dirty
反向边表 GRAPH_REVERSE 可以在加载配表时一次性建好,不必每次都扫全图。
这里有个容易忽略的性能陷阱:多个脏节点可能汇聚到同一个下游节点。如果一个角色同时中了 3 个 buff,分别修改 atk、crit_rate、attack_speed,那么 expected_dps 会被三条路径都标记为脏。如果实现里是"每条路径触发一次重算",expected_dps 就会算 3 次。用脏集合 + 拓扑序统一重算,可以保证每个节点每次最多求值一次。
拓扑排序不唯一:同一个 DAG 可以有多个合法的拓扑序。而拓扑序不同,(a + b) + c 与 a + (b + c) 的浮点结果可能不同。
所以如果你的结算链路里有浮点加法链,求值顺序必须固定并版本化。两个实用做法:
atk 是 100,crit_rate 是 101),按编号升序求值,而不是按拓扑序。这样顺序就与图结构解耦,图改了顺序也不变。我更推荐第一种:把顺序变成数据而不是算法输出,这样它可以被 diff、被 code review、被写进回归基线。拓扑排序的作用退化为"检测环",而不是"决定顺序"。
"给角色加 20% 攻击力"这句话在实现上至少有六个维度需要定死:
这些规则如果不写进配表,就会在代码里散落成无数个特判。
BUFF_RULES = {
"berserk": {
"max_stacks": 3,
"duration_mode": "per_stack", # per_stack | shared
"refresh": "reset", # reset | extend | max
"stack_merge": "same_source", # same_source | global | none
"exclusive_group": "attack_mod", # 同组内互斥
"priority": 10,
},
"weaken": {
"max_stacks": 1,
"duration_mode": "shared",
"refresh": "max",
"stack_merge": "global",
"exclusive_group": "attack_mod",
"priority": 50,
},
}
这张表的每个字段都对应一个真实的策划需求。好处是:策划可以在配表里调这些字段,研发不用改代码。上面这段示例里,berserk 优先级 10、weaken 优先级 50,两者同属 attack_mod 互斥组——所以当"狂暴"和"虚弱"同时存在时,只有优先级更高的"虚弱"生效(50 > 10),"狂暴"被压制。
互斥组是 buff 系统里最容易出错的地方。"同组内互斥"这句话本身是不够的,因为互斥有两种完全不同的语义:
覆盖式互斥:只生效优先级最高的那一个,其余的完全不生效。
叠加式互斥:所有成员都生效,但按优先级从低到高逐个套用(即后来的覆盖前面的数值)。
这两种语义在具体游戏里都有人用。如果不写清楚,策划说"这两个 buff 能一起用吗",没人答得上来。
我建议把互斥语义显式写进配表,而不是靠组名暗示:
"exclusive_group": "attack_mod",
"exclusive_mode": "override_top", # override_top | stack_by_priority
override_top 表示只有优先级最高者生效;stack_by_priority 表示按优先级升序依次套用,后者实际上等价于一个有序的加法区——注意,此时顺序会影响浮点结果,所以套用顺序必须也是固定的。
"可叠加三层"的 buff,最容易出错的是"同源"判定:如果玩家同时装备了两把都提供"力量"的手套,力量是加两次还是加一次?如果是加两次,那玩家实际上可以无限叠加上限,破坏平衡。
判定同源需要一个稳定的作用域键:
def source_key(buff_id, source):
rule = BUFF_RULES[buff_id]
if rule["stack_merge"] == "same_source":
return (buff_id, source.scope_key) # scope_key 来自来源实例
if rule["stack_merge"] == "global":
return (buff_id,)
return None
scope_key 必须由来源自己提供且稳定——对于装备是槽位 ID,对于技能是施法者 ID,对于天赋是天赋 ID。如果 scope_key 不稳定(比如用对象指针或 UUID),那么同一个来源每帧重新施加都会变成"新来源",层数就永远叠不上去。这类 bug 表现为"叠层时有时好有时坏",极难复现。
buff 移除不是简单的 remove(buff_id)。当一个 buff 被移除时,它可能:
所以正确的移除是引用计数式的:
def remove_buff(actor, buff_instance):
actor.active_buffs.discard(buff_instance)
for eff in buff_instance.effects:
if eff.kind == "attr_mod":
ctx.attr_mods[eff.target].remove(eff)
ctx.dirty_roots.add(eff.target) # 触发脏标记
for cb in buff_instance.on_remove_callbacks:
ctx.defer(cb) # 延后执行,避免重入
ctx.defer(cb) 那个延后很重要。如果在遍历 buff 列表时直接执行回调,回调里可能又移除别的 buff,导致迭代器失效。所有"结算过程中修改结算状态"的回调都应该排队,在当前结算帧结束后统一执行。 这是结算层重入问题的通用解法。
玩家按了跳跃键,角色没跳。原因是输入采样帧、逻辑帧、表现帧三者不同步:
如果玩家在第 37 帧按下跳跃,而逻辑帧边界在第 40 帧,那这次输入要等 3 帧才被处理——这很正常。但如果按键恰好在逻辑帧边界之后才被采样、而逻辑帧又在同一时刻读输入,就可能这一次按键既不属于上一帧也不属于这一帧,被"跨过去"。
输入缓冲就是解决这个的:按下跳跃的瞬间就记录一个 jump_buffered_until = now + buffer_window,逻辑帧只要发现当前时间还没超过这个截止时间,就执行跳跃。
缓冲窗口太短,玩家仍然会感觉"偶尔丢输入";太长,则会出现"我早就松开跳跃键了,角色还在跳"这种黏滞感。常见取值在 100–200 毫秒量级,具体取决于角色的跳跃动画起步时间——动画起步越长,缓冲窗口需要越宽,因为玩家的直觉是"我按了,画面还没起跳,那肯定算数"。
工程上,缓冲窗口应该是一个配置项,并且和角色的动画参数绑定:
INPUT_BUFFER_MS = {
"jump": 150,
"dash": 120,
"attack_light": 180,
}
容易混淆的一点:输入缓冲解决"输入没被读到",判定窗口解决"在窗口内按下是否生效"。
后者指的是很多动作游戏里的"取消窗口"(cancel window):在攻击动作的特定帧区间内,玩家按跳跃可以取消攻击后摇直接起跳。这个区间是动作数据的一部分,不是全局输入缓冲。
一个动作通常有三类窗口:
三者的关系是:输入在 t 时刻到达 → 若 t 落在动作的输入窗口内且该输入类型在允许列表里,则进入消费流程 → 若当前动作无法立即消费(如正在播放不可取消的技能),则进入缓冲队列 → 缓冲在 t + buffer_window 内被后续动作消费。
工程化的关键是把"帧"换成"时间"。因为渲染帧率不固定(60/90/120 Hz,或者掉帧到 45 Hz),如果窗口用帧号表示,在不同设备上窗口的实际时长会不同——这正是"高刷玩家手感比低刷玩家灵敏"的常见根因。所以窗口应该用秒或毫秒定义,在动作数据里存关键帧的时间戳而不是帧号。
一个稍微进阶但收益很大的技巧是抢先帧:在玩家的输入被采样的那一帧,就提前假设这次输入成功,立刻应用其影响,而不是等到下一个逻辑帧。
这能省掉最多一个逻辑帧的延迟,在高速动作游戏里手感差别明显。实现代价是:一旦这个假设最终被否决(输入被判定为无效,比如在无敌帧内按了闪避),你已经应用的效果需要回滚。
所以抢先帧要求结算层支持回滚。这正好又牵扯到第六节的确定性——回滚之后重新模拟必须得到和"没抢先帧"时完全一样的结果,否则你无法安全地回滚。
这是一个漂亮的收敛点:确定性不只是为了跨端一致,也是为了让客户端敢做预测。很多团队不敢在客户端做抢先帧,就是因为结算层不确定,回滚之后结果对不上。
"确定性"在客户端语境下应该定义清楚。我们要求的是:
相同的输入事件序列、相同的配置版本,在同一台设备上重复执行任意次,得到逐位相同的结算结果。
注意这里没有要求跨设备一致——跨设备一致是更强的要求,代价也大得多(见 6.4)。但同设备内的可复现性是所有调试工作的前提:如果连本机都复现不了,那"回放 bug"根本无从谈起。
第一类:表达式重排。加法的结合律在浮点下不成立。(a+b)+c 与 a+(b+c) 结果可能差 1 ULP。
第二类:FMA(融合乘加)的差异。fma(a, b, c) 计算的是 a*b+c 且中间不舍入,而 a*b+c 会先把 a*b 舍入到目标精度再相加。IEEE 754 允许实现选择是否提供融合语义,不同 CPU 架构(x86-64 的 FMA 指令、ARMv8 的 FMA 指令、非 FMA 的通用路径)选择不同。同一个表达式在启用 FMA 的路径和不启用的路径上结果不同。
第三类:中间精度(extended precision)。x87 架构上,表达式可能在 80 位扩展精度下求值,最后才舍入到 64 位;现代 x86-64 用 SSE2 默认不开启,但编译器优化(如 FMA 收缩、是否把中间结果留在寄存器里)仍然可能改变结果。
这三条合起来,意味着**"同一份代码在不同编译器/不同 CPU 上算出略微不同的伤害数字"**是常态,而不是意外。
一个务实的做法是不要全链路定点,而是分级:
float,随便算,不参与任何一致性要求对结算层,如果你的游戏数值本身是整数或定点友好的(比如伤害是整数、百分比是万分比),那就全程用整数:
# 万分比定点:10000 = 100%
PER_MILLE_BASE = 10000
def apply_ratio(value, permille):
# 明确采用向下取整 + 定点乘法的定义,避免浮点参与
return (value * permille) // PER_MILLE_BASE
整数乘除在所有主流平台上都是精确且一致的(Python 的大整数没有溢出问题,C#/C++ 用 64 位整数并明确溢出行为即可)。这一条能把绝大多数结算路径从浮点的坑里救出来。
如果数值确实需要小数(比如 0.15 倍率),在配表阶段就把它转成定点整数,而不是在运行时用 float 算。策划写 1.15,导入管线转成 11500(万分比),运行时全程整数。
真正要跨设备逐位一致,需要:
#pragma STDC FP_CONTRACT OFF 或等效编译器选项)sin/exp/pow,而不是用平台 libm——不同平台的 libm 在最后一位上确实会不同)代价很高,而且会拖慢开发速度。我的建议是:先明确你的游戏是否真的需要跨端逐位一致。
对于绝大多数非竞技(非 PvP 排行、不做数值校验对抗)的游戏,跨端一致到"玩家感知不到"的精度就够了——也就是误差在 0.1% 以内。做法是:结算层全整数,HP 用整数存储,飘字用整数除法得到(damage * 100 // max_hp 得到千分比血条),这样跨端天然一致。
只有竞技类游戏才需要把"客户端本地预判结果 vs 服务端权威结果"做成严格比对,那种场景才值得付出 6.4 的全套代价。
要复现一场战斗,你至少需要三样东西:
第 3 项经常被忽略。结算链路上任何一处 rand() 都是回放的破坏者——即使这次没触发随机分支,随机数生成器的状态也会被推进,下一次回放时状态不同。
正确做法是:结算层不直接调用全局随机源,而是持有自己的 RNG 实例,并且这个实例由种子 + 调用点索引共同决定。
class SettlementRNG:
def __init__(self, seed):
self.state = seed
self.calls = 0
def next_u32(self):
self.calls += 1
# PCG-XSH-RR 或其他确定性算法;此处为示意
self.state = (self.state * 6364136223846793005 + 1442695040888963407) & 0xFFFFFFFFFFFFFFFF
xorshifted = (((self.state >> 18) ^ self.state) >> 27) & 0xFFFFFFFF
rot = (self.state >> 59) & 31
return ((xorshifted >> rot) | (xorshifted << ((-rot) & 31))) & 0xFFFFFFFF
关键不是算法,是这个对象被显式地传递。ctx.rng 而不是全局 rand()。这样回放时只要喂进同一个种子,整条链路的随机序列就完全一致。
上游可以参考 imneme/pcg-cpp(截至 2026-10-25 实测 855 stars,Apache-2.0,最近提交 2024-05-17),这是 PCG 家族的 C++ 参考实现;PCG 的设计目标之一正是"比传统方法更可复现、更快"。
快照不是"每帧截一张图",而是记录状态变化的差量。有两种粒度:
逻辑帧级快照:每逻辑帧记录所有发生变化的属性值。优点是简单、可直接用于时间轴回放;缺点是数据量大。
事件级快照:只记录状态变更事件("buff X 上身"、"角色 Y 受到 120 伤害")。优点是数据量小、可读性好、易于做 diff;缺点是需要一个事件总线和一套重放语义。
两者结合是最佳实践:事件流用于"为什么"(可解释性),关键节点的逻辑帧快照用于"当时是什么"(精确重现)。
有了回放,你需要一个快速的方法判断"这次回放和那次是不是同一件事"。方法是对关键状态做哈希:
def state_hash(actor):
h = 1469598103934665603
for v in actor.hp, actor.mp, actor.level:
for b in struct.pack("<q", v):
h ^= b
h = (h * 1099511628211) & 0xFFFFFFFFFFFFFFFF
return h
这个 hash 的用途很实际:
注意第 2 条和第 3 条:hash 变了不一定是 bug,可能只是数值策划有意调整。但这个"变了"必须被看见,而不是静悄悄地发布出去。
数值表通常是 JSON、CSV 或引擎原生格式。无论是哪种,它都需要 schema 校验,而且校验必须发生在两个地方:编辑器导入时(给策划即时反馈)和 CI 中(防止绕过编辑器直接改文件)。
JSON Schema 是一个务实的选择。ajv-validator/ajv(截至 2026-10-25 实测 14864 stars,MIT,支持 draft-04/06/07/2019-09/2020-12)是 JS 生态里最常用的校验器;如果你的工具链在 Go 侧,santhosh-tekuri/jsonschema(1289 stars,Apache-2.0,同样支持 draft 2020-12 及以下多个版本)也是成熟选择。
一个属性表的 schema 大致长这样:
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"type": "object",
"required": ["id", "base_damage", "crit_rate", "resist"],
"properties": {
"id": { "type": "integer", "minimum": 1 },
"base_damage": { "type": "integer", "minimum": 0, "maximum": 1000000 },
"crit_rate": { "type": "integer", "minimum": 0, "maximum": 10000 },
"resist": {
"type": "object",
"additionalProperties": false,
"properties": {
"physical": { "type": "integer", "minimum": -1000, "maximum": 10000 },
"fire": { "type": "integer", "minimum": -1000, "maximum": 10000 }
},
"required": ["physical", "fire"]
}
}
}
这个 schema 抓得住的典型错误:负伤害、暴击率超过 10000(万分比上限是 100%)、漏配某种抗性、拼错字段名(additionalProperties: false 会直接报出来)。
schema 只能验证类型和范围,验证不了语义。这类错误需要专门的校验器:
buff.apply_to 指向的 attribute id 是否存在?技能引用的特效资源是否存在?纯配置变更是最危险也最容易被忽略的一类改动。它不碰一行代码,却可能改变 90% 玩家的战斗体验,而且 code review 里只看到几百行 JSON 数字变化。
数值回归的做法是:维护一批标准测试场景(每个场景 = 一份初始状态 + 一串输入 + 期望结果),跑完把结果和基线对比,任何超出容差的变化都要求显式确认。
SCENARIOS = [
("basic_hit", init_state(lv=1, atk=100), [("attack", "enemy")], tol=0),
("crit_hit", init_state(lv=1, atk=100, crit=10000), [("attack", "enemy")], tol=0),
("buff_stack", init_state(lv=1), [("apply", "berserk", 3), ("attack", "enemy")], tol=0),
("multi_target", init_state(lv=50, atk=1000), [("aoe", "pack")], tol=0),
]
对这些场景跑结算,输出(最终 HP、伤害序列、状态哈希),与基线对比。整数结算下 tol=0(完全一致)应该是常态;如果你的结算层有浮点,给一个很小的相对容差。
这个测试套件应该和单元测试同等地位,跑在 CI 里,改数值配表和改代码一样会触发它。
nlohmann/json(截至 2026-10-25 实测 50799 stars,MIT,最近提交 2026-10-24)是 C++ 侧最常用的 JSON 库;simdjson/simdjson(24455 stars,Apache-2.0)在解析吞吐上更激进,适合超大配置文件的启动期加载;google/flatbuffers(26579 stars,Apache-2.0)则在"内存效率优先"的场景更合适——如果你的配置表大到启动加载成为瓶颈,可以考虑在打包期转成 FlatBuffers,运行时零解析直接内存映射。
引擎侧的对应能力:Unity 的 ScriptableObject 官方文档(Unity Manual 6.6 / 6000.6)说明它是"可序列化的 Unity 类型,派生自 UnityEngine.Object",实例通常通过 Assets 菜单创建,作为项目中的资产存在、不挂载到 GameObject 上,但因为继承自 UnityEngine.Object,可以直接拖进 Inspector 的字段里——这正是"数值配表资产化"的标准做法。Godot 侧的对应物是 Resource(官方文档说明 Resource 可以在对象属性与序列化文本/二进制数据 *.tres、*.res 之间自由转换,并继承 RefCounted 的引用计数内存管理,官方文档明确列出这相比 JSON、CSV 或自定义 TXT 的优势)。
引擎文档版本要注意时效:本次实测 Godot 文档标注为 stable,Unreal 文档标注为 Unreal Engine 5.8,Unity 文档标注为 6.6 (6000.6)。
遥测的关键设计原则是在发生地埋点,而不是事后从别处推算。结算层是伤害、治疗、资源变化的唯一产地,在这里埋点能拿到最完整、最准确的数据。
每次结算至少上报这几个字段:
telemetry.emit({
"battle_id": ctx.battle_id,
"frame": ctx.frame,
"attacker": ctx.attacker.id,
"target": ctx.target.id,
"skill_id": ctx.skill_id,
"base": snap["base_value"],
"additive": snap["additive_zone"],
"crit": snap["crit_zone"],
"multi": snap["multi_zone"],
"reduction": snap["reduction_zone"],
"final": result,
"state_hash": hash_after,
})
带上每一层的中间值之后,"为什么我一下只掉了三分之一血"这类问题就能直接从遥测里回答,不需要复现。
全量上报在战斗密集的场景下开销不小。实用的折中是分层采样:
遥测字段本身会演进。加字段容易,但改字段语义(比如把 multi 从"乘区系数"改成"乘区数值")会让历史数据无法对比。
因此每个埋点定义要有版本号,且只增不改语义:要改语义就发新的 schema_version,保留旧字段至少一个数据周期。这也是为什么上面那组字段名要写得具体——additive_zone 比 bonus 好,因为前者明确是"这一层的乘数"。
一旦结算层有了完整的分层数据,你就能回答那些原本只能靠猜的问题:
这些是数据驱动设计的原料,而它们全部依赖结算层在发生地留下结构化记录。
把前面讨论的坑汇总成一份可对照的清单:
公式层
属性图层 5. 属性依赖存在环,运行时才暴露为 NaN 6. 脏标记只标记直接依赖,没有沿 DAG 传播 7. 多个脏路径汇聚导致同一节点重复求值 8. 求值顺序依赖拓扑排序算法,同一份数据两次运行顺序不同
buff 层
9. 同源判定用不稳定的 scope_key(指针/UUID),叠层时好时坏
10. 互斥组没定义"覆盖式"还是"叠加式",策划预期与实现不符
11. 移除 buff 时直接执行回调,导致遍历中修改集合
12. 层数上限不生效,配表里加了几百层也没钳制
输入与判定层 13. 窗口用帧号定义,在不同刷新率设备上实际时长不同 14. 输入缓冲窗口硬编码,没跟动画起步时间联动 15. 抢先帧没有回滚机制,或回滚后结果与未抢先帧不一致
浮点与确定性层
16. 结算链路中混用 float 与 double,精度在中间被截断
17. 允许编译器做 FMA 收缩,表达式结果随平台变化
18. 用平台 libm 的 sin/pow,不同平台最后一位不同
19. 结算层直接调用全局随机源,回放无法复现
工程与工具链层 20. 配表无 schema 校验,拼错的字段静默变成默认值 21. 只有类型校验,没有语义校验(引用完整性、可达性、平衡边界) 22. 纯配置变更不触发任何测试,靠人肉 review JSON 23. 遥测只在失败时记录,成功路径没有基线 24. 埋点字段改语义不改版本号,历史数据无法对比
回到开头那句"结算层是客户端最难交付的一块"。难的原因不是公式复杂,而是它同时要满足可配表、可回放、可解释、可观测、跨设备一致这五组彼此拉扯的目标。
把这五组目标排好优先级,方案其实不复杂:
第一步,把公式分层。加法区、暴击区、独立乘区、减伤区,顺序固定并写进文档。这一步收益最大、风险最低,多数团队的第一版改造停在这里就已经能消灭一大半数值 bug。
第二步,把属性依赖显式化。用一张依赖表替代代码里的隐式求值顺序,拓扑排序只用来检测环,实际顺序由配表里的属性编号决定。
第三步,把 buff 语义配置化。层数、刷新、互斥模式、优先级全部进表,互斥语义(覆盖式 vs 叠加式)必须显式声明。
第四步,建立回放基座。结算层持有自己的 RNG、每层落快照、输出状态哈希。有了这个,"复现"才从"看日志猜"变成"跑一遍就知道"。
第五步,把数值变更纳入 CI。schema 校验 + 语义校验 + 场景回归,三者一起跑。纯配置变更从此也有门禁。
第六步,结算层自埋点。分层数据在发生地采集,异常必报、正常抽样、字段版本化。
这六步不需要一次性全做。我建议的顺序是:第一步和第五步优先(收益最快、风险最低),第三步次之(最依赖策划配合),第二步和第四步可以并行推进,第六步在有了前四步的字段定义后自然落地。
最后提醒一句本文的边界:以上全部是客户端侧的结算工程。它不涉及服务端权威结算的实现,也不涉及同步协议设计。客户端结算做得再好,服务端权威逻辑仍然是最终裁决者——但一个确定性的客户端结算层,能让你在客户端预判、结算回放、本地数值校验这三件事上真正有底气去做,而不是每次都担心"我这边算的和那边不一样"。
Conversation
0 条