资讯动态

Cocos Creator回合制战斗系统设计:数据驱动与模块化实战

发布时间:2026/10/14 13:20:26 来源:尧图企业网站定制
简介CocoKnightManager 是一套基于 Cocos Creator 的开源回合制游戏系统设计面向希望快速搭建回合制战斗框架的开发者尤其适合具备一定 TypeScript 与引擎基础、想深入理解模块化架构的中高级学习者。资源包共 22 个文件约 216KB包含 7 个 json 配置、7 个 meta 元数据、2 个 ts 脚本、3 张 png 图片及 fire、md 等工程文件覆盖场景、脚本、纹理与项目配置等模块结构清晰便于按需查阅。系统围绕回合管理器、行动规则引擎与状态机三大组件展开将行动顺序、指令执行与状态切换抽象为独立模块并采用数据驱动方式通过 JSON 配置角色属性、效果与事件降低逻辑与内容耦合。目前已有 834 人学习读者可借此掌握回合制流程编排、模块拆分与数据配置思路并直接借鉴其目录组织与代码结构用于自身项目的快速原型搭建与二次扩展。1. 回合制战斗的骨架为什么值得从零搭一遍CocoKnightManager 这个标题核心落在两件事上一是 Cocos Creator二是回合制游戏的系统设计。很多做客户端的朋友第一次接回合制需求第一反应是“回合制简单不就是你一刀我一刀”真动手才发现战斗流程、行动队列、状态机、技能结算、数据驱动这几块一旦耦合在一起改一个技能数值能牵出三处崩溃。我见过一个模拟项目策划临时把“速度决定出手顺序”改成“速度加随机权重”结果整个战斗模块重写了将近四成。这篇笔记讲的就是怎么在 Cocos Creator 里把一套回合制战斗系统拆成能独立演进、能数据驱动、能复现的模块。适合两类人一类是刚接手回合制项目、需要一套可落地骨架的客户端开发者另一类是想把战斗逻辑从 UI 里剥出来、做单元验证的熟手。下面按“先立结构、再落代码、最后讲坑”的顺序推每一步都给到能直接抄的参数和命令。2. 战斗系统的模块切分与数据驱动选型回合制系统的复杂度不在“回合”两个字而在回合之间发生的所有事谁先动、动了之后谁掉血、掉血触发什么被动、被动又改了谁的出手顺序。如果把这些逻辑全塞进一个 BattleManager前期写得飞快后期每加一个技能都要动核心文件。所以第一步不是写代码是切模块。2.1 把战斗拆成五层而不是一个 Manager我一般会把回合制战斗切成五层每层只依赖下一层不反向调用层级职责典型文件表现层动画、飘字、血条、镜头BattleView流程层回合推进、阶段切换BattleFlow规则层出手排序、命中、伤害公式BattleRule单位层属性、状态、技能持有BattleUnit数据层配置表、存档、事件总线BattleData这样切的好处是规则层可以脱离 Cocos 引擎单独跑测试。比如伤害公式我可以在 Node 环境里用纯 TypeScript 跑几百组用例不用启动编辑器。很多团队翻车就翻在规则和表现混写改一个公式要等编辑器加载两分钟。选型上Cocos Creator 3.x 的 TypeScript 环境已经足够支撑这套结构不需要额外引入行为树或 ECS 框架。回合制的单位数量通常在个位数到几十ECS 的性能优势体现不出来反而增加理解成本。数据驱动用 JSON 配置加一个轻量注册表就够了别上数据库。2.2 用 JSON 配置驱动技能而不是硬编码技能是回合制里变化最频繁的部分。硬编码的写法是每个技能一个类加技能就要加文件、改工厂。数据驱动的写法是技能只描述“做什么”由解释器执行。先定义技能配置结构{ id: skill_1001, name: 重击, targetType: enemy_single, cost: { mp: 10 }, effects: [ { type: damage, formula: atk * 1.5 - def * 0.8, critRate: 0.15, critMultiplier: 2.0 }, { type: add_status, statusId: stun, duration: 1, chance: 0.3 } ] }effects是一个有序数组解释器按顺序执行。formula用字符串存运行时用安全表达式求值而不是eval。这里的关键参数是duration和chanceduration以“回合”为单位chance是 0 到 1 的浮点。我一般会把chance的判定放在效果执行前而不是伤害计算后否则会出现“伤害打出来了但眩晕没生效”的观感问题。解释器的核心是一个注册表type EffectHandler (ctx: BattleContext, effect: SkillEffect) void; const effectRegistry: Recordstring, EffectHandler { damage: (ctx, effect) { const value evalFormula(effect.formula, ctx.attacker, ctx.target); const isCrit Math.random() effect.critRate; const final isCrit ? value * effect.critMultiplier : value; ctx.target.hp - Math.max(1, Math.floor(final)); ctx.emit(damage, { target: ctx.target.id, value: final, isCrit }); }, add_status: (ctx, effect) { if (Math.random() effect.chance) return; ctx.target.status.add(effect.statusId, effect.duration); } };evalFormula不要用eval用一个小型解析器或者把公式预编译成函数。Math.max(1, ...)是保底伤害防止高防御单位完全免伤导致战斗卡死。ctx.emit走事件总线表现层订阅后播动画规则层不碰节点。2.3 行动队列用优先队列别用数组排序出手顺序是回合制的核心。常见错误是每回合把所有单位塞进数组按速度sort一次。单位少的时候没问题一旦有“行动后插入新单位”“速度被 buff 改变”的情况数组排序的时机就很难把握。正确做法是维护一个优先队列键是“行动值”。每个单位有一个actionValue速度越高行动值增长越快。每次取行动值最高的单位行动行动后它的行动值减去一个固定阈值。class ActionQueue { private units: BattleUnit[] []; push(unit: BattleUnit) { this.units.push(unit); this.units.sort((a, b) b.actionValue - a.actionValue); } pop(): BattleUnit { const unit this.units.shift()!; unit.actionValue - ACTION_THRESHOLD; this.push(unit); return unit; } updateSpeed(unit: BattleUnit, newSpeed: number) { unit.speed newSpeed; unit.actionValue unit.actionValue * (newSpeed / unit.baseSpeed); this.units.sort((a, b) b.actionValue - a.actionValue); } }ACTION_THRESHOLD我一般设 1000速度 100 的单位每回合行动值增长 100大约 10 次行动后触发一次。updateSpeed里按比例调整行动值而不是直接重置这样速度变化对出手顺序的影响是连续的不会出现“加了速度反而排到后面”的玄学现象。3. 在 Cocos Creator 里跑通最小战斗循环结构定好之后落到 Cocos Creator 里。这一章给一个能跑起来的最小循环两个单位、一个技能、一次伤害结算。不涉及美术资源用 Label 显示血量即可。3.1 场景搭建与脚本挂载新建一个 2D 场景层级如下Canvas ├── BattleRoot (空节点挂 BattleFlow) │ ├── UnitA (Label: HP) │ └── UnitB (Label: HP) └── LogLabel (Label)BattleFlow是流程层入口挂载后自动开始战斗。单位节点上挂UnitView负责把数据层的血量同步到 Label。import { _decorator, Component, Label } from cc; const { ccclass, property } _decorator; ccclass(UnitView) export class UnitView extends Component { property(Label) hpLabel: Label null!; private unitId: string ; bind(unitId: string) { this.unitId unitId; BattleData.instance.on(hp_changed, this.onHpChanged, this); } private onHpChanged(payload: { id: string; hp: number }) { if (payload.id ! this.unitId) return; this.hpLabel.string HP: ${payload.hp}; } onDestroy() { BattleData.instance.off(hp_changed, this.onHpChanged, this); } }bind在BattleFlow初始化时调用传入单位 id。事件总线用单例BattleData避免节点之间互相引用。onDestroy里必须解绑否则场景切换后旧节点还会收到事件这是 Cocos 里最常见的泄漏来源之一。3.2 回合推进的驱动方式回合推进有两种驱动一种是update里轮询一种是事件驱动。回合制我推荐事件驱动因为每个阶段之间通常要等动画。用async/await把流程写成顺序代码可读性最好。async startBattle() { while (!this.isBattleOver()) { const unit this.queue.pop(); this.log(轮到 ${unit.name} 行动); const skill unit.selectSkill(); if (!skill) continue; await this.executeSkill(unit, skill); await this.waitAnimation(); this.checkBattleOver(); } this.log(战斗结束); } private executeSkill(attacker: BattleUnit, skill: SkillConfig) { return new Promisevoid((resolve) { const ctx new BattleContext(attacker, this.pickTarget(attacker, skill)); for (const effect of skill.effects) { const handler effectRegistry[effect.type]; if (handler) handler(ctx, effect); } resolve(); }); }waitAnimation返回一个 Promise表现层动画播完后 resolve。这样流程层不需要知道动画多长只等一个信号。pickTarget根据targetType选目标enemy_single就选对方血量最低的enemy_all返回数组这些规则都放在规则层。3.3 伤害结算与事件回传伤害结算在规则层完成结果通过事件总线回传表现层。关键参数是伤害公式的系数和保底值。function evalFormula(formula: string, attacker: BattleUnit, target: BattleUnit): number { const vars: Recordstring, number { atk: attacker.atk, def: target.def, hp: attacker.hp, maxHp: attacker.maxHp }; // 用预编译的表达式求值这里用简化版 const expr formula.replace(/[a-zA-Z_]/g, (m) String(vars[m] ?? 0)); return Function(use strict; return (${expr});)() as number; }Function构造在这里只用于演示生产环境建议换成解析器避免注入风险。公式里的变量名要和vars的键一致atk、def这些是约定。伤害计算后通过ctx.emit(damage, ...)发出UnitView订阅后更新 Label同时可以触发飘字动画。4. 状态机与技能结算的避坑清单这一章集中讲踩过的坑。回合制系统里状态和技能结算是 bug 重灾区很多问题不是逻辑错而是时机错。4.1 状态持续时间在回合开始还是结束扣现象眩晕状态有时持续两回合有时一回合策划反复提 bug。原因状态扣减的时机不统一。有的写在回合开始时有的写在单位行动后。如果单位被眩晕跳过行动行动后的扣减就不会执行导致状态多留一回合。解决统一在“回合结束”阶段扣减所有单位的 status duration。流程层每轮所有单位行动完后调用statusManager.tickAll()。tickAll里对每个 status 的duration - 1减到 0 移除。这样无论单位是否行动状态都会正常倒计时。4.2 被动技能触发顺序导致伤害计算错误现象两个被动同时改攻击力最终伤害和预期差很多。原因被动触发顺序不确定。如果先算伤害再触发加攻被动伤害就少了如果先触发再算就对了。但多个被动之间还有依赖。解决给被动定义优先级数值修正类被动优先级高于伤害计算类。在executeSkill里分两阶段先跑preCalculate阶段的被动再算伤害最后跑postCalculate阶段的被动。优先级用数字表示越小越先执行。4.3 行动值溢出导致单位连续行动现象某个高速单位连续行动三次其他单位没机会出手。原因行动值没有上限速度 buff 叠加后单次增长超过阈值导致actionValue一直最高。解决给actionValue设上限比如ACTION_THRESHOLD * 2。超过上限时截断保证一个单位最多连续行动两次。同时速度 buff 的叠加用乘法而非加法避免数值爆炸。4.4 事件总线在场景切换后仍持有旧节点现象切换场景后控制台报错提示节点已销毁。原因UnitView在onDestroy里解绑了但BattleFlow订阅的事件没解绑或者事件总线是全局单例跨场景存活。解决所有订阅必须在onDestroy或onDisable里解绑。事件总线提供offAll(target)方法在场景根节点销毁时统一清理。另外战斗结束后主动BattleData.instance.clear()不要依赖 GC。4.5 配置表热更新后旧存档读不出来现象策划改了技能配置玩家旧存档加载时技能 id 找不到战斗直接卡死。原因存档里存的是技能 id配置表更新后 id 被删或改名。解决配置表只增不删废弃的技能标记deprecated: true而不是删除。存档加载时如果找不到技能回退到默认技能并打日志不要让流程中断。同时给配置表加版本号存档里记录版本加载时做迁移。5. 用日志和回放验证战斗正确性战斗系统的验证比普通模块难因为涉及随机数。我的习惯是给战斗加一个可复现的随机种子配合结构化日志任何一局战斗都能回放。5.1 随机种子与确定性随机所有随机数走一个统一的Random实例种子在战斗开始时确定。class SeededRandom { private seed: number; constructor(seed: number) { this.seed seed; } next(): number { this.seed (this.seed * 9301 49297) % 233280; return this.seed / 233280; } }9301、49297、233280是线性同余的经典参数周期够用。战斗中所有Math.random()替换成random.next()。这样只要种子相同暴击、命中、状态触发的结果完全一致。5.2 结构化日志与回放脚本每次关键结算打一条 JSON 日志function logEvent(type: string, data: Recordstring, unknown) { const entry { tick: BattleData.instance.tick, type, data }; BattleData.instance.logs.push(entry); console.log(JSON.stringify(entry)); }tick是回合内的行动序号每次pop后加一。回放时按tick顺序重放对比每步的 hp、status 是否一致。我一般会写一个 Node 脚本读日志文件用同样的种子和配置跑一遍断言结果一致。这个脚本在 CI 里跑能挡住大部分规则层的回归。5.3 用表格对比预期与实际验证伤害公式时我会列一张表把典型单位的属性代入手算预期值和程序输出对比。攻击方 atk防御方 def公式预期伤害实际伤害10050atk1.5 - def0.8110110100200atk1.5 - def0.8118030atk1.5 - def0.89696第二行是保底伤害的验证Math.max(1, ...)生效。这张表放在测试用例里每次改公式跑一遍比口头确认靠谱。5.4 一个具体技巧把战斗跑在 Node 里Cocos Creator 的脚本默认依赖引擎但规则层可以剥离。把BattleUnit、BattleRule、ActionQueue放在一个不 importcc的目录里用ts-node直接跑。npx ts-node ./tests/battle.spec.tsbattle.spec.ts里构造两个单位跑一百局断言没有死循环、没有负血量、没有状态残留。这个习惯让我在改公式时不用打开编辑器几秒钟就能验证。血泪经验是越早把规则层和引擎解耦后期改需求越轻松。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑