回合制游戏在许多人眼里是一个“入门级”项目不就是双方轮流出手把血量扣到零就结束了吗但真正动手之后你才会发现事情没有那么简单。回合制游戏对代码设计的要求恰恰是新手最容易低估的部分。战斗流程如何用代码表达“回合”这个抽象概念怎么落地玩家指令、技能结算、敌人AI、状态效果、胜负判定这些系统彼此交织如果一开始没有清晰的设计代码很快就会变成一团乱麻。本文就基于 Cocos Creator 来讨论自制回合制游戏项目的核心设计与实现。我会从一个可运行的最小战斗循环出发讲解回合制项目的数据层设计、事件驱动架构、技能系统、敌方AI、UI 绑定和扩展方向并给出完整的 TypeScript 示例代码。读完这篇文章你可以得到一个能够继续扩展的战斗框架而不是一团只能跑通 Demo 的零散脚本。另外要提前说明本文讨论的是通用设计思路Cocos Creator 的具体版本和面板操作可能会随编辑器更新有所调整我会把重点放在代码架构和工程思路上。这类知识一旦掌握了换到 Unity、Godot甚至纯前端项目里也同样适用。1. 用 Cocos 做回合制游戏真正的难点是什么先给一个明确判断用 Cocos Creator 做回合制游戏难点不在渲染、不在动画、甚至不在资源管理而在战斗流程的状态控制。原因很简单。回合制战斗本质上是一套状态机它的问题在于“流程复杂但单次计算简单”。一个怪物回合里你要处理的事情包括判断当前行动者、播放行动者待机动画、调用AI逻辑决定技能目标、播放技能前摇、计算伤害、显示飘字反馈、更新血条、检查死亡状态、播死亡动画、决定下一回合归属。这套流程如果全写在同一个update()里用一堆if嵌套来控制项目到中期一定会失控。因为你很快会发现战斗中不是只有“玩家回合”和“敌人回合”两个状态还要加入“技能动画播放中”“等待玩家选择目标”“敌人正在行动”“战斗结算中”等中间状态。我们需要的是这一套东西明确的数据模型把角色、技能、战斗结果都变成可序列化的数据一个事件驱动的流程控制器让各个系统彼此解耦一个可扩展的“技能即数据”方案不要给每个技能写一套单独的 if 逻辑稳定的状态切换机制而不是散落各处的布尔标记。这套设计的终点就是让“新增一个技能”和“新增一个敌人”从改代码变成填数据。2. 基础概念回合、行动点与事件驱动2.1 回合到底是什么很多新手写回合制习惯用一个大循环来驱动战斗比如“先处理玩家所有操作再处理敌人所有操作循环N次”。这在只有两三个角色的 Demo 里没问题但只要加入多个敌人、不同速度的角色、多段技能这种写法就顶不住了。更合理的模型是回合不是循环而是状态序列。一个回合制战斗的完整周期可以拆成回合开始阶段通知所有战斗单位“新回合开始了”触发 buff 结算、回合开始效果行动阶段当前行动者从可执行列表中选择一个行动技能或普通攻击结算阶段计算伤害、修改血量、触发受击效果回合结束阶段检查死亡单位、移除过期 buff、判断战斗胜负移交阶段把行动权交给下一个单位。在代码层面这些阶段就可以成为状态机里的不同状态。Cocos Creator 里我们可以用一个 BattleManager 来管理这个状态流转用事件向 UI 和角色节点广播变化。2.2 行动顺序的两种常见方案回合制游戏最常见的行动顺序方案有两种双方交替制玩家方全体行动完毕敌方全体行动再回到玩家方。经典 RPG 如早期《勇者斗恶龙》就是这种模式。速度排序制每个单位有自己的速度值速度高者先行动战斗过程中按速度重新排队。这在很多现代日式 RPG 中更常见。对新手项目先用“双方交替制”会更简单因为它天然避免了一方单位多次行动的复杂调度。本文示例也采用这种模型。如果你想做速度排序制只需要把行动队列的数据结构改成一个按速度排序的数组然后从这个数组中不断取队首单位行动即可整体架构不需要推翻。2.3 为什么要用事件驱动而不是直接调用回合制战斗里A 打 BB 掉血血条要更新角色要播受击动画战斗日志要加一行文字可能还有一个伤害飘字提示。如果伤害结算函数里直接调用所有相关系统那你就把战斗逻辑和界面逻辑、动画逻辑耦合在一起了。事件驱动则要求伤害结算模块只管计算数值并发出一个“DamageDealt”事件至于谁监听这个事件、怎么响应都由其他系统自行处理。这么设计的好处有两个增加新系统不需要改动伤害计算核心代码。比如你要加一个“击杀回血”成就系统只需要监听击杀事件即可。逻辑可以被独立测试。你可以单独跑一场战斗而不依赖任何 UI 节点。3. Cocos Creator 项目结构与数据模型设计3.1 项目结构规划我们按照分层思想来组织代码而不是把所有逻辑堆进组件里。推荐的最小项目结构如下assets/ scripts/ data/ BattleTypes.ts // 战斗相关类型定义 SkillData.ts // 技能配置数据 EnemyData.ts // 敌人配置数据 battle/ BattleManager.ts // 战斗流程控制器 TurnActor.ts // 战斗单位封装 SkillSystem.ts // 技能系统 EnemyAI.ts // 敌人AI ui/ BattleUIBinder.ts // UI绑定与事件监听 scenes/ Battle.scene这个结构的好处是职责清晰data目录只存放数据定义和配置不参与逻辑battle目录负责战斗流程ui目录负责界面层只处理显示和交互。3.2 基础类型定义在BattleTypes.ts中我们先定义最核心的两个类型角色属性和战斗结果。// 文件路径assets/scripts/data/BattleTypes.ts /** 基础战斗属性 */ export interface BattleAttributes { maxHp: number; attack: number; defense: number; speed: number; } /** 战斗单位运行时状态 */ export interface BattleUnitState { id: string; name: string; attributes: BattleAttributes; currentHp: number; // 单位所在阵营player 或 enemy camp: player | enemy; } /** 伤害结算结果 */ export interface DamageResult { targetId: string; damage: number; isCrit: boolean; targetHpAfter: number; } /** 一次完整行动的结果 */ export interface ActionReport { actorId: string; skillId: string; targetIds: string[]; damageResults: DamageResult[]; }这里把“属性”拆成了两个部分BattleAttributes是基础面板数据不会在战斗中改变currentHp是运行时状态。这样的区分很重要因为战斗中你会遇到“攻击力 buff 加成”“防御力临时提升”等需求把基础属性和临时状态分开扩展起来会方便很多。如果只用单一的attributes对象并在战斗中直接修改它那么战斗结束后想恢复原始面板属性就还得另存一份备份绕了一圈。3.3 技能与敌人的数据驱动设计技能定义是回合制项目的关键。我建议把所有技能写成配置数据而不是为每个技能写一个类。// 文件路径assets/scripts/data/SkillData.ts import { BattleAttributes } from ./BattleTypes; export interface SkillEffect { // 治疗效果表示为负数伤害为正数 damage: number; // 攻击倍率实际伤害 基础攻击 * multiplier multiplier: number; // 目标类型single 单体 或 all 全体 targetType: single | all; // 目标阵营enemy 敌方 或 ally 友方 targetCamp: enemy | ally; } export interface SkillConfig { id: string; name: string; description: string; // 技能消耗的魔法示例项目没有魔法系统时可留 0 mpCost: number; effect: SkillEffect; } export const SKILL_LIST: SkillConfig[] [ { id: normal_attack, name: 普通攻击, description: 对单个敌人造成 100% 攻击力的伤害, mpCost: 0, effect: { damage: 0, multiplier: 1.0, targetType: single, targetCamp: enemy } }, { id: fireball, name: 火球术, description: 对单个敌人造成 150% 攻击力的伤害, mpCost: 5, effect: { damage: 0, multiplier: 1.5, targetType: single, targetCamp: enemy } }, { id: group_thunder, name: 雷击术, description: 对全体敌人造成 120% 攻击力的伤害, mpCost: 10, effect: { damage: 0, multiplier: 1.2, targetType: all, targetCamp: enemy } }, { id: heal, name: 治疗术, description: 恢复我方单体 80% 攻击力的生命值, mpCost: 6, effect: { damage: -0.8, multiplier: 0.8, targetType: single, targetCamp: ally } } ]; export function getSkillById(id: string): SkillConfig | undefined { return SKILL_LIST.find(skill skill.id id); }敌人配置可以走同样的路线// 文件路径assets/scripts/data/EnemyData.ts import { BattleAttributes } from ./BattleTypes; export interface EnemyConfig { id: string; name: string; attributes: BattleAttributes; // 敌方AI会从下面按顺序尝试执行技能 skillIds: string[]; } export const ENEMY_LIST: EnemyConfig[] [ { id: slime, name: 史莱姆, attributes: { maxHp: 120, attack: 18, defense: 5, speed: 8 }, skillIds: [normal_attack] }, { id: goblin, name: 哥布林, attributes: { maxHp: 80, attack: 24, defense: 3, speed: 12 }, skillIds: [normal_attack, quick_attack] } ];这里出现了一个新的技能quick_attack实际项目中你只需要把它加进SKILL_LIST即可不需要改任何战斗逻辑。这就是数据驱动设计的好处。这种写法的核心思想是“技能”和“敌人”在代码层面不再拥有自己的专属逻辑它们只是配置数据行为由通用的SkillSystem解释执行。4. 核心流程拆解从玩家点击到伤害飘字4.1 整体流程概述在写代码之前我们把一次完整的行动流程在脑子里跑一遍。假设当前轮到玩家行动玩家点击“火球术”后选择了一个敌人目标接下来会发生什么BattleManager 检测到当前状态是WAITING_PLAYER_ACTION玩家通过 UI 选择技能和目标BattleManager 把行动请求交给 SkillSystemSkillSystem 读取技能配置计算伤害生成 ActionReportBattleManager 发出事件BattleEvent.ACTION_PERFORMEDUI 收到事件后更新血条、飘字、战斗日志动画播放完毕后BattleManager 检查战斗是否结束如果没有结束切换到敌人回合。这个流程里最关键的一点是BattleManager 不知道 UI 是怎么显示血条的UI 也不知道伤害是怎么算出来的。它们之间只有事件作为桥梁。4.2 状态设计我们定义如下的战斗状态export enum BattlePhase { BATTLE_START BATTLE_START, WAITING_PLAYER_ACTION WAITING_PLAYER_ACTION, PROCESSING_ACTION PROCESSING_ACTION, ENEMY_TURN ENEMY_TURN, BATTLE_WIN BATTLE_WIN, BATTLE_LOSE BATTLE_LOSE }代码结构上我们只需要一个currentPhase变量。每个状态下只处理对应的事当事件完成后切换到下一个状态。这个状态机的意义在于任何时候你都能知道游戏此刻处于什么阶段玩家点按钮有没有效敌人AI能不能启动都有一个明确判断依据而不是靠一堆if (isPlayerTurn !isAnimating canAct)来猜。4.3 完整流程代码下面给出 BattleManager 的核心实现。由于这是一个最小可运行的框架我略去了一些动画播放细节重点放在流程控制上。// 文件路径assets/scripts/battle/BattleManager.ts import { BattlePhase, BattleUnitState, DamageResult, ActionReport } from ../data/BattleTypes; import { SkillSystem } from ./SkillSystem; import { EnemyAI } from ./EnemyAI; import { EventTarget } from cc; export enum BattleEvent { PHASE_CHANGED PHASE_CHANGED, ACTION_PERFORMED ACTION_PERFORMED, UNIT_DIED UNIT_DIED, BATTLE_END BATTLE_END } export class BattleManager { public static instance: BattleManager; private phase: BattlePhase BattlePhase.BATTLE_START; private playerUnits: BattleUnitState[] []; private enemyUnits: BattleUnitState[] []; // 事件总线UI 和动画系统监听它 public eventBus: EventTarget new EventTarget(); private skillSystem: SkillSystem; private enemyAI: EnemyAI; constructor() { BattleManager.instance this; this.skillSystem new SkillSystem(); this.enemyAI new EnemyAI(); } /** 初始化一场新战斗 */ public initBattle(players: BattleUnitState[], enemies: BattleUnitState[]): void { this.playerUnits players; this.enemyUnits enemies; this.setPhase(BattlePhase.WAITING_PLAYER_ACTION); } /** 玩家通过UI发起行动请求 */ public requestPlayerAction(skillId: string, targetIds: string[]): boolean { if (this.phase ! BattlePhase.WAITING_PLAYER_ACTION) { console.warn([Battle] 当前不在玩家行动阶段无法执行操作); return false; } const actor this.playerUnits[0]; if (!actor) { return false; } this.setPhase(BattlePhase.PROCESSING_ACTION); this.executeAction(actor, skillId, targetIds); return true; } /** 执行一次行动 */ private executeAction(actor: BattleUnitState, skillId: string, targetIds: string[]): void { const report this.skillSystem.useSkill(actor, skillId, targetIds, this.enemyUnits, this.playerUnits); this.eventBus.emit(BattleEvent.ACTION_PERFORMED, report); // 处理死亡 this.checkDiedUnits(); // 检查胜负 if (this.checkBattleEnd()) { return; } // 如果是玩家发起的行动且战斗未结束则进入敌人回合 if (this.phase BattlePhase.PROCESSING_ACTION) { this.enterEnemyTurn(); } } /** 进入敌人回合 */ private enterEnemyTurn(): void { this.setPhase(BattlePhase.ENEMY_TURN); // 延迟执行AI给玩家一个“看到行动结果”的时间窗口 this.scheduleOnce(() { this.processEnemyActions(); }, 0.6); } /** 敌人依次行动 */ private processEnemyActions(): void { for (const enemy of this.enemyUnits) { if (enemy.currentHp 0) { continue; } const aiDecision this.enemyAI.decide(enemy, this.playerUnits); if (aiDecision) { this.executeAction(enemy, aiDecision.skillId, aiDecision.targetIds); } if (this.checkBattleEnd()) { return; } } // 所有敌人行动完毕回到玩家回合 this.setPhase(BattlePhase.WAITING_PLAYER_ACTION); } /** 检查死亡单位 */ private checkDiedUnits(): void { const allUnits [...this.playerUnits, ...this.enemyUnits]; for (const unit of allUnits) { if (unit.currentHp 0) { this.eventBus.emit(BattleEvent.UNIT_DIED, unit); } } } /** 检查战斗是否结束 */ private checkBattleEnd(): boolean { const playersAlive this.playerUnits.filter(u u.currentHp 0); const enemiesAlive this.enemyUnits.filter(u u.currentHp 0); if (playersAlive.length 0) { this.setPhase(BattlePhase.BATTLE_LOSE); this.eventBus.emit(BattleEvent.BATTLE_END, lose); return true; } if (enemiesAlive.length 0) { this.setPhase(BattlePhase.BATTLE_WIN); this.eventBus.emit(BattleEvent.BATTLE_END, win); return true; } return false; } private setPhase(newPhase: BattlePhase): void { this.phase newPhase; this.eventBus.emit(BattleEvent.PHASE_CHANGED, newPhase); } public getPhase(): BattlePhase { return this.phase; } public getPlayerUnits(): BattleUnitState[] { return this.playerUnits; } public getEnemyUnits(): BattleUnitState[] { return this.enemyUnits; } private scheduleOnce(callback: () void, delay: number): void { // 这里使用 Cocos Creator 的定时器或者 setTimeout 都可以 setTimeout(callback, delay * 1000); } }注意上面代码里的scheduleOnce使用了setTimeout简化实现。在真实 Cocos Creator 项目中可以使用Component.scheduleOnce或Director的定时器避免在组件销毁之后触发回调。4.4 技能系统实现SkillSystem 是计算伤害的地方。它的核心职责只有一个输入使用者和目标输出行动结果。// 文件路径assets/scripts/battle/SkillSystem.ts import { BattleUnitState, DamageResult, ActionReport } from ../data/BattleTypes; import { SkillConfig, SkillEffect, getSkillById } from ../data/SkillData; export class SkillSystem { useSkill( actor: BattleUnitState, skillId: string, targetIds: string[], enemies: BattleUnitState[], allies: BattleUnitState[] ): ActionReport { const skill getSkillById(skillId); if (!skill) { throw new Error(技能不存在: ${skillId}); } const liveTargets this.filterTargets(skill.effect, targetIds, enemies, allies); const damageResults: DamageResult[] liveTargets.map(target { const damage this.calculateDamage(actor, target, skill.effect); target.currentHp Math.max(0, target.currentHp - damage); return { targetId: target.id, damage: damage, isCrit: false, targetHpAfter: target.currentHp }; }); return { actorId: actor.id, skillId: skillId, targetIds: liveTargets.map(t t.id), damageResults: damageResults }; } private filterTargets( effect: SkillEffect, targetIds: string[], enemies: BattleUnitState[], allies: BattleUnitState[] ): BattleUnitState[] { const pool effect.targetCamp enemy ? enemies : allies; if (effect.targetType all) { return pool.filter(unit unit.currentHp 0); } return pool.filter(unit targetIds.includes(unit.id) unit.currentHp 0); } private calculateDamage(actor: BattleUnitState, target: BattleUnitState, effect: SkillEffect): number { // 伤害 攻击力 × 倍率 - 防御力 const rawDamage actor.attributes.attack * effect.multiplier effect.damage; const defenseReduction target.attributes.defense * 0.5; const result Math.max(1, Math.floor(rawDamage - defenseReduction)); // 治疗时的 damage 是负数不受防御影响 if (effect.damage 0) { return Math.floor(actor.attributes.attack * Math.abs(effect.multiplier)); } return result; } }这里顺便演示了“治疗”的处理技能效果里的damage如果是负数就表示治疗。虽然从命名上不太优雅但胜在数据结构简单。实际商业项目里更好的做法是在SkillEffect里加一个effectType字段显式区分damage、heal、buff、debuff。本文示例先保留这个简化版本方便你看清核心机制。4.5 敌方 AI 实现最简单的敌人AI就是“能用技能就用技能否则普攻目标选择当前血量最低的玩家”。// 文件路径assets/scripts/battle/EnemyAI.ts import { BattleUnitState } from ../data/BattleTypes; import { getSkillById, SkillConfig } from ../data/SkillData; export interface AIDecision { skillId: string; targetIds: string[]; } export class EnemyAI { decide(actor: BattleUnitState, playerUnits: BattleUnitState[]): AIDecision | null { const alivePlayers playerUnits.filter(unit unit.currentHp 0); if (alivePlayers.length 0) { return null; } // 从配置中取第一个可用技能这里可以加入“蓝量不足”等判断 const skillConfig this.selectSkill(actor); if (!skillConfig) { return null; } let targetIds: string[]; if (skillConfig.effect.targetType all) { targetIds alivePlayers.map(unit unit.id); } else { // 选择血量最低的玩家作为目标 const target alivePlayers.reduce((prev, curr) curr.currentHp prev.currentHp ? curr : prev ); targetIds [target.id]; } return { skillId: skillConfig.id, targetIds: targetIds }; } private selectSkill(actor: BattleUnitState): SkillConfig | undefined { // 简化实现从敌人配置中的技能列表里选一个可以使用的技能 // 真实项目里可以在这里加权重、策略和冷却判断 const allSkills actor.skillIds?.map(getSkillById).filter((s): s is SkillConfig !!s); return allSkills allSkills.length 0 ? allSkills[0] : getSkillById(normal_attack); } }这里隐含了一个问题BattleUnitState类型中并没有skillIds字段。在实际项目中你需要在敌人初始化时把EnemyConfig里的skillIds合并到战斗单位状态中。这一点在下一节的项目初始化代码里会演示。5. 场景搭建与 UI 绑定5.1 场景最小元素在 Cocos Creator 中你可以新建一个Battle.scene场景里只需要三种节点Canvas 下创建PlayerHpBar用于显示玩家血量创建EnemyNode配置文件用于展示敌人节点创建几个Button对应不同技能。场景搭建不是本文核心但你要清楚UI 节点只是数据的投影不要直接在 UI 节点的组件里计算伤害逻辑。5.2 UI 与事件绑定在真实项目中UI绑定一般用一个BattleUIBinder组件来完成。它挂在 Canvas 节点上负责初始化监听、刷新界面、发布按钮事件。// 文件路径assets/scripts/ui/BattleUIBinder.ts import { _decorator, Component, Label, Node, Button, ProgressBar } from cc; import { BattleManager, BattleEvent } from ../battle/BattleManager; import { BattlePhase, BattleUnitState } from ../data/BattleTypes; import { ActionReport } from ../data/BattleTypes; import { SKILL_LIST } from ../data/SkillData; const { ccclass, property } _decorator; ccclass(BattleUIBinder) export class BattleUIBinder extends Component { property({ type: Label }) public statusLabel: Label | null null; property({ type: ProgressBar }) public playerHpBar: ProgressBar | null null; property({ type: Label }) public playerHpLabel: Label | null null; property({ type: Button }) public skillButtonPrefab: Button | null null; private battleManager: BattleManager; onLoad() { this.battleManager new BattleManager(); this.battleManager.eventBus.on(BattleEvent.PHASE_CHANGED, this.onPhaseChanged, this); this.battleManager.eventBus.on(BattleEvent.ACTION_PERFORMED, this.onActionPerformed, this); this.battleManager.eventBus.on(BattleEvent.BATTLE_END, this.onBattleEnd, this); this.setupSkillButtons(); } start() { // 初始化一场测试战斗1个玩家 vs 1个敌人 const player: BattleUnitState { id: player1, name: 勇者, attributes: { maxHp: 200, attack: 30, defense: 10, speed: 15 }, currentHp: 200, camp: player, skillIds: [normal_attack, fireball, group_thunder, heal] }; const enemy: BattleUnitState { id: slime1, name: 史莱姆, attributes: { maxHp: 120, attack: 18, defense: 5, speed: 8 }, currentHp: 120, camp: enemy, skillIds: [normal_attack] }; this.battleManager.initBattle([player], [enemy]); } private setupSkillButtons(): void { // 遍历技能配置创建对应按钮 // 实际项目中请从预制体实例化后挂在固定容器下 for (const skill of SKILL_LIST) { const btnNode new Node(Skill_${skill.id}); const btn btnNode.addComponent(Button); btn.node.on(Button.EventType.CLICK, () { this.onSkillClicked(skill.id); }, this); // 简化为直接输出日志实际应加入布局容器 console.log([UI] 已创建技能按钮: ${skill.name}); } } private onSkillClicked(skillId: string): void { const phase this.battleManager.getPhase(); if (phase ! BattlePhase.WAITING_PLAYER_ACTION) { console.warn([UI] 当前不可操作); return; } const playerUnits this.battleManager.getPlayerUnits(); const enemyUnits this.battleManager.getEnemyUnits(); // 简化逻辑单体技能选第一个敌人全体技能自动选择所有敌人 const skill SKILL_LIST.find(s s.id skillId); if (!skill) return; let targets: string[] []; if (skill.effect.targetType all) { targets enemyUnits.filter(u u.currentHp 0).map(u u.id); } else if (skill.effect.targetCamp ally) { targets [playerUnits[0].id]; // 治疗默认选择自己 } else { targets [enemyUnits[0].id]; // 攻击默认选择第一个敌人 } this.battleManager.requestPlayerAction(skillId, targets); } private onPhaseChanged(phase: BattlePhase): void { if (this.statusLabel) { const phaseText { [BattlePhase.BATTLE_START]: 战斗开始, [BattlePhase.WAITING_PLAYER_ACTION]: 等待玩家行动, [BattlePhase.PROCESSING_ACTION]: 行动处理中, [BattlePhase.ENEMY_TURN]: 敌人回合, [BattlePhase.BATTLE_WIN]: 战斗胜利, [BattlePhase.BATTLE_LOSE]: 战斗失败 } as Recordstring, string; this.statusLabel.string phaseText[phase] || phase; } } private onActionPerformed(report: ActionReport): void { for (const result of report.damageResults) { console.log([战斗] ${report.actorId} 对 ${result.targetId} 造成 ${result.damage} 点伤害); console.log([战斗] ${result.targetId} 剩余 HP: ${result.targetHpAfter}); } // 实际项目中在这里刷新血条 UI this.refreshHpBars(); } private refreshHpBars(): void { const players this.battleManager.getPlayerUnits(); if (this.playerHpBar this.playerHpLabel players.length 0) { const player players[0]; this.playerHpBar.progress player.currentHp / player.attributes.maxHp; this.playerHpLabel.string ${player.currentHp}/${player.attributes.maxHp}; } } private onBattleEnd(result: string): void { console.log([战斗] 战斗结束结果${result}); // 实际项目中跳转结算界面 } }注意上面代码中的skillIds字段在类型上并未定义。实际项目有两种处理方式一是扩展BattleUnitState类型增加这个可选字段二是在初始化时把EnemyConfig的skillIds作为参数传入并单独维护一个“单位技能配置”映射表。为了避免误导我会在后续的“常见问题”里专门说明这个问题。5.3 敌人节点的视觉表现如果需要让敌人节点显示在场景中最简单的做法是创建一个 Enemy 展示组件监听UNIT_DIED事件来控制节点隐藏。// 文件路径assets/scripts/ui/EnemyNodeView.ts import { _decorator, Component, Animation } from cc; import { BattleEvent } from ../battle/BattleManager; const { ccclass, property } _decorator; ccclass(EnemyNodeView) export class EnemyNodeView extends Component { public unitId: string ; onLoad() { // 记录自己的单位ID然后在绑定时连接事件 } public bindUnit(unitId: string): void { this.unitId unitId; // 在真实项目中BattleManager.instance.eventBus.on(BattleEvent.UNIT_DIED, this.onUnitDied, this); } private onUnitDied(unit: { id: string }): void { if (unit.id this.unitId) { this.node.active false; } } }这再次印证了事件驱动的好处敌人节点只需要监听“某个单位死亡”的事件如果死亡的正好是我绑定的这个单位我就隐藏自己。它不需要知道是谁打死了我也不需要在每次伤害计算后被循环查找。6. 运行验证与调试方案在写完上述代码之后你可以直接在 Cocos Creator 编辑器里运行场景来测试战斗流程。预期日志输出顺序大致如下[UI] 已创建技能按钮: 普通攻击 [UI] 已创建技能按钮: 火球术 [UI] 已创建技能按钮: 雷击术 [UI] 已创建技能按钮: 治疗术 [战斗] 战斗开始 [战斗] 等待玩家行动 点击火球术按钮 [战斗] 玩家对 史莱姆 造成 39 点伤害 [战斗] 史莱姆 剩余 HP: 81 [战斗] 敌人回合 等待0.6秒 [战斗] 史莱姆 对 玩家 造成 13 点伤害 [战斗] 玩家 剩余 HP: 187 [战斗] 等待玩家行动这里计算伤害的公式是玩家火球术30 * 1.5 - 5 * 0.5 45 - 2.5 42.5向下取整为 42。敌人普通攻击18 * 1.0 - 10 * 0.5 18 - 5 13。如果你看到的数值和预期不一致优先检查你的防御计算公式和Math.floor的调用时机。6.1 如何验证流程状态切换战斗状态是回合制游戏最容易出 bug 的部分。你可以在setPhase中打印一条带时间戳的日志观察状态切换是否按预期进行private setPhase(newPhase: BattlePhase): void { this.phase newPhase; console.log([状态] ${Date.now()} 进入阶段: ${newPhase}); this.eventBus.emit(BattleEvent.PHASE_CHANGED, newPhase); }特别要注意的坑是如果玩家在行动处理过程中快速点击其他技能按钮requestPlayerAction里的阶段判断会拒绝掉所有非法请求但仍然建议在 UI 层主动禁用按钮组防止“能点但不能执行”的困惑。6.2 调试面板建议如果要用可视化方式排查战斗逻辑可以为 BattleManager 挂一个调试面板使用 Label 实时显示当前战斗阶段玩家当前 HP敌人当前 HP最后一条行动报告摘要。这些数据都是 BattleManager 已经通过事件广播出来的UI 只需监听并显示即可不需要额外侵入战斗逻辑。这也是事件驱动设计的隐性收益之一调试工具和正式UI可以共享同一条事件流。7. 常见问题与排查方法以下是根据类似回合制项目中最常出现的问题整理出的排查表覆盖代码架构层和 Cocos Creator 使用层。问题现象可能原因排查方式解决方案战斗卡在“行动处理中”不再继续玩家行动后未正确切换到敌人回合查看日志最后一条状态是什么检查executeAction结束后是否调用了enterEnemyTurn敌人血量归零后仍在行动死亡单位未被过滤检查processEnemyActions中是否有currentHp 0判断循环中跳过死亡单位战斗结束后仍然能点击技能requestPlayerAction没有拦截BATTLE_WIN/LOSE状态在requestPlayerAction开头打印当前状态增加phase ! WAITING_PLAYER_ACTION的拦截UI 血条不刷新UI 未监听ACTION_PERFORMED事件检查eventBus.on是否在onLoad中注册注册 UI 刷新事件治疗术把敌人加血了filterTargets中目标池选错打印targetCamp的过滤结果确保治疗技能的targetCamp为ally节点点击没反应UI 按钮节点被其他节点遮挡打开节点层级检查按钮位置调整 Canvas 子节点顺序或检查触摸事件设置回调执行时节点已销毁使用setTimeout延时但场景已切换在节点销毁时清理定时器改用 Cocos 的scheduleOnce或做节点有效性判断其中“节点点击没反应”在回合制游戏中非常常见因为战斗场景里通常会有多个全屏节点、半透明背景、技能介绍弹窗等遮挡情况复杂。遇到这类问题优先用节点管理器中“是否阻塞触摸”的开关排查。8. 最佳实践与工程建议8.1 战斗逻辑与表现层彻底分离战斗逻辑里不应该出现Animation、SpriteFrame、tween等表现层类型。所有表现效果要么通过事件通知有表现层去播放要么在表现层中根据ActionReport自行触发。例如技能动画完全可以在 UI 层监听ACTION_PERFORMED之后播放this.battleManager.eventBus.on(BattleEvent.ACTION_PERFORMED, (report: ActionReport) { // 在 UI 层播放攻击者出手动画、受击动画、飘字 this.playSkillAnimation(report); });这样做的好处是哪怕未来你决定把动画系统换成龙骨、Spine 或纯特效粒子战斗核心代码一行都不用改。8.2 数据配置尽量外置如果技能和敌人数值直接写在 TS 文件里后续做数值平衡调整就要反复修改代码并重新构建。对于更大一点的项目建议把技能配置和敌人配置抽成 JSON 或导入 Cocos Creator 的配置表让策划可以独立调整。示例 JSON 格式如下{ id: fireball, name: 火球术, description: 对单个敌人造成 150% 攻击力的伤害, mpCost: 5, effect: { damage: 0, multiplier: 1.5, targetType: single, targetCamp: enemy } }代码读取时只需要用资源管理器加载并解析即可。8.3 Buff 系统不要急着扩展关于状态效果中毒、灼烧、眩晕、护盾我的建议是先把无 Buff 的回合循环跑通再加上最简单的持续伤害最后再做控制类效果。顺序很重要。因为“控制类 Buff”眩晕、冰冻会直接改变行动权影响状态机的切换逻辑。而“持续伤害”只会在回合结束时额外扣血不会干扰流程。先易后难能避免你在状态机还没成熟时就陷入“行动顺序与 Buff 判定”的泥潭。8.4 数值公式先行回合制项目的数值平衡非常依赖公式。不要让“攻击减防御”成为唯一公式至少要让倍率、随机浮动、暴击等可以配置。常见的最小伤害公式// 基础伤害 const base attack * multiplier - defense * 0.5; // 随机浮动 ±10% const randomFactor 0.9 Math.random() * 0.2; // 最终伤害至少为1 const damage Math.max(1, Math.floor(base * randomFactor));把随机浮动放在公式里比每次测试伤害数值都一样好得多因为游戏的“可重玩感”很大程度上来自数值的不确定性。8.5 注意单例的生命周期本文中BattleManager采用单例模式。这在原型阶段很方便但有一个风险如果场景切换后单例仍然持有旧战斗数据下一场战斗就会带着残留状态。一个稳妥的做法是在战斗场景的onDestroy中重置单例或者在initBattle里强制清空所有旧数据。另外事件监听要在节点销毁时小心处理避免已销毁的 UI 组件仍被事件调用。9. 扩展方向从 Demo 到完整游戏如果上面这套框架已经跑通你可以沿着以下路线继续扩展你的回合制游戏项目。9.1 多角色队伍目前示例中玩家和敌人都只有一个单位。要支持多人队伍你只需要把playerUnits数组中的多个角色都显示出来增加“选中当前操作角色”的状态玩家行动后依次切换下一个存活角色所有角色行动完毕后再进入敌人回合。这里会遇到一个新的设计问题玩家选择技能和目标时技能可能来自当前选中角色但目标可能由所有角色共享。建议做法是给requestPlayerAction(actorId, skillId, targetIds)增加一个actorId参数而不是默认取playerUnits[0]。9.2 技能动画序列目前executeAction在计算完伤害后立刻进入下一阶段。真实项目中应该在ACTION_PERFORMED事件之后等待动画播放完成再切换阶段。实现方式是在BattleManager里增加一个“是否等待动画”的配置或者发布一个ACTION_ANIMATION_FINISHED事件供表现层通知。核心判断是战斗流程的速度必须由表现层控制而不是逻辑层硬编码一个固定延时。技能动画、飘字、顿帧、慢镜头都会影响战斗手感。9.3 存档序列化回合制游戏非常适合加入“战斗可中断、可恢复”的设计因为整个战斗中的状态都以数据为中心。如果你从一开始就把BattleUnitState和ActionReport设计为纯数据对象那么序列化存档就非常简单interface BattleSaveData { phase: BattlePhase; playerUnits: BattleUnitState[]; enemyUnits: BattleUnitState[]; history: ActionReport[]; }将这组数据保存到本地或服务端后重新进入战斗时只需要反序列化并重新初始化BattleManager即可。这也是前面强调“数据驱动设计”的长期价值所在。9.4 通关与成长系统在战斗系统之外回合制项目通常还需要“关卡推进”和“角色成长”。这两个系统同样可以模块化关卡推进每次战斗结束后根据BATTLE_END事件更新关卡数据角色成长读取ActionReport里的伤害总量结算经验与金币。它们的共同点是都不需要改动战斗核心只需要监听现有事件即可。这就是事件驱动架构在完整游戏项目中真正的威力。10. 总结与下一步实践建议本文围绕 Cocos Creator 自制回合制游戏项目从架构设计角度解决了三个核心问题第一把“回合”落成状态机解决了战斗流程控制这个最容易出错的环节第二把技能、敌人和角色数据统一成配置数据大幅降低新增内容的代码成本第三通过事件驱动解耦逻辑层与表现层让 UI、动画、存档、成就系统都可以独立扩展。你现在可以做的下一步实践是安排一次完整的跑通验证。创建一个新的 Cocos Creator 项目将本文中的BattleTypes.ts、SkillData.ts、SkillSystem.ts、EnemyAI.ts、BattleManager.ts和BattleUIBinder.ts按目录结构放入搭一个最简场景手动点击技能按钮观察控制台输出和血条刷新。先把这一条链路跑通再逐步加入多角色、Buff 系统和技能动画。记住一个原则回合制游戏项目做得好不好早期不取决于美术效果而取决于“新增一个技能时你需不需要改核心逻辑”。如果能做到改配置而不动代码你的框架就已经超过了大部分新手项目。
阅读完成 · 觉得有帮助?