Cocos Creator回合制战斗框架:TurnManager与ActionQueue调度解析 简介CocoKnightManager 是一套基于 Cocos Creator 引擎的开源回合制游戏系统设计工程面向希望掌握回合制玩法架构的初中级游戏开发者与独立团队。项目以模块化为核心将回合管理器、行动规则引擎与角色状态机拆分为独立组件并通过 JSON 等数据配置驱动属性、效果与事件便于灵活组合与二次扩展。压缩包共 22 个文件约 216KB包含 7 个 json 配置、7 个 meta 资源描述、3 张 png 贴图、2 个 ts 脚本及 fire、md 等工程与说明文件完整呈现了场景、脚本与配置的组织方式。目前已有 836 人学习下载。读者可从中获取回合流程控制、行动规则抽象与状态切换的参考实现理解数据驱动设计如何降低内容调整成本并借助开源代码快速搭建自己的回合制原型节省底层系统搭建时间。1. 回合制战斗的骨架CocoKnightManager 到底管了什么很多回合制项目死在“战斗逻辑和界面糊在一起”上按钮点一下伤害计算、动画播放、状态刷新全塞在同一个回调里改一个技能要翻五个文件。CocoKnightManager 这个开源工程就是冲着这个痛点来的它把回合制游戏里最容易失控的那部分——回合调度、行动队列、技能结算、状态机切换——单独抽成一套可复用的系统层Cocos Creator 只负责表现。换句话说它管的不是“一个骑士长什么样”而是“谁先动、动了之后世界变成什么样”。适合两类人手里有 Cocos Creator 基础、想做一个能扩展的回合制 Demo 的开发者以及已经写过一版战斗逻辑、被状态同步和技能叠加折磨过、想看看别人怎么分层的人。系统开源代码结构比功能数量更值得看。2. 工程结构与技术选型为什么把战斗逻辑从节点树里拽出来2.1 目录分层表现层、系统层、数据层三刀切拿到工程先别急着点运行把目录扫一遍能省掉后面一半的迷茫。这个项目的目录不是按“场景/预制体/脚本”这种引擎默认习惯堆的而是按职责切assets/ scripts/ core/ # 回合调度器、行动队列、事件总线 TurnManager.ts ActionQueue.ts EventBus.ts battle/ # 战斗规则伤害公式、技能结算、buff 叠加 BattleContext.ts SkillResolver.ts BuffSystem.ts units/ # 单位数据与运行时状态 UnitModel.ts UnitState.ts ui/ # 纯表现血条、行动条、飘字 BattleHUD.ts TurnIndicator.ts prefabs/ units/ # 单位预制体只挂表现脚本 data/ skills.json # 技能配置逻辑层读取 units.json # 单位基础属性core不 import 任何cc的 UI 组件battle只依赖core和unitsui单向依赖battle暴露的事件。这条依赖方向是整个工程能二次开发的前提——你想换一套美术表现只动ui和prefabs规则层一行不用碰。2.2 技术栈与版本假设TypeScript 事件驱动工程用 TypeScript 写Cocos Creator 3.x 的组件体系。为什么强调 TS回合制的状态字段多当前 HP、行动值、buff 剩余回合、技能冷却用any满天飞的 JS 写两周后自己都不记得state.t是回合数还是目标。项目里UnitState是个明确的接口// units/UnitState.ts export interface UnitState { id: string; // 单位唯一标识战斗内不变 hp: number; // 当前生命 maxHp: number; actionValue: number; // 行动值越小越先动 buffs: BuffInstance[]; // 当前挂载的 buff 列表 cooldowns: Recordstring, number; // 技能 id - 剩余冷却回合 }字段含义直接决定调度逻辑actionValue用“越小越先动”而不是“速度越大越先动”是为了让插入行动比如某个技能让目标立刻再动一次变成简单的数值减法不用重排整个队列。这是回合制里很常见的一个设计取舍新手容易一开始用速度排序后面加“推条/拉条”技能时就得推倒重来。2.3 事件总线UI 不轮询逻辑不碰节点EventBus是个极简的发布订阅没有引入第三方库。核心就三个方法on、off、emit。战斗层算完一次伤害emit(unit:damaged, payload)UI 层监听后播飘字、刷血条。好处是逻辑层完全不知道血条节点叫什么名字测试时甚至可以不起场景直接跑纯逻辑。// core/EventBus.ts type Handler (payload?: any) void; export class EventBus { private static handlers: Mapstring, Handler[] new Map(); static on(event: string, fn: Handler) { const list this.handlers.get(event) ?? []; list.push(fn); this.handlers.set(event, list); } static off(event: string, fn: Handler) { const list this.handlers.get(event); if (!list) return; this.handlers.set(event, list.filter(h h ! fn)); // 防止重复注册导致回调叠加 } static emit(event: string, payload?: any) { this.handlers.get(event)?.forEach(fn fn(payload)); } }参数说明event用模块:动作的命名约定unit:damaged、turn:start、skill:cast避免不同模块撞名payload不约束类型是刻意的简化但实际项目里建议按事件名建一个 payload 类型映射表否则半年后没人记得turn:start带的是回合数还是单位 id。这里就是典型的“先跑通再收紧”的写法。3. 回合调度核心TurnManager 与 ActionQueue 怎么配合3.1 行动队列用最小堆还是排序数组回合制的“谁先动”本质是个优先队列问题。项目里ActionQueue用的是排序数组而不是堆原因很实际一场战斗通常 612 个单位每次取最小值时sort一下的开销可以忽略而数组在调试时能直接console.log看到完整顺序堆看不到。这是典型的“规模决定数据结构”的取舍别一上来就上堆。// core/ActionQueue.ts import { UnitState } from ../units/UnitState; export class ActionQueue { private units: UnitState[] []; push(unit: UnitState) { this.units.push(unit); } // 取出行动值最小的单位行动值相同则按 id 稳定排序 popNext(): UnitState | undefined { this.units.sort((a, b) a.actionValue ! b.actionValue ? a.actionValue - b.actionValue : a.id.localeCompare(b.id) ); return this.units.shift(); } // 行动结束后把单位放回队列并推进其行动值 requeue(unit: UnitState, baseActionValue: number) { unit.actionValue baseActionValue; // 累加而非重置保证速度差异体现在长期频率上 this.units.push(unit); } peekAll(): UnitState[] { return [...this.units].sort((a, b) a.actionValue - b.actionValue); } }关键在requeue里的而不是。如果每次行动后把行动值重置成固定值那速度属性就只影响第一次出手后面大家轮流坐庄回合制的节奏感就没了。累加的做法让“速度 120 的单位每 100 行动值动一次、速度 80 的每 150 动一次”这种频率差异自然浮现。peekAll是给 UI 画行动条用的返回副本防止外部改到内部数组。3.2 TurnManager一个回合的完整生命周期TurnManager是调度的大脑它不关心伤害怎么算只负责“轮到谁、什么时候开始、什么时候结束”。一个回合的链路是turn:start→ 取队首单位 → 检查能否行动眩晕等控制→ 交给外部决策玩家输入或 AI→ 执行行动 →turn:end→ 单位回队 → 下一回合。// core/TurnManager.ts import { ActionQueue } from ./ActionQueue; import { EventBus } from ./EventBus; import { UnitState } from ../units/UnitState; export class TurnManager { private queue new ActionQueue(); private round 0; private current: UnitState | null null; init(units: UnitState[]) { units.forEach(u this.queue.push(u)); } startNextTurn(): UnitState | null { const next this.queue.popNext(); if (!next) return null; this.current next; this.round; EventBus.emit(turn:start, { unit: next, round: this.round }); // 控制类状态在这里拦截而不是散落在各技能里 if (this.isStunned(next)) { EventBus.emit(turn:skipped, { unit: next }); this.endTurn(); return this.startNextTurn(); // 递归推进到下一个可行动单位 } return next; } endTurn() { if (!this.current) return; EventBus.emit(turn:end, { unit: this.current }); this.queue.requeue(this.current, this.current.baseActionValue ?? 100); this.current null; } private isStunned(u: UnitState): boolean { return u.buffs.some(b b.type stun b.remaining 0); } }这里有两个值得抄的点。第一控制状态眩晕、冰冻统一在startNextTurn里拦截而不是让每个技能自己判断“目标能不能动”否则新增一种控制就要改 N 个技能。第二被跳过回合的单位仍然走endTurn回队保证行动值正常推进不会因为被控就永久掉队。递归调用startNextTurn在极端情况下全队被控会栈溢出实际项目里建议改成while循环这是我在类似结构上翻过车的地方。3.3 和 UI 的对接行动条只读 peekAllTurnIndicator这个 UI 组件每帧或每次turn:start时调queue.peekAll()拿排序后的单位列表按顺序摆头像。它不持有任何调度逻辑也不改队列。这种“UI 只读、逻辑只写”的边界是后面加“预览未来三回合行动顺序”功能时不用动核心代码的原因。如果你接手后发现行动条和实际出手顺序对不上八成是 UI 自己维护了一份顺序而不是读peekAll。4. 技能结算与 Buff 系统伤害公式和状态叠加怎么不打架4.1 SkillResolver把“技能”拆成配置加解析器技能不写成一个个类而是skills.json里的数据 一个统一的SkillResolver解析。这样加新技能是改配置不是加文件。配置长这样{ id: slash, name: 横斩, targetType: enemy_single, effects: [ { type: damage, power: 1.2, scaling: atk }, { type: buff, buffId: bleed, chance: 0.3, duration: 2 } ], cooldown: 2 }effects是数组意味着一个技能可以同时造成伤害和挂 buff顺序执行。chance让概率类效果不用写进代码。解析器按type分发// battle/SkillResolver.ts import { BattleContext } from ./BattleContext; import { BuffSystem } from ./BuffSystem; export class SkillResolver { static resolve(ctx: BattleContext, casterId: string, targetId: string, skill: any) { const caster ctx.getUnit(casterId); const target ctx.getUnit(targetId); if (!caster || !target) return; for (const effect of skill.effects) { if (effect.chance ! undefined Math.random() effect.chance) continue; switch (effect.type) { case damage: { const base caster.atk * effect.power; // 攻击力 × 倍率 const reduced base * (1 - target.def / (target.def 100)); // 防御减伤100 是调参常数 const final Math.max(1, Math.round(reduced)); // 保底 1 点防止高防无敌 target.hp - final; ctx.emit(unit:damaged, { unitId: targetId, amount: final, source: casterId }); break; } case buff: BuffSystem.apply(target, effect.buffId, effect.duration ?? 1); break; } } } }防御减伤用def / (def 100)而不是直接减法是为了避免“防御堆到超过攻击力就完全免伤”的数值崩坏这是回合制里最常见的公式选择。Math.max(1, ...)的保底也是血泪经验没有它高防单位会让战斗变成无限平局。常数 100 是调参入口想让你游戏前期节奏快就调小后期数值膨胀就调大。4.2 BuffSystem叠加规则必须显式定义Buff 最容易出 bug 的地方不是“加没加上”而是“同一个 buff 加两次会怎样”。项目里BuffInstance带remaining和stacks叠加策略在BuffSystem.apply里按 buff 类型查表// battle/BuffSystem.ts import { UnitState } from ../units/UnitState; interface BuffDef { id: string; type: stun | bleed | atk_up; stackMode: refresh | stack | ignore; // 叠加策略 maxStacks?: number; } export class BuffSystem { static apply(unit: UnitState, buffId: string, duration: number) { const def BuffSystem.getDef(buffId); const existing unit.buffs.find(b b.id buffId); if (!existing) { unit.buffs.push({ id: buffId, type: def.type, remaining: duration, stacks: 1 }); return; } switch (def.stackMode) { case refresh: // 只刷新持续时间不叠层 existing.remaining Math.max(existing.remaining, duration); break; case stack: // 叠层受上限约束 existing.stacks Math.min((existing.stacks ?? 1) 1, def.maxStacks ?? 99); existing.remaining Math.max(existing.remaining, duration); break; case ignore: // 已存在则不再施加 break; } } // 每回合结束时统一递减避免各技能自己减导致漏减或双减 static tickAll(unit: UnitState) { unit.buffs.forEach(b b.remaining--); unit.buffs unit.buffs.filter(b b.remaining 0); } private static getDef(id: string): BuffDef { // 实际项目从 buffs.json 读这里给默认值示意 return { id, type: bleed, stackMode: refresh }; } }tickAll统一在turn:end时对全场单位调用一次而不是让每个 buff 自己记时。这样“持续 2 回合”的语义是确定的施加当回合不算之后两个回合结束各减一次。很多项目 buff 时长错乱就是因为施加时机和递减时机分散在不同地方。4.3 BattleContext一次战斗的共享状态容器BattleContext持有所有单位、当前回合数、随机数种子如果要回放战斗并提供getUnit、emit等便捷方法。它不写业务逻辑只做状态聚合。这样SkillResolver和BuffSystem都是无状态的静态方法测试时构造一个BattleContext塞几个UnitState就能跑不用起 Cocos 场景。这是整个工程最值得学的一点把“能纯逻辑测试”当成架构目标而不是事后补测试。5. 避坑与排查跑起来之后最容易卡住的几个点5.1 现象行动条顺序和实际出手对不上原因通常是 UI 自己缓存了一份顺序或者peekAll返回的是内部数组引用被外部改了。解决确认peekAll返回副本工程里用了[...this.units]UI 每次turn:start重新拉取不要onLoad时拉一次就存着。另外检查requeue的有没有被误改成。5.2 现象某个单位被眩晕后永久不再行动原因startNextTurn里被跳过的单位没有走endTurn没回队列。解决跳过分支里必须先endTurn再递归/循环取下一个。工程里递归写法在“连续多个单位被控”时还可能栈溢出建议改成while (true)循环取到可行动单位再break。5.3 现象buff 持续时间比配置多一回合或少一回合原因施加 buff 的时机和tickAll的时机在同一回合内顺序不定。解决约定tickAll只在turn:end调用一次且施加 buff 的技能结算发生在turn:start之后、turn:end之前。如果技能是在turn:end里触发的比如持续伤害要明确它是否参与当回合递减工程里默认不参与。5.4 现象伤害飘字重复播放或播放两次原因EventBus.on在组件onEnable里注册但onDisable没off场景切换后旧回调还在。解决所有on必须配对off或者用onEnable/onDisable成对管理。EventBus是静态的生命周期比节点长这是静态事件总线最常见的坑。5.5 现象技能配置改了但游戏里没生效原因skills.json被 Cocos 当资源加载后缓存或者路径写错读到了旧文件。解决确认 JSON 放在resources下用resources.load读改完清一次构建缓存开发期可以在BattleContext初始化时打印读到的技能数量数量不对就是没读到。6. 二次开发换一套战斗规则要动哪几层想把这个工程改成自己的游戏最省力的路径是从数据层往上改而不是从 UI 往下改。第一步改units.json和skills.json把职业、技能、数值换成你的设定这一步不动代码就能看到完全不同的战斗。第二步如果要做“多段攻击”或“追击”在SkillResolver的effects里加一个type: multi_hit分支按hits字段循环调用伤害计算注意每段都要单独emit飘字事件否则 UI 只显示一次。第三步如果要做“行动条推拉”在ActionQueue加modifyActionValue(unitId, delta)直接改actionValue后重新sort因为队列规模小不用维护堆的索引。验证改造是否正确的办法很土但有效写一个纯逻辑的测试脚本不启动 Cocos直接new TurnManager()、塞三个UnitState、循环调startNextTurn和endTurn二十次打印每次出手的单位 id 和行动值。如果顺序和频率符合预期再回引擎里接 UI。这个习惯让我在改数值和调度规则时少走了很多“改完跑场景看半天”的弯路。改造目标要动的文件是否碰 UI换数值/技能data/*.json否加新效果类型SkillResolver.ts否改出手频率规则ActionQueue.ts否加新控制状态TurnManager.ts BuffSystem.ts否换表现/动画ui/ prefabs/是最后说个我自己的教训早期我总想先把架构设计得很完整再写结果TurnManager里塞了太多“以后可能用到”的钩子反而看不清主链路。这个工程的好处是每个类都只干一件事你接手时先跑通默认战斗再按上表逐层替换比一上来就重构整个调度器稳得多。从那以后我每次改回合制逻辑都强制先跑一遍纯逻辑脚本再进引擎希望帮到你。本文还有配套的精品资源点击获取