简介:CocoKnightManager 是一套基于 Cocos Creator 的开源回合制游戏系统设计工程,面向希望掌握回合制玩法架构的初中级游戏开发者与独立团队,帮助其解决回合流程控制、行动规则抽象与角色状态管理等常见设计难题。压缩包共 22 个文件,约 216KB,以 json 配置、meta 资源描述、png 贴图、ts 脚本及 fire 场景文件为主,配合 README 说明,构成可直接导入引擎的轻量工程骨架。项目围绕回合管理器、行动规则引擎与状态机三大组件展开,并采用数据驱动思路,将属性、效果与事件交由 JSON 配置,便于非程序人员调整内容。目前已有 836 人学习下载,读者可从中获取模块化系统拆分方式、场景与脚本组织范例以及可复用的回合制逻辑参考,适合作为学习借鉴与二次开发的基础模板。
1. 回合制战斗的“黑匣子”:为什么我盯上了这套 Cocos Creator 系统设计
接手一个半成品的回合制项目,最让人头疼的不是写不出伤害公式,而是战斗流程散落在十几个脚本里,谁先出手、buff 什么时候结算、动画播到一半玩家点了跳过会怎样,全靠口口相传。我最近拆的一套开源资源 CocoKnightManager,就是冲着这个痛点去的:它把回合制游戏里最容易被写成一锅粥的部分——行动队列、状态机、技能结算、表现层解耦——拆成了可复用的模块,基于 Cocos Creator 的组件体系落地。适合两类人:正在用 Cocos Creator 做回合制 Demo、卡牌或战棋原型,卡在“流程能跑但改不动”阶段的开发者;以及想看懂一套完整战斗系统该怎么分层、怎么留扩展点的人。系统开源,意味着你能直接对着结构改,而不是从零猜。
2. 拆开行动队列:回合制的时间轴到底怎么排
回合制游戏的“回合”两个字,落到代码里其实是一个调度问题。CocoKnightManager 没有用简单的数组轮询,而是把每个参战单位抽象成一个带速度属性的行动实体,用一个基于速度累积的调度器决定下一个行动者。这个选择很关键:纯轮询实现快,但一旦要做“速度影响出手频率”“某些技能插队”“眩晕跳过回合”,轮询就会变成到处打补丁。速度累积模型的好处是,所有“谁先动”的逻辑收敛到一个地方,改规则只动调度器。
2.1 行动实体的数据结构与速度累积
先看单位在战斗中的核心字段。这套设计里,单位不是直接拿节点当数据源,而是有一层纯数据描述,节点只是表现。常见做法是定义一个 BattleUnit 类,把运行时状态和配置分开:
// BattleUnit.js —— 战斗单位的纯数据描述,不持有节点引用 export default class BattleUnit { constructor(config) { this.uid = config.uid; // 唯一标识,用于队列和查找 this.camp = config.camp; // 阵营,决定敌我判定 this.baseSpeed = config.speed; // 基础速度,来自配置表 this.speedModifier = 0; // 速度增减,buff 写入这里 this.actionGauge = 0; // 行动条累积值,达到阈值即行动 this.hp = config.hp; this.maxHp = config.hp; this.alive = true; this.buffs = []; // 状态列表,结算时遍历 } get currentSpeed() { // 速度下限保护,避免减速叠满后除零或负数 return Math.max(1, this.baseSpeed + this.speedModifier); } }这段代码的重点不在字段多少,而在actionGauge和currentSpeed的分离。行动条是“已经攒了多少”,速度是“每帧攒多快”。调度器每推进一个时间片,就给所有存活单位累加currentSpeed * deltaTime,谁的 gauge 先越过阈值谁行动。逻辑说明:把“速度”和“进度”拆开,意味着减速 buff 只改speedModifier,不需要去动已经攒下的进度,避免“减速后进度倒退”这种反直觉表现。参数上,阈值一般取 1000 或 100,取大一点精度高、取小一点计算省,回合制对精度要求不高,100 足够。
2.2 调度器的推进与插队处理
调度器是这套系统里我最愿意抄的部分。它维护一个待行动列表,每次 tick 找出 gauge 最高的单位,而不是维护一个固定顺序:
// TurnScheduler.js —— 基于行动条的战斗调度器 export default class TurnScheduler { constructor(units, threshold = 100) { this.units = units; this.threshold = threshold; this.extraTurns = []; // 插队队列,优先级高于正常累积 } // 推进时间片,返回本片内应行动的单位列表 tick(deltaTime) { const ready = []; // 插队单位优先出队,不参与累积 while (this.extraTurns.length > 0) { ready.push(this.extraTurns.shift()); } for (const unit of this.units) { if (!unit.alive) continue; unit.actionGauge += unit.currentSpeed * deltaTime; if (unit.actionGauge >= this.threshold) { unit.actionGauge -= this.threshold; // 保留溢出,避免浪费 ready.push(unit); } } // 同一时间片多人就绪时,按当前速度降序,速度相同按 uid 稳定排序 ready.sort((a, b) => b.currentSpeed - a.currentSpeed || a.uid - b.uid); return ready; } // 技能触发的额外行动,直接塞进插队队列 grantExtraTurn(unit) { this.extraTurns.push(unit); } }逻辑说明:actionGauge -= threshold而不是清零,是为了保留溢出进度,高速度单位不会因为“刚好越过”而损失一点积累,长期看出手频率才和速度成正比。ready.sort里加了 uid 兜底,是因为速度相同的两个单位如果排序不稳定,同一场战斗在不同设备上出手顺序可能不一样,这种玄学问题排查起来非常费劲。参数说明:deltaTime建议用固定步长(比如 1/30 秒)而不是真实帧间隔,回合制不需要跟真实时间强绑定,固定步长能让战斗结果可复现,做录像回放和自动化测试时省很多事。插队队列单独维护,是为了让“再行动”“反击”这类技能不污染正常累积,规则边界清晰。
3. 状态机与技能结算:buff 什么时候生效才不出乱子
回合制最容易翻车的地方不是伤害算错,而是结算顺序。中毒和回血同时存在,先扣还是先加?buff 到期是在行动前还是行动后移除?CocoKnightManager 用了一个显式的战斗状态机,把“回合开始、行动、回合结束”切成明确的阶段,每个阶段只做该做的事。这个思路值得所有做回合制的人借鉴:不要在一个 update 里把所有结算揉在一起。
3.1 战斗阶段划分与状态迁移
状态机把一场战斗切成几个阶段,每个阶段有进入和退出钩子。常见做法是用一个枚举加 switch,而不是引入重型状态机库,回合制的阶段数量有限,手写更可控:
// BattleStateMachine.js —— 显式阶段划分 export const BattlePhase = { INIT: 'init', TURN_START: 'turnStart', WAIT_ACTION: 'waitAction', ACTION_EXECUTE: 'actionExecute', TURN_END: 'turnEnd', SETTLE: 'settle', }; export default class BattleStateMachine { constructor(context) { this.ctx = context; // 战斗上下文,持有调度器和单位列表 this.phase = BattlePhase.INIT; } enter(phase) { this.phase = phase; switch (phase) { case BattlePhase.TURN_START: // 回合开始:结算持续伤害、刷新 buff 持续时间 this.ctx.applyTurnStartEffects(); break; case BattlePhase.TURN_END: // 回合结束:移除到期 buff、检查胜负 this.ctx.expireBuffs(); this.ctx.checkBattleEnd(); break; default: break; } } // 由表现层在动画播完后回调,驱动状态前进 onActionFinished() { if (this.phase === BattlePhase.ACTION_EXECUTE) { this.enter(BattlePhase.TURN_END); } } }逻辑说明:把TURN_START和TURN_END分开,是为了让“持续伤害在回合开始结算、buff 到期在回合结束移除”这条规则有明确落点。很多项目把两者混在行动前统一处理,结果就是“本回合刚上的 buff 立刻掉一层”,玩家会觉得莫名其妙。参数上,applyTurnStartEffects内部遍历 buff 列表时,要按 buff 的优先级字段排序,护盾类先于伤害类,避免“先扣血再上盾”导致本该挡住的伤害没挡住。onActionFinished由表现层回调,是这套设计解耦的关键:逻辑层不关心动画多久,只等一个完成信号。
3.2 技能结算的伤害管线与 buff 叠加规则
技能结算被拆成一条管线:取目标、算基础伤害、过减伤、过暴击、应用 buff、写回结果。每一步都是纯函数,方便单测。buff 叠加规则单独抽出来,因为这是策划改得最频繁的地方:
// DamagePipeline.js —— 伤害结算管线,每步可替换 export function calcDamage(attacker, defender, skill) { // 1. 基础伤害:攻击力 * 技能倍率 let dmg = attacker.atk * skill.ratio; // 2. 防御减伤:常见做法是除法公式,避免高防无敌 dmg = dmg * (100 / (100 + defender.def)); // 3. 暴击:暴击率来自属性,暴击伤害倍率单独配置 const isCrit = Math.random() < attacker.critRate; if (isCrit) dmg *= attacker.critDamage; // 4. 取整并保底 1 点,避免出现 0 伤害的观感问题 return { value: Math.max(1, Math.floor(dmg)), isCrit }; } // BuffStackRule.js —— 叠加规则集中管理 export function applyBuff(unit, buffConfig) { const exist = unit.buffs.find(b => b.buffId === buffConfig.buffId); if (!exist) { unit.buffs.push({ ...buffConfig, stacks: 1, remainTurns: buffConfig.duration }); return; } switch (buffConfig.stackType) { case 'refresh': // 刷新持续时间,不叠层 exist.remainTurns = buffConfig.duration; break; case 'stack': // 叠层,受最大层数限制 exist.stacks = Math.min(exist.stacks + 1, buffConfig.maxStacks); exist.remainTurns = buffConfig.duration; break; case 'independent': // 独立存在,各自计时 unit.buffs.push({ ...buffConfig, stacks: 1, remainTurns: buffConfig.duration }); break; default: break; } }逻辑说明:伤害公式用100 / (100 + def)而不是减法,是为了让防御收益递减,避免后期堆防导致完全免伤,这是回合制数值设计的血泪经验。Math.max(1, ...)保底 1 点,是因为 0 伤害在表现上很难和“未命中”区分,玩家会以为是 bug。buff 叠加用stackType字段驱动,而不是在技能里写 if-else,好处是新增 buff 只改配置不改代码。参数说明:maxStacks必须配,否则叠层类 buff 会无限增长;duration的单位要和状态机的回合计数对齐,别一个用秒一个用回合。
4. 表现层解耦:逻辑跑完再播动画,还是边跑边播
这是回合制项目里争议最大的地方。CocoKnightManager 的选择是逻辑先行、表现跟随:战斗逻辑一次性算出结果,把“发生了什么”打包成事件序列,表现层按序列播放。这样做的好处是逻辑可单测、可回放、可跳过;代价是表现层要能处理“事件排队”。我见过太多项目把动画回调写进伤害计算里,结果加个二倍速就全乱套。
4.1 事件队列与动画回调的衔接
逻辑层产出事件,表现层消费事件,中间用队列缓冲。关键点是:表现层播放期间,逻辑层不能继续推进,否则事件会堆积错位:
// BattleEventQueue.js —— 逻辑与表现之间的缓冲 export default class BattleEventQueue { constructor() { this.queue = []; this.playing = false; } push(event) { // event: { type: 'damage' | 'buff' | 'death', payload: {...} } this.queue.push(event); this.tryPlayNext(); } tryPlayNext() { if (this.playing || this.queue.length === 0) return; this.playing = true; const event = this.queue.shift(); // 交给表现层,播完调用 onComplete this.presenter.play(event, () => { this.playing = false; this.tryPlayNext(); }); } // 跳过:清空队列,直接结算到最终状态 skipAll() { this.queue.length = 0; this.playing = false; this.presenter.snapToFinalState(); } }逻辑说明:playing标志保证同一时刻只有一个事件在播,避免两个伤害数字同时弹出导致看不清。skipAll是后悔药:玩家点了跳过,不能只是把动画加速,而要把队列清空并让表现层直接对齐逻辑层的最终状态,否则会出现“动画没播完但血条已经变了”的割裂。参数说明:事件里的payload要带足够信息(伤害值、是否暴击、目标 uid),表现层不应该反查逻辑层状态,否则解耦就白做了。常见做法是事件只描述“结果”,不描述“过程”,过程由表现层自己决定怎么演。
4.2 用组件挂载表现,避免逻辑脚本膨胀
Cocos Creator 的组件体系在这里派上用场:每个战斗单位节点挂一个表现组件,订阅事件队列,而不是让战斗管理器直接操作节点。这样换一套美术资源,逻辑层一行不用改:
// UnitView.js —— 挂在单位节点上的表现组件 const { ccclass, property } = cc._decorator; @ccclass export default class UnitView extends cc.Component { @property(cc.Label) hpLabel = null; @property(cc.Animation) anim = null; onLoad() { // 向事件总线注册,只处理与自己 uid 相关的事件 this.node.on('battle-event', this.onBattleEvent, this); } onBattleEvent(event) { if (event.payload.targetUid !== this.unitUid) return; switch (event.type) { case 'damage': this.playHurt(event.payload.value, event.payload.isCrit); break; case 'death': this.playDeath(); break; default: break; } } playHurt(value, isCrit) { // 动画播放期间不阻塞逻辑,逻辑层靠事件队列的 playing 标志等待 this.anim.play(isCrit ? 'hurt_crit' : 'hurt'); this.showDamageNumber(value, isCrit); } }逻辑说明:表现组件只认targetUid,不关心伤害怎么算出来的,这是解耦的底线。playHurt里动画和飘字并行,但整体完成信号由事件队列的onComplete统一发出,表现组件不需要自己通知逻辑层“我播完了”,职责单一。参数说明:unitUid要在组件初始化时从逻辑层注入,别在组件里自己生成,否则和逻辑层对不上。这套结构下,加一个新技能特效,只需要在表现层加一个事件类型分支,逻辑层完全不动。
5. 避坑与排查:这套系统落地时最容易踩的五个坑
拆开源项目最怕“看着能跑,一改就崩”。下面这几条是我在复现和改造过程中真实遇到的,按现象、原因、解决写清楚,能帮你少走弯路。
5.1 行动条溢出导致出手顺序漂移
现象:同一套配置,两次战斗的出手顺序偶尔不一样,尤其是速度接近的单位。原因:调度器里actionGauge越过阈值后如果直接清零,高速度单位每次都会损失一点溢出进度,累积下来顺序就变了;另外排序没加稳定兜底,速度相同时顺序随机。解决:用actionGauge -= threshold保留溢出,排序时加 uid 作为第二关键字,并且deltaTime用固定步长而非真实帧间隔。
5.2 buff 到期时机错位,护盾挡不住当回合伤害
现象:玩家本回合上的护盾,敌方同回合攻击却没挡住,下回合护盾还在。原因:buff 的remainTurns在TURN_START就减一,导致刚上的 buff 立刻少一层;或者移除逻辑写在行动前,把本该生效的护盾提前摘了。解决:buff 持续时间统一在TURN_END递减和移除,TURN_START只做持续伤害结算;上 buff 的当回合不扣持续时间,从下一回合开始算。
5.3 表现层回调丢失,战斗卡在“等待动画”
现象:偶尔战斗停住,没有任何报错,点哪里都没反应。原因:某个动画事件因为节点被销毁或动画被中断,onComplete没触发,事件队列的playing永远是 true。解决:给每个表现事件加超时兜底,超过预期时长强制调用onComplete;节点销毁时主动从事件队列注销,别让已销毁的组件继续持有回调。
5.4 伤害出现 0 或负数,玩家以为是 bug
现象:高防御单位受到攻击显示 0 伤害,或者减伤叠满后出现负数。原因:减法公式在高防时归零,或者多个减伤乘算后浮点误差。解决:公式改成除法100/(100+def),结果Math.max(1, Math.floor(dmg))保底 1 点;减伤类 buff 用乘算并限制总减伤上限(比如 80%),别让多个减伤线性叠加到 100%。
5.5 逻辑层直接引用节点,换皮时全线崩
现象:想换一套单位美术,结果发现逻辑脚本里到处是this.node.getChildByName(...)。原因:逻辑和表现没分层,战斗单位直接持有节点引用。解决:逻辑层只操作BattleUnit纯数据,表现层通过 uid 订阅事件;节点查找只在表现组件内部做,逻辑层拿不到也不该拿节点。改造时先把所有node引用从逻辑脚本里剔出去,再逐个补表现组件。
6. 进阶:把战斗做成可回放、可自动测试的确定性系统
这套系统真正值钱的地方,不是它能跑一场战斗,而是它的结构允许你把战斗变成“确定性”的——同样的初始状态和同样的随机种子,必然得到同样的结果。做到这一点,录像回放、战斗校验、自动化平衡测试才有基础。我一般会做两件事:把随机数收口到一个可注入种子的生成器,把整场战斗的输入输出序列化。
先看随机数收口。伤害管线里的暴击判定如果直接用Math.random(),回放就对不上。常见做法是包一层带种子的伪随机:
// SeededRandom.js —— 可复现的随机数,战斗内所有随机走这里 export default class SeededRandom { constructor(seed) { this.seed = seed >>> 0; } // 线性同余,够用且可复现,不需要密码学强度 next() { this.seed = (this.seed * 1664525 + 1013904223) >>> 0; return this.seed / 4294967296; } // 替换 Math.random 的调用点,暴击、命中、掉落都走它 chance(rate) { return this.next() < rate; } }逻辑说明:种子在战斗开始时确定并记录,回放时用同一个种子重建生成器,暴击序列就能完全复现。参数上,seed用 32 位无符号整数,>>> 0保证不溢出成负数;线性同余的乘加常数是固定的,不需要改,够回合制用。注意所有随机调用必须走同一个实例,别有的地方用Math.random有的用生成器,那样种子就失去意义了。
再进一步,把整场战斗的“输入”记录下来:初始单位快照、每个行动者的技能选择、随机种子。输出则是事件序列。回放时重放输入,比对输出事件序列是否一致,不一致就说明有隐藏的随机源或状态泄漏。这套东西做出来之后,改数值、改 buff 规则,跑一遍自动对战就能发现哪条规则把平衡打破了,比人工点几十场靠谱得多。
| 验证项 | 做法 | 通过标准 |
|---|---|---|
| 出手顺序确定性 | 同种子跑 100 次 | 事件序列完全一致 |
| 伤害可复现 | 固定种子比对伤害值 | 每次数值相同 |
| buff 时序 | 构造护盾+中毒同回合 | 结算顺序符合阶段定义 |
| 跳过一致性 | 正常播完 vs 跳过 | 最终状态完全相同 |
最后说个习惯。从那以后我每次接回合制项目,第一件事不是写技能,而是先把调度器和状态机的骨架搭出来,用两个木桩单位跑通“谁先动、buff 怎么掉、跳过对不对”,这三件事稳了再往上堆内容。骨架不稳,后面每加一个技能都是在给黑匣子添乱。希望帮到你。
本文还有配套的精品资源,点击获取