简介:CocoKnightManager 是一套基于 Cocos Creator 引擎的开源回合制游戏系统设计,面向希望快速搭建回合制战斗框架的独立开发者与学习游戏架构的初中级程序员。资源包共 22 个文件,约 216KB,以 json 配置、meta 元数据、ts 脚本和 png 贴图为主,另含 fire 场景文件与 README 说明,整体结构轻量,便于直接导入工程阅读与二次开发。系统围绕模块化与数据驱动展开,将回合管理器、行动规则引擎和状态机拆分为独立组件,角色属性、技能效果与事件均可通过 JSON 配置调整,降低编程耦合。已有 836 人学习下载,读者可从中获取回合流程控制、行动顺序判定、状态切换等核心逻辑的实现思路,并借鉴其目录组织与组件划分方式,作为自己回合制项目的架构参考或功能扩展起点。
1. 回合制战斗的骨架,比画面更值得先想清楚
做回合制游戏,很多人第一反应是先把角色立绘、技能特效、战斗界面铺出来,结果做到第三周发现:角色属性改一个数值要翻五个脚本,技能释放顺序一乱就卡死,存档读回来状态全丢。这类翻车在课程设计和毕设里太常见了。CocoKnightManager 这套方案要解决的正是这个问题——它把回合制游戏里最容易被忽视、却最决定成败的部分先立起来:回合调度、角色状态机、技能结算、数据配置与存档。适合正在做 Cocos Creator 游戏课设、毕设,或者要交付一个可演示、可扩展的回合制原型的人。系统开源的意义不在于代码多漂亮,而在于这套骨架你能直接拆开、替换素材、接上自己的战斗规则。
2. 需求与选型:为什么回合制要先做管理器而不是先做界面
2.1 从玩法倒推系统边界
回合制游戏的核心循环其实很短:进入战斗 → 决定行动顺序 → 当前单位选择行动 → 结算效果 → 判定胜负 → 下一回合。看起来简单,但每一步都藏着状态。谁先手、谁被控制、谁身上挂着持续伤害、技能冷却还剩几回合、战斗结束后这些状态怎么清——这些不是界面能替你管的。我一般会把系统边界划成四块:战斗流程控制、单位状态管理、技能与效果结算、数据与存档。界面只负责把状态画出来、把玩家输入传回去,不持有任何规则判断。这样划分之后,改一个技能数值不需要动界面,换一套 UI 也不需要重写战斗逻辑,这是后面所有设计的前提。
需求上还要明确一点:回合制不等于慢。玩家感知到的节奏来自「行动反馈是否及时」,而不是动画长短。所以系统设计里要预留事件通知机制,让界面能在状态变化的瞬间收到消息,而不是每帧去轮询。这一点在课设答辩时经常被问到,提前想清楚能省很多解释。
2.2 技术选型:Cocos Creator 里怎么组织逻辑层
Cocos Creator 本身是组件化引擎,节点挂脚本、脚本管表现,这套模式做界面很顺,但直接拿来做战斗规则会很快失控——规则散落在各个节点的 update 里,谁也说不清一次攻击到底改了哪些数据。我的做法是把战斗逻辑做成纯 TypeScript 类,不继承 Component,不依赖节点,只依赖数据。引擎层只保留一个战斗入口脚本,负责把玩家输入转成指令、把逻辑层抛出的事件转成动画和 UI 更新。
// BattleUnit.ts —— 纯逻辑类,不继承 Component export class BattleUnit { id: string; hp: number; maxHp: number; atk: number; speed: number; alive: boolean = true; // 状态容器:buff/debuff 统一挂在这里,便于统一结算 states: Map<string, BattleState> = new Map(); constructor(cfg: UnitConfig) { this.id = cfg.id; this.maxHp = cfg.maxHp; this.hp = cfg.maxHp; this.atk = cfg.atk; this.speed = cfg.speed; } // 受击只改数据,不播动画;动画由事件驱动 takeDamage(amount: number): number { const real = Math.max(0, amount); this.hp = Math.max(0, this.hp - real); if (this.hp === 0) this.alive = false; return real; } }这段代码的关键不在写法,而在约束:逻辑类里没有this.node、没有schedule、没有tween。好处是它可以在 Node 环境里直接跑单元测试,也可以被存档系统直接序列化。参数上speed决定行动顺序,states用 Map 而不是数组,是为了按状态 id 去重和覆盖,避免同一个 buff 叠出一堆重复条目。选型上没有引入额外框架,Cocos Creator 自带的 TypeScript 加一个轻量事件总线就够了,依赖越少,课设环境里越不容易出玄学问题。
2.3 数据驱动:配置表先行,代码后写
回合制游戏的数值和技能配置会反复改,如果写死在代码里,每次调整都要重新编译、重新对界面。通常的做法是把单位、技能、状态定义抽成 JSON 配置,逻辑层启动时加载,运行时只读。配置结构大致如下:
{ "units": [ { "id": "knight", "maxHp": 320, "atk": 28, "speed": 12, "skills": ["slash", "guard"] } ], "skills": [ { "id": "slash", "power": 1.2, "target": "enemy", "cooldown": 0 }, { "id": "guard", "power": 0, "target": "self", "cooldown": 2, "applyState": "shield" } ], "states": [ { "id": "shield", "duration": 2, "modify": { "defense": 0.5 } } ] }配置表的价值在于把「策划改动」和「程序改动」分开。power是伤害系数,cooldown是冷却回合数,applyState指向状态表里的条目,状态表再用modify描述属性修正。这样加一个新技能只需要加两条配置,不用碰结算代码。注意配置加载要做校验,缺字段或引用不存在的状态 id 时直接报错并打印 id,别让它静默变成 0 伤害——这种问题排查起来非常费时间。
3. 总体架构设计:四层结构与回合调度的职责划分
3.1 分层结构:表现层、控制层、逻辑层、数据层
整套系统按四层组织。表现层是 Cocos Creator 的场景和预制体,负责血条、行动条、技能按钮、飘字;控制层是一个BattleController脚本,接收玩家点击、调用逻辑层接口、订阅逻辑层事件并驱动表现层;逻辑层是纯 TypeScript 的战斗核心,包含回合调度器、单位、技能结算器、状态结算器;数据层是配置表和存档序列化。层与层之间只允许相邻调用,表现层不能直接改单位血量,逻辑层也不能直接操作节点。
这样分的好处是职责清晰:答辩时被问「你这个伤害数字是怎么算出来的」,你可以从按钮点击一路讲到结算函数,中间没有断点。坏处是初期要多写一些转发代码,但比起后期改一处崩三处的代价,这点成本很值。我一般会在控制层维护一个指令队列,玩家操作先入队,回合调度器按顺序消费,避免连点导致同一回合结算两次。
3.2 回合调度器:行动顺序与回合推进
回合调度的核心是「谁下一个行动」。常见做法有两种:固定顺序轮转,或者按速度值排序。这套方案采用速度排序加行动值累积,速度高的单位行动更频繁,但不会出现永远轮不到的情况。调度器维护一个待行动队列,每次取行动值最高的单位,行动结束后扣减行动值并重新排序。
// TurnScheduler.ts —— 回合调度核心 export class TurnScheduler { private units: BattleUnit[] = []; private actionGauge: Map<string, number> = new Map(); addUnit(u: BattleUnit) { this.units.push(u); this.actionGauge.set(u.id, 0); } // 返回下一个行动单位;无可用单位时返回 null next(): BattleUnit | null { const alive = this.units.filter(u => u.alive); if (alive.length === 0) return null; // 行动值 = 当前累积值 + 速度,取最大者 let picked: BattleUnit | null = null; let best = -Infinity; for (const u of alive) { const gauge = (this.actionGauge.get(u.id) ?? 0) + u.speed; this.actionGauge.set(u.id, gauge); if (gauge > best) { best = gauge; picked = u; } } if (picked) { // 行动后扣减,形成速度差带来的频率差 this.actionGauge.set(picked.id, best - 100); } return picked; } }这里的100是行动值阈值,速度 12 的单位大约每 8 到 9 次调度行动一次,速度 20 的单位频率明显更高。参数可以按项目节奏调,阈值越小行动越频繁。注意next()每次都会给所有存活单位累加行动值,所以调用频率要和回合概念对齐——一次next()就是一次「行动机会」,不是一帧。这个区别在写循环时很容易搞混,搞混了就会出现行动条飞涨的 bug。
3.3 状态机与事件总线:让界面和逻辑解耦
单位状态用状态机管理,每个状态有进入、每回合结算、退出三个阶段。事件总线负责把逻辑层的变化广播出去,表现层订阅自己关心的事件。事件定义要克制,通常只需要单位受伤、单位死亡、状态添加、状态移除、回合开始、战斗结束这几类。
// EventBus.ts —— 极简事件总线 type Handler = (payload: any) => void; export class EventBus { private map: Map<string, Handler[]> = new Map(); on(event: string, fn: Handler) { const list = this.map.get(event) ?? []; list.push(fn); this.map.set(event, list); } emit(event: string, payload: any) { (this.map.get(event) ?? []).forEach(fn => fn(payload)); } }事件总线的坑在于订阅后忘记取消,场景切换后旧回调还在跑,轻则报空引用,重则内存泄漏。我的习惯是在控制层的onDestroy里统一清理,或者给on返回一个取消函数,谁订阅谁负责。状态结算的顺序也要固定:先结算持续伤害,再结算增益到期,最后判定死亡,顺序乱了会出现「已经死了还在回血」的诡异现象。
4. 核心模块实现:技能结算、存档与战斗流程
4.1 技能结算器:伤害公式与目标选择
技能结算是整个系统里改动最频繁的模块,所以它必须足够独立。结算器接收「施法者、技能配置、目标列表」,输出一组结算结果,不直接改单位数据,由调用方统一应用。这样做的目的是让结算可预览、可测试,也方便以后加战斗预测。
// SkillResolver.ts —— 技能结算,纯函数式 export interface ResolveResult { targetId: string; damage: number; heal: number; stateId?: string; } export function resolveSkill( caster: BattleUnit, skill: SkillConfig, targets: BattleUnit[] ): ResolveResult[] { return targets.map(t => { // 基础伤害 = 攻击力 × 系数,防御减伤走状态修正 const base = caster.atk * skill.power; const defenseRate = t.states.get('shield') ? 0.5 : 1; const damage = skill.power > 0 ? Math.floor(base * defenseRate) : 0; return { targetId: t.id, damage, heal: skill.power < 0 ? Math.floor(-base) : 0, stateId: skill.applyState }; }); }power为正表示伤害技能,为负表示治疗,这个约定要在配置表注释里写清楚,否则接手的人一定会踩坑。目标选择放在调用方,单体、群体、随机都由控制层根据技能配置的target字段决定,结算器不关心选谁,只关心算多少。伤害取整用Math.floor而不是四舍五入,是为了避免玩家看到「显示 10 实际扣 11」的信任问题。
4.2 存档与读档:序列化哪些字段、跳过哪些字段
存档最容易翻车的地方是「存了不该存的」。节点引用、事件回调、临时动画状态都不能进存档,否则读档时要么报错,要么行为诡异。我的做法是给每个需要持久化的类写显式的toJSON和fromJSON,只存 id、数值、状态列表和回合数,不存任何运行时对象。
// SaveManager.ts —— 显式序列化,避免存运行时引用 export function serializeBattle(units: BattleUnit[], turn: number): string { return JSON.stringify({ turn, units: units.map(u => ({ id: u.id, hp: u.hp, alive: u.alive, states: Array.from(u.states.entries()).map(([k, v]) => ({ id: k, remain: v.remain })) })) }); }读档时按 id 从配置表重建单位,再把 hp 和状态覆盖回去,顺序不能反——先重建再覆盖,否则状态挂在不存在的单位上。存档版本号建议一起存,配置表结构变了可以按版本做迁移,没有版本号的存档在迭代两周后基本就是黑匣子,读出来什么样全看运气。
4.3 战斗流程控制:从进入战斗到结算奖励
控制层把整个战斗串成一条状态线:初始化 → 回合开始 → 等待输入 → 执行行动 → 结算状态 → 判定胜负 → 下一回合或结束。每个阶段只做一件事,阶段之间通过事件通知界面切换按钮状态。等待输入阶段要禁用重复点击,执行行动阶段要锁住输入,这两个锁没做好,玩家连点就能让一个角色一回合放三次技能。
胜负判定放在状态结算之后,先判敌方全灭,再判我方全灭,同时全灭按失败处理。结算奖励时只读战斗结果,不回写战斗中的临时数据,避免奖励计算依赖已经被清空的状态。流程控制看起来是胶水代码,但它决定了整个系统在异常输入下是否稳定,值得多花半天把它写扎实。
5. 避坑与排查:这类项目最容易翻车的五个点
5.1 现象:行动顺序偶尔错乱,速度高的单位反而后手
原因通常是调度器在单位死亡后没有从队列里剔除,或者行动值在死亡单位上继续累积,重新上场时带着旧值。解决方式是每次next()前过滤存活单位,死亡时清空其行动值,并在复活逻辑里显式重置。排查时打印每个单位的行动值和速度,跑十回合看曲线是否单调,比盯着界面猜快得多。
5.2 现象:技能伤害显示和实际扣血对不上
原因多半是结算用了浮点、显示用了取整,或者防御修正只在一处生效。解决方式是结算统一在resolveSkill里完成并返回整数,界面只显示返回值,不自己再算一遍。另一个常见原因是状态修正被重复应用,检查states是否按 id 去重,Map 的 key 用状态 id 而不是技能 id。
5.3 现象:读档后 buff 消失或回合数错位
原因是存档只存了单位数值,没存状态剩余回合,或者读档时先覆盖状态再重建单位。解决方式是序列化时把remain一起存,读档严格按「重建 → 覆盖数值 → 挂状态」的顺序执行。版本号缺失时,至少校验字段存在性,缺字段直接拒绝读档并提示,别让它半残运行。
5.4 现象:场景切换后旧战斗还在后台跑
原因是事件总线订阅没清理,或者定时器没停。解决方式是控制层统一在onDestroy里取消所有订阅、停止所有调度,事件总线提供off或返回取消函数。排查时可以在场景切换后打印订阅数量,数量不归零就是漏了。
5.5 现象:配置表改一个字段,整个战斗报空引用
原因是配置加载没有校验,缺字段时静默变成undefined,一路传到结算才炸。解决方式是加载后做一次结构校验,必填字段缺失、引用 id 不存在都直接抛错并打印具体条目。这个校验写起来半小时,能省掉后面几小时的定位时间,属于典型的后悔药。
6. 验收与答辩:怎么证明这套系统真的能跑
验收这套系统,我一般不看界面多花哨,看四件事能不能当场演示。第一,改配置表里的一个技能系数,不重启逻辑层,重新进战斗伤害数字跟着变,证明数据驱动是真的。第二,连续快速点击技能按钮十次,行动只执行一次,证明输入锁有效。第三,战斗中存档、退出、读档,血量、状态剩余回合、当前回合数完全一致,证明序列化干净。第四,手动把某个单位血量改成 0,战斗在结算后正确结束并给出结果,证明胜负判定不依赖界面。
答辩时被问得最多的是「你的回合顺序怎么保证公平」和「状态叠加怎么处理」。前者用行动值曲线回答,跑十回合把每个单位的行动次数列出来,速度差和行动次数差对得上就站得住。后者用 Map 去重加结算顺序回答,先说持续伤害、再说增益到期、最后判死亡,顺序固定就不会出现已死回血。下面这张表可以直接放进答辩 PPT 的验收页:
| 验收项 | 操作 | 通过标准 |
|---|---|---|
| 数据驱动 | 改技能系数后重进战斗 | 伤害随之变化,无需改代码 |
| 输入防抖 | 连点技能按钮 | 单回合仅执行一次行动 |
| 存档一致性 | 存档后读档 | 血量、状态、回合数一致 |
| 胜负判定 | 强制单位死亡 | 结算后正确结束并出结果 |
| 事件清理 | 切换场景再返回 | 无报错、无重复回调 |
还有一个容易被忽略的技巧:答辩前把配置表故意改错一个字段,演示系统如何报错并指出具体条目。这比演示一切正常更有说服力,因为它证明系统在异常输入下是可预期的,而不是靠运气跑通。从那以后我每次交方案前,都会强制走一遍「改错—看报错—改回」的流程,确认错误信息足够定位问题才敢交付。希望这套骨架能帮到你,把回合制课设里最费时间的部分先稳住,剩下的素材和特效反而好办。
本文还有配套的精品资源,点击获取