资讯动态

Cocos Creator回合制战斗系统:调度器与状态机设计实战

发布时间:2026/9/25 21:06:19 来源:尧图企业网站定制
简介CocoKnightManager 是一套基于 Cocos Creator 的开源回合制游戏系统设计面向希望快速搭建回合制战斗框架的开发者与学习者。它围绕模块化与数据驱动思路将回合管理器、行动规则引擎、状态机等核心组件拆分实现帮助解决回合流程控制、角色行动逻辑与状态切换等常见难题适合具备一定 TypeScript 与引擎基础的中级开发者参考。资源包共 22 个文件以 json 配置、meta 元数据、ts 脚本、png 贴图为主另含 fire 场景文件与 md 说明文档整体约 216KB结构轻量便于阅读源码。目前已有 834 人学习下载。通过该资源可了解回合制系统的目录组织方式、模块间协作关系与数据配置思路为自研战斗框架或二次扩展提供可借鉴的实现参考。1. CocoKnightManager回合制战斗系统从设计到落地的完整拆解回合制游戏最折磨人的地方从来不是战斗公式本身而是「状态在谁手里、什么时候结算、谁来驱动下一回合」。我见过太多项目把回合逻辑写进 UI 按钮回调里前期跑得飞快一旦加入 buff 叠加、速度排序、反击、连携整个战斗模块就变成一团没法维护的玄学代码。CocoKnightManager 就是冲着这个问题来的——它是一套基于 Cocos Creator 的回合制游戏系统设计实现把战斗流程、回合调度、角色状态、技能结算这几块拆成了独立可复用的模块。如果你正在做卡牌、战棋或者传统 RPG 的回合制战斗又不想从零搭一套状态机这份资源值得你花时间拆一遍。它适合有 Cocos Creator 基础、能看懂 TypeScript 的中级开发者新手也能跟着步骤跑起来但想改得动得先理解它的调度思路。2. 回合调度与状态机为什么不能把逻辑塞进按钮回调2.1 回合制系统的核心矛盾谁来决定「下一个是谁」回合制战斗的本质是一个有序的状态推进问题。每一回合系统需要回答三个问题当前行动者是谁、它做了什么、做完之后轮到谁。听起来简单但实际项目里会迅速复杂化——速度属性决定行动顺序、某些技能插入额外回合、buff 在回合开始或结束时结算、角色死亡要即时移出队列。如果这些逻辑散落在各个按钮的点击事件里你很快就会发现「先手判定」和「buff 结算」互相打架。CocoKnightManager 的做法是引入一个回合调度器TurnScheduler把「决定行动顺序」和「执行行动内容」彻底分开。调度器只负责维护一个行动队列按速度或其他权重排序每次取出队首作为当前行动者行动者执行完自己的逻辑后通知调度器推进到下一个。这样做的直接好处是新增一种「插入回合」的技能时你只需要改调度器的入队逻辑不用去动任何角色的行动代码。常见做法是用一个数组模拟队列配合一个currentIndex指针。但要注意如果角色在行动过程中死亡队列需要实时更新否则会出现「死人还在排队」的翻车现场。CocoKnightManager 在每次推进前会做一次存活校验把无效单位剔除。2.2 用 TypeScript 定义回合状态与调度器骨架下面这段代码是调度器的核心骨架我按它的设计思路还原了一个可运行的版本。你可以直接放进 Cocos Creator 的脚本目录里跑。// TurnScheduler.ts // 回合调度器负责维护行动队列与推进回合 interface ICombatant { id: string; speed: number; // 速度属性决定排序权重 isAlive: boolean; // 存活标记死亡后移出队列 onTurnStart(): void; // 回合开始回调 onTurnEnd(): void; // 回合结束回调 } export class TurnScheduler { private queue: ICombatant[] []; private currentIndex: number 0; private roundCount: number 0; // 初始化把所有参战单位按速度降序排入队列 public init(combatants: ICombatant[]): void { this.queue combatants .filter(c c.isAlive) .sort((a, b) b.speed - a.speed); this.currentIndex 0; this.roundCount 1; } // 获取当前行动者 public getCurrent(): ICombatant | null { this.cleanup(); if (this.queue.length 0) return null; return this.queue[this.currentIndex] || null; } // 推进到下一个行动者 public advance(): void { this.cleanup(); this.currentIndex; if (this.currentIndex this.queue.length) { this.currentIndex 0; this.roundCount; this.onRoundStart(); } } // 清理死亡单位防止「死人排队」 private cleanup(): void { this.queue this.queue.filter(c c.isAlive); if (this.currentIndex this.queue.length) { this.currentIndex 0; } } private onRoundStart(): void { // 每轮开始时触发可在此结算持续伤害、buff 倒计时等 this.queue.forEach(c c.onTurnStart()); } }逻辑说明init方法在战斗开始时调用按速度排序建立初始队列。getCurrent每次返回当前行动者但在返回前会先清理死亡单位。advance是推进回合的唯一入口当索引超出队列长度时说明一轮结束roundCount加一并触发轮次开始事件。参数方面speed越大越先手这是最常见的设定如果你的游戏是「速度越低越先手」把排序的b.speed - a.speed改成a.speed - b.speed即可。cleanup里的过滤是防止死亡角色继续占用队列位置这个细节很多新手会漏掉导致战斗卡死。2.3 状态机如何管理 buff 与技能结算调度器解决了「谁行动」但「行动时发生了什么」需要另一套机制。CocoKnightManager 用了一个轻量的状态容器来管理每个角色身上的 buff、debuff 和持续效果。每个效果是一个对象包含type类型、duration剩余回合数、onApply施加时触发、onTick每回合触发、onRemove移除时触发四个关键字段。// StatusEffect.ts // 状态效果定义buff/debuff 的通用结构 export enum EffectType { Buff buff, Debuff debuff, DOT dot, // 持续伤害 HOT hot, // 持续治疗 } export interface StatusEffect { id: string; type: EffectType; duration: number; // 剩余回合数-1 表示永久 value: number; // 效果数值如攻击力加成、每回合伤害 onApply(target: any): void; onTick(target: any): void; onRemove(target: any): void; } export class StatusContainer { private effects: StatusEffect[] []; public add(effect: StatusEffect, target: any): void { // 同 id 效果可叠加或刷新这里采用刷新策略 const existing this.effects.find(e e.id effect.id); if (existing) { existing.duration effect.duration; return; } this.effects.push(effect); effect.onApply(target); } public tick(target: any): void { // 每回合结算一次倒计时并触发 tick this.effects.forEach(e { e.onTick(target); if (e.duration 0) e.duration--; }); // 移除过期效果 this.effects this.effects.filter(e { if (e.duration 0) { e.onRemove(target); return false; } return true; }); } public removeById(id: string, target: any): void { const idx this.effects.findIndex(e e.id id); if (idx 0) { this.effects[idx].onRemove(target); this.effects.splice(idx, 1); } } }逻辑说明add方法处理效果叠加这里用的是「同 id 刷新持续时间」策略你也可以改成层数叠加。tick在每个回合结束时调用先触发onTick比如 DOT 扣血再递减持续时间最后清理过期效果。参数duration设为 -1 表示永久效果代码里用 0判断避免误减。removeById用于驱散类技能。这套结构的好处是新增一个「每回合回血」的 buff你只需要实现一个onTick里调用治疗逻辑的 effect 对象不用改任何调度代码。3. 角色与技能模块数据驱动还是硬编码3.1 为什么角色属性要用配置表而不是写死在类里很多 Cocos Creator 项目初期图省事直接把角色属性写在Player.ts的properties里。等到策划要调数值、要加新角色、要做多语言你就得一个个改代码、重新编译。CocoKnightManager 的设计思路是数据驱动角色的基础属性、技能列表、成长曲线全部放在 JSON 配置里运行时加载并注入到角色实例。常见做法是在resources/configs/下放characters.json结构大致如下{ characters: [ { id: knight_001, name: 见习骑士, baseHp: 1200, baseAtk: 85, baseDef: 60, speed: 95, skills: [slash, shield_up], growth: { hp: 80, atk: 6, def: 4 } } ] }加载时用resources.load读取然后根据id实例化对应的战斗单位。这样做的好处是策划改数值不需要碰代码新增角色只需要加一条配置。参数growth用于升级时的属性成长每级按此数值累加。注意skills数组里存的是技能 id实际技能数据放在另一个skills.json里通过 id 关联避免配置臃肿。3.2 技能系统的三种触发时机与实现技能不是「点一下扣血」这么简单。一个合格的回合制技能系统至少要支持三种触发时机主动释放、被动触发、条件触发。CocoKnightManager 用统一的技能接口来覆盖这三类。// Skill.ts // 技能基类定义技能的生命周期 export enum SkillTrigger { Active active, // 主动释放 OnHit on_hit, // 受击时触发 OnTurnStart on_turn_start, // 回合开始时触发 } export interface SkillContext { caster: any; // 释放者 targets: any[]; // 目标列表 scheduler: any; // 调度器引用用于插入额外回合 } export abstract class BaseSkill { public id: string; public trigger: SkillTrigger; public cooldown: number 0; // 冷却回合数 private currentCd: number 0; public canCast(): boolean { return this.currentCd 0; } public cast(ctx: SkillContext): void { if (!this.canCast()) return; this.onCast(ctx); this.currentCd this.cooldown; } // 子类实现具体效果 protected abstract onCast(ctx: SkillContext): void; // 每回合递减冷却 public tickCooldown(): void { if (this.currentCd 0) this.currentCd--; } }逻辑说明trigger字段决定技能何时被调用主动技能由玩家操作触发被动技能在OnHit或OnTurnStart时由系统自动调用。cooldown是冷却回合数currentCd是当前剩余冷却tickCooldown在每个回合结束时调用。cast方法先检查冷却再执行onCast最后重置冷却。参数ctx里带了scheduler引用这是为了支持「释放技能后获得额外回合」这类效果——技能内部可以调用scheduler.advance()或插入自定义逻辑。注意冷却递减的时机要和你的回合结算顺序对齐否则会出现「冷却好了但还没轮到」的时序 bug。3.3 伤害结算公式与防御减伤的参数选择伤害公式是回合制游戏最容易被玩家研究透的部分。CocoKnightManager 采用的是一种减法与乘法混合的公式兼顾直观和可控// DamageCalculator.ts // 伤害计算混合公式避免纯减法导致的高防无敌 export function calculateDamage( atk: number, def: number, skillMultiplier: number 1.0, critRate: number 0.0, critDamage: number 1.5 ): { damage: number; isCrit: boolean } { // 基础伤害 攻击力 * 技能倍率 let base atk * skillMultiplier; // 减伤系数防御力越高减伤越明显但不会减到 0 const reduction def / (def 200); let damage base * (1 - reduction); // 暴击判定 const isCrit Math.random() critRate; if (isCrit) damage * critDamage; // 保底伤害防止高防单位完全免伤 return { damage: Math.max(1, Math.floor(damage)), isCrit }; }逻辑说明reduction用的是def / (def 200)这种递减函数200 是一个可调常数越大则防御收益越低。这种设计避免了纯减法公式「防御超过攻击就无敌」的问题。skillMultiplier是技能倍率普攻传 1.0大招传 2.5 之类。critRate和critDamage分别控制暴击率和暴击倍率。最后的Math.max(1, ...)是保底伤害确保任何攻击至少造成 1 点伤害防止战斗陷入僵局。参数 200 这个值需要根据你的数值体系调整一般取角色平均防御的 1.5 到 2 倍比较合适。4. 避坑与排查回合制系统最容易翻车的五个地方4.1 现象战斗卡死回合无法推进原因死亡单位没有及时从队列移除currentIndex指向了一个已死亡的对象而该对象的onTurnEnd永远不会被调用导致advance无法触发。解决在getCurrent和advance里都加存活校验如 2.2 节代码中的cleanup方法。更稳妥的做法是在角色死亡时主动通知调度器移除自己而不是等调度器轮询。4.2 现象buff 倒计时错乱有时多扣一回合有时少扣一回合原因buff 的tick调用时机和回合推进的时机没有对齐。如果 buff 在「回合开始时」结算但你的tick写在「回合结束时」就会出现持续时间对不上的情况。解决统一约定 buff 在持有者回合开始时结算并在onTurnStart回调里调用statusContainer.tick()。同时注意施加 buff 的当回合不应该立即 tick否则会少一回合。4.3 现象技能冷却显示为 0 但无法释放原因currentCd在cast时被重置为cooldown但tickCooldown的调用频率和回合推进不一致。比如技能在回合中途释放但冷却递减只在轮次开始时触发导致冷却「卡住」。解决把tickCooldown绑定到角色自己的回合结束而不是全局轮次开始。这样每个角色行动完自己的技能冷却减一逻辑更直观。4.4 现象Cocos Creator 打包 APK 后战斗逻辑异常编辑器里正常原因常见于使用了Date.now()或Math.random()做战斗种子打包后环境差异导致随机序列不同。另外如果配置表用了require动态加载打包后路径可能变化。解决战斗随机数用独立的种子生成器不要依赖系统时间配置表统一放resources目录用resources.load加载不要用相对路径require。打包前在真机上跑一遍完整战斗流程别只信编辑器预览。4.5 现象新增角色后技能不生效控制台无报错原因skills.json里技能 id 拼写错误或者characters.json里引用的技能 id 在技能表中不存在。由于是运行时按 id 查找找不到时往往静默返回undefined。解决在加载配置后加一层校验遍历所有角色的skills数组检查每个 id 是否在技能表中存在不存在就打印警告。这个校验放在开发环境即可正式包可以去掉。5. 进阶技巧用事件总线解耦战斗表现与逻辑5.1 为什么战斗逻辑不该直接调用动画新手最容易犯的错是在伤害计算函数里直接写playAnimation(hit)。这样逻辑和表现绑死想做战斗回放、加速、跳过动画都极其困难。CocoKnightManager 的进阶用法是引入一个事件总线逻辑层只负责发出「谁对谁造成了多少伤害」这样的事件表现层订阅事件后自己决定播什么动画、飘什么数字。// EventBus.ts // 极简事件总线逻辑层与表现层的解耦桥梁 type Handler (payload: any) void; export class EventBus { private static handlers: Mapstring, Handler[] new Map(); public static on(event: string, handler: Handler): void { if (!this.handlers.has(event)) { this.handlers.set(event, []); } this.handlers.get(event)!.push(handler); } public static emit(event: string, payload: any): void { const list this.handlers.get(event); if (!list) return; list.forEach(h h(payload)); } public static off(event: string, handler: Handler): void { const list this.handlers.get(event); if (!list) return; const idx list.indexOf(handler); if (idx 0) list.splice(idx, 1); } }逻辑说明on注册监听emit触发事件off取消监听。战斗逻辑里只调用EventBus.emit(damage, { target, value, isCrit })表现层在 UI 脚本里EventBus.on(damage, ...)然后播放对应动画。参数payload是任意对象建议按事件类型定义明确的接口避免字段拼写错误。注意在场景切换时调用off清理监听否则会出现「上一场战斗的监听还在响应」的内存泄漏。5.2 战斗回放的实现思路与验证方法有了事件总线战斗回放就变得简单在战斗过程中把所有emit的事件按顺序存进一个数组回放时按时间戳依次重新emit即可。验证方法是打一场战斗记录事件序列然后清空表现层重新回放看结果是否一致。如果一致说明逻辑层没有依赖表现层的副作用如果不一致说明某处逻辑偷偷读了动画状态需要排查。我一般会在开发阶段加一个「回放模式」开关开启后战斗不响应玩家输入只按记录的事件序列推进。这个习惯帮我抓出过好几次「逻辑依赖动画回调」的隐蔽 bug。从那以后我每次写完新的技能效果都强制走一遍「记录-回放-比对」的流程确认逻辑层干净。希望这套拆解能帮到你把回合制战斗这块硬骨头啃下来。本文还有配套的精品资源点击获取

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

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

免费获取报价 →
↑