简介CocoKnightManager 是一套基于 Cocos Creator 的回合制游戏系统设计开源工程面向希望理解或复用回合制战斗框架的独立开发者、学生与初中级程序。它把回合流程拆成回合管理器、行动规则引擎与角色状态机等模块并用数据驱动方式配置属性、效果和事件适合用来学习战斗流程编排、模块解耦与内容配置也可作为小型回合制原型的基础骨架。压缩包共 22 个文件约 216KB包含 7 个 JSON 配置、7 个 meta 资源描述、3 张 PNG 贴图、2 个 TypeScript 脚本以及项目配置与说明文档等体量轻便便于快速导入 Cocos Creator 查看目录结构和运行逻辑。目前已有 840 人学习下载。读者可从中获得一套可参考的回合制系统分层思路、角色行动与状态切换的实现样例以及基于配置扩展技能和规则的实践线索适合对照自己的项目做拆解与二次开发。1. 一场双方同归于尽的战斗CocoKnightManager 要管的到底是什么有次看一个 Cocos Creator 的回合制 Demo打到第三回合两个骑士互相反击同归于尽血条都清零了回合指针却停住不动——逻辑层已经判了胜负表现层还在等一个永远不会来的actionEnd。单机小样里这种问题基本不出现单位一多、技能一带追击和反伤回合调度就是整个项目最容易翻车的地方。CocoKnightManager 要解决的正是这件事把回合推进、行动队列、技能结算、Buff 生命周期、配置驱动这几件最麻烦的事拆成一组职责单一的管理器跑在 Cocos Creator 的 TypeScript 环境里逻辑层和表现层靠事件解耦。这套系统开源的思路适合两类人——刚接触引擎、想照着搭出一个能跑通的回合制战斗骨架的新手以及手里项目已经写成一锅粥、想找套可替换参考的老手。2. 工程骨架目录划分、启动链路与一个不绑节点的 EventBus第一次翻这个工程我去scripts底下找那个「最大的战斗文件」没找到。整套战斗被切成十几个两百行以内的小文件单个文件读起来毫无压力。这是它最值得借鉴的地方不是代码写得多巧妙而是边界划得够狠。2.1 目录划分逻辑层不许 import 渲染组件assets/ scripts/ core/ # 与业务无关的底座 EventBus.ts # 全局事件总线 ObjectPool.ts # 飘字、特效的对象池 Rand.ts # 带种子的随机战斗回放要用 battle/ # 纯逻辑不出现 Node / Sprite BattleManager.ts # 战斗门面对外只暴露 start/quit TurnManager.ts # 回合状态机 行动队列 BattleUnit.ts # 单位数据与属性计算 DamagePipeline.ts # 伤害结算管线 ai/SimpleAI.ts # 敌方决策 skill/ SkillRunner.ts # 技能执行器 effects/ # 每种效果一个 handler buff/ BuffManager.ts # 加Buff、叠层、到回合结束扣时长 config/ ConfigLoader.ts # 配置表加载与索引 ui/ # 表现层只订阅事件不改数据 BattleHUD.ts DamageText.ts resources/ config/ # 导出的 json 表 prefabs/规矩就一条battle/、skill/、buff/里除了import { Vec3 } from cc这种纯数据类型不允许出现任何渲染相关的引用。只要守住这条线后面想把这套战斗逻辑搬到服务端跑校验改动量能压到很小。模块职责允许依赖core事件、对象池、随机数无battle回合、单位、伤害core、configskill / buff效果执行、状态维护core、battle 的数据结构ui播动画、飘字、血条只能监听事件提示如果只想快速验证战斗数值可以把ui/整个目录排除在构建外逻辑层照样跑得通这条线没守住就做不到。2.2 启动链路从 BattleManager 到 TurnManager整个战斗只有一个人口。UI 点「开始」之后调用的是BattleManager.ins.startBattle(stageId)剩下的都由它往下传。// battle/BattleManager.ts export class BattleManager { private static _ins: BattleManager; static get ins() { return this._ins ?? new BattleManager(); } private units: BattleUnit[] []; private turn!: TurnManager; private pipeline!: DamagePipeline; async startBattle(stageId: number) { // 1. 读关卡配置敌方阵容、站位、是否先手 const stage await ConfigLoader.ins.get(stage, stageId); // 2. 建单位逻辑层只持有数据一个 Node 都不碰 this.units []; for (const e of stage.entrances) { const u await BattleUnit.create(e.knightId, e.camp, e.slot); this.units.push(u); } // 3. 组装管线与回合管理器管线是每场战斗新建的避免上一场注册的 Buff 残留 this.pipeline new DamagePipeline(); this.turn new TurnManager(this.units, this.pipeline); // 4. 广播出去表现层自己决定怎么建节点 EventBus.ins.emit(BattleEvent.BattleStart, { units: this.units }); this.turn.run(); } }几个参数值得说清楚camp用 1/2 表示敌我排序和胜负判定都靠它slot是站位号同速时用来兜底排序保证逻辑端算出来的出手顺序是确定的pipeline刻意做成每场新建因为 Buff 会把修正函数注册进去复用会带上一场的脏数据这个坑后面还会提。startBattle是 async 的因为配置和单位资源都要异步拿。调用方必须await否则units还是空的就往下走了表现出来的就是「战斗开始但场上没人」。2.3 EventBus先解决「节点销毁了回调还在跑」逻辑层不碰节点那表现层怎么知道该播动画靠事件。这套事件总线写了不到六十行但off和遍历安全这两点处理得比很多现成库都稳。// core/EventBus.ts interface Listener { cb: Function; target?: object; } export class EventBus { private static _ins: EventBus; static get ins() { return this._ins ?? new EventBus(); } private map new Mapstring, Listener[](); on(evt: string, cb: Function, target?: object) { const arr this.map.get(evt) ?? []; arr.push({ cb, target }); this.map.set(evt, arr); } off(evt: string, cb: Function, target?: object) { const arr this.map.get(evt); if (!arr) return; for (let i arr.length - 1; i 0; i--) { if (arr[i].cb cb arr[i].target target) arr.splice(i, 1); } } emitT(evt: string, data?: T) { const arr this.map.get(evt); if (!arr) return; // 复制一份再遍历回调里可能 on/off直接遍历原数组会漏人或者错位 for (const l of arr.slice()) l.cb.call(l.target, data); } }target这个参数是后加的起因是一次切场景报了一堆「节点已销毁仍被访问」。on的时候把组件实例带进去组件onDestroy里按target一次性摘干净比记住每个匿名函数的引用靠谱得多。事件名我一般写成常量对象而不是裸字符串编译期就能挡住turnBegain这种拼错// battle/BattleEvent.ts export const BattleEvent { BattleStart: battle_start, RoundStart: round_start, TurnBegin: turn_begin, TurnEnd: turn_end, Damage: damage, UnitDead: unit_dead, BattleEnd: battle_end, } as const;3. 回合调度拆解TurnManager 的状态机、行动队列与伤害管线战斗逻辑最怕的不是算不出来是算出来了但两边不一致。TurnManager 的设计目标很明确任何时刻战斗处于哪个阶段必须是可枚举、可断言、可打日志的。3.1 回合状态机六个状态与合法迁移// battle/TurnManager.ts片段 export enum TurnState { Idle, // 未开始 RoundStart, // 回合开始重排行动队列、结算回合初 Buff WaitAction, // 等当前单位行动结束 Resolving, // 伤害结算中禁止再派发行动 RoundEnd, // 回合末Buff 扣时长、持续伤害 Finished, // 已分胜负 } private setState(next: TurnState) { // 只允许白名单里的迁移出问题第一时间在控制台炸出来别等到线上 const legal: RecordTurnState, TurnState[] { [TurnState.Idle]: [TurnState.RoundStart], [TurnState.RoundStart]: [TurnState.WaitAction, TurnState.Finished], [TurnState.WaitAction]: [TurnState.Resolving, TurnState.Finished], [TurnState.Resolving]: [TurnState.WaitAction, TurnState.RoundEnd, TurnState.Finished], [TurnState.RoundEnd]: [TurnState.RoundStart, TurnState.Finished], [TurnState.Finished]: [], }; if (!legal[this.state].includes(next)) { console.error(非法回合迁移: ${TurnState[this.state]} - ${TurnState[next]}); } this.state next; }我把Resolving单独拆出来是因为早期版本里技能连带追击在结算途中又把控制权交回队列结果同一个单位一回合动了三次。加了这一层nextAction()在Resolving状态下直接 return问题从源头消失。状态机的价值不在代码漂亮在于你可以在怀疑人生的时候打一行日志TurnManager.ins.state立刻知道卡在哪一步。3.2 行动队列与「后悔药」行动队列不是每回合都重新算的——那样 Buff 改变速度就白改了。做法是回合开始时按当前速度拍一份快照回合内不再重排中途阵亡的跳过。private buildQueue(): BattleUnit[] { return this.units .filter(u u.alive) // 速度降序同速用阵营、站位兜底保证双端算出来一致 .sort((a, b) b.spd - a.spd || a.camp - b.camp || a.slot - b.slot); } private nextAction() { if (this.state TurnState.Finished) return; while (this.queue.length) { const unit this.queue.shift()!; if (!unit.alive) continue; // 队列是快照中途被反伤打死的人要跳过 this.setState(TurnState.WaitAction); this.current unit; EventBus.ins.emit(BattleEvent.TurnBegin, { unit, round: this.round }); this.armFallback(unit); // 后悔药表现层不回 actionEnd 也不能停 return; } this.endRound(); } // 表现层动画播完必须回调且必须幂等 onActionEnd(unit: BattleUnit) { if (this.state ! TurnState.WaitAction || this.current ! unit) return; this.clearFallback(); this.current null; this.setState(TurnState.Resolving); this.checkFinish() ? this.finish() : this.nextAction(); } private armFallback(unit: BattleUnit) { this.clearFallback(); this._timer setTimeout(() { console.warn([Turn] ${unit.name} 的 actionEnd 超时强制推进); this.onActionEnd(unit); }, 3000); }那个 3 秒兜底是血泪经验。上线前测了无数次都好好的低端机上一卡顿、动画被切后台挂起actionEnd就再也没回来玩家看到的是整个战斗僵死。加了超时之后即使表现层出问题逻辑也能自己往下走顶多是一次动画穿帮不至于卡死流程。onActionEnd里的两次判断不能省state挡住重复回调动画事件偶尔会触发两次current ! unit挡住「上一单位的延迟回调把当前单位结束了」这种错位。3.3 伤害管线所有加成都挂在一个数组上伤害公式如果直接写成一串乘法后面每次加新 Buff 都得回去改公式。这里的做法是把结算拆成一条管线公式只负责给初始值其余修正按优先级插进去。// battle/DamagePipeline.ts export interface DamageContext { from: BattleUnit; to: BattleUnit; base: number; // 面板值算出来的原始伤害 kind: physical | magic; incRate: number; // 增伤万分比 decRate: number; // 减伤万分比 crit: boolean; final: number; } type Modifier (ctx: DamageContext) void; export class DamagePipeline { private mods: { fn: Modifier; priority: number }[] []; // 返回解绑函数Buff 到期时直接调用不用记住注册顺序 register(fn: Modifier, priority 100) { this.mods.push({ fn, priority }); this.mods.sort((a, b) a.priority - b.priority); return () { this.mods this.mods.filter(m m.fn ! fn); }; } run(ctx: DamageContext) { for (const m of this.mods) m.fn(ctx); // 全程整数运算最后兜一次底避免出现 0 伤害导致对面永远打不死 ctx.final Math.max(1, Math.floor(ctx.base)); ctx.to.applyDamage(ctx.final, ctx.from); EventBus.ins.emit(BattleEvent.Damage, ctx); return ctx.final; } }优先级约定099 给基础公式和装备100199 给 Buff 增减速200 以上给「免伤、护盾、复活」这类兜底效果。护盾必须放在最后因为它要看最终伤害值扣盾放前面就会被后面的乘算改掉。applyDamage里同步判断死亡并广播UnitDead但不直接操作队列——队列由 TurnManager 自己在下一次nextAction时跳过死者职责不串。这是这套骨架里我最认可的一处分工DamagePipeline 只负责「算出多少血」TurnManager 只负责「下一个谁动」。4. 数据驱动层骑士、技能、Buff 三张表怎么把策划案变成可跑的配置战斗逻辑写死一份策划要改数值就得找程序这种项目撑不过第二个版本。这套系统的做法是三张表 一张技能效果描述表全部走 json改数值不用重新编译脚本。4.1 三张表的字段设计表关键字段说明knightid、name、hp、atk、def、spd、skills[]、aiId一个骑士的静态面板与技能列表skillid、targetType、power、effects[]技能选目标的方式 效果数组buffid、type、stacks、duration、mods[]type 决定叠加规则独立 / 叠层 / 刷新时长stageid、entrances[]、firstCamp关卡阵容与先手方属性一律用整数存倍率用万分比。rate: 12000就是 120%比存 1.2 的浮点数省心两端比对的时候不会因为0.30000000000000004扯半天。技能效果写成有序数组不是对象{ id: 1001, name: 寒霜突刺, targetType: enemySingle, effects: [ { type: damage, kind: physical, rate: 12000 }, { type: buff, buffId: 2001, stacks: 1, duration: 2 } ] }有序这一点很关键。先打伤害再加减速和先减速再打伤害在「伤害是否享受减速带来的增伤」这种规则下结果完全不同写成对象就没法表达顺序了。4.2 技能执行器一个 handler 表 顺序遍历// skill/SkillRunner.ts type EffectHandler (c: SkillContext, e: any) void; const Handlers: Recordstring, EffectHandler { damage(c, e) { const crit Rand.ins.next() c.caster.critRate / 10000; for (const t of c.targets) { if (!t.alive) continue; // 前一条 effect 可能已经把人打死了 c.pipeline.run({ from: c.caster, to: t, kind: e.kind, base: c.caster.atk * e.rate / 10000, incRate: c.caster.incRate, decRate: t.decRate, crit, final: 0, }); } }, buff(c, e) { for (const t of c.targets) BuffManager.ins.add(t, e.buffId, e.stacks, e.duration); }, }; export function runSkill(c: SkillContext) { // 配置表里的 effects 是有序数组必须严格按顺序执行 for (const e of c.skill.effects) { const h Handlers[e.type]; if (!h) { console.warn(未注册的 effect 类型: ${e.type}); continue; } h(c, e); if (!c.caster.alive) break; // 反伤把自己反死后面的效果不再执行 } }crit在循环外算一次多目标技能要么全暴击要么全不暴击这是规则层面的决定写进循环里就变成每个目标独立暴击了改起来很难说清哪种才是对的。!t.alive那行判断看着多余实则不然——AOE 打死第一个目标后如果后续还有个「吸血」效果按目标数回血不检查就会给尸体回血。加新效果只要往Handlers里塞一个键不用改runSkill也不用改配置加载。这套扩展点在二次开发里最省事。4.3 配置加载缓存 二次索引// config/ConfigLoader.ts export class ConfigLoader { private static _ins: ConfigLoader; static get ins() { return this._ins ?? new ConfigLoader(); } private cache new Mapstring, Mapnumber, any(); private loading new Mapstring, Promisevoid(); async get(table: string, id: number) { if (!this.cache.has(table)) await this.load(table); const row this.cache.get(table)!.get(id); if (!row) console.error(配置缺失: ${table}#${id}); return row; } private load(table: string) { // 并发请求同一张表时只真正加载一次避免重复 IO if (!this.loading.has(table)) { this.loading.set(table, new Promisevoid((resolve, reject) { resources.load(config/${table}, JsonAsset, (err, asset) { if (err) return reject(err); const map new Mapnumber, any(); for (const row of (asset.json as any).rows) map.set(row.id, row); this.cache.set(table, map); resolve(); }); })); } return this.loading.get(table)!; } }loading这张表是为了解决一个很隐蔽的问题战斗开始瞬间UI 和逻辑层同时去要knight表resources.load被触发两次第二次回调回来把第一次的结果覆盖掉运气不好就是空引用。加了这个去重之后同一张表只会真正加载一次。5. 踩坑排查五个让回合停摆、血条失真的现场这一节列的都是我自己踩过或者帮人定位过的每一条都能对上具体日志。5.1 回合推进类队列里的死人和停不下来的回合现象一战斗打到一半敌方全灭但回合还在转一直刷turn_begin胜负结算弹窗死活不出来。原因checkFinish()写在onActionEnd里而反伤致死的情况下最后一下伤害是在Resolving阶段打出来的那个阶段根本没有actionEnd回调于是没人去判胜负队列空了就一路空转。解决把胜负判定从「行动结束」挪到「伤害结算之后」applyDamage发现目标死亡就抛UnitDeadTurnManager 监听后立刻checkFinish()同时endRound里补一次兜底判断。判定逻辑要幂等Finished之后所有入口直接 return。现象二某个单位一回合连续行动三四次日志里turn_begin同一个 id 出现多次。原因技能带追击追击在Resolving阶段执行完直接调了nextAction()把控制权提前交回去而原来的onActionEnd稍后又调了一次。解决就是 3.1 里那个状态机非WaitAction状态下nextAction直接拒绝配合onActionEnd的current校验两条路只可能有一条走通。5.2 数值结算类Buff 叠乘把伤害算飞现象三玩家同时挂三个增伤 Buff伤害从 1000 跳到 8000远超策划表里的预期。原因每个 Buff 的 handler 都是ctx.base * (1 rate)这么写的三个 30% 叠起来是 2.197 倍策划以为是 1.9 倍。更麻烦的是伤害和减伤交错乘顺序一变结果全变。解决改成加减法汇总ctx.incRate rate、ctx.decRate rate最后统一走一次base * (10000 inc - dec) / 10000。所有 Buff 只改汇总值不直接改base顺序无关两端也好对账。护盾、免伤这类必须看最终值的注册到 200 以上的优先级。现象四客户端显示打出 0 伤害服务端校验判定正常两边日志对不上。原因客户端管线里Math.floor之后没兜底某些高防御单位算出来是 0.6落地变成 0服务端用了Math.round变成 1。这类分歧在数值边界上出现得比想象中频繁。解决统一Math.max(1, Math.floor(x))而且这套取整规则必须两边抄同一份代码。我现在直接把 DamagePipeline 抽成一个不依赖引擎的纯计算模块前后端共用改一次两边都对。5.3 生命周期类切场景报「节点已销毁」现象五打完一场退回主城控制台刷一屏节点已销毁的报错偶尔还伴随伤害飘字飘到界面上。原因UI 组件在onLoad里EventBus.on(...)注册了Damage和TurnBeginonDestroy里没off更糟的是有几个回调是匿名函数想解绑都拿不到引用。解决一是on时把this作为 target 传进去onDestroy里按 target 批量摘二是对象池回收的飘字节点必须归还前停掉 tween三是场景切换时先emit(BattleEnd)让表现层主动清场再销毁节点。顺序反了就会收到「已经销毁的组件处理战斗事件」这种莫名其妙的报错。注意对象池不是万能药。节点归还后如果还有 tween 在跑下次取出时状态是脏的飘字会出现在奇怪的位置排查起来极其费劲。6. 二次开发加一个新骑士和一套技能要动哪几个文件给个具体场景策划要加一个「霜誓骑士」两个技能——「寒霜突刺」单体物理伤害并附带减速「冰墙」给自己套护盾。整个过程不需要动TurnManager也不用改DamagePipeline。步骤文件改动1resources/config/knight.json加一行配好面板和skills: [1001, 1002]2resources/config/skill.json加两行effects按顺序写3resources/config/buff.json减速 Buff 若不存在则新增mods里写{ spd: -2000 }4battle/ai/SimpleAI.ts若需要「血少优先套盾」的判断扩一个分支5ui/BattleHUD.ts新技能如果有特殊特效加一个事件分支护盾这个效果如果原来没有就得往SkillRunner的Handlers里加一条// skill/effects/shield.ts // 护盾不进 DamagePipeline 的乘算而是在最后一步抵扣所以优先级给 300 Handlers[shield] (c, e) { for (const t of c.targets) { const value Math.floor(c.caster.hpMax * e.rate / 10000); const unreg c.pipeline.register((ctx) { if (ctx.to ! t || t.shield 0) return; const absorb Math.min(t.shield, ctx.base); t.shield - absorb; ctx.base - absorb; // 只减 base后面还有别的修正会再算 }, 300); t.addShield(value, unreg); // 盾破或到期时调用 unreg 摘掉修正 } };这里有个容易翻车的地方unreg必须跟着盾走。单位阵亡、盾被打破、Buff 到期三条路径都要摘掉少摘一条下一场战斗里这个闭包还挂在管线上扣的是上一场的单位。我的做法是给BattleUnit.addShield内部维护一个清理列表reset()时统一清空BattleManager.startBattle里对所有单位调一次reset()。验证方法我固定用一套给Rand设种子跑 500 场无渲染模拟输出版本号、种子、双方阵容、每回合出手顺序和最终血量存成文本。改动前后各跑一遍diff 一下多出来的伤害、少掉的回合数全都藏不住。比肉眼看战斗快十倍而且能覆盖到那些手动点不出来的组合。从那以后我每加一个新技能都强制先跑一遍这 500 场模拟看 diff再进游戏手点一次——手点只能证明「能跑」模拟才能证明「没算错」。这套习惯帮我挡掉过好几次上线前的数值事故希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?