简介:CocoKnightManager是一套基于Cocos Creator引擎的回合制游戏系统设计资源,面向希望快速搭建回合制玩法并学习系统架构的开发者。资源以模块化理念组织,覆盖回合流程控制、角色行动规则、状态管理等关键环节,能够帮助开发者理解战斗系统、状态机与事件处理之间的协作方式。压缩包共22个文件,以JSON配置、TypeScript脚本及meta场景资源为主,附带PNG素材与说明文档,整体仅216KB,便于快速阅读与复用。资源内场景、脚本与配置目录划分清晰,适合按模块对照学习。已有834人浏览学习。通过该资源,读者可获得回合管理器、行动规则引擎、状态机等核心组件的实现思路,并了解如何以JSON数据驱动方式配置角色属性、效果与事件,从而省去从零搭建基础框架的时间;对刚开始接触Cocos Creator回合制开发的初学者,或需要参考模块化设计的进阶开发者,都是不错的参考。
1. 项目定位:CocoKnightManager到底解决什么问题
去做回合制战斗,八成的人会被同一个问题恶心到:逻辑不复杂,写着写着就成了一团浆糊。技能要等动画播完再扣血,扣完血要判断目标死活,死了要触发下一个技能,没死就套buff……每一步都在跟“下一个动作”耦合,改一个技能,三四个文件跟着遭殃。CocoKnightManager是一个基于Cocos Creator的回合制游戏系统设计项目,核心玩法就是把这套战斗拆成几个互相独立的管理器:战斗只负责播报状态,角色只负责提供属性,技能只负责算伤害。三层之间用事件对接,系统开源的结构里你能直接拿走这套骨架,搬进卡牌、RPG或者战棋项目都能用,不用再写“回调套回调”的战斗逻辑。适合正在做回合制战斗、又不打算在战斗流程里继续踩坑的人。
2. 系统架构:分层设计与管理器职责分配
2.1 模块分层:数据、逻辑、表现各管一段
我拆完这个项目的第一反应是:它把“管理器”这三个字用得很克制。没有把战斗逻辑全堆在一个 BattleScene 组件里,而是按数据、逻辑、表现三个层级切干净。数据层就是一堆纯数据类和 JSON 表,只负责“世界是什么样”;逻辑层是几个管理器单例,只负责“事情怎么发生”;表现层则是场景里的节点、动画和 UI 组件,只负责“玩家看到了什么”。
这样做最直接的好处是:一条技能要改伤害倍率,不需要打开场景编辑器,改 JSON 表就行;一条规则要换顺序,不需要动动画代码,改 TurnManager 就行。回合制游戏的核心复杂度就在于状态与行为的交错,把状态留在数据类里、把行为拆到各管理器,交错就被压到最小的接口面上,而不是散落在几十个回调里。
在数据层里,RoleData 记录攻击、防御、血量、速度、能量这些基础属性;技能倍率、消耗、冷却放在 SkillConfig 里;Buff 的持续回合和叠加规则放在 BuffConfig 里。这里刻意不写业务逻辑,比如“防御减伤怎么算”不属于数据层,只在 SkillManager 内部用。这样角色数据的来源和去向都是明确的,不会出现“角色属性被某个UI组件偷偷改了”这种查半天查不到的鬼事。
逻辑层是这套设计的骨架,一共五个管理器,各自职责可以从这张表看明白:
| 管理器 | 核心职责 | 对外接口说明 |
|---|---|---|
| BattleManager | 战斗开始/结束、胜负判定 | startBattle()、checkEnd() |
| TurnManager | 回合切换、行动方裁决 | next()、getCurrentSide() |
| SkillManager | 技能注册、释放、伤害结算 | registerSkill()、cast() |
| BuffManager | Buff 添加、刷新、过期清理 | addBuff()、onTick() |
| EventBus | 模块间事件广播与订阅 | on()、emit()、off() |
以 SkillManager 为例,它的 cast 接口接收施法者、目标列表、技能 id,返回一个结构化的结算结果,而不会直接去改 UI。BattleUI 那边只需要订阅一个“角色扣血”的消息,把数字贴到血条上。哪怕把整套技能重做,UI 层一行代码都不用动。
表现层反而最薄。场景里每个角色节点只做三件事:播待机动画、播受击动画、显示飘字。至于谁打谁、什么时候打,全部通过 EventBus 收消息,节点自己不管战斗规则。这种设计对美术换资源特别友好,换一套特效或者换套骨骼动画,完全不用碰逻辑代码。
三个层级加在一起,就是一份可以独立替换任意单层的系统设计:你想把 2D 战斗换成 3D 模型,只需要换表现层;你想把回合改成半即时制,只需要动 TurnManager 和 BattleManager。这正是系统开源项目该有的样子——每一层都能单独拿出来二次开发。
2.2 目录结构:拿到手先知道去哪改
项目里的脚本按功能分目录,而不是按“界面”分目录,这一点是我比较欣赏的。先看目录结构:
assets/ ├── scenes/ │ └── Battle.scene # 战斗主场景 ├── scripts/ │ ├── core/ # 逻辑层:管理器全集 │ │ ├── BattleManager.ts # 战斗流程与胜负判定 │ │ ├── TurnManager.ts # 回合状态机 │ │ ├── SkillManager.ts # 技能释放与伤害计算 │ │ ├── BuffManager.ts # Buff 生命周期管理 │ │ └── EventBus.ts # 跨模块事件总线 │ ├── data/ # 数据层:运行时数据容器 │ │ ├── RoleData.ts # 角色属性类 │ │ ├── SkillConfig.ts # 技能配置类 │ │ └── BuffConfig.ts # Buff 配置类 │ └── ui/ # 表现层:UI 与角色表现 │ ├── BattleUI.ts # 血条/按钮/日志面板 │ └── RoleView.ts # 角色节点动画控制 └── config/ ├── roles.json # 角色基础属性表 ├── skills.json # 技能倍率与消耗表 └── buffs.json # Buff 数值与持续回合表这个结构的信息量在于它把“类型”和“配置”分开了。RoleData 是运行时对象,roles.json 是静态表;如果你想在战斗里临时给一个角色加 20% 攻击力,改的是 RoleData 的实例字段,而不是去改 JSON。如果你想调整所有新人的初始血量,改 roles.json 就够了。
我第一次跑这个项目时,改血量的路径是:打开 config/roles.json,找到目标角色的 hp 字段,改成需要的值。保存后回到 Cocos Creator,资源会自动刷新,重新预览场景就生效了。整个修改没有碰一行逻辑代码,这就是数据驱动设计带来的改动效率:配置层的东西永远不要散落到代码里。
还有一个小细节:EventBus 被放在 core 里而不是 data 里。它是逻辑层的一部分,负责在各管理器之间传递“角色倒下”“回合结束”这类消息。项目里约定事件名全部用字符串常量,比如 ROLE_DAMAGED、TURN_CHANGED,字符串常量放在 EventBus 文件头部统一导出,这样全局搜事件名不会搜出多个五花八门的写法,重构时也只需要改一处。
3. 核心实现拆解:回合流转、技能释放与伤害结算
3.1 回合状态机:从“战斗开始”到“回合结束”
回合制游戏的底层是一台状态机,这也是整个项目里最值得先读的部分。CocoKnightManager 的 TurnManager 把流程切成 BattleStart、PlayerTurn、EnemyTurn、RoundEnd 四个阶段,每次调用 next() 就往前推进一格。看懂这个流转,比看懂任何单一文件的代码都重要。
// TurnManager.ts —— 回合状态机核心 import { BattlePhase } from '../data/BattlePhase'; export class TurnManager { private phase: BattlePhase = BattlePhase.BattleStart; private round: number = 1; private currentSide: Side = Side.Player; /** 手动推进一次阶段 */ next(): void { switch (this.phase) { case BattlePhase.BattleStart: this.phase = BattlePhase.PlayerTurn; this.currentSide = Side.Player; this.emitTurnChanged(); break; case BattlePhase.PlayerTurn: this.phase = BattlePhase.EnemyTurn; this.currentSide = Side.Enemy; this.emitTurnChanged(); break; case BattlePhase.EnemyTurn: this.round++; this.phase = BattlePhase.PlayerTurn; this.currentSide = Side.Player; this.emitRoundChanged(); this.emitTurnChanged(); break; default: console.warn('当前阶段无法推进:', this.phase); } } }这里的核心思路是:阶段推进只发生在 TurnManager 内部,外部代码不能直接把 phase 改成 EnemyTurn,只能通过 next() 来推。这样做的意义在于,所有回合切换的收尾工作都集中在一个方法里,不会出现“A 处切了回合、B 处忘了重置能量”这类散落式 bug。
几个关键点可以展开说:round 从 1 开始,表示当前是第几回合;currentSide 记录行动方,UI 层就是根据这个值决定亮哪个按钮;emitTurnChanged 会通过 EventBus 广播“回合变更”消息,SkillManager 和 BuffManager 收到消息后各自做回合结算,比如清理“持续 1 回合”的 buff。我实际调试时经常在 emitTurnChanged 这一行打日志,观察 state 变化,比在控制台反复翻 UI 日志快得多。
在玩家回合里,角色行动完,需要由 BattleManager 决定是继续让下一个角色行动,还是整回合结束。常见做法是 BattleManager 维护一个待行动队列,当前行动者执行完技能后调用 turnManager.next() 推进阶段。你改这个项目时,如果想做“每个角色各自行动一次”的回合制,可以把 PlayerTurn 再拆成多个子阶段,但状态机的推法不用变——仍然是一格一格前进,只是 next() 里多一个队列判断。
3.2 技能释放与伤害结算:从注册到命中
技能系统是这套设计里第二个值得读的部分。SkillManager 用一张 Map 在运行时管理所有技能配置,角色发技能只传一个技能 id,具体倍率和消耗全查表。这样做的好处是技能数量再多,代码量也不涨,新技能只是 JSON 里的一行。
// SkillManager.ts —— 技能注册与释放入口 import { SkillConfig } from '../data/SkillConfig'; import { RoleData } from '../data/RoleData'; export class SkillManager { private skillTable: Map<string, SkillConfig> = new Map(); /** 注册技能:通常在战斗初始化时调用 */ registerSkill(config: SkillConfig): void { this.skillTable.set(config.id, config); } /** 释放技能,返回结构化结算结果 */ cast(caster: RoleData, targetIds: string[], skillId: string): CastResult { const skill = this.skillTable.get(skillId); if (!skill) { console.warn('技能未注册:', skillId); return { ok: false, hits: [] }; } if (caster.energy < skill.cost) { return { ok: false, hits: [], reason: '能量不足' }; } caster.energy -= skill.cost; const hits: DamageHit[] = []; for (const targetId of targetIds) { const target = this.findRole(targetId); if (!target || target.hp <= 0) continue; hits.push(this.calcDamage(caster, target, skill)); } return { ok: true, hits }; } /** 伤害计算:倍率 × 攻击 / 防御减免 / 暴击 / 浮动 */ private calcDamage(caster: RoleData, target: RoleData, skill: SkillConfig): DamageHit { const base = skill.power * caster.attack; const mitigated = base * (100 / (100 + target.defense)); const isCrit = Math.random() < caster.critRate; const finalDamage = mitigated * (isCrit ? 2 : 1) * (0.9 + Math.random() * 0.2); return { targetId: target.id, damage: Math.floor(finalDamage), isCrit }; } }cast 在动手前先做了两件事:查表确认技能存在、判断施法方能量是否够。我在改这个项目时习惯把“能量不足”这类业务失败和“技能不存在”这类配置错误分开,前者是正常的玩家行为,后者是开发期错误。开发期错误用 console.warn 打出来就够了,业务失败则要返回 reason,让 UI 层决定弹什么提示——这两者混在一起是最容易误导排查的。
伤害公式里三个参数值得细调。skill.power 是技能倍率,通常 1.0 就是普攻,2.0 以上是爆发技能;target.defense 不能太大,我用的是 100/(100+defense) 这种经典减伤形式,防御 100 时减伤 50%,再往上收益递减,属于相对平滑的曲线;critRate 是暴击率,暴击伤害直接翻倍,配合最后的 0.9~1.1 浮动,一局战斗的伤害跳字就不会是教科书式的整数,看着更自然。
另外注意,命中目标时跳过了 hp<=0 的目标,避免打尸体。这个判断放这里是刻意的——战斗里“目标死亡”是一个正常状态,不该让技能抛异常,而是默默跳过。很多回合制项目在选择目标时就算一遍、命中时再算一遍,两处逻辑还容易不一致,这套设计把“是否存活”的判断收敛在 cast 内,改起来只动一个地方。
4. 从零跑通:环境准备、导入与调试路径
4.1 环境准备与项目导入
跑这个项目之前,你需要一个 Cocos Creator 3.x 版本,我习惯用 3.x 的稳定版。有一点提醒:Cocos Creator 的项目是比较挑版本的,3.x 打开 2.x 项目会有一堆迁移提示,反之更惨。所以第一次导入前先确认自己装的版本和项目标注的编辑器版本在同一个大版本内,这一步能省掉后面大量的“为什么我打开是黑屏”的问题。
具体步骤分四步走。第一步,新建一个空项目,选择“空白”模板即可,这个系统设计不需要额外的插件或第三方库,TypeScript 和内置 UI 组件就够用。第二步,把下载解压后的 assets、settings、package.json 三个文件夹/文件拷贝到新建项目的根目录,选择覆盖,再回到 Cocos Creator 里等待资源刷新。
第三步,打开 assets/scenes/Battle.scene,等场景里的资源加载完。这时你会看到战斗场景里有两个阵营的站位节点、一个 Log 面板和一个 Canvas。如果控制台没有红色报错,说明资源导入成功。第四步,点编辑器上方的预览按钮,在浏览器里启动游戏,初始画面会出现一个“开始战斗”按钮,点它就会进入第一回合。
注意:拷贝资源时如果原来的 settings 里有别的编辑器版本配置,项目可能会提示“打开失败”。常见做法是直接删掉 settings,让 Cocos Creator 自动重新生成一份,再打开场景就行。我跑很多开源项目都顺手用这个姿势处理。
4.2 运行入口与日志调试
战斗的入口不在场景上,而在 GameRoot 这个空节点的 BattleManager 组件里。场景启动后,BattleManager 的 onLoad 里会依次做三件事:读取 config 目录下的三张 JSON 表、实例化各管理器、调用 turnManager.next() 进入玩家回合。你如果想改初始属性,不用打断点,直接在 JSON 表里改数字。
我在改这个项目时最常用的调试手段是看 Cocos Creator 控制台的过滤日志。项目里有一个 debugLog 开关,放在 BattleManager 顶部,改成 true 之后,每个回合切换、每次伤害结算都会打印一行格式化日志。这个开关很重要,它不打印业务细节,只打印状态流转,能让你快速判断问题是“逻辑没走到”还是“数据不对”。
如果战斗没有按预期跑起来,我一般按三层排查:先看控制台有没有报错,比如“技能未注册”这类开发期错误;再看 roles.json、skills.json 里填的数据是不是匹配,比如技能 id 写错了,cast 会打印警告;最后看表现层,角色动画没播但日志显示造成伤害了,那问题在 RoleView 的事件订阅,不在逻辑层。
这种排查顺序对应前面讲的系统分层:先数据、再逻辑、最后表现。每修一层再重开预览,不要同时改多个文件,不然你根本不知道是哪个改动把战斗修好的。反正我踩过的坑基本都是因为一次改太多,回滚都不知道回哪行。控制台里把 Filter 设成 Battle 前缀,所有管理器日志就都能滤出来。
5. 避坑记录:回合卡死、节点回调与数据污染
这一章写我在跑这个项目时实际遇到过的三个坑,也是回合制战斗系统里最容易翻车的三个点。它们不会让编辑器报错,只会让战斗结果变怪,是最难排查的一类 bug。下面的三条每一条我都按现象、原因、解决写清楚,你看的时候可以拿着自己的工程对照一遍。
5.1 回合卡在“敌方回合”不动了
现象是:敌方行动完,控制台没有任何报错,但轮到玩家时按钮一直不亮,回合卡在 EnemyTurn 阶段再也推进不了。这种问题最折磨人,因为没有任何错误提示,看起来像编辑器抽风或者什么“玄学”,其实跟运气没关系。
原因在敌方 AI 的执行路径里。敌方的脚本是异步的——先播放攻击动画,动画结束回调里再去扣血,扣完血再调用 turnManager.next() 推进回合。如果动画播放时目标节点已经被移出场景,回调走不到,next() 永远不会被调用,阶段就卡死了。这个问题在极端情况才会触发,比如最后一击把目标打死的同一帧还挂着其他动画回调。
解决方法是给回合推进加一个超时兜底。我一般会在 TurnManager 里加一个 watchdog 定时器,AI 执行超过 3 秒就强制调用 next() 并打一条警告日志。同时在 BattleManager 里加阶段断言,发现 phase 和 currentSide 不匹配就打印日志。如果你也遇到阶段不动,第一步别去翻 AI 逻辑,先看控制台有没有异常,再看日志停在哪一行,两分钟就能定位。
5.2 Buff 叠加把伤害算成负值
现象是:给敌方套了两层减攻 debuff 之后,自己的伤害反而变高了。表面上看是伤害公式有问题,其实是 Buff 数值叠加没有做下限保护。这是我拆这个项目时最容易忽视的逻辑陷阱,因为单层数值永远正常,只有叠加到多层才暴露。
我拆这个项目时仔细看过 BuffManager 的刷新逻辑:每层 debuff 直接做减法,minusAttack 是 10 就减 10,套两层就把攻击减成负的。伤害公式里 base 乘出来是负数,负数再乘暴击,负数减负变成了加——于是伤害反而涨了。这属于数据污染,根子在数值没有做 clamp。
解决方式很直接,在 BuffManager 刷新数值时加一个 Math.max(0, base + delta) 的下限约束,不光是攻击,防御、暴击这类属性全部做上下限。另外在伤害计算里再做一层防御兜底:如果 target.defense 小于等于 0,就按 0 处理,这样就算某个 buff 漏了保护,也不会把伤害算成负数。数值系统的下限保护尽量集中在一个地方写,不要散在每个 Buff 效果里,否则新加一个 Buff 很容易忘记补保护。
5.3 场景切换后事件监听残留
现象是:从战斗场景返回主界面后,角色明明已经不在场景里,控制台还在打印“受到伤害”。这不是场景没销毁,而是事件监听没有跟着销毁。听起来不起眼,但这类幽灵日志会让人误以为有角色在后台战斗,查半天才发现是残留回调。
这个项目用 EventBus 做模块通信,而 EventBus 是一个跟战斗场景绑定的对象。问题出在 RoleView 在 onLoad 里订阅了 ROLE_DAMAGED 事件,但没在 onDestroy 里反订阅。战斗场景销毁时,节点和组件被回收,监听回调却还留在 EventBus 的 Map 里,战斗数据一广播,回调被触发,内存也跟着泄漏。
解决方式是我在这个项目里重点推荐的:EventBus 提供 on 和 off 成对接口,组件在 onDestroy 里必须调用 off。想彻底避免这个问题,还可以给 EventBus 加一个 register(target, event, cb) 接口,内部用一个 WeakMap 记录监听者和回调的对应关系,在 target 销毁时自动清理。这样新写的模块只要用 register,就永远不会漏掉反订阅,这个技巧在战斗外的 UI 界面同样适用。
6. 二次开发指南:扩展技能、角色与战斗规则
6.1 新增角色与技能的两步注册
这个项目的扩展链路很短,新增一个会放“流血”的刺客只需要改三处:config/roles.json 里加一行角色数据,config/skills.json 里加一条流血技能配置,然后在 SkillManager 的初始化位置调用一次 registerSkill。如果只是数值型技能,比如倍率更高的单体攻击,连代码都不用加,JSON 里加配置就能用。
6.2 调整回合规则:让速度属性参与排序
如果你想做更接近战棋的回合顺序——按速度排序决定行动顺序,而不是固定玩家先手——TurnManager 需要从单状态机改成行动队列。入队时从角色表里取出速度和先手值做排序,生成当前回合的 actionQueue,每次 next 直接把队头弹出,弹完再出发。这个改动不影响 SkillManager 和 BuffManager,因为伤害和 Buff 的消费接口是稳定的,外部拿到的还是同一个 cast 接口。
我自己的习惯是:每次扩展完,先在 JSON 里把数值调到极端情况测试一轮——比如把目标防御改成 0、把暴击率调到 100%——跑一局看日志是否出现异常值。如果极端值都合理,常规战斗基本不会翻车。从那以后我每次扩展系统都强制走一遍:先加配置、再注册、最后跑一轮极端数值验证。希望帮到你。
本文还有配套的精品资源,点击获取