☰
Cocos Creator 回合制战斗系统设计:分层架构与数据驱动实战
2026/10/14 13:24:24 网站建设 项目流程

简介:CocoKnightManager 是一套基于 Cocos Creator 的开源回合制游戏系统设计,面向希望快速搭建回合制战斗框架的开发者,尤其适合具备一定 TypeScript 基础、想深入理解模块化架构的中高级学习者。资源包共 22 个文件,约 216KB,以 json 配置、meta 元数据、ts 脚本、png 贴图及 fire 场景文件为主,涵盖项目配置、资源描述、核心逻辑与场景搭建等用途,结构紧凑便于直接导入引擎运行。系统围绕回合管理器、行动规则引擎与状态机三大组件展开,将行动顺序、指令执行与状态切换抽象为独立模块,并采用数据驱动方式,通过 JSON 配置角色属性、效果与事件,降低非程序人员调整内容的门槛。目前已有 834 人学习下载,读者可借此掌握回合制流程的抽象方法、模块拆分思路与数据驱动实践,为自研战斗系统提供可复用的参考模板。

1. 回合制战斗的“骨架”长什么样:从一份 Cocos Creator 系统设计源码说起

很多做 Cocos Creator 的朋友都有过这种经历:战斗逻辑写到最后,技能、Buff、回合调度、AI 决策全糊在一个脚本里,改一个数值要翻五个文件,加一个新职业得动核心循环。这份名为 CocoKnightManager 的开源工程,就是冲着这个痛点来的——它把一套回合制游戏的系统设计拆成了可独立替换的模块,用 TypeScript 在 Cocos Creator 里跑通了一整套“回合调度 + 技能结算 + 状态管理”的闭环。它适合两类人:一是正在用 Cocos Creator 做回合制或半回合制项目、想找一套能直接参考的架构骨架的开发者;二是想搞清楚“回合制战斗系统到底该怎么分层”的进阶学习者。源码本身不依赖特定美术资源,核心逻辑与表现层做了分离,拿过来改数值、换技能、接自己的 UI 都能跑。

2. 系统分层与回合调度:为什么不是把所有逻辑塞进一个 Update

2.1 三层拆分:数据层、逻辑层、表现层的边界在哪

拿到这份工程,第一件值得看的事是目录结构。它没有把战斗写成一个大文件,而是按职责切成了三层:

  • 数据层(Data):角色属性、技能配置、Buff 定义,全部以纯数据对象存在,不继承 Node,不引用 cc 模块。这一层的好处是可以在 Node.js 环境里单独跑单元测试,不用启动引擎。
  • 逻辑层(Logic):回合调度器、技能结算器、Buff 管理器。这一层只操作数据层对象,通过事件或回调通知表现层。
  • 表现层(View):挂在 Cocos Creator 节点上的组件,负责播放动画、刷新血条、弹伤害数字。它订阅逻辑层的事件,不反向调用逻辑层的方法。

常见做法是逻辑层和表现层通过一个事件总线解耦。工程里用的是一个轻量的 EventTarget 封装,逻辑层结算完伤害后派发battle:damage事件,表现层收到后播动画。这样做的好处是:如果你想把战斗逻辑搬到服务端做校验,逻辑层可以整体搬走,表现层换一套渲染就行。

提示:判断分层是否干净,有个简单标准——把 View 目录整个删掉,Logic 和 Data 目录能不能通过 TypeScript 编译。这份工程可以。

2.2 回合调度器的实现:速度排序与行动队列

回合制的核心是“谁先动”。这份工程没有用简单的“我方全动完再敌方动”,而是实现了一个基于速度值的行动队列。核心逻辑在TurnScheduler里,简化后的调度流程如下:

// TurnScheduler.ts 核心调度逻辑(简化示意) interface Combatant { id: string; speed: number; // 速度值,决定行动顺序 actionGauge: number; // 行动条进度,初始为 0 } class TurnScheduler { private combatants: Combatant[] = []; private readonly GAUGE_MAX = 1000; // 行动条满值 // 每帧推进行动条,谁先满谁行动 tick(deltaTime: number): Combatant | null { for (const c of this.combatants) { // 速度越快,行动条增长越快 c.actionGauge += c.speed * deltaTime; } // 找到第一个行动条满的单位 const ready = this.combatants.find(c => c.actionGauge >= this.GAUGE_MAX); if (ready) { ready.actionGauge -= this.GAUGE_MAX; // 扣除,保留溢出值 return ready; } return null; } }

这段代码的关键参数是GAUGE_MAX和speed的配合。GAUGE_MAX设为 1000 时,速度 100 的单位大约每 10 秒行动一次,速度 200 的单位每 5 秒一次。溢出值保留这个细节很重要——如果不做-=而是直接归零,高速单位会损失一部分行动条进度,长期跑下来行动次数会偏少,这是很多自研回合制容易忽略的数值偏差。

2.3 行动队列与“插入行动”的处理

除了常规的速度排序,工程里还处理了“插入行动”的场景,比如某个技能效果是“立即获得一次行动机会”。实现方式是在调度器里维护一个pendingActions队列,优先级高于常规行动条:

// 插入行动的优先级处理 enqueueImmediate(combatant: Combatant): void { // 插入到队首,下一次 tick 优先返回 this.pendingActions.unshift(combatant); } tick(deltaTime: number): Combatant | null { // 优先处理插入行动 if (this.pendingActions.length > 0) { return this.pendingActions.shift()!; } // 常规行动条逻辑... }

这里有个容易翻车的点:插入行动如果处理不当,可能造成同一个单位连续行动多次,或者插入行动的单位在行动条上的进度没有正确扣除。工程里的做法是插入行动不消耗行动条,但会记录一个extraActionCount,在回合结束时统一结算。这个设计取舍值得参考——它避免了行动条和插入行动两套计时系统互相干扰。

3. 技能与 Buff 系统:数据驱动怎么落地才不乱

3.1 技能配置表的结构设计

技能系统是回合制里最容易膨胀的部分。这份工程用了一套“配置表 + 效果执行器”的方案。技能本身不写逻辑,只描述“做什么”,具体“怎么做”交给效果执行器。技能配置的结构大致如下:

// 技能配置示例(数据层,纯 JSON 或 TS 对象) const skillConfig = { id: "skill_fireball", name: "火球术", targetType: "enemy_single", // 目标类型:单体敌方 cost: { mp: 20 }, // 消耗 effects: [ { type: "damage", // 效果类型:伤害 formula: "atk * 1.5 + 50", // 伤害公式 element: "fire" // 元素属性,用于克制计算 }, { type: "buff", // 效果类型:附加 Buff buffId: "buff_burn", chance: 0.3, // 触发概率 duration: 3 // 持续回合数 } ] };

这种结构的好处是加新技能不用改代码,策划填表就行。但前提是效果执行器要覆盖足够多的type。工程里内置了 damage、heal、buff、debuff、dispel、summon 六种执行器,基本覆盖了常见回合制的需求。

3.2 Buff 管理器的生命周期与叠加规则

Buff 系统是回合制的另一个重灾区。这份工程的BuffManager把 Buff 的生命周期拆成了四个阶段:添加时(onApply)、回合开始时(onTurnStart)、回合结束时(onTurnEnd)、移除时(onRemove)。每个 Buff 可以按需实现这些钩子。

叠加规则用了一个stackRule字段来控制,常见取值和含义如下:

stackRule 取值含义典型场景
refresh刷新持续时间,不叠加层数大多数控制类 Buff
stack叠加层数,持续时间独立计算中毒、灼烧等 DoT
replace替换旧 Buff,用新数值护盾类
ignore已有则不再添加唯一性光环
// BuffManager 添加 Buff 的核心逻辑 addBuff(target: Combatant, buffId: string, source: Combatant): void { const config = BuffConfig.get(buffId); const existing = target.buffs.find(b => b.id === buffId); if (existing) { switch (config.stackRule) { case "refresh": existing.duration = config.duration; // 只刷新时间 break; case "stack": existing.stacks += 1; // 叠层 existing.duration = config.duration; // 每层独立计时需额外处理 break; case "replace": target.buffs = target.buffs.filter(b => b.id !== buffId); target.buffs.push(new Buff(config, source)); break; case "ignore": return; // 直接忽略 } } else { target.buffs.push(new Buff(config, source)); } }

注意:stack规则下“每层独立计时”这个需求,工程里其实没有完全实现——它用的是统一刷新持续时间。如果你的项目需要每层独立计时,得自己扩展 Buff 对象,给每层存一个独立的剩余回合数。这是这份源码的一个边界,用之前要清楚。

3.3 伤害结算的公式与克制关系

伤害结算在DamageCalculator里,流程是:基础伤害 → 属性克制修正 → 防御减免 → 随机浮动 → 最终取整。公式部分用了一个简单的表达式解析器,支持配置表里写atk * 1.5 + 50这样的字符串。

// 伤害计算的核心步骤 calculate(attacker: Combatant, defender: Combatant, effect: EffectConfig): number { // 1. 解析公式,代入攻击方属性 let damage = ExpressionParser.eval(effect.formula, { atk: attacker.atk, def: defender.def, hp: attacker.hp }); // 2. 属性克制:火克草,伤害 x1.5 const multiplier = ElementChart.getMultiplier(effect.element, defender.element); damage *= multiplier; // 3. 防御减免:采用除法公式,避免高防无敌 damage = damage * (100 / (100 + defender.def)); // 4. 随机浮动 ±10% damage *= (0.9 + Math.random() * 0.2); // 5. 取整,最小为 1 return Math.max(1, Math.floor(damage)); }

防御用除法公式而不是减法,是为了避免“防御堆到一定值后伤害归零”的问题。100 / (100 + def)这个形式在 def 趋近无穷大时伤害趋近于 0 但不会到 0,数值上更平滑。随机浮动 ±10% 是常见做法,给战斗增加一点变数,但不会大到影响策略判断。

4. 避坑与排查:这套系统设计里最容易翻车的几个地方

4.1 行动条溢出值处理不当导致行动次数偏差

现象:高速角色在长时间战斗中,实际行动次数比理论值少,速度优势不明显。原因:行动条满后直接归零,没有保留溢出值。比如速度 200 的单位,每帧增长 200*dt,当 dt 较大时,一帧可能增长超过 1000,归零就浪费了超出部分。解决:用actionGauge -= GAUGE_MAX而不是actionGauge = 0,保留溢出值。同时确保 tick 的 deltaTime 不要过大,必要时在固定时间步长里跑逻辑。

4.2 Buff 叠加时持续时间刷新逻辑写反

现象:中毒 Buff 叠了 5 层,但每层持续时间没有独立计算,导致总伤害偏低或偏高。原因:stack规则下用了统一刷新持续时间,所有层共享一个 duration,先叠的层被后叠的层“续命”了。解决:如果项目需要精确的每层独立计时,Buff 对象里要存一个layers: { remaining: number }[]数组,每回合遍历扣减。工程里没做这个,用之前要评估自己的需求。

4.3 技能目标选择在单位死亡后的空指针

现象:AOE 技能结算时,如果第一个目标被击杀,后续目标选择报错或跳过。原因:目标列表在结算过程中被修改,遍历时索引错位或引用了已移除的单位。解决:结算前先对目标列表做一次快照([...targets]),结算过程中只读快照,实际移除操作延迟到结算结束后统一执行。

4.4 表现层动画未播完就推进回合

现象:伤害数字还在飘,下一个单位的行动已经开始,画面混乱。原因:逻辑层结算完直接派发下一个回合事件,没有等待表现层动画完成。解决:在表现层加一个isAnimating标志,逻辑层在派发下一个行动前检查这个标志,或者用 Promise/async 等待动画完成回调。工程里用的是事件 + 状态标志的方式,简单但有效。

4.5 配置表数值改了但运行没生效

现象:改了技能配置的伤害公式,重新运行游戏,伤害没变化。原因:Cocos Creator 的资源缓存,或者配置表被序列化进了 prefab。解决:确认配置表是运行时读取的 JSON 还是编译进代码的 TS 对象。如果是 JSON,检查 resources 目录的加载路径;如果是 TS 对象,确认没有被打包缓存。开发期可以在加载配置的地方加一行console.log确认读的是最新文件。

5. 把这套骨架接进你自己的项目:三个具体动作

5.1 替换配置表,跑通第一个自定义技能

拿到工程后,最快的验证方式是改一个技能配置,看能不能跑通。找到SkillConfig所在的文件,把skill_fireball的 formula 改成atk * 2.0 + 100,运行战斗,看伤害数字是否变化。这一步能确认配置表的读取链路是通的。

然后加一个新技能,比如“冰霜新星”,targetType 设为enemy_all,effects 里加一个 damage 和一个减速 Buff。如果 AOE 目标选择和 Buff 添加都正常,说明技能系统的扩展性是够的。

5.2 接入自己的角色数据

工程里的角色数据是硬编码的示例数据。要接入自己的项目,需要做两件事:一是把角色属性字段映射到Combatant接口,二是把技能列表从角色配置里读进来。常见做法是写一个CharacterAdapter,把外部数据转成Combatant对象。

// 角色数据适配器示例 function adaptCharacter(raw: any): Combatant { return { id: raw.id, name: raw.name, hp: raw.hp, maxHp: raw.maxHp, atk: raw.atk, def: raw.def, speed: raw.speed, element: raw.element, skills: raw.skillIds.map(id => SkillConfig.get(id)), buffs: [], actionGauge: 0 }; }

5.3 用固定种子复现战斗过程

回合制战斗如果有随机浮动,调试起来很痛苦。工程里没有内置随机种子控制,但可以自己加。把Math.random()替换成一个可注入的随机数生成器,调试时固定种子,就能复现同一场战斗。

// 可复现的随机数生成器(线性同余) class SeededRandom { private seed: number; constructor(seed: number) { this.seed = seed; } next(): number { // 线性同余参数,数值来自常见实现 this.seed = (this.seed * 1664525 + 1013904223) % 4294967296; return this.seed / 4294967296; } }

把DamageCalculator里的Math.random()换成this.rng.next(),调试时传入固定 seed,每次跑出来的伤害序列完全一致。这个习惯我是在一次排查“为什么同样的操作有时候能过有时候不能过”的问题时养成的,从那以后我每次做带随机的战斗系统,都强制走一遍种子复现的流程。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询