简介:CocoKnightManager 是一套基于 Cocos Creator 的回合制游戏系统设计源码,面向希望理解或自行搭建回合制玩法框架的初中级游戏开发者,尤其适合需要参考模块拆分与数据驱动实现思路的读者。项目围绕回合流程抽象出回合管理器、行动规则引擎与状态机三类组件,分别负责行动顺序判定、攻击防御与技能等指令执行、角色状态切换,并把属性、效果和事件外置为 JSON 等数据格式,便于非程序人员调整数值与内容。
压缩包为 zip 格式,共 22 个文件、约 216KB,其中 7 个 json 承担工程与构建参数、数据表配置,2 个 ts 脚本承载核心逻辑,另有 7 个 meta 资源索引、3 张 png 贴图以及场景文件与 md 说明文档,体量轻便,导入引擎即可阅读调试。目前已有 840 人学习下载。读者可获得一套可直接对照的目录结构与模块划分范例,学习回合调度、行动规则扩展和状态流转的具体写法,也可将其作为二次开发起点,在开源代码基础上补充自己的战斗机制与内容配置。
1. 回合制战斗最怕的不是数值做不平,而是同一场仗跑两遍结果不一样
玩家在手机上看到暴击秒掉对面,回放或者服务端重算出来却是自己先倒下,这种问题排查起来像个黑匣子——单步日志每一步都对,合起来就是不对。CocoKnightManager 这套基于 Cocos Creator 的回合制战斗系统设计,盯的就是「谁先动、伤害怎么算、怎么复现」这三件事,把它们拆成能单独测试的模块。它适合正在做卡牌、战棋、放置类回合战斗的团队,尤其是已经被「战斗结果无法复现」折腾过一轮、想在下个版本重写内核的人。仓库是开源的,代码结构不算复杂,但想真正接进项目,得先弄明白它的调度模型和结算管线是怎么设计的,否则抄了接口也跑不出预期。
2. 回合制内核拆两层:行动条调度与状态机怎么落地
回合制战斗的核心矛盾是「顺序」和「时序」。顺序决定谁先出手,时序决定一次行动内部按什么次序结算。很多项目把这两件事塞进一个巨型 update 里,前期跑得通,单位一多、Buff 一挂就开始玄学。这套设计把内核切成三个互不依赖的部件:行动条负责产出一个「可以行动的单位」,状态机负责约束当前处于哪个阶段,事件总线负责把逻辑结果广播给表现层。三者之间只通过接口和事件通信,任何一个都能单独写单测。
2.1 行动顺序:速度权重、满条阈值与溢出保留
最省事的做法是按速度排序轮流行动,但速度差距只体现在先后,体现不出频率。速度 200 的单位和速度 100 的单位,在一个纯回合模型里出手次数是一样的,这跟玩家的直觉不符。行动条模型(业内常叫 ATB)解决的就是这个问题:每个单位每 tick 按速度往自己的行动条里充能,充到阈值就能行动,行动后扣掉阈值、保留溢出量。速度 200 的单位大约两倍频率出手,这个「大约」来自溢出保留——如果不保留溢出,高速单位会白白浪费充能。
先把单位的数据结构定下来,速度用整数、行动条用整数刻度,这一点后面避坑章还会展开:
// ActionGauge.ts export interface Combatant { id: string; speed: number; // 每 tick 充能量,正整数 gauge: number; // 当前行动条进度,0 ~ GAUGE_FULL alive: boolean; } export const GAUGE_FULL = 1000; // 满条阈值,整数刻度 export class ActionGauge { constructor(private units: Combatant[]) {} /** 推进一个 tick,返回本 tick 内所有满条单位,按溢出量降序 */ tick(): Combatant[] { // 1. 存活单位按速度充能 for (const u of this.units) { if (!u.alive) continue; u.gauge += u.speed; } // 2. 收集满条单位 const ready = this.units .filter(u => u.alive && u.gauge >= GAUGE_FULL) .sort((a, b) => { // 溢出多的先动;溢出相同用 id 做稳定排序,避免顺序抖动 const diff = b.gauge - a.gauge; return diff !== 0 ? diff : a.id.localeCompare(b.id); }); // 3. 扣减阈值,保留溢出 for (const u of ready) { u.gauge -= GAUGE_FULL; } return ready; } }GAUGE_FULL取 1000 是把常见速度值(80~300)放大到整数区间,让「速度 133」这种值也能被精确表达,同时避免小数累加。speed直接当作每 tick 充能量,改动它等于改速度,不需要额外换算。排序里用 id 兜底是血泪经验:当两个单位溢出量完全相等,sort在不同引擎版本上的稳定性不一致,轻则出手顺序抖动,重则回放对不上。扣减时只减GAUGE_FULL而不是清零,溢出量就自然留到了下一轮。
同一 tick 返回多个满条单位是正常情况。上层拿到数组后,通常只取第一个进入行动,剩下的留在队列里,等当前行动结算完再依次处理。这个「待行动队列」是必要的,否则一次行动期间其他单位的行动条状态会被破坏。
三种常见调度模型的取舍可以对照下面这张表:
| 模型 | 实现复杂度 | 速度表达 | 适合场景 |
|---|---|---|---|
| 纯回合排序 | 低 | 只影响先后 | 战棋、固定回合 |
| 行动条 ATB | 中 | 影响行动频率 | 卡牌、放置、MMO 回合 |
| 冷却制 CTB | 中高 | 影响技能循环 | 动作与回合混合 |
选 ATB 的前提是策划希望「速度」成为一个有感知的成长属性。如果数值策划明确说速度只影响先手,那用纯回合排序更省事,别为了框架好看硬上 ATB。
2.2 战斗状态机:七个状态与非法迁移拦截
战斗流程如果全靠if (phase === ...)判断,很快就会出现「在结算阶段又触发了一次回合开始」这种越界。状态机的作用不是好看,是把非法迁移变成显式报错。这套设计划了七个状态:待机、回合开始、等待行动、结算中、回合结束、胜利、失败。迁移关系用一张表锁死,任何不在这张表里的跳转直接抛异常,测试阶段能第一时间抓到逻辑写错的地方。
// BattleStateMachine.ts export enum BattleState { Idle, // 未开战 TurnStart, // 回合开始,处理 Buff 跳伤、行动条推进 WaitingAction, // 等待玩家或 AI 提交行动 Resolving, // 结算中,伤害、效果、死亡判定 TurnEnd, // 回合结束,清理即将过期的 Buff Victory, Defeat, } const TRANSITIONS: Record<BattleState, BattleState[]> = { [BattleState.Idle]: [BattleState.TurnStart], [BattleState.TurnStart]: [BattleState.WaitingAction, BattleState.Victory, BattleState.Defeat], [BattleState.WaitingAction]: [BattleState.Resolving], [BattleState.Resolving]: [BattleState.TurnEnd], [BattleState.TurnEnd]: [BattleState.TurnStart, BattleState.Victory, BattleState.Defeat], [BattleState.Victory]: [], [BattleState.Defeat]: [], }; export class BattleStateMachine { private state: BattleState = BattleState.Idle; get current(): BattleState { return this.state; } canTransit(to: BattleState): boolean { return TRANSITIONS[this.state].includes(to); } transit(to: BattleState): void { if (!this.canTransit(to)) { throw new Error(`非法状态迁移: ${BattleState[this.state]} -> ${BattleState[to]}`); } this.state = to; } }迁移表里把胜负判定放在TurnStart和TurnEnd两处,是因为持续伤害和治疗都可能在任何一端造成死亡,只放在结算后会出现「单位已经 0 血还在场上等一回合」的情况。Victory和Defeat设计成终态,空数组意味着进去就出不来,想重开一局只能新建实例,这比复用实例更安全。
实际接线时,TurnStart里做三件事:推进行动条、处理回合开始的持续效果、检查是否有人死亡。WaitingAction是唯一允许外部输入的状态,玩家点技能、AI 决策都只能在这个状态提交,提交后立刻迁到Resolving,防止连点造成重复出手。
2.3 事件总线:让技能逻辑和表现层互不认识
逻辑层算完伤害,表现层要播动画、飘数字、抖屏。如果逻辑代码里直接this.unitNode.getComponent(...).playHurt(),这套逻辑就没法在纯 Node 环境跑测试,也没法做服务端校验。事件总线把「发生了什么」和「怎么表现」切开,逻辑只emit('damage:done', payload),表现层自己订阅。
// EventBus.ts type Handler = (payload?: any) => void; export class EventBus { private map = new Map<string, Handler[]>(); on(evt: string, fn: Handler): void { const list = this.map.get(evt) ?? []; list.push(fn); this.map.set(evt, list); } off(evt: string, fn: Handler): void { const list = this.map.get(evt); if (!list) return; const idx = list.indexOf(fn); if (idx >= 0) list.splice(idx, 1); } emit(evt: string, payload?: any): void { const list = this.map.get(evt); if (!list) return; // 复制一份再遍历,防止回调里 on/off 改动原数组 [...list].forEach(fn => fn(payload)); } }emit里那次数组复制不是防御性编程的洁癖。技能 A 命中后触发连锁技能 B,B 在注册阶段又往同一个事件上挂了监听,如果直接遍历原数组,JS 的forEach行为会随增删时机变化,表现为「同一次攻击偶尔多算一段伤害」。这个问题在单体测试里几乎抓不到,只有多段技能叠在一起才复现。
事件命名建议按域:动作的格式,比如turn:start、action:submit、damage:done、unit:die。订阅方只关心自己需要的域,逻辑层换实现时事件契约不变,表现层就不用跟着改。
3. 技能与 Buff 的数据驱动:配置表怎么定、结算顺序怎么排
内核跑起来之后,第二个要命的问题是技能越写越多、代码越改越乱。每加一个技能就加一个if (skillId === ...),半年后没人敢动。数据驱动的做法是把技能拆成「目标选择 + 数值倍率 + 效果列表」三段,代码只认结构不认具体技能。这套设计里的技能表就是按这个思路定的,新增技能基本只改配置,不改逻辑。下面按配置结构、伤害结算顺序、Buff 容器三块拆开说。
3.1 技能配置:一张表说清目标、倍率与效果挂载
技能配置的字段要克制。字段一多,策划填表就容易填错,程序校验也写不完。这套设计保留的核心字段是:目标类型、基础倍率、伤害类型、命中段数、冷却、效果列表。效果列表是数组,按顺序执行,顺序本身就是语义——先加攻再算伤害,和先算伤害再加攻,结果完全不同。
{ "id": "skill_charge_001", "name": "冲锋", "targetType": "enemy_single", "power": 120, "damageType": "physical", "hits": 1, "cooldown": 2, "effects": [ { "type": "damage", "scale": "atk", "ratio": 1.2 }, { "type": "buff", "buffId": "buff_stun", "chance": 0.3, "duration": 1 } ] }power是百分比形式的技能倍率,120 表示 120%,配置里统一用整数百分比,避免策划填 1.2 和 120 混用。hits是多段攻击段数,多段技能每一段单独走一次伤害管线,这样暴击、闪避可以按段判定,也方便做「每段回能」这类机制。effects里的chance是独立概率,每段单独掷,不是整场只掷一次。
效果类型用一张注册表分发,新增效果类型只需要注册一个处理器,不用改主流程:
// EffectRunner.ts export interface EffectContext { caster: Unit; target: Unit; cfg: EffectConfig; rng: RNG; } export type EffectHandler = (ctx: EffectContext) => void; const handlers: Record<string, EffectHandler> = { damage: ctx => { /* 调用 resolveDamage,走完整管线 */ }, heal: ctx => { /* 治疗,注意 clamp 到 maxHp */ }, buff: ctx => { if (ctx.rng.next() < (ctx.cfg.chance ?? 1)) { ctx.target.buffs.apply(ctx.cfg.buffId, ctx.cfg.duration); } }, }; export function runEffects(effects: EffectConfig[], ctx: EffectContext): void { for (const cfg of effects) { const handler = handlers[cfg.type]; if (!handler) { console.warn(`未注册的效果类型: ${cfg.type}`); continue; } handler({ ...ctx, cfg }); } }遍历顺序就是数组顺序,不能改成并行或乱序,否则「先加攻后结算」和「先结算后加攻」的差异会让数值对不上。未注册的效果类型选择console.warn而不是抛异常,是因为线上遇到脏配置时,让战斗继续跑完比中途崩掉更可控,但测试环境应该把这条 warn 升级成断言。
配置文件的组织方式,常见做法是按技能、Buff、单位分三个 JSON,构建时合并成一张索引表,用 id 做主键。热更时整表替换,不要做增量合并,增量合并最容易出现「旧技能引用了已删除的 Buff」这种悬空引用。
3.2 伤害结算:加算区、乘算区与防御公式的先后
伤害公式的坑不在公式本身,在顺序。同样是「攻击力 × 倍率 + 固定加成 × 百分比加成」,先加后乘和先乘后加能差出 30% 的伤害。这套设计把加成明确分成两个区:加算区(所有固定值求和)和乘算区(所有百分比连乘),固定顺序是「基础值 → 加算 → 乘算 → 防御减伤 → 暴击 → 取整」。顺序写死在管线里,不允许各技能自己拼公式。
// Damage.ts export interface DamageInput { atk: number; def: number; power: number; // 技能倍率,百分比 flatBonus: number; // 加算区总和 multiplier: number; // 乘算区连乘结果 critRate: number; critDamage: number; } export function resolveDamage(input: DamageInput, rng: RNG): number { // 1. 基础值 let dmg = input.atk * (input.power / 100); // 2. 加算区:先合并所有固定加成 dmg += input.flatBonus; // 3. 乘算区:再统一相乘 dmg *= input.multiplier; // 4. 防御减伤,用比例公式避免除零和负数 dmg *= 100 / (100 + input.def); // 5. 暴击,放在防御之后,保证暴击收益不被减伤稀释两次 if (rng.next() < input.critRate) { dmg *= input.critDamage; } // 6. 统一向下取整,最低保底 1 点 return Math.max(1, Math.floor(dmg)); }防御用100 / (100 + def)而不是dmg - def,原因有两个:减法公式在防御接近攻击力时会出现 0 伤害甚至负伤害,需要额外 clamp;比例公式天然有边际递减,防御堆到 200 时减伤 66%,堆到 400 时到 80%,不会出现「防御一过阈值就无敌」。取整统一用Math.floor,前端显示和后端计算必须用同一个取整方向,四舍五入会让 0.5 的差异在显示层放大成 1 点血。
flatBonus和multiplier由外部聚合,通常来自 Buff 容器和装备。聚合时要注意加算区可以随便加,乘算区必须明确「同类取最高」还是「全部连乘」,这两种规则在长线游戏里差别巨大,配置表里要有一个字段写明,不能靠约定。
3.3 Buff 容器:叠加、刷新与回合末清理
Buff 最容易出问题的地方是叠加规则不统一。同一个「攻击提升」,有的技能叠层、有的技能刷新时长、有的技能直接覆盖,玩家看到的图标一样,实际效果三种。这套设计把规则收敛到配置里,容器只负责执行,规则字段固定为:最大层数、是否刷新时长、每层是否独立结算。
| 规则字段 | 含义 | 常见取值 |
|---|---|---|
| maxStacks | 同 id 最大层数 | 1 / 3 / 5 |
| refresh | 再次施加是否重置时长 | true / false |
| independent | 每层是否独立跳伤 | true / false |
// BuffContainer.ts interface BuffInstance { id: string; stacks: number; remaining: number; } export class BuffContainer { private buffs = new Map<string, BuffInstance>(); apply(cfg: BuffConfig): void { const exist = this.buffs.get(cfg.id); if (exist) { // 先叠层,再决定是否刷新 exist.stacks = Math.min(exist.stacks + 1, cfg.maxStacks); if (cfg.refresh) { exist.remaining = cfg.duration; } return; } this.buffs.set(cfg.id, { id: cfg.id, stacks: 1, remaining: cfg.duration }); } /** 回合结束时调用,返回本回合过期的 buff id 列表 */ tickTurnEnd(): string[] { const expired: string[] = []; for (const [id, b] of this.buffs) { b.remaining -= 1; if (b.remaining <= 0) expired.push(id); } // 先收集再删除,避免边遍历边改 Map expired.forEach(id => this.buffs.delete(id)); return expired; } }apply里先叠层再刷新的顺序是有讲究的:如果先刷新再判层数上限,超上限的那次施加会白白重置时长。tickTurnEnd先收集再删除,是因为在部分运行时里边遍历Map边delete当前项虽然安全,但删非当前项的行为依赖实现,统一改成两段式最稳。返回过期列表是为了让上层能触发「Buff 消失时」的连锁效果,比如护盾破碎时造成范围伤害。
跳伤类 Buff 的结算时机要单独定:放在TurnStart还是TurnEnd,直接影响「中毒单位能否在死前打出最后一击」。这套设计统一放在TurnStart,理由是玩家更容易理解「回合开始先掉血」,而且能避免「回合结束跳伤致死但已经行动过」的争议。
4. 接进 Cocos Creator:节点怎么摆、脚本怎么挂、存档怎么存
内核是纯 TypeScript,不依赖引擎,这是它能被测试的前提。接进 Cocos Creator 的时候,最容易犯的错是把逻辑写进组件、把节点引用传进结算函数,导致逻辑层和表现层缠在一起。正确的边界是:组件只做三件事——初始化内核、把输入转成动作、订阅事件驱动表现。下面按节点结构、控制器脚本、存档回放三块说。
4.1 场景节点结构:表现层与逻辑层的边界
节点结构不用很深,但职责要清楚。战斗根节点挂控制器,下面分单位容器、UI 层、特效层三块。单位节点只挂表现组件,不持有战斗数据,数据从逻辑层通过事件推过来。
| 节点 | 挂载组件 | 职责 |
|---|---|---|
| BattleRoot | BattleController | 持有状态机与内核实例 |
| BattleRoot/Units | 无 | 单位节点容器,按 id 索引 |
| BattleRoot/Units/Unit_0 | UnitView | 血条、动画、受击表现 |
| BattleRoot/UILayer | BattleHUD | 行动条、技能按钮、回合数 |
| BattleRoot/FxLayer | 无 | 特效挂点,层级最高 |
UnitView只暴露一个bind(data)方法和若干播放接口,数据变更通过订阅damage:done、unit:die这类事件触发。不要在图层面板里手动拖一堆引用到控制器上,单位数量一多就维护不动,用unitRoot.children按命名规则索引更省事。
提示:
update里不要推进回合。Cocos Creator 的update每帧调用,把行动条推进放进去会让战斗速度和帧率绑定,低端机上直接变慢动作。回合推进应该由控制器在动作结算完成后主动调用。
4.2 BattleController:把三个内核组件串起来
控制器是唯一同时持有行动条、状态机、事件总线的类。它的职责是编排,不是计算。下面这段是骨架,重点看它怎么把「推进 → 等待 → 结算 → 再推进」串成一个循环,而不是递归。
// BattleController.ts import { _decorator, Component, Node } from 'cc'; import { ActionGauge, Combatant, GAUGE_FULL } from './ActionGauge'; import { BattleStateMachine, BattleState } from './BattleStateMachine'; import { EventBus } from './EventBus'; const { ccclass, property } = _decorator; @ccclass('BattleController') export class BattleController extends Component { @property(Node) unitRoot: Node = null!; private gauge!: ActionGauge; private fsm = new BattleStateMachine(); private bus = new EventBus(); private actionQueue: Combatant[] = []; start(): void { const units: Combatant[] = this.unitRoot.children.map(n => { const view = n.getComponent('UnitView') as any; return view.data as Combatant; }); this.gauge = new ActionGauge(units); this.fsm.transit(BattleState.TurnStart); this.advance(); } /** 推进到下一个可行动单位,循环而非递归 */ private advance(): void { while (this.actionQueue.length === 0) { const ready = this.gauge.tick(); if (ready.length === 0) continue; // 无人满条,继续充能 this.actionQueue.push(...ready); } const actor = this.actionQueue.shift()!; this.fsm.transit(BattleState.WaitingAction); this.bus.emit('turn:start', actor); } /** 玩家点击或 AI 决策后调用 */ submitAction(action: BattleAction): void { if (this.fsm.current !== BattleState.WaitingAction) return; this.fsm.transit(BattleState.Resolving); this.bus.emit('action:submit', action); this.fsm.transit(BattleState.TurnEnd); this.bus.emit('turn:end'); this.fsm.transit(BattleState.TurnStart); this.advance(); } }advance用while而不是递归,是为了避免一整轮无人满条时(比如全员被减速)递归几十层。actionQueue缓存同一 tick 内满条但还没行动的单位,保证它们按溢出顺序依次出手。submitAction开头的状态判断是防连点的第一道闸,玩家快速双击技能按钮时第二次提交会被直接忽略,而不是排进队列。
this.bus.emit('action:submit', action)之后,结算逻辑订阅这个事件并执行,执行完再迁TurnEnd。这里有个隐含约定:结算必须是同步的,不能在事件回调里开await。异步结算会让状态机的迁移时机不确定,回放时无法复现。
4.3 存档与回放:快照存什么、动作序列存什么
战斗存档分两种:一种是「断线重连快照」,存当前完整状态;一种是「回放记录」,存种子和动作序列。前者体积大但恢复快,后者体积小但依赖逻辑完全确定性。生产环境通常两个都存,快照用于即时恢复,动作序列用于事后核对。
// Snapshot.ts export interface BattleSnapshot { seed: number; // 随机数种子,回放时重建同一个 RNG turn: number; // 当前回合数 units: { id: string; hp: number; gauge: number; buffs: { id: string; stacks: number; remaining: number }[]; }[]; } export interface BattleRecord { seed: number; actions: BattleAction[]; // 只记录决策,不记录结果 } export function takeSnapshot(seed: number, turn: number, units: Unit[]): string { const snap: BattleSnapshot = { seed, turn, units: units.map(u => ({ id: u.id, hp: u.hp, gauge: u.gauge, buffs: u.buffs.dump(), })), }; return JSON.stringify(snap); }快照里的seed是回放能成立的前提,所有随机都必须走同一个种子驱动的 RNG,不能混用Math.random()。动作序列只记录「谁对谁放了哪个技能」,不记录伤害数字,伤害由回放时重新计算。这样做的代价是逻辑必须完全确定,好处是记录体积小、能验证逻辑一致性。
注意:序列化时不要直接
JSON.stringify整个单位对象,里面可能有对节点、组件的引用,循环引用会直接抛错。手动挑字段是麻烦,但比线上存档写失败强。
5. 避坑排查:五个让战斗结果对不上的坑
这套设计在几个小项目里跑过之后,出问题的往往不是架构,而是细节约定没统一。下面五条都是「现象 → 原因 → 解决」的格式,按我踩到的频率排序,前三条和数值随机有关,后两条和流程事件有关。
5.1 数值与随机相关的三个坑
坑一:行动条用浮点,三十回合后顺序乱了。现象是前几回合出手顺序正常,打到中后期高速单位偶尔被低速单位抢先。原因是行动条用 0~1 的浮点表示,每次充能都累加一个带小数的速度值,几十次累加后误差累积,两个本该相等的值比较结果翻转。解决办法是把行动条改成整数刻度,速度也取整数,比较用>=而不是===。这套设计里GAUGE_FULL = 1000、speed为正整数,就是为这个。
坑二:回放时伤害对不上,差一两点。现象是同一场战斗重放,某几段伤害和记录里的数字差 1。原因通常有两个:一是暴击用了Math.random(),回放时随机序列不同;二是取整方向不统一,前端Math.round、后端Math.floor。解决是把随机统一收敛到一个种子驱动的 RNG 实例,所有需要随机的地方都从它取;取整统一用Math.floor,并在伤害管线里只取整一次,中间过程保留浮点。
坑三:Buff 叠加后属性溢出。现象是叠满攻击 Buff 后,伤害突然变成负数或者异常高。原因是百分比加成在加算区被当成固定值反复相加,或者乘算区对同一个来源连乘了多次。解决是明确「加算区只放固定值、乘算区只放百分比」,并在聚合时对来源去重,同一个 Buff id 的多层只计算一次乘算系数,层数只影响数值大小。
5.2 流程与事件相关的两个坑
坑四:技能连锁触发导致无限递归。现象是某个带反伤的技能被打中后,反伤又触发对方的被动,被动再触发反伤,战斗卡死或者直接栈溢出。原因是事件总线在遍历监听器时,回调里又emit了同一个事件。解决办法是emit时复制监听器数组再遍历,同时给连锁深度加一个上限(比如 8 层),超过就丢弃并打日志。复制数组解决的是遍历错乱,深度上限解决的是真正的循环触发,两个都要有。
坑五:死亡单位还在行动队列里。现象是一个单位在本回合被击杀,但它的行动条已经满了,排在同 tick 队列里,结果「死了还打了一下」。原因是行动队列在入队时判定了存活,出队时没有再判。解决办法是出队后再判一次alive,死了就跳过并继续取下一个;同时死亡事件里要主动把该单位从待行动队列中移除,双保险。
6. 用固定种子回放对齐结果:一套可重复的验证手法
战斗逻辑写完,怎么证明它是对的?靠手点几十场不现实,靠人眼看伤害数字更不靠谱。我一般会写一个离线回放脚本,喂同一个种子和同一串动作,跑两遍比对快照,不一致就打印出第一处差异。这套手法不需要启动引擎,纯 Node 就能跑,接进 CI 之后每次改伤害公式都会自动跑一遍。
// replay.test.ts import { BattleSim } from './BattleSim'; import { RNG } from './RNG'; export function replay(seed: number, actions: BattleAction[]): string { // 用同一个种子重建 RNG,保证随机序列一致 const rng = new RNG(seed); const sim = new BattleSim(rng); for (const action of actions) { sim.resolve(action); // 每步结算内部所有随机都走 rng } return sim.snapshot(); } // 断言:同一输入两次回放结果必须完全一致 const seed = 20240611; const actions = loadRecord('battle_001.json'); const a = replay(seed, actions); const b = replay(seed, actions); if (a !== b) { throw new Error(`回放不一致:\nA=${a}\nB=${b}`); }seed选一个固定值就行,关键是同一批用例共用同一个种子,改逻辑后如果结果变了,说明这次改动影响了确定性。actions从线上记录里导,覆盖普攻、多段技能、带概率的 Buff、连锁反伤这几类,基本能把前面五个坑扫一遍。比对用字符串全等而不是逐字段比,是为了连字段顺序变化也能抓到——JSON.stringify的键顺序在对象结构稳定时是稳定的,一旦有人往快照里加了字段又忘了同步,字符串比对会立刻报出来。
再进一步,可以做「差分回放」:把旧版本的结算结果存成基线,新版本跑同样的输入,逐回合对比血量。差异出现在第几回合、哪个单位身上,基本就能定位到是哪条公式改了。这个手法在调数值的时候特别好用,策划改完配置表,跑一遍差分就知道影响面有多大,不用等 QA 回归。
还有个习惯值得养成:任何涉及随机的逻辑,先在单测里把 RNG 换成固定序列的假实现,让它按脚本返回「必暴击」「必不暴击」两组值,分别断言伤害区间。真随机在测试里是干扰项,可控随机才是可验证的。从那以后我每次改伤害管线,都强制先跑一遍固定种子回放和两组假随机单测,再谈手感。希望帮到你。
本文还有配套的精品资源,点击获取