Cocos Creator回合制战斗框架:管理器拆分、事件总线与状态机设计指南
发布时间:2026/10/15 16:35:52 锦皓数字建站

简介CocoKnightManager是一款基于Cocos Creator的回合制游戏系统设计开源项目面向希望高效搭建回合制玩法逻辑的游戏开发者重点解决战斗流程、角色状态管理、行动顺序与规则判定等模块的复用与扩展问题。资源包共包含22个文件约216KB涵盖TypeScript脚本、JSON配置、Meta资源索引、PNG预览图及Markdown说明文档等整体结构与Cocos Creator工程目录对应便于直接导入工程或按模块查阅。目前已有834人浏览学习适合初中级开发者通过源码了解回合制游戏的核心架构。项目围绕模块化与数据驱动设计提供了回合管理器、行动规则引擎、状态机等关键组件开发者可据此快速掌握从行动排序、指令执行到状态切换的完整流程并在现有基础上自定义攻击、防御、技能等规则适配不同策略深度是学习Cocos Creator回合制游戏设计的实用参考资料。1. CocoKnightManager 是什么回合制系统的第一刀切在哪做回合制游戏最容易翻车的往往不是美术表现也不是数值平衡而是战斗流程里绵延不绝的「顺序问题」——谁先手、谁结算、buff 先扣还是后加、死亡的角色还能不能吃到治疗。CocoKnightManager 这个名字拆开看就是一套基于 Cocos Creator 的回合制游戏管理器框架把战斗、角色、技能、关卡资源这些模块拆成独立管理器用清晰的生命周期和事件路由来维持它们之间的协作关系。它的核心思路不是教你写某一个战斗动画而是告诉你「系统设计这一刀应该切在哪」哪些逻辑归战斗管、哪些归 UI 管、哪些数据允许被跨模块直接读、哪些必须通过事件通知。本文要解决的问题很直接——用 Cocos Creator 做回合制代码该按什么粒度拆分流程怎么驱动参数怎么定以及最后怎么验证你的系统没有埋雷。适合已经会用 Creator 写基础场景、但没完整搭过一套回合制框架的从业者。2. 回合制核心模块怎么拆管理器注册、调度与生命周期2.1 为什么回合制适合「管理器 事件总线」而不是「组件互相引用」很多人在 Cocos Creator 里写回合制习惯做法是直接在战斗场景里挂一堆组件A 技能要调用 B 角色、C 血条要监听 D 行为组件之间用property互相拖引用。好处是写起来快坏处是三个月后项目膨胀任何一次改动都要顺着引用链翻一遍而且战斗回放、自动化测试根本无从下手。回合制游戏有一个天然优势——它是离散的。不存在每帧都要同步的物理模拟所有行为都能拆成一个一个「指令」A 角色选择技能、B 角色受到伤害、C 角色阵亡。离散系统最适合用「管理器 事件总线」驱动。管理器的职责是对外提供能力事件总线负责模块间通信模块之间不知道彼此的存在只知道自己发出了什么事件、关心什么事件。// 事件总线整个框架的心脏所有跨模块通信走这里 export class EventBus { private static listeners: Mapstring, SetFunction new Map(); static on(event: string, cb: Function, target?: any) { if (!this.listeners.has(event)) { this.listeners.set(event, new Set()); } this.listeners.get(event)!.add(cb.bind(target)); } static off(event: string, cb: Function) { this.listeners.get(event)?.delete(cb); } static emit(event: string, data?: any) { this.listeners.get(event)?.forEach(cb cb(data)); } }这段代码是整个 CocoKnightManager 框架的地基。on注册监听off注销监听emit广播事件。你可能会问这和直接用 Cocos Creator 的EventTarget有什么区别区别在于这层封装可以在事件触发时统一加日志、加错误捕获方便后面做战斗回放。实际项目中我会把emit里包一层try/catch——某个监听器抛异常不应该阻断其他监听器执行这在多技能结算时尤其重要。2.2 五个核心管理器各自的职责边界我一般会把一个回合制项目切成 5 个管理器BattleManager战斗主流程、RoleManager角色与阵容、SkillManager技能与 Buff、LevelManager关卡与敌人配置、AudioManager音效与视觉反馈。其中 AudioManager 容易被忽略但它的存在恰恰是为了把「表现」从「逻辑」里彻底赶出去。管理器核心职责不负责的事BattleManager回合推进、出手顺序、胜负判定具体伤害数值公式RoleManager角色属性、装备、编队战斗中的瞬时状态SkillManager技能释放、目标选定、buff 挂载与结算角色移动与动画LevelManager关卡怪物配置、掉落、波次角色养成AudioManager播放音效、驱动动画、飘字一切数值运算划分边界的判断标准只有一个这条逻辑如果不依赖表现层能不能在纯函数环境里跑通能就归逻辑管理器不能就归 AudioManager。所以 RoleManager 只负责数据读取不负责血条刷新血条刷新是 BattleManager 发出hp_change事件后UI 组件自己监听处理的事。2.3 管理器注册顺序为什么初始化顺序会坑掉整个框架CocoKnightManager 里每个管理器都继承同一个基类基类定义init、reset、destroy三个生命周期方法。初始化顺序不能乱先事件总线再 RoleManager数据底座再 SkillManager依赖角色数据再 LevelManager需要从角色数据里拿练度配怪最后 BattleManager编排上面所有模块。// 管理器基类统一生命周期 export abstract class BaseManager { abstract init(data?: any): void; abstract reset(): void; abstract destroy(): void; protected log(msg: string) { console.log([${this.constructor.name}] ${msg}); } } // 游戏入口串行初始化 const managers [ new RoleManager(), new SkillManager(), new LevelManager(), new BattleManager(), ]; managers.forEach(m m.init());这里有个坑init和reset容易被混用。我的约定是init只在整个游戏生命周期里调用一次负责挂事件监听reset负责清空上一局数据——角色血量拉满、buff 列表清空、回合数归零。如果你在 init 里又清数据又挂监听第二局开始时会重复挂监听事件回调执行两遍伤害翻倍这种「灵异现象」就是这么来的。2.4 事件命名的三个强制规范绕开事件总线耦合问题的最后一块拼图是事件命名。项目里我强制要求以下规范代码评审时直接照着查事件名一律模块名_动作_对象例如battle_turn_start、role_hp_change、skill_cast_begin。事件载荷只允许传纯数据对象禁止传组件引用。传组件引用等于把事件总线重新变成了强耦合。API 级事件用枚举而不是裸字符串IDE 里能补全重构时不会漏改少部分临时调试事件可以先用字符串但要带debug_前缀。// 事件枚举集中管理别散落各处 export enum BattleEvent { TurnStart battle_turn_start, TurnEnd battle_turn_end, RoundStart battle_round_start, RoundEnd battle_round_end, FightEnd battle_fight_end, }3. 战斗流程设计回合状态机与多技能结算顺序3.1 用状态机而不是回调地狱驱动回合流程回合制战斗流程如果写成一串嵌套回调前期看着流畅一旦加入「中毒回合结束扣血」「眩晕跳过行动」「反击插入到攻击前后」这些规则回调嵌套会直接演变成灾难。CocoKnightManager 的做法是用一个状态机描述战斗的大阶段每个阶段内部再拆小步骤。export enum BattlePhase { Ready ready, // 战前准备 TurnStart turn_start, // 回合开始buff 结算 Action action, // 角色行动 TurnEnd turn_end, // 回合结束debuff 结算 Victory victory, Defeat defeat, } export class BattleFSM { private phase: BattlePhase BattlePhase.Ready; private handlers: RecordBattlePhase, () void { [BattlePhase.Ready]: () this.onReady(), [BattlePhase.TurnStart]: () this.onTurnStart(), [BattlePhase.Action]: () this.onAction(), [BattlePhase.TurnEnd]: () this.onTurnEnd(), [BattlePhase.Victory]: () this.onVictory(), [BattlePhase.Defeat]: () this.onDefeat(), }; change(phase: BattlePhase) { this.phase phase; this.handlers[phase](); } }状态机的价值不是「看起来整齐」而是每个阶段只有一个入口和出口。比如行动阶段内部要处理完当前角色的全部技能指令才允许切到 TurnEnd——这种约束天然防止了「上一个角色的结算还没结束下一个角色就开始出手」的竞态问题。实际项目中我会在change()里加一行console.log([BattleFSM], this.phase)在编辑器里盯着控制台看流程推进排查顺序错误时会省很多事。3.2 技能结算队列为什么普通思维写出来的结算顺序必出 bug回合制里最隐蔽的问题出在「多个效果同时在同一个时间点触发」。假设 A 攻击 BC 为 B 挡刀同时 A 的攻击附带吸血B 身上挂着反伤——结算顺序不同A 可能死也可能活。用直觉思维按技能写的顺序逐行执行必然会在某个边界 case 上翻车。解决办法是引入「结算队列 优先级」概念每个技能动作拆成一个一个EffectItem按优先级排序后依次入队执行。interface EffectItem { priority: number; // 越小越先执行 name: string; execute(): void; } class EffectQueue { private queue: EffectItem[] []; push(item: EffectItem) { this.queue.push(item); this.queue.sort((a, b) a.priority - b.priority); } run() { while (this.queue.length 0) { const item this.queue.shift()!; item.execute(); } } }这里priority的设计是整个系统的关键参数。项目里有一个固定的优先级表修正类效果如加伤减伤 直接伤害 治疗 护盾 召唤 反击 追击 触发类被动。不同游戏的逻辑不同但存在一张明确写死的表且能让所有策划查阅比「临时在代码里调数字」要可靠得多。要特别注意的是同优先级内必须有稳定的次序一般用入队序号seq做第二排序键否则同一个技能打到两个不同目标身上结算顺序每次都可能不一致测试会疯掉。3.3 回合驱动的最小可跑命令跳过表现层直接出结果很多人在搭建战斗框架时会把 UI 表现和逻辑混在一起调试结果每次想验证一条规则都要先准备好动画、音效、飘字。更稳妥做法是把战斗逻辑核心写成不依赖任何编辑器节点和组件引用的纯函数模块挂到任意脚本里就能跑。# 用 ts-node 直接在命令行跑战斗逻辑不需要打开 Cocos Creator npx ts-node test/simulate_battle.ts --rounds 3// test/simulate_battle.ts无头战斗模拟 import { BattleManager } from ../src/managers/BattleManager; import { RoleManager } from ../src/managers/RoleManager; import { SkillManager } from ../src/managers/SkillManager; // 初始化三个核心管理器不触碰任何 UI new RoleManager().init(); new SkillManager().init(); const battle new BattleManager(); battle.init(); // 按固定种子初始化保证每次跑的结果可复现 battle.setRandomSeed(12345); battle.startFight([hero_001], [monster_001]);这里的关键是setRandomSeed(12345)——很多回合制战斗过程带随机数暴击、闪避、技能浮动都会让同样的输入产生不同的结果。一旦接了回放系统随机种子不一致会导致录制的指令全部无效。参数说明上rounds 3表示只模拟 3 回合适合快速跑通主流程排错时建议固定种子后反复跑同一场战斗比较输出的日志差异。4. CocoKnightManager 落地避坑配置、UI 与调试的三个重灾区4.1 坑一角色属性配置散落在代码里数值想调只能发版现象某天策划说「骑士血量减 5%攻击加 3%」你发现这些数字散落在三个组件和两个 Json 文件里还混着几处硬编码常量「等下172 这个数哪来的应该是某次测试临时改的」。每次临时调数值都得重新构建产物发布周期被拖了两天。原因没有把「角色属性」和「技能数值」抽离成统一配置表。解决回合制项目我建议从第一天就建立一张配置表JSON 或 CSV 均可角色初始攻击、防御、血量全部走配置读取。结构大概是{ id: hero_001, name: 骑士, hp: 1200, atk: 85, def: 60 }。属性读写统一走 RoleManager 的getRoleProp / setRoleProp接口禁止任何组件直接改角色属性字段。后续加上 Buff 属性修正时只需要在 RoleManager 内部维护一层「基础值 修正值」的叠加逻辑外部无感知。4.2 坑二战斗 UI 直接操作角色节点逻辑层无法独立测试现象回合逻辑跑不通你想在命令行里复现结果发现技能伤害的计算函数里面const label this.hpLabel离开场景节点就报 Null。逻辑和 UI 在代码层面完全焊死导致自动化测试和调试工具全线瘫痪。原因战斗逻辑代码直接引用了 UI 组件和节点。解决用事件驱动替换直接引用。技能命中时逻辑层只负责发battle_hit事件并携带{ targetId, damage }血条、飘字、受击动画全部由 UI 组件自行监听事件并响应。只要伤害数值计算不触碰任何 Node 对象无头模拟就能跑起来这是整套框架能验证的前提。4.3 坑三兵败如山倒——战斗结束判定过于激进现象Boss 还剩 1000 血角色一个连击把 Boss 血量打到 -20系统直接判胜利但 UI 上 Boss 阵亡动画还在播放玩家还差一个成就条件「单次伤害超过 2000」没有记录上。原因在伤害结算过程中就做了胜负判定而后续还有「伤害记录」「成就计数」「掉落结算」等步骤未执行。解决胜负判定必须在整轮动作全部结算完成之后统一做从状态机上看就是只在TurnEnd阶段检测全角色血量判定通过后再发fight_end事件让关卡层拉起结算面板。战斗中任何血量变化只做数值更新和 UI 刷新不作为胜负依据。4.4 坑四Buff 重复结算与生命周期混乱现象毒伤害在回合开始结算一次又在回合结束结算一次眩晕效果本应在第二次行动前消失结果第三回合还能控住人。原因Buff 的「生效时机」「持续时长」没有统一定义。有的 Buff 按回合数计算有的按行动次数计算混在一起没有规范。解决给 Buff 明确四个属性类型、剩余持续时间按回合还是按行动、叠加规则、生效时机。在 SkillManager 里统一注册每个 Buff 的触发点turn_start/action_begin/action_end/turn_end只在注册过的触发点上结算。这个表的定期复查要和数值策划一起——他们最容易觉得 Buff 不好用然后绕过主动作系统直接改状态字段把病毒扩散到整个战斗结算流程。4.5 坑五读表时类型强转出错数值全对但逻辑全崩现象配置表里def: 60读出来发现是字符串60加法变拼接减伤计算成了60 - 20得出 NaN整个战斗流程卡死。原因JSON 转 TS 对象时缺少运行时类型校验。接口字段变化时没有兜底报错。解决读取配置时统一走一个映射函数包一层类型校验。类型不符直接抛异常且带上字段名宁可启动时报错也不要运行到一半数据悄悄变脏。function requireNumber(value: any, field: string): number { const n Number(value); if (Number.isNaN(n)) { throw new Error(配置字段 ${field} 期望 number实际为 ${value}); } return n; }5. 用战斗日志与回放验证你的回合系统一个值得投入的排查技巧5.1 无头模式下录制指令流让每次战斗都能重演回合制系统调试最大的痛点是「随机数」和「时序」。要解决这个问题最可靠的做法是把整场战斗的指令流——谁在什么时机对谁用了什么技能——序列化记录下来并把Math.random()的种子一并记录。重演时用同一个种子、同一条指令序列去驱动逻辑层就能精确复现战斗过程。// 指令录制器挂在 BattleManager 内部包一层 battle 操作 export class BattleRecorder { private frames: any[] []; private seed: number; constructor(seed: number) { this.seed seed; } recordAction(actorId: string, skillId: string, targetIds: string[]) { this.frames.push({ t: Date.now(), actorId, skillId, targetIds, }); } export() { return JSON.stringify({ seed: this.seed, frames: this.frames }); } }seed参数直接传给任何依赖随机的纯函数比如暴击判定isCritical(seedSeq)。种子固定后同样的指令序列必然导向同一份战斗结果谁的伤害怎么播、谁在第几步死、最终胜负如何全部可查。排查问题时的标准操作是——用户反馈 bug本地复现导出指令流用脚本重演战斗并打印出每一步结算日志找差异点。5.2 运行时打印决策日志给战斗过程装上黑匣子很多战斗 bug 具有「间歇性」特征开着调试器就复现不出来。我的习惯是项目从第一天就常驻一个战斗日志模块每次伤害结算都打印结构化日志[turn 3] hero_001 使用 十字斩 - monster_001 伤害 342 (暴击) [turn 3] monster_001 使用 撕咬 - hero_002 伤害 108 附加 流血 [turn 3] 流血结算 hero_002 受到 15 伤害这些日志平时不打扰正式游戏但在开发阶段全部输出到控制台。一旦收到线上异常反馈就直接去查对应时间段的日志文件而不是让玩家描述「我当时好像点了什么」。日志的格式有一个细节每一行都要带回合数等于给系统建立了一个时间坐标系也是回放系统的调试基础。没有回合数的时间戳翻起日志来就是一团乱麻。5.3 自定义断言把「应该发生的事」写进测试脚本我最后要做的一件事是把「系统应该怎样工作」固化成可运行的脚本断言而不是靠人肉盯日志。比如验证「一个 3 回合的流血 buff 恰好触发 3 次」「击杀敌人后经验金币事件恰好发一次」「角色死亡后不再响应治疗」。每一条都是一次回归测试跑在无头模式下输入固定种子输出对战数据脚本自动比对期望值。import { assert } from console; // 模拟一场战斗主角 3 回合击败怪物 const battle runSimulation(hero_001, monster_001, { maxRounds: 3 }); assert(battle.victor hero, 主角应该获胜); assert(battle.roundNumber 3, 不应该超过 3 回合); assert(battle.logs.filter(l l.type crit).length 0, 应该有暴击记录);这些断言挂在 CI 上每次改动之前先跑一遍比任何临时手工测试都可靠。说句实话我当年第一次搭这个框架时也觉得写测试很慢但靠手工测试漏掉一个「击杀后复活再被投放技能」的边界 bug排查了两天。从那以后我盯着 CI 里跑战斗测试的绿色对勾才会放心发版——这套日志与回放系统覆盖的验证习惯后来帮我在多次调数值和加新技能时免掉了大量返工。以上是 CocoKnightManager 从模块拆分到流程驱动、从配置管理到验证回放的一整套落地路径。这里面的每一个设计——事件总线、状态机、结算队列、种子复现、指令录制——都不是高深莫测的东西而是每一道工序里实打实用的土办法。我的习惯是每写一个管理器先问自己「这条逻辑拿掉表现层还能跑吗」能跑就放进核心框架。这样在 Creator 里做回合制战斗时才能把绝大多数时间留给玩法和数值设计而不是跟只出现一次的时间线 Bug 搏斗。希望帮到你。本文还有配套的精品资源点击获取
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。