Cocos Creator回合制战斗系统设计:状态机与数据驱动实战
发布时间:2026/9/26 11:38:33 作者:尧图编辑部 阅读量:1,286

简介CocoKnightManager 是一套基于 Cocos Creator 的开源回合制游戏系统设计面向希望快速搭建回合制战斗框架的开发者尤其适合具备一定 TypeScript 与引擎基础、想深入理解模块化架构的中高级学习者。资源包共 22 个文件约 216KB包含 7 个 json 配置、7 个 meta 元数据、2 个 ts 脚本、3 张 png 贴图以及 fire 场景、md 说明等覆盖工程配置、资源描述与核心逻辑代码。系统围绕回合管理器、行动规则引擎与状态机三大组件展开将行动顺序、指令执行与状态切换抽象为独立模块并采用数据驱动方式通过 JSON 配置角色属性、效果与事件便于非程序员调整内容。目前已有 834 人学习下载。读者可借此理解回合制流程的抽象思路、模块划分与数据配置方法并直接借鉴其目录结构与脚本组织方式用于自己的项目原型或二次开发。1. CocoKnightManager 到底在管什么回合制战斗的骨架拆解如果你用 Cocos Creator 做过回合制游戏大概率经历过这种局面角色属性散落在各个脚本里Buff 计时靠setTimeout硬撑回合推进写在按钮回调里等到要加一个「速度决定出手顺序」的机制时发现得把半个项目翻一遍。CocoKnightManager 这个标题指向的就是把这堆散装逻辑收进一套可维护的系统设计里——它不是一个现成的插件而是一套围绕 Cocos Creator 搭建回合制游戏核心层的架构思路。适合谁看已经能用 Cocos Creator 做出角色移动和碰撞、但一碰到「回合」「状态」「技能结算」就写成一锅粥的开发者。这篇笔记按「先立骨架、再填血肉、最后排雷」的顺序走每一步都落到能跑的 TypeScript 代码和可调参数上。2. 回合制核心循环从状态机到可复现的战斗流程2.1 为什么不用 update 里堆 if-elseCocos Creator 的update(dt)每帧都在跑很多人的第一反应是把回合逻辑塞进去用一堆布尔标志判断「现在该谁动」。这在只有两个角色、三种技能的原型阶段能跑通但一旦加入「眩晕跳过回合」「反击插入结算」「持续伤害在回合开始触发」这类机制标志位会膨胀到十几个调试时根本分不清当前处于哪个阶段。常见做法是引入显式状态机。回合制战斗的本质是一个确定性流程战斗开始 → 回合开始 → 结算回合前效果 → 等待玩家输入 → 执行行动 → 结算行动后效果 → 检查胜负 → 下一回合。每个箭头都是一个明确的状态状态之间的迁移由事件驱动而不是由帧驱动。这样做的好处是战斗过程可以被完整记录和回放——这在做战斗验证和 bug 复现时是救命的东西。我一般会把状态定义成枚举用一个BattleStateMachine类持有当前状态所有迁移都走一个transition(event)方法。这样任何时刻你都能打印出「当前状态 触发事件」而不是面对一堆布尔值猜谜。2.2 用 TypeScript 写一个最小可跑的回合状态机下面这段代码是骨架不依赖任何 Cocos 组件可以单独跑单元测试。把它挂到场景里的某个节点上或者作为纯逻辑模块引入都行。// BattleState.ts —— 回合制战斗的状态枚举 export enum BattleState { Idle Idle, // 战斗未开始 TurnStart TurnStart, // 回合开始结算持续效果 WaitInput WaitInput, // 等待玩家选择行动 ActionExec ActionExec, // 执行行动与技能结算 TurnEnd TurnEnd, // 回合结束清理临时状态 Victory Victory, Defeat Defeat, } // BattleEvent.ts —— 驱动状态迁移的事件 export enum BattleEvent { Start Start, TurnBegin TurnBegin, PlayerChosen PlayerChosen, ActionDone ActionDone, TurnFinished TurnFinished, CheckResult CheckResult, }// BattleStateMachine.ts —— 状态机核心 import { BattleState, BattleEvent } from ./BattleState; type TransitionTable { [key in BattleState]?: PartialRecordBattleEvent, BattleState; }; export class BattleStateMachine { private current: BattleState BattleState.Idle; private table: TransitionTable; constructor() { // 迁移表当前状态 事件 下一个状态 this.table { [BattleState.Idle]: { [BattleEvent.Start]: BattleState.TurnStart, }, [BattleState.TurnStart]: { [BattleEvent.TurnBegin]: BattleState.WaitInput, }, [BattleState.WaitInput]: { [BattleEvent.PlayerChosen]: BattleState.ActionExec, }, [BattleState.ActionExec]: { [BattleEvent.ActionDone]: BattleState.TurnEnd, }, [BattleState.TurnEnd]: { [BattleEvent.TurnFinished]: BattleState.TurnStart, [BattleEvent.CheckResult]: BattleState.Victory, }, }; } get state(): BattleState { return this.current; } can(event: BattleEvent): boolean { const next this.table[this.current]?.[event]; return next ! undefined; } transition(event: BattleEvent): BattleState { const next this.table[this.current]?.[event]; if (!next) { console.warn([BattleSM] 非法迁移: ${this.current} ${event}); return this.current; } console.log([BattleSM] ${this.current} --${event}-- ${next}); this.current next; return next; } }逻辑说明迁移表把「什么状态能接受什么事件」写死成数据而不是散落在 if-else 里。can()用于 UI 层判断按钮是否可点transition()在非法迁移时只警告不崩溃方便调试期发现问题。参数方面BattleState和BattleEvent的枚举值可以按项目需要扩展但建议保持「状态描述位置、事件描述动作」的命名习惯否则半年后自己都看不懂。2.3 回合推进的驱动方式定时器还是事件链状态机本身不推进时间它只负责「现在在哪」。真正让回合往前走的是驱动层。两种常见做法一种是用setTimeout或 Cocos 的scheduleOnce在状态迁移后延迟触发下一个事件另一种是纯事件链每个状态的处理函数在完成自己的逻辑后主动发出下一个事件。我倾向事件链原因是定时器在战斗加速、暂停、回放时会变成玄学——你永远不知道那个延迟回调在暂停期间有没有偷偷跑完。事件链的写法是TurnStart的处理函数结算完持续伤害后直接调用sm.transition(BattleEvent.TurnBegin)。这样整个流程是同步的暂停就是暂停加速就是改动画播放速度逻辑层不受影响。如果确实需要等待动画播完再推进把动画完成回调作为事件源而不是用固定延迟。比如ActionExec状态播放攻击动画动画的onComplete回调里发ActionDone。这样动画长短不影响逻辑正确性。3. 角色与技能系统数据驱动怎么落地3.1 属性、Buff、技能的数据结构设计回合制游戏的角色不是「一个血条加一个攻击力」那么简单。至少需要拆成三层基础属性力量、敏捷、智力、派生属性攻击力、防御力、速度、暴击率、运行时状态当前血量、Buff 列表、技能冷却。基础属性在战斗开始时确定派生属性由基础属性加装备加成计算得出运行时状态每回合都在变。Buff 的设计是重灾区。很多人把 Buff 写成角色身上的一个数组每个 Buff 对象带duration和effect函数。这能跑但有两个坑一是 Buff 的结算时机不明确回合开始还是回合结束二是同类 Buff 的叠加规则容易写乱。我的做法是给每个 Buff 定义timing字段取值OnTurnStart、OnAction、OnTurnEnd然后在对应状态的处理函数里统一遍历结算。叠加规则用stackRule字段控制Refresh刷新持续时间、Stack层数叠加、Ignore不叠加。技能则用配置表驱动。每个技能一条记录包含id、cost、targetType单体/群体/自身、effects效果列表。效果列表里每一项描述「对目标做什么」扣血、加 Buff、驱散。这样加新技能就是加配置不用改代码。3.2 用配置表加一个「突刺」技能假设配置表用 JSON 存放在resources/config/skills.json。下面是一个最小示例{ skills: [ { id: thrust, name: 突刺, cost: 10, targetType: single_enemy, effects: [ { type: damage, formula: atk * 1.5 - def * 0.3, element: physical }, { type: buff, buffId: bleed, chance: 0.3, duration: 2 } ] } ], buffs: [ { id: bleed, name: 流血, timing: OnTurnStart, stackRule: Stack, maxStack: 3, effect: { type: dot, formula: casterAtk * 0.2 } } ] }对应的解析和执行代码// SkillExecutor.ts —— 技能效果执行器 import { BuffManager } from ./BuffManager; interface SkillEffect { type: string; formula?: string; buffId?: string; chance?: number; duration?: number; } export class SkillExecutor { // 用 Function 构造简易公式求值生产环境建议换安全表达式解析器 private evalFormula(formula: string, ctx: Recordstring, number): number { const keys Object.keys(ctx); const vals keys.map(k ctx[k]); const fn new Function(...keys, return ${formula};); return fn(...vals); } execute(effects: SkillEffect[], caster: any, target: any, buffMgr: BuffManager) { for (const eff of effects) { if (eff.type damage) { const dmg Math.max(1, Math.floor( this.evalFormula(eff.formula!, { atk: caster.atk, def: target.def, }) )); target.hp - dmg; console.log(${caster.name} 对 ${target.name} 造成 ${dmg} 点伤害); } else if (eff.type buff) { if (Math.random() (eff.chance ?? 1)) { buffMgr.addBuff(target, eff.buffId!, caster, eff.duration ?? 1); } } } } }逻辑说明evalFormula把公式字符串转成可执行函数上下文里放atk、def等变量。参数方面formula里能用的变量名要和传入的ctx键一致否则会报undefined。chance不填默认 1即必定触发。duration不填默认 1 回合。注意Math.max(1, ...)保证最低伤害为 1避免出现「打不动」的挫败感——这是数值策划的常见约定但最好在配置层也标注清楚。3.3 Buff 结算与回合钩子的绑定Buff 的timing字段决定了它在哪个状态被结算。在TurnStart状态的处理函数里遍历所有角色的 Buff 列表挑出timing OnTurnStart的逐个执行然后层数减一归零则移除。OnTurnEnd同理在TurnEnd状态处理。这里有个容易翻车的地方Buff 结算过程中如果角色死亡后续 Buff 是否还结算我的处理是结算前检查hp 0死亡角色直接跳过并清理其所有 Buff。另一个坑是 Buff 的caster引用——如果施法者已经死亡依赖施法者攻击力的持续伤害怎么算常见做法是在 Buff 创建时快照施法者的攻击力而不是每次结算都去读施法者当前值。这样即使施法者死了流血伤害仍然按施加时的强度计算逻辑上也更符合直觉。4. 避坑与排查回合制系统设计里最容易翻车的五件事4.1 现象战斗回放时结果对不上原因逻辑层依赖了Math.random()但没有记录种子或者依赖了Date.now()这类真实时间。回放时随机序列不同导致暴击、闪避结果不一致。解决所有随机数走一个统一的Random实例战斗开始时用固定种子初始化并把种子存进战斗记录。回放时用同一种子重建Random。时间相关逻辑一律用回合计数代替真实时间。4.2 现象Buff 持续时间偶尔多一回合或少一回合原因Buff 的添加时机和结算时机不在同一个状态里。比如在ActionExec阶段添加 Buff但TurnStart的结算已经过了导致这个 Buff 实际生效了「当前回合 下一回合」两次。解决统一约定 Buff 在TurnEnd阶段添加在下一个TurnStart开始结算。添加时duration直接写期望的持续回合数结算时先执行效果再减层归零移除。这样「持续 2 回合」就是实打实两个回合开始各触发一次。4.3 现象技能公式里除零导致伤害变成 Infinity原因公式写了atk / def而某个敌人的def为 0。解决公式求值前对分母做保护或者在配置规范里约定除法一律写成atk / (def 1)。更稳妥的做法是在evalFormula里捕获异常并返回 0同时打日志报警让策划知道配置有问题。4.4 现象状态机卡死按钮点了没反应原因某个状态的处理函数抛了异常导致后续事件没有发出状态机停在原地。而异常被 Cocos 的全局捕获吞掉了控制台看不到。解决在transition()里加日志是第一步。更关键的是在每个状态的处理函数外层包 try-catch捕获后强制迁移到一个Error状态或回退到WaitInput避免整个战斗卡死。同时把异常信息打到显眼位置别让它静默消失。4.5 现象打包成 APK 后战斗逻辑变慢或行为异常原因Cocos Creator 打包 APK 时new Function在部分安卓 WebView 环境下受 CSP 限制会抛异常导致公式求值失败。另外console.log在真机上开销比编辑器大得多大量日志会拖慢帧率。解决生产构建时把公式求值换成预编译的表达式解析器或者干脆在构建阶段把公式转成静态函数。日志用开关控制release 版本关闭console.log。这个坑在编辑器里完全看不出来只有真机跑才暴露属于典型的「打包 APK 后才翻车」场景。5. 进阶技巧用战斗日志做确定性验证与数值调试做到这里系统能跑了但你怎么确认它「跑对了」我的习惯是给战斗系统加一个日志层每次状态迁移、每次伤害结算、每次 Buff 增减都写一条结构化记录。这些记录不只是给调试看的它们可以变成验证工具。具体做法定义BattleLogEntry类型包含turn、state、actor、action、value等字段。战斗结束后把日志序列化成 JSON存到本地或上传到测试服务器。然后写一个校验脚本读入日志检查几件事回合数是否单调递增、每个角色的血量变化是否与伤害记录一致、Buff 的添加和移除是否配对。这相当于给战斗逻辑做了一次「对账」。更进一步你可以用日志做数值调试。比如策划说「突刺技能太强了」你不用反复改配置跑游戏而是把历史战斗日志喂给一个模拟脚本批量重算不同攻击系数下的战斗结果看胜率曲线怎么变。这比手动试快得多。下面是一个日志条目和校验脚本的骨架// BattleLogger.ts —— 结构化战斗日志 export interface BattleLogEntry { turn: number; state: string; actor: string; action: string; target?: string; value?: number; extra?: Recordstring, any; } export class BattleLogger { private entries: BattleLogEntry[] []; log(entry: BattleLogEntry) { this.entries.push({ ...entry }); } export(): string { return JSON.stringify(this.entries, null, 2); } // 校验血量变化是否与伤害记录一致 validate(): string[] { const errors: string[] []; const hpMap: Recordstring, number {}; for (const e of this.entries) { if (e.action damage e.target e.value) { hpMap[e.target] (hpMap[e.target] ?? 0) - e.value; } if (e.action heal e.target e.value) { hpMap[e.target] (hpMap[e.target] ?? 0) e.value; } } // 这里只做示例实际应与角色最终血量比对 console.log([Validate] 累计血量变化:, hpMap); return errors; } }参数说明turn用回合计数而非时间戳保证回放一致性。value对伤害为负、治疗为正统一符号方便累加。extra放元素类型、是否暴击等附加信息校验脚本可以按需读取。validate()目前只打印累计值实际项目中应该和战斗结束时的角色快照做比对不一致就报错。我踩过的一个坑是日志写得太细一场战斗几万条导出 JSON 几十兆手机存储直接爆。后来改成只记录关键节点——状态迁移、伤害结算、Buff 增减、死亡——普通属性计算不记。这样一场战斗几百条既够用又不占地方。另一个习惯是每次改完战斗逻辑先跑三场固定种子的战斗对比日志差异。如果差异只出现在预期改动的地方说明没引入意外副作用如果别的地方也变了那就有隐藏的耦合。这个习惯帮我省下了大量「改 A 坏 B」的排查时间。希望帮到你。本文还有配套的精品资源点击获取