简介:CocoKnightManager 是一套基于 Cocos Creator 的回合制游戏系统设计开源工程,面向希望掌握回合制玩法架构的初中级游戏开发者与独立团队,帮助其解决战斗流程抽象、角色状态管理与规则扩展等核心问题。资源包共 22 个文件,约 216KB,以 json 配置、meta 元数据、ts 脚本、png 贴图及 fire 场景文件为主,配合 README 说明,覆盖工程配置、场景资源与逻辑脚本等层面,结构轻量便于快速导入与二次开发。目前已有 836 人学习下载。项目以模块化为核心,将回合管理器、行动规则引擎与状态机拆分为独立组件,并采用数据驱动方式通过 JSON 配置属性、效果与事件,读者可据此理解回合顺序判定、指令执行与状态切换的完整链路,也能借鉴其代码组织方式,为自研回合制项目搭建可维护、可复用的系统骨架。
1. 回合制战斗的骨架,藏在一个叫 CocoKnightManager 的工程里
第一次拿到 CocoKnightManager 这个工程时,我本以为它只是个 Cocos Creator 的练手 Demo,结果翻到战斗流程那一层才发现,它把回合制游戏里最容易写乱的东西——行动顺序、状态结算、技能触发、战斗事件分发——拆成了几个互相解耦的管理器。换句话说,这不是一个“能跑就行”的示例,而是一套可以照着搭自己回合制项目的系统设计骨架。它适合两类人:一类是刚接触 Cocos Creator、想搞明白一个完整战斗循环怎么落地的新手;另一类是手里已经有半成品战斗逻辑、但越写越像一锅粥、想找一套可参考的分层结构的老手。系统开源这件事的价值就在这里——你能直接看到别人怎么把“回合”这件事拆开、再拼回去。
2. 工程结构与技术选型:先看清这套骨架由哪些层组成
2.1 技术栈与运行环境
这个工程建立在 Cocos Creator 之上,语言是 TypeScript,走的是组件化 + 管理器分层的路子。Cocos Creator 的版本建议对齐工程内package.json与project.json里声明的版本,跨大版本打开经常会出现场景节点丢失、组件脚本挂载失败这类问题,这是血泪经验,别问我怎么知道的。运行环境上,编辑器内预览和构建到 Web 端是最省事的验证方式,回合制逻辑本身不依赖特定平台能力,所以先在浏览器里把战斗跑通,再考虑原生构建。
工程的核心目录大致是这样组织的(不同分支可能略有出入,以实际下载的为准):
CocoKnightManager/ ├── assets/ │ ├── scripts/ │ │ ├── manager/ # 各类管理器:战斗、回合、单位、事件 │ │ ├── entity/ # 单位、技能、状态的数据与行为 │ │ ├── ui/ # 战斗界面、血条、行动条 │ │ └── config/ # 数值配置、技能表 │ ├── prefabs/ # 单位预制体、特效预制体 │ └── scenes/ # 战斗场景、主菜单场景 ├── settings/ └── package.json这个分层的关键在于:manager层负责流程调度,entity层负责数据与行为,ui层只负责表现,config层提供静态数值。回合制最容易翻车的地方就是把“谁该行动”和“血条怎么动”写在一起,一旦要加个新状态或者改行动规则,牵一发动全身。这套结构把调度和表现隔开,改规则时基本不用碰 UI。
2.2 管理器分层:谁在指挥这场战斗
战斗的指挥权集中在几个管理器上,职责划分大致如下:
| 管理器 | 职责 | 典型方法 |
|---|---|---|
| BattleManager | 战斗生命周期、胜负判定、事件分发 | startBattle、endBattle、checkResult |
| TurnManager | 回合推进、行动顺序、回合计数 | nextTurn、getCurrentUnit、sortBySpeed |
| UnitManager | 单位注册、查找、存活状态维护 | register、getAliveUnits、remove |
| EventBus | 战斗事件订阅与广播 | on、emit、off |
这种拆法的好处是,回合推进的逻辑只出现在 TurnManager 里,战斗结束的判定只出现在 BattleManager 里。想加一个“回合开始时触发某状态”的机制,只需要在 TurnManager 的回合切换点挂事件,不用去改单位本身的代码。下面这段是回合推进的典型写法,逻辑上对应工程里 TurnManager 的核心循环:
// TurnManager.ts —— 回合推进核心 export class TurnManager { private units: Unit[] = []; private currentIndex: number = 0; private roundCount: number = 0; // 按速度排序,速度相同则按注册顺序,保证结果可复现 public sortBySpeed(): void { this.units.sort((a, b) => { if (b.speed !== a.speed) return b.speed - a.speed; return a.registerOrder - b.registerOrder; }); } // 推进到下一个行动单位,一轮结束则回合数 +1 public nextTurn(): Unit { this.currentIndex++; if (this.currentIndex >= this.units.length) { this.currentIndex = 0; this.roundCount++; EventBus.emit('roundStart', this.roundCount); // 回合开始事件 } const unit = this.units[this.currentIndex]; EventBus.emit('turnStart', unit); // 单位行动开始事件 return unit; } public getCurrentUnit(): Unit { return this.units[this.currentIndex]; } }这段代码里有两个参数值得注意:registerOrder是单位注册时分配的序号,用来在速度相同时打破平局,避免每次排序结果不稳定导致战斗不可复现;roundCount是回合计数,很多状态效果(比如“持续三回合”)都依赖它来倒计时。EventBus.emit的两个事件点是扩展的关键——roundStart适合挂回合级的 buff 结算,turnStart适合挂单位行动前的状态检查。把扩展点做成事件而不是硬编码调用,是这套设计里最值得抄的一点。
2.3 单位与状态的数据建模
单位的数据结构决定了后续能扩展出多少玩法。工程里单位通常包含基础属性、当前状态列表、技能列表三块。状态(buff/debuff)用独立对象表示,带剩余回合数和结算时机标记,这样状态结算就能统一在回合事件里处理,而不是散落在各个技能逻辑里。常见做法是给状态加一个timing字段,标记它在回合开始、回合结束还是行动前结算,TurnManager 在对应事件点遍历所有单位的状态列表做统一处理。这样加一个新状态只需要写它的效果和时机,不用改调度代码。
3. 把战斗跑起来:从打开工程到第一场对局
3.1 环境准备与工程导入
先把 Cocos Creator 装好,版本对齐工程声明,然后用 Dashboard 的“导入项目”指向工程根目录。导入后如果控制台报脚本编译错误,优先检查 TypeScript 版本和tsconfig.json是否被改动过。导入完成后打开assets/scenes下的战斗场景,编辑器里应该能看到单位预制体被实例化到场景中。这一步最容易出的问题是预制体引用丢失,表现为场景里单位显示为空白节点,原因通常是资源路径变更或 meta 文件冲突,解决方式是重新拖拽预制体到对应挂载点,或者清理library目录让编辑器重新生成资源索引。
3.2 战斗流程的启动链路
战斗的启动一般从场景脚本的onLoad或按钮回调进入,链路是:注册单位 → 初始化管理器 → 启动第一回合。下面这段是典型的启动代码,对应工程里战斗入口的写法:
// BattleEntry.ts —— 战斗入口 import { BattleManager } from './manager/BattleManager'; import { TurnManager } from './manager/TurnManager'; export class BattleEntry extends Component { private battleManager: BattleManager; private turnManager: TurnManager; start() { // 1. 创建管理器实例 this.battleManager = new BattleManager(); this.turnManager = new TurnManager(); // 2. 注册双方单位(数据来自配置表或场景挂载) const playerUnits = this.loadUnits('player'); const enemyUnits = this.loadUnits('enemy'); this.battleManager.registerUnits([...playerUnits, ...enemyUnits]); // 3. 按速度排序并启动首回合 this.turnManager.sortBySpeed(); this.battleManager.startBattle(); } private loadUnits(camp: string): Unit[] { // 从配置或预制体读取单位数据,此处省略具体实现 return []; } }这段代码的要点在于顺序:先注册再排序,排序必须在启动前完成,否则第一回合的行动顺序是错的。loadUnits的camp参数用来区分阵营,实际工程里通常还会带上初始站位信息。启动后,BattleManager 会监听turnStart事件驱动当前单位行动,行动结束再调用nextTurn,形成闭环。调试时可以在nextTurn里打日志,观察行动顺序是否符合速度预期,这是排查回合逻辑最直接的手段。
3.3 用事件总线串起表现层
表现层不直接调用战斗逻辑,而是订阅事件。比如血条更新订阅unitHpChanged,行动条高亮订阅turnStart。这种做法的好处是,战斗逻辑可以在没有 UI 的情况下单独测试——写个纯逻辑的测试脚本,手动触发事件,看数据变化是否正确。工程里 EventBus 的实现通常很轻,一个 Map 存事件名到回调数组的映射,on注册、emit遍历调用、off移除。注意在场景销毁时把订阅清掉,否则切场景后旧回调还在,会出现“血条更新到了已销毁节点”的报错,这是回合制项目里非常常见的坑。
4. 核心模块拆解:回合循环、技能结算与胜负判定
4.1 回合循环的完整链路
一个完整的回合循环包含这几个阶段:回合开始事件 → 按顺序取行动单位 → 单位行动(选技能、结算伤害、结算状态)→ 行动结束 → 检查胜负 → 下一单位或下一回合。工程里这条链路是显式写出来的,而不是靠回调层层嵌套,好处是流程可读、断点好打。下面这段是循环驱动的核心:
// BattleManager.ts —— 战斗主循环 public startBattle(): void { this.isRunning = true; EventBus.emit('battleStart'); while (this.isRunning) { const unit = this.turnManager.nextTurn(); // 跳过已阵亡单位 if (!unit.isAlive()) continue; // 单位行动:选择目标、释放技能、结算 this.executeAction(unit); // 每次行动后检查胜负 if (this.checkResult()) { this.endBattle(); break; } } } private executeAction(unit: Unit): void { const action = unit.decideAction(this.getBattleContext()); if (!action) return; // 无可用行动则跳过 const targets = this.resolveTargets(action, unit); for (const target of targets) { const damage = this.calculateDamage(unit, target, action); target.applyDamage(damage); EventBus.emit('unitHpChanged', target); } }decideAction把“这个单位这回合干什么”交给单位自己决定,AI 单位和玩家单位可以有不同的实现,这是策略扩展的入口。calculateDamage集中处理伤害公式,改数值平衡只动这一处。checkResult在每次行动后调用,而不是每回合结束才调,避免出现“单位已全灭但还多打了一轮”的尴尬。注意while循环里对已阵亡单位的continue判断不能省,否则死掉的单位还会被拉出来行动。
4.2 技能与状态的结算顺序
技能结算的顺序直接影响战斗结果的正确性。常见做法是:先结算直接伤害,再结算附加状态,最后检查状态是否触发连锁效果(比如“受到伤害时反弹”)。工程里把结算拆成几个独立方法,按固定顺序调用,而不是在一个大函数里混着写。状态结算则统一挂在回合事件上,按timing字段分组处理。这里有个容易忽略的点:状态施加和状态结算要分开,施加时记录剩余回合数,结算时递减并在归零时移除,两个动作混在一起会导致“刚加上的状态立刻被扣一回合”。
4.3 胜负判定与战斗收尾
胜负判定要覆盖三种情况:一方全灭、回合数超限、主动撤退。工程里checkResult返回枚举而不是布尔值,这样收尾时能区分是胜利、失败还是平局。战斗结束时通过battleEnd事件通知表现层,同时清理事件订阅和单位引用,避免内存泄漏。收尾阶段还要把战斗结果(剩余血量、回合数、掉落)整理成结果对象,供结算界面使用。这一步别偷懒直接读单位对象,单位在清理后可能已被回收,读出来是空值。
5. 避坑与排查:回合制项目里最容易翻车的几处
5.1 行动顺序不稳定,同样的阵容每次结果不同
现象是同一套阵容反复打,胜负结果飘忽。原因多半是排序时没有处理速度相同的情况,Array.sort在部分环境下对相等元素的顺序不保证稳定。解决办法是给单位加注册序号,排序比较函数里速度相等时按序号兜底,保证结果可复现。这个坑在调试数值平衡时特别折磨人,因为你会以为是数值问题,其实是顺序问题。
5.2 状态结算重复触发,buff 掉得比预期快
现象是“持续三回合”的状态两回合就没了。原因通常是状态在回合开始和行动前各结算了一次,或者事件被重复订阅。排查方式是给状态结算加日志,打印每次递减的时机和剩余回合数,看是不是同一帧内被扣了两次。解决上,状态结算只在一个明确的时机做,事件订阅在注册前先off再on,避免重复挂载。
5.3 切换场景后报“节点已销毁”
现象是战斗结束返回主菜单再进战斗,控制台报节点相关错误。原因是 EventBus 的订阅没有在场景销毁时清理,旧回调持有已销毁节点的引用。解决方式是在场景脚本的onDestroy里统一off掉本场景注册的所有事件,或者给 EventBus 加一个按上下文批量清理的接口。这个坑几乎每个回合制项目都会踩一次。
5.4 伤害计算出现负数或溢出
现象是血量变成负数还继续扣,或者大数值下伤害算错。原因是伤害公式没有做下限保护,以及数值类型用了浮点导致精度问题。解决上,伤害结果做Math.max(0, ...)保护,血量扣减后钳制在 0 到最大值之间,涉及大数值时统一用整数运算。别小看这个,数值一膨胀,浮点误差能让战斗结果完全跑偏。
6. 二次开发:把这套骨架改成你自己的战斗系统
拿到这套工程后,最实际的改造路径是从配置层入手,而不是先动管理器。先把config里的单位表、技能表换成你自己的数值,跑一遍看流程是否还通,这一步能验证你对数据结构的理解是否正确。然后改decideAction,把 AI 行为换成你想要的策略,比如优先攻击血量最低的目标,或者按技能冷却轮转。再往后才是动 TurnManager,比如加入“行动条”机制替代固定顺序,这时候因为调度和表现已经解耦,改动范围是可控的。
验证改造是否成功,我一般会写一个纯逻辑的测试脚本,不挂 UI,直接构造单位和技能,跑若干回合,断言血量、状态、回合数的变化。这套工程的分层让这种测试很容易写,因为管理器不依赖场景节点。下面是一个最小的验证片段:
// battle.test.ts —— 纯逻辑验证 const turnManager = new TurnManager(); const battleManager = new BattleManager(); const attacker = new Unit({ speed: 10, attack: 50 }); const defender = new Unit({ speed: 5, hp: 100 }); battleManager.registerUnits([attacker, defender]); turnManager.sortBySpeed(); // 断言:速度高的先行动 console.assert(turnManager.getCurrentUnit() === attacker, '行动顺序错误'); // 模拟一次攻击后断言血量 defender.applyDamage(50); console.assert(defender.hp === 50, '伤害结算错误');改造时还有一点值得注意:新增状态或技能时,优先通过事件挂载而不是改主循环。主循环是骨架,改一次影响全局;事件是插槽,加多少都不影响原有流程。我吃过一次亏,为了加一个“回合结束回血”的效果直接改了nextTurn,结果后面所有依赖回合切换的逻辑都跟着出问题,排查了大半天。从那以后我每次动主循环前都先问自己:这个需求能不能用事件解决?能就不碰循环。希望这套骨架能帮到你,少走我走过的弯路。
本文还有配套的精品资源,点击获取