Cocos Creator回合制战斗系统拆解:状态机、技能与Buff设计 简介CocoKnightManager是一套基于Cocos Creator构建的回合制游戏系统设计面向卡牌对战、策略战棋等回合制玩法核心服务需要快速落地战斗框架的游戏开发者和初中级编程学习者。资源包共22个文件由TypeScript源码、JSON数据配置、资源meta文件、PNG图片和Markdown说明文档组成压缩包仅216KB目录粒度适合按模块对照学习。系统围绕回合制特性进行了清晰抽象包含三个关键组件回合管理器负责控制角色行动顺序与暂停等待行动规则引擎处理攻击、防御、技能等指令并支持自定义规则以适配不同策略深度状态机则维护移动、受伤、攻击等角色状态确保逻辑顺畅转换三者形成完整的状态流转闭环。设计上强调数据驱动角色属性、技能效果、事件流程均可在JSON中配置简化编程工作同时便于非程序员快速调整游戏数值。整体采用模块化架构各系统可灵活组合和扩展提高代码维护性与复用性。这套开源源码既可以作为组件直接集成到Cocos Creator工程中也能作为设计范本帮助开发者学习模块化拆解、数据配置与规则解耦的具体实现路径省去从零搭建系统的时间。目前已有834人浏览学习适合希望快速构建回合制框架或研究游戏系统架构的开发者参考借鉴。1. CocoKnightManager一套能直接改的开源回合制战斗系统CocoKnightManager是一套基于Cocos Creator的回合制游戏系统设计开源骨架。卡牌、战棋、Roguelike这些“打架逻辑是回合制”的项目都可以拿它当战斗层的地基。我拿到它拆包时的第一感觉是它不碰美术资源也不替你定玩法数值但把最容易被新手写成一坨乱麻的战斗状态机、行动顺序、技能数据表、Buff结算链先钉好了。你半天能跑起Demo一周内能下手改自己的技能。这篇是我的拆包笔记按“循环怎么驱动→技能怎么结算→常见翻车点→怎么加自动战斗”的顺序讲代码是核心逻辑示意和源码包里的实现思路一致。2. 把回合循环做成状态机行动顺序、回合管理与驱动方式2.1 为什么回合制必须先定状态机而不是先写技能很多人做回合制时犯的第一个错是先写技能把“火球术”“连斩”写得风生水起结果技能之间没法连招战斗节奏一塌糊涂。原因很简单技能是树叶回合循环才是树干。回合制游戏本质上是“严格的并发游戏”——多个单位轮番行动每次只有一个行为源在推进数据。如果不用状态机钉死你就得靠一堆布尔变量去猜“现在是第几阶段”最后查Bug查到怀疑人生。CocoKnightManager的核心做法是用一个BattlePhase枚举配合一个BattleController去持有所有战斗状态。枚举固定为五个阶段Idle等待开局、RoundStart回合开始、ActorTurn谁行动、SkillResolve技能结算、RoundEnd回合结束。整个战斗循环就是在这五个状态里串行流转。状态机的价值不在于“高大上”而在于它给你一道明显的闸门玩家手再快动画没放完、按钮连点一百次只要阶段不在ActorTurn点击事件一概进不到结算逻辑里。这就是回合制最需要的“状态锁”。有人会问状态机和普通if分支有什么区别。区别在于状态机是“唯一的事实来源”战斗系统里所有写数据的逻辑都得经过Phase的许可。写if散弹枪的写法是互相独立的判断多个判断同时成立时数据就改乱了。状态机从结构上把这个可能性封死尤其是你做连击、援护、待机这类“插入式行动”时没有状态机几乎是做不出来的。2.2 最小可运行的战斗循环一个简化版BattleLoopCocoKnightManager里那套循环在源码包里有完整实现。我先给一个能看懂核心思路的最小版本// BattleLoop.ts —— 核心循环示意 export enum BattlePhase { Idle Idle, RoundStart RoundStart, ActorTurn ActorTurn, SkillResolve SkillResolve, RoundEnd RoundEnd } export interface Actor { id: string; speed: number; skills: string[]; } export class BattleLoop { private queue: Actor[] []; private phase: BattlePhase BattlePhase.Idle; private listeners: Mapstring, Function[] new Map(); // 战斗开始把单位按速度排序写入行动队列 start(fighters: Actor[]) { this.queue this.sortBySpeed(fighters); this.enterPhase(BattlePhase.RoundStart); } // 唯一的状态推进入口 private enterPhase(p: BattlePhase) { this.phase p; this.emit(p); if (p BattlePhase.RoundStart) { this.nextActor(); } else if (p BattlePhase.RoundEnd) { // 清空队列并准备下一回合 this.queue this.sortBySpeed(this.queue); this.enterPhase(BattlePhase.RoundStart); } } // 当前行动者操作完毕时由渲染层手动调用 onActorDone() { if (this.phase ! BattlePhase.ActorTurn) return; this.queue.shift(); this.nextActor(); } // 让渲染层挂监听 on(event: string, cb: Function) { if (!this.listeners.has(event)) this.listeners.set(event, []); this.listeners.get(event)!.push(cb); } private emit(event: string, ...args: any[]) { this.listeners.get(event)?.forEach(cb cb(...args)); } private nextActor() { if (this.queue.length 0) { this.enterPhase(BattlePhase.RoundEnd); return; } this.enterPhase(BattlePhase.ActorTurn); } // 速度高的排前这是最朴素的回合排序 private sortBySpeed(actors: Actor[]): Actor[] { return [...actors].sort((a, b) b.speed - a.speed); } }逻辑说明整个循环只有一个推进入口enterPhase谁想改阶段都必须从它走。RoundStart时触发一次回合开始事件然后立刻把队列头的人拉进ActorTurn。当前单位操作完时渲染层调用onActorDone()这里先检查当前的阶段不在ActorTurn阶段就直接拒绝防止等动画时的连点把队列推乱。队列空了就进RoundEnd把全员重新排序开始下一回合。参数说明sortBySpeed是“速度一轮一排”的方案适合传统回合制如果你的战斗是速度条随时间累积的ATB制就不要用排序改用timePoint累加的方式每次更新后找timePoint最大且未行动的Unit。这套结构里同样可以接ATB但要把nextActor改成“按时间轴扫描”而不是弹出队首。2.3 接上Cocos场景把渲染层与战斗层隔开拿到源码包后最常犯的错是直接战斗里写this.node.getChildByName(hpBar)。CocoKnightManager的边界划得很清楚战斗层只处理和返回数据渲染层只负责展示和回放。接入Cocos时在场景里挂一个BattleViewAdapter组件把它和BattleLoop绑定起来// BattleViewAdapter.ts —— Cocos 场景里的桥接组件 import { _decorator, Component, Animation } from cc; import { BattleLoop, BattlePhase } from ./BattleLoop; const { ccclass, property } _decorator; ccclass(BattleViewAdapter) export class BattleViewAdapter extends Component { property(Animation) attackAnim: Animation | null null; private battle: BattleLoop new BattleLoop(); start() { // 局势变化时刷新界面 this.battle.on(BattlePhase.ActorTurn, (actor: any) { this.showSkillButtons(actor.skills); this.setAllButtonsEnabled(true); }); this.battle.on(BattlePhase.SkillResolve, () { this.setAllButtonsEnabled(false); }); } setAllButtonsEnabled(enable: boolean) { // 实际项目里遍历按钮节点控制 interactable this.buttonGroup.active enable; } onSkillClicked(skillId: string) { // 状态锁只有轮到行动才能响应 if (this.battle.phase ! BattlePhase.ActorTurn) return; this.setAllButtonsEnabled(false); // 这里只是示意真正的技能派发看第3章的 SkillResolver this.playSkillAnimation(() { this.battle.onActorDone(); }); } private playSkillAnimation(done: Function) { if (this.attackAnim) { this.attackAnim.play(); this.attackAnim.once(Animation.EventType.FINISHED, () done()); } else { done(); // 没有动画时直接完成防止流程卡死 } } }逻辑说明batch.on把战斗层的数据变化转发给UIonSkillClicked先检查phase防止SkillResolve阶段再次响应然后播动画动画结束后调用onActorDone()。动画这场演出就是状态锁的延时器这点非常重要后面第4章我会专门讲它引起的坑。参数说明动画时长直接影响回合手感。我一般建议普通攻击0.5~0.8秒技能演出1~1.5秒。太短玩家看不清打谁太长整个战斗像在熬汤。CocoKnightManager源码里把动画时长作为配置项暴露给策划而不是写死在代码里。3. 技能与Buff结算让一套代码吃下所有角色与敌人3.1 技能配置表把技能从逻辑变成数据拆这个项目时我最满意的地方是技能不是写死的if (skillId fireball)而是全部读配置表。项目里用JSON作为配置载体字段大致如下字段类型说明idstring技能唯一ID逻辑层只认它namestring技能名给策划和本地化用的targetTypestringenemy单体制敌 / enemyAll群敌 / self等multipliernumber攻击倍率0表示纯功能技能hitRatenumber命中率0~1暴击与闪避走独立字段effectListstring[]BuffID数组命中后按序挂载animNamestring渲染层播放的动画名逻辑层不关心对应到JSON大概是这个结构{ skills: [ { id: slash, name: 横斩, targetType: enemy, multiplier: 1.0, hitRate: 0.95, effectList: [], animName: slash_anim }, { id: fireball, name: 火球术, targetType: enemy, multiplier: 1.5, hitRate: 0.85, effectList: [burn_3], animName: fireball_anim } ] }逻辑说明配置表的价值在于让策划可以通过填表加技能而不是改代码。每加一个新技能相当于在逻辑层注册一个新的“行为类型”但结算走的是同一套管线。数据驱动的代价是需要约定好字段命名一旦策划把布尔值填成字符串错误要到运行期才爆出来。源码包里带了一份字段校验脚本接项目时建议保留。参数说明effectList里的字符串会到Buff注册表里查找对应的Buff工厂。CocoKnightManager注册表的key是字符串IDvalue是创建Buff的构造函数这种映射方式在Cocos原生环境下也能正常工作因为它不依赖动态反射。如果你的项目用了Cocos Creator 3.x的插件机制也可以把Buff实现类注册到插件里但字符串映射是最不容易出错的。3.2 伤害公式与Buff结算链把顺序钉死伤害结算最怕的是“计算结果不稳定”。同样一次攻击先算暴击还是先算减伤结果能差出20%。CocoKnightManager的做法是提供一个统一的DamageResolver所有技能都从它过// DamageResolver.ts —— 伤害结算核心所有技能都走这 export interface BuffContext { name: string; remainingTurn: number; stacks: number; } export function calcDamage( attacker: { attack: number; critRate: number; critDamage: number }, skill: { multiplier: number; hitRate: number }, defender: { defense: number; buffs: BuffContext[] } ): { damage: number; isCrit: boolean; isHit: boolean } { // 1. 判定命中 if (Math.random() skill.hitRate) { return { damage: 0, isCrit: false, isHit: false }; } // 2. 基础攻击力×技能倍率得到基础伤害 let base attacker.attack * skill.multiplier; // 3. 减防Buff统一在这个位置作用 let defense defender.defense; const defBuff defender.buffs.find(b b.name def_break); if (defBuff) { defense - defense * 0.3 * Math.min(defBuff.stacks, 3); } // 4. 减伤Buff在这里生效每层额外减伤5% const guardBuff defender.buffs.find(b b.name guard); base * 1 - (guardBuff ? 0.05 * guardBuff.stacks : 0); // 5. 最终伤害为(基础伤害-防御)下限保护保证最低1点 let damage Math.max(1, Math.floor(base - defense)); // 6. 暴击判定放在减伤之后暴击伤害不参与防御减免 const isCrit Math.random() attacker.critRate; if (isCrit) { damage Math.floor(damage * attacker.critDamage); } return { damage, isCrit, isHit: true }; }逻辑说明计算顺序是“是否命中 → 基础攻击 → 减防 → 减伤 → 最终数值 → 暴击”。这个顺序不是随便定的减防应该作用在防御面板上不能作用在已经结算完的伤害上暴击放在最后意思是暴击伤害不再吃第二次防御。如果你想要“暴击无视防御”的机制应该加在defense计算的前一步而不是在damage上乘一个系数。参数说明Math.min(defBuff.stacks, 3)意思是减防最多叠3层防止多段技能把防御打成负数。这个叠加上限建议作为配置暴露给策划不要写死。Math.max(1, ...)是伤害下限它能避免低攻角色打不动高防坦克产生的“个位数伤害”也是一种反馈。3.3 扩展一个“连击被动”不动结算链怎么加新机制要验证系统好不好扩展最快的方法是自己加一个被动。CocoKnightManager的风格是Buff自行订阅结算事件而不是把判断塞进calcDamage里。比如要做“攻击时25%概率追加一刀”只需要挂一个buff// ExtraAttackBuff.ts —— 挂上之后攻击时概率追加一次普通攻击 export function createExtraAttackBuff(chance: number) { return { name: extra_attack, remainingTurn: 3, stacks: 1, onAfterSkill(attacker: any, target: any, resolver: any) { if (Math.random() chance * this.stacks) { // 复用解析器而不是新写一套伤害计算 const fakeSkill { multiplier: 0.6, hitRate: 0.9 }; const result resolver.calc(attacker, fakeSkill, target); resolver.emitDamageNumber(target, result.damage); } } }; }逻辑说明onAfterSkill是CocoKnightManager里Buff可以订阅的一个钩子。普通攻击或技能结算完毕后系统会遍历所有存活单位身上的Buff调用对应钩子。连击追加的伤害依然走resolver.calc所以暴击、减伤、减防在追加伤害里一样生效不需要额外写一套。参数说明chance * this.stacks的意思是叠层每层增加概率。这类参数很容易被策划调整出数值膨胀接项目时建议在Buff注册表里做一层合法性检查数值超过0.8就直接报警。扩展性的核心是新技能的机制由“配置表字段 新Buff的钩子函数”组合出来calcDamage永远不动。4. 高频翻车点排查动画演出、异步回调和数值越界4.1 现象动画播放时连点技能按钮回合进度整段垮掉这个坑是最常见的。表现是玩家在技能演出阶段狂按其他技能结果行动单位变了伤害数字还在飘甚至出现“对尸体放技能”。原因渲染层没有把阶段状态当作唯一闸门onSkillClicked里没检查battle.phase或者检查组了但用了某个已过时的副本变量。解决像第2章示例那样在onSkillClicked的第一行强制校验phase ! BattlePhase.ActorTurn就return然后立刻把按钮组interactable置灰。即便玩家手速快事件也会被这层闸门过滤掉。我见过最稳的方案是加一个isInputLocked的布尔量不止依赖phase因为战斗进入结算子流程时phase可能是ActorTurn但此时仍然不该响应点击。4.2 现象上一回合挂的减伤Buff新回合还一直生效表现是敌人给自己上的“守备”效果被主角打了一套连击后完全没消失下回合还在。原因BuffManager的过期逻辑只依赖remainingTurn为0时移除而没有在RoundEnd阶段统一扫一遍。如果某个Buff的remainingTurn初始化时填了-1表示无限持续它就会永远留在Actor身上。解决在RoundEnd阶段做全量清理对所有Buff执行tick(onRoundEnd)并检查过期标记。我习惯给每个Buff加一个isExpired字段由钩子自己决定何时过期清理函数只负责移除过期项。这样“持续到战斗结束”“持续到下回合开始”这些特殊Buff都能用同一套逻辑表达。4.3 现象技能结算时伤害跳字的顺序和设计不一致表现是群体技能的跳字每次都不一样有时先飘最右边敌人的数字有时先飘左边看起来像随机的。原因结算过程里用了多个独立异步回调每个敌人被命中时立刻发一个“伤害数字”事件而Cocos的UI刷新顺序并不保证和结算顺序一致。解决把所有结算结果先算完成一个数组比如[{targetId, damage}, ...]再一次性派发给渲染层。渲染层拿到数组后按数组顺序播放飘字动画。这么做还有一个好处回放和自动战斗可以复用同一套“结算结果数组”而不必真的等到动画放完再解析战果。4.4 现象场景切换后再进战斗技能回调打到了已销毁的Node上表现是切场景后开新战斗控制台报node is not active或Cannot read property of destroyed node。原因战斗管理器的EventTarget是全局单例场景里的组件在onDestroy时只解绑了自己监听的Cocos事件没解绑BattleLoop里的自定义事件旧节点的回调仍然被引用。解决在场景组件的onDestroy里强制调用battle.off()或者在BattleLoop内部用WeakRef持有监听函数。我拆这个项目时源码包用的是on/off配对法接入时一定要记得在onDestroy里对应清理。一个快速排查技巧在emit函数里console.warn当前监听函数数量如果切换一次场景数量没变少就是有泄漏。4.5 现象打包APK后首场进战斗卡顿在编辑器里完全不卡表现是Android打包后点“开始战斗”黑屏两秒技能图标和音效慢慢蹦出来编辑器预览却一切正常。原因Cocos Creator默认资源是按需加载编辑器本地读盘快感觉不出来APK里资源在压缩包内缺少预加载时首场景就要现解压。解决在进入战斗场景的onLoad里主动resources.preload技能图标、音效和动画缓存同时把Buff注册表在gameStart时就构建完成不要在First Battle时才去遍历注册。参考CocoKnightManager的启动流程它把战斗相关资源拆到一个独立Bundle里加载完Bundle再Start Battle首场卡顿基本能压到0.5秒以内。5. 进阶把自动战斗、回放和动效接进这套管理器自动战斗是回合制游戏的高频需求。很多人以为自动战斗要重写一套AI系统实际上在CocoKnightManager里只需要一个策略函数替换掉“人的点击入口”。我接项目时把onSkillClicked的调用源抽象成一个CommandProvider手动模式下是UI按钮自动模式下是AI策略函数两者最终都指向同一个battle.executeSkill(skillId, targetId)方法// AutoBattleAI.ts —— 自动战斗的极简策略 export function chooseAction(actor: any, enemies: any[], battle: any) { // 低血量先奶自己否则用第一个攻击技能打血量最少的敌人 if (actor.hp / actor.maxHp 0.3 actor.skills.includes(heal)) { return { skillId: heal, targetId: actor.id }; } const weakest enemies.reduce((a, b) (a.hp b.hp ? a : b)); return { skillId: actor.skills[0], targetId: weakest.id }; }逻辑说明AI只负责“决定做什么”真正的执行依然走battle.executeSkill。这样自动战斗和手动战斗共用一套状态机和结算不会出现“手动正常、自动打到一半卡死”的分叉问题。我这个策略函数没有引用任何渲染层数据是因为战斗层的数据全部存在Actor对象里AI和UI读的是同一份快照。回放功能也是从这套结构里自然长出来的。我不建议在每一帧去录战斗状态那样数据量太大还容易和Cocos的场景节点绑定在一起。正确做法是只录操作指令{ tick: 第几回合, actorId: 谁, skillId: 用什么, targetId: 打谁 }。回放时把时间轴快进通过同一个executeSkill重新执行指令即可。需要注意的是回放时要把随机数种子固定否则暴击和命中的判定每次都会不一样回放就成了“重新打一场”。动效方面伤害飘字和技能拖尾在回合制里很吃手感。源码包里对motionstreak的用法是把它当作技能的残影效果干净利落地在动画播放时激活、播完隐藏。打包APK后这类拖尾经常出问题表现为拖尾不消失、颜色发白或闪线。我排查过几次原因多半是拖尾组件在节点禁用时没有正确清理顶点数据。建议在动画结束回调里显式调用motionstreak.reset()而不是只隐藏节点。如果出现stretch检查一下Cocos给拖尾的Overflow设为Hidden还是Visible这会直接影响合图和渲染结果。自动战斗、回放、这些需求我都实现过但每次改完都必须回到那个最基本的问题上战斗层和渲染层的边界有没有被破坏。从那以后我接任何回合制项目第一件事就是先定BattlePhase枚举和BattleLoop的接口把它们当作文档看。源码包里的目录结构也维持了同一套风格你照着拆能少走很多弯路。这套系统设计是一个能撑住小中型回合制项目的骨架希望帮到你。本文还有配套的精品资源点击获取