资讯动态

Cocos Creator回合制战斗框架:用状态机理清战斗流程

发布时间:2026/10/21 2:49:44 来源:尧图企业网站定制
回合制 RPG 看起来简单真正动手做的时候卡住你的往往不是美术和动画而是“现在该轮到谁行动”“玩家点完技能之后下一步去哪”这类流程问题。如果你准备用 Cocos Creator 做一款回合制游戏这篇文章想给你一个明确判断回合制战斗的骨架不是特效不是 UI而是一个有限状态机。下面我会用 TypeScript 在 Cocos Creator 里从零搭一套可运行的回合制战斗框架包括状态流转、伤害公式、技能配置、玩家输入和敌方 AI最后落到工程结构和常见排错上。很多第一次做回合制项目的开发者习惯把战斗流程写在一个大update()里用一堆 boolean 变量判断“能不能点击、有没有结算、要不要换回合”。刚开始只有两个角色的时候确实能跑一旦加入技能动画、Buff、多个敌人流程就会开始错乱重复点击技能导致结算两次、敌人行动没结束玩家又能操作、动画还没播放完 HP 就已经扣完了。这不是偶然的 Bug而是架构上的问题。回合制游戏的复杂度恰恰集中在“流程状态转移”和“结算顺序”上这两件事需要被显式管理。我推荐的方案是BattleManager 作为唯一决策者用状态机管理回合阶段用事件解耦 UI 和表现层用数据配置驱动技能扩展。这篇教程适合已经开始接触 Cocos Creator、想认真做一个回合制原型或者做到一半发现流程越来越难维护的开发者。读完你会得到一个清晰的工程结构可以在上面继续加角色、加技能、加 Buff而不需要推翻重来。1. 这篇文章真正要解决的问题先说一个常见的开发轨迹。刚开始你会觉得“回合制不就是你打我一下我打你一下吗”于是很快就把玩家攻击、敌人 AI、扣血逻辑都写在场景脚本里。跑通第一次之后你开始加技能、加暴击、加防御于是update()里的 if 越来越多脚本之间互相调用也越来越乱。等到你想加一个“中毒每回合扣血”的效果时会发现几乎每个系统都要被改动一遍。这个问题的本质是你把回合流程、游戏规则和表现细节混在了一起。具体到代码里通常有四种坏味道流程状态分裂谁在行动、能不能操作、是否在结算这些信息分散在多个组件的多个变量里。UI 和逻辑耦合技能按钮点击后直接修改 HP 文本、播放粒子特效没有统一的结算入口。技能扩展困难每加一个技能就要改战斗循环甚至要改 UI 代码。异步时序混乱动画播放结束才结算或者结算完了动画还没结束两种顺序都没有被约束。这篇文章要解决的就是把回合制战斗里的“规则”和“表演”拆开。规则由 BattleManager 用状态机推进玩家回合、技能结算、敌方回合、回合结束。UI 和动画只做表演显示技能面板、更新血量、播放受击动作。表演层不能改变规则层的结果只能向规则层发送“玩家选择了什么指令”这类信号。读完这篇文章你至少能独立完成以下功能搭建战斗场景、按速度排序行动顺序、玩家选择技能并指定目标、伤害与治疗结算、敌方 AI 自动行动、胜负判定以及用日志确认每一步发生了什么事。2. 回合制战斗的核心概念与架构思路在写代码之前先把回合制战斗拆成几个核心概念否则后面容易在术语上绕晕。回合Turn一次完整的行动周期通常包含玩家方行动和敌方方行动。行动者Actor参与战斗的角色单位可以是玩家角色也可以是敌人。战斗阶段Phase整个战斗流程中的一个稳定状态例如等待玩家输入、正在结算技能、敌人行动中。结算Resolve把“技能被选择”变成“实际效果”的过程包括伤害计算、治疗、附加状态等。一场标准的回合制战斗抽象出来就是这样一条流程战斗开始 → 按速度排序生成行动顺序 → 轮到当前行动者 → 根据阵营选择指令玩家输入 / 敌方 AI → 执行技能结算 → 检查胜负 → 如果战斗未结束切换到下一个行动者或进入下一回合这个流程的关键点在于任意时刻系统只处于一个阶段。玩家在“等待输入”阶段可以操作按钮在“技能结算”阶段输入必须被屏蔽在“敌方回合”阶段玩家只能看演出。传统 if 写法的问题是你无法保证这些阶段互斥结果就是“敌人行动中还能点技能”。用**有限状态机FSM**管理阶段是回合制战斗最常见的解决方案。状态机的核心思想是当前状态决定当前行为状态之间靠事件或条件跳转。放到回合制里就是让BattleManager成为一个状态机Idle - Begin - PlayerTurn - SkillResolving - EnemyTurn - TurnEnd - PlayerTurn - ... - BattleEnd第二个关键设计是事件驱动。BattleManager 不直接调用 UI 组件的方法而是发事件例如“进入玩家回合”“结算完成”“战斗结束”。UI 监听这些事件刷新界面。反过来UI 也不直接调用 BattleManager 的结算方法而是发送“玩家选择了技能 A目标是敌人 B”的事件。这样可以随时替换 UI 皮肤而不影响战斗逻辑。第三个关键设计是数据驱动。技能数值、敌人属性不要写死在代码里。定义一个SkillData结构用 JSON 或 TS 对象维护一份技能配置表。以后新增技能只是往配置里加一条记录而不是修改战斗循环。下面用一张表格对比传统写法和状态机写法的差异对比维度传统 if 写法状态机 事件驱动流程控制散落在update()和各组件中集中在 BattleManager 的状态机里玩家输入点击后直接改逻辑无法统一屏蔽事件统一处理结算阶段自动忽略输入新增技能需要改战斗循环和 UI 代码增加配置数据必要时扩展结算逻辑表现层扩展逻辑与表现耦合动画顺序难以控制表现层只消费事件可随意替换排查问题靠打断点和日志逐行猜看状态机当前阶段直接定位问题这几种设计并不是花架子。对独立开发者来说它的直接收益是你可以先不管动画专注把规则跑通美术资源后补也不会影响战斗系统。3. Cocos Creator 项目结构与基础场景搭建3.1 项目创建与环境准备本文以 Cocos Creator 3.x TypeScript 为例具体版本以你本机安装的 Creator 为准。思路在 Cocos Creator 2.x 中也基本适用只是导入模块和组件装饰器的写法会有差异。安装好 Cocos Dashboard 和 Creator 之后新建一个空项目或 2D 模板项目语言选择 TypeScript。新建项目即可不需要额外安装第三方依赖。3.2 目录规划为了让战斗逻辑不散落建议先规划目录结构。下面是一个适合中小型回合制游戏的基础分层assets/ scenes/ Battle.scene resources/ config/ skills.json enemies.json scripts/ battle/ BattleManager.ts BattleActor.ts CombatEnums.ts DamageCalculator.ts data/ SkillData.ts EnemyAI.ts ui/ BattleUI.tsscenes存放战斗场景。resources/config存放技能、敌人等数据配置。scripts/battle存放战斗核心逻辑。scripts/data存放数据结构和 AI 逻辑。scripts/ui存放 UI 表现层代码。在实际小项目中不一定每个文件都单独建但至少要保证“战斗逻辑相关脚本”和“UI 相关脚本”分开。否则代码会随着功能增加迅速腐化。3.3 场景层级设计在Battle.scene中创建一个空节点BattleRoot然后在这个节点下搭建以下层级BattleRoot PlayerGroup Player1 // 挂 BattleActor 组件设置属性 Player2 EnemyGroup Enemy1 Enemy2 UILayer SkillBar HpLabel LogPanel EndPanel节点名字不重要重要的是职责边界PlayerGroup/EnemyGroup只负责容纳角色节点。每个角色节点挂BattleActor组件把 HP、MP、攻击、防御、速度等属性填好。UILayer专门放界面相关节点后面用BattleUI组件驱动。BattleManager可以挂在BattleRoot上也可以在场景里单独建一个BattleManager节点。这样一个场景层级能让美术资源和战斗逻辑尽量解耦。角色节点换成新的预制体战斗逻辑不受影响。4. 战斗核心模块设计现在把系统拆成几个模块先明确每个模块的职责。CombatPhase枚举表示战斗当前处于哪个阶段。它是我整个状态机的基础。BattleActor挂在角色节点上保存角色的战斗属性和当前状态提供伤害、治疗、存活判断等方法。SkillData技能数据接口定义技能 ID、名称、MP 消耗、倍率、目标类型等字段。DamageCalculator纯计算类不接触任何场景节点负责根据攻击方、防御方和技能数据计算最终伤害。BattleManager战斗总控持有所有行动者负责状态机流转、玩家输入接收、结算分发和胜负判定。BattleUI表现层监听 BattleManager 的事件更新按钮、血条、日志和结算面板。EnemyAI敌方行动决策逻辑根据当前敌人属性选择一个技能。这些模块之间的调用关系要单向流动。最常见的数据流是这样的BattleManager 进入PlayerTurn阶段发事件通知 UI 显示技能按钮。玩家点击技能按钮BattleUI 收集技能 ID 和目标 ID发送PlayerCommand事件。BattleManager 收到事件后关闭监听、解除输入等待进入SkillResolving阶段。BattleManager 调用DamageCalculator计算伤害调用BattleActor.takeDamage写入结果。BattleManager 发出“技能结算完成”事件UI 更新血条和日志。BattleManager 检查胜负。如果未结束切换到EnemyTurn让敌方 AI 行动。这套流程中UI 永远不直接修改战斗数据BattleManager 是修改战斗数据的唯一入口。这是避免“重复点击导致重复扣血”等问题的关键。5. 代码实现状态机与战斗流程下面开始写核心代码。先定义回合阶段和技能类型。5.1 战斗阶段与技能数据定义// assets/scripts/battle/CombatEnums.ts export enum CombatPhase { Idle idle, PlayerTurn player_turn, SkillResolving skill_resolving, EnemyTurn enemy_turn, TurnEnd turn_end, BattleEnd battle_end, } export enum SkillTargetType { SingleEnemy single_enemy, AllEnemies all_enemies, Self self, }// assets/scripts/data/SkillData.ts import { SkillTargetType } from ../battle/CombatEnums; export interface SkillData { id: string; name: string; description: string; mpCost: number; damageMultiplier: number; flatDamage: number; targetType: SkillTargetType; healRatio?: number; requiredHpRatio?: number; }requiredHpRatio是可选的比如“生命低于 30% 时才能使用”的技能就会用到它。这样设计不是把每个技能做成一个类而是把技能变成一条可配置的数据。后续加新技能主要工作在配置而不是改代码。5.2 行动者组件// assets/scripts/battle/BattleActor.ts import { _decorator, Component } from cc; import { SkillData } from ../data/SkillData; const { ccclass, property } _decorator; ccclass(BattleActor) export class BattleActor extends Component { property actorId: string ; property displayName: string ; property maxHp: number 100; property hp: number 100; property maxMp: number 50; property mp: number 50; property attack: number 20; property defense: number 10; property speed: number 12; property critRate: number 0.1; skills: SkillData[] []; public isAlive(): boolean { return this.hp 0; } public canCast(skill: SkillData, _target?: BattleActor): boolean { if (this.mp skill.mpCost) { return false; } if (skill.requiredHpRatio ! undefined) { const currentRatio this.hp / this.maxHp; if (currentRatio skill.requiredHpRatio) { return false; } } return true; } public takeDamage(damage: number): void { this.hp Math.max(0, this.hp - damage); } public heal(amount: number): void { this.hp Math.min(this.maxHp, this.hp amount); } public costMp(amount: number): void { this.mp Math.max(0, this.mp - amount); } public getSkillById(skillId: string): SkillData | undefined { return this.skills.find(s s.id skillId); } }这里把角色属性直接暴露在property上方便在编辑器里配置。如果你是数据驱动爱好者也可以改成从 JSON 初始化BattleActor。无论哪种方式核心方法是统一的takeDamage、heal、canCast、costMp。5.3 战斗管理器状态机BattleManager 是整个系统的关键。它需要做到管理行动顺序、等待玩家输入、执行技能结算、驱动敌方 AI、判定胜负。// assets/scripts/battle/BattleManager.ts import { _decorator, Component } from cc; import { BattleActor } from ./BattleActor; import { CombatPhase } from ./CombatEnums; import { SkillData } from ../data/SkillData; import { DamageCalculator } from ./DamageCalculator; const { ccclass, property } _decorator; ccclass(BattleManager) export class BattleManager extends Component { property({ type: [BattleActor] }) playerActors: BattleActor[] []; property({ type: [BattleActor] }) enemyActors: BattleActor[] []; private _turnOrder: BattleActor[] []; private _currentIndex: number 0; private _phase: CombatPhase CombatPhase.Idle; private _battleRunning: boolean false; private _battleFinished: boolean false; private _resolvePlayerInput: (() void) | null null; public startBattle(): void { if (this._battleRunning) { return; } this._battleRunning true; this._battleFinished false; this._currentIndex 0; this._buildTurnOrder(); this._log(战斗开始); this._enterPhase(CombatPhase.PlayerTurn); } private _buildTurnOrder(): void { const allActors [...this.playerActors, ...this.enemyActors]; this._turnOrder allActors .filter(actor actor.isAlive()) .sort((a, b) b.speed - a.speed); } private async _enterPhase(phase: CombatPhase): Promisevoid { this._phase phase; this.node.emit(BattlePhaseChanged, phase); if (phase CombatPhase.PlayerTurn) { await this._waitPlayerAction(); await this._resolveSelectedSkill(); } else if (phase CombatPhase.EnemyTurn) { await this._processEnemyTurn(); } else if (phase CombatPhase.BattleEnd) { this._battleFinished true; this._battleRunning false; } if (this._battleFinished) { return; } const hasAlivePlayer this.playerActors.some(actor actor.isAlive()); const hasAliveEnemy this.enemyActors.some(actor actor.isAlive()); if (!hasAlivePlayer || !hasAliveEnemy) { this._battleFinished true; this._battleRunning false; this.node.emit(BattleEnd, hasAlivePlayer ? win : lose); return; } this._currentIndex (this._currentIndex 1) % this._turnOrder.length; const nextActor this._turnOrder[this._currentIndex]; const nextPhase this.playerActors.includes(nextActor) ? CombatPhase.PlayerTurn : CombatPhase.EnemyTurn; this._enterPhase(nextPhase); } private _waitPlayerAction(): Promisevoid { return new Promisevoid((resolve) { this._resolvePlayerInput resolve; const currentActor this._turnOrder[this._currentIndex]; this.node.emit(EnterPlayerTurn, currentActor); this.node.once(PlayerCommand, this._onPlayerCommand, this); }); } private _onPlayerCommand(command: { skillId: string; targetId: string }): void { if (this._phase ! CombatPhase.PlayerTurn) { return; } this.node.off(PlayerCommand, this._onPlayerCommand, this); this._selectedCommand command; if (this._resolvePlayerInput) { this._resolvePlayerInput(); this._resolvePlayerInput null; } } private async _resolveSelectedSkill(): Promisevoid { if (!this._selectedCommand) { this._log(未收到有效指令跳过技能结算); this._enterPhase(CombatPhase.EnemyTurn); return; } const currentActor this._turnOrder[this._currentIndex]; const skill currentActor.getSkillById(this._selectedCommand.skillId); const target this._findActorById(this._selectedCommand.targetId); if (!skill || !target || !target.isAlive()) { this._log(技能或目标无效取消行动); this._enterPhase(CombatPhase.EnemyTurn); return; } this._phase CombatPhase.SkillResolving; this._log(${currentActor.displayName} 使用 ${skill.name}); const targets this._collectTargets(currentActor, skill, target); const results DamageCalculator.calculateSkill(currentActor, targets, skill); results.forEach((result, index) { const t targets[index]; if (result.isHeal) { t.heal(result.value); this._log(${t.displayName} 恢复 ${result.value} 点生命); } else { t.takeDamage(result.value); this._log(${t.displayName} 受到 ${result.value} 点伤害${result.isCrit ? 暴击 : }); } }); currentActor.costMp(skill.mpCost); await this._playSkillAnimation(skill); this.node.emit(SkillResolved, { caster: currentActor, skill, targets, results, }); } private _collectTargets(caster: BattleActor, skill: SkillData, primaryTarget: BattleActor): BattleActor[] { if (skill.targetType SkillTargetType.Self) { return [caster]; } if (skill.targetType SkillTargetType.AllEnemies) { return this._getCurrentEnemies(caster); } return [primaryTarget]; } private _getCurrentEnemies(caster: BattleActor): BattleActor[] { const isPlayer this.playerActors.includes(caster); return (isPlayer ? this.enemyActors : this.playerActors) .filter(actor actor.isAlive()); } private _findActorById(actorId: string): BattleActor | undefined { return [...this.playerActors, ...this.enemyActors] .find(actor actor.actorId actorId); } private async _processEnemyTurn(): Promisevoid { const currentActor this._turnOrder[this._currentIndex]; this._log(敌人回合${currentActor.displayName}); await this._delay(0.6); if (!currentActor.isAlive()) { return; } const skill EnemyAI.chooseSkill(currentActor); const targets this._collectTargets(currentActor, skill, null); const results DamageCalculator.calculateSkill(currentActor, targets, skill); results.forEach((result, index) { const t targets[index]; if (result.isHeal) { t.heal(result.value); this._log(${t.displayName} 恢复 ${result.value} 点生命); } else { t.takeDamage(result.value); this._log(${t.displayName} 受到 ${result.value} 点伤害${result.isCrit ? 暴击 : }); } }); currentActor.costMp(skill.mpCost); await this._playSkillAnimation(skill); this.node.emit(SkillResolved, { caster: currentActor, skill, targets, results, }); } private _playSkillAnimation(_skill: SkillData): Promisevoid { // 实际项目中在这里播放动画并通过动画结束事件 resolve return this._delay(0.4); } private _delay(seconds: number): Promisevoid { return new Promise((resolve) { this.scheduleOnce(() resolve(), seconds); }); } private _log(message: string): void { console.log([Battle] ${message}); } }关于上面这段代码有几点需要重点解释。第一_buildTurnOrder在战斗开始时把所有存活角色按速度排序。这意味着死亡角色不会进入后续回合。实际项目中如果中途加入援军你需要在对应时机重新调用排序或插入行动者。第二_waitPlayerAction返回一个 Promise。Promise 的 resolve 保存在_resolvePlayerInput中等待 UI 层发送PlayerCommand事件。这是把“异步等待玩家输入”变成可await的关键。一旦收到指令Promise 被 resolve状态机继续往下走。第三_onPlayerCommand里判断了this._phase ! CombatPhase.PlayerTurn。这可以防止玩家在结算阶段或敌方回合点击按钮时仍然发送指令进来。即使 UI 没有完全禁用按钮核心逻辑层也不会被误导。第四_processEnemyTurn使用了一个_delay(0.6)模拟“敌人思考一秒再行动”的演出效果。这个延时是表现层的一部分但它放在逻辑层也完全没问题因为敌人 AI 毕竟是逻辑的一部分。你甚至可以把延时去掉让 AI 瞬间行动。第五胜负判定放在了_enterPhase的最后而不是每个技能结算后。这样写的好处是所有阶段转换只有一个出口整个流程不会出现“这边结算完那边已经提前结束”的混乱。这里没有写死_selectedCommand的声明。实际使用时你需要在 BattleManager 里加一个字段private _selectedCommand: { skillId: string; targetId: string } | null null;并且在_onPlayerCommand中给这个字段赋值。我推荐把成员变量统一放在类顶部上面的代码为了紧凑没有把所有字段都贴出来实际项目里要注意完整。6. 代码实现伤害计算、技能配置与 UI 交互6.1 伤害计算器伤害计算是回合制游戏最容易产生“数值对不上”的部分。我的建议是把它独立成一个纯函数类不在 BattleManager 里散落计算逻辑。// assets/scripts/battle/DamageCalculator.ts import { BattleActor } from ./BattleActor; import { SkillData } from ../data/SkillData; export interface DamageResult { value: number; isCrit: boolean; isHeal: boolean; } export class DamageCalculator { public static calculateSkill( caster: BattleActor, targets: BattleActor[], skill: SkillData ): DamageResult[] { const isHeal skill.healRatio ! undefined; return targets.map(target { if (isHeal) { const amount Math.floor(caster.maxHp * (skill.healRatio ?? 0)); return { value: amount, isCrit: false, isHeal: true }; } const base caster.attack * skill.damageMultiplier skill.flatDamage; const mitigation target.defense / (target.defense 100); let damage Math.max(1, Math.floor(base * (1 - mitigation))); let isCrit false; if (Math.random() caster.critRate) { damage Math.floor(damage * 1.5); isCrit true; } return { value: damage, isCrit, isHeal: false }; }); } }这套公式不算复杂但代表了一个很容易理解的设计思路最终伤害 max(1, floor((攻击力 × 技能倍率 固定伤害) × (1 - 防御减伤率))) × 暴击倍率 防御减伤率 防御 / (防御 100)防御 / (防御 100)这类减伤公式的好处是防御属性永远不可能把伤害减到负数或零。如果你用“攻击 - 防御”一旦攻击小于防御伤害就变成负数你需要额外写很多判断。虽然公式简单但已经足够支撑一个战斗原型。6.2 技能配置技能不要写在代码里而是写进 JSON 配置文件。// assets/resources/config/skills.json [ { id: normal_attack, name: 普通攻击, description: 对单个敌人造成 100% 攻击力的物理伤害, mpCost: 0, damageMultiplier: 1.0, flatDamage: 0, targetType: single_enemy }, { id: fireball, name: 火球术, description: 对单个敌人造成 180% 攻击力的法术伤害, mpCost: 10, damageMultiplier: 1.8, flatDamage: 5, targetType: single_enemy }, { id: heal, name: 治疗术, description: 恢复自身 25% 最大生命值, mpCost: 8, damageMultiplier: 0, flatDamage: 0, targetType: self, healRatio: 0.25 } ]在代码里加载 JSON 时需要把数据转成SkillData[]并且把targetType映射为枚举。最简单的方法是在加载后处理一次const skillList: any[] JsonAsset; const skills: SkillData[] skillList.map(item ({ ...item, targetType: item.targetType as SkillTargetType }));如果不想引入 JSON 加载流程也可以直接在data/SkillData.ts里导出一份常量技能表。两者都算数据驱动核心是不要让技能逻辑和数值散落在战斗循环里。6.3 UI 交互绑定玩家操作技能按钮最终要落到 BattleManager 的事件上。UI 组件的职责是展示阶段、生成按钮、收集玩家选择、发送指令。// assets/scripts/ui/BattleUI.ts import { _decorator, Component, Node, Label, Button } from cc; import { BattleManager } from ../battle/BattleManager; import { BattleActor } from ../battle/BattleActor; import { SkillData } from ../data/SkillData; const { ccclass, property } _decorator; ccclass(BattleUI) export class BattleUI extends Component { property({ type: BattleManager }) battleManager: BattleManager | null null; property({ type: Label }) hpLabel: Label | null null; property({ type: Label }) logLabel: Label | null null; property({ type: Node }) skillButtonPrefab: Node | null null; property({ type: Node }) skillBar: Node | null null; private _currentActor: BattleActor | null null; private _enemyTargets: BattleActor[] []; onLoad(): void { if (this.battleManager) { this.battleManager.node.on(EnterPlayerTurn, this._onEnterPlayerTurn, this); this.battleManager.node.on(SkillResolved, this._onSkillResolved, this); this.battleManager.node.on(BattleEnd, this._onBattleEnd, this); } } private _onEnterPlayerTurn(actor: BattleActor): void { this._currentActor actor; this._refreshHp(); this._refreshSkillButtons(actor); } private _refreshSkillButtons(actor: BattleActor): void { if (!this.skillBar || !this.skillButtonPrefab) { return; } this.skillBar.removeAllChildren(); for (const skill of actor.skills) { if (!actor.canCast(skill)) { continue; } const buttonNode instantiate(this.skillButtonPrefab); const buttonLabel buttonNode.getComponentInChildren(Label); if (buttonLabel) { buttonLabel.string ${skill.name} (MP ${skill.mpCost}); } const button buttonNode.getComponent(Button); if (button) { button.node.on(Button.EventType.CLICK, () { this._onSkillClicked(skill); }, this); } this.skillBar.addChild(buttonNode); } } private _onSkillClicked(skill: SkillData): void { if (!this.battleManager || !this._currentActor) { return; } const targetId this._resolvePrimaryTarget(skill); if (!targetId) { this._log(没有可用的目标); return; } this.battleManager.node.emit(PlayerCommand, { skillId: skill.id, targetId, }); } private _resolvePrimaryTarget(skill: SkillData): string | null { if (!this._currentActor || !this.battleManager) { return null; } if (skill.targetType SkillTargetType.Self) { return this._currentActor.actorId; } // 简化目标选取默认选择敌方列表中第一个存活单位 // 实际项目中应该弹出目标选择界面 const enemies this.battleManager.enemyActors.filter(a a.isAlive()); return enemies.length 0 ? enemies[0].actorId : null; } private _onSkillResolved(): void { this._refreshHp(); } private _onBattleEnd(result: string): void { this._log(result win ? 战斗胜利 : 战斗失败); } private _refreshHp(): void { if (this.hpLabel this._currentActor) { this.hpLabel.string HP ${this._currentActor.hp} / ${this._currentActor.maxHp}; } } private _log(message: string): void { if (this.logLabel) { this.logLabel.string message; } console.log([UI] ${message}); } }这段 UI 代码有一个明显的简化目标选择是“默认选第一个存活敌人”。如果你要做单机回合制游戏这显然不够。你可以扩展成点击敌人节点选择目标方法是在_onEnterPlayerTurn时为敌人节点绑定点击事件把敌人actorId记录下来。核心思路不变UI 负责交互BattleManager 负责逻辑。instantiate是 Cocos Creator 中实例化预制体的函数需要从cc模块导入。上面代码里没有写全 import实际使用时要加上import { instantiate } from cc;6.4 敌人 AI敌方回合需要一个简单 AI。最基础的行为就是从可用技能里随机选一个优先用消耗低、效果直接的技能。更复杂的 AI 可以做成决策树、行为树或者权重评分但战斗原型阶段根本不需要过度设计。// assets/scripts/data/EnemyAI.ts import { BattleActor } from ../battle/BattleActor; import { SkillData } from ../data/SkillData; export class EnemyAI { public static chooseSkill(actor: BattleActor): SkillData { const usableSkills actor.skills.filter(skill actor.canCast(skill)); if (usableSkills.length 0) { throw new Error(${actor.displayName} 没有可用技能); } const index Math.floor(Math.random() * usableSkills.length); return usableSkills[index]; } }你可以给敌人也配置一套技能表比如普通攻击永远可用技能冷却完毕才可释放。这样 AI 只需要从“当前可用技能”里选一个后续加技能也不会改 AI 结构。7. 运行结果与效果验证7.1 场景配置步骤把代码写完之后怎么知道它真的能跑建议按下面的步骤做一次最小验证。第一步在Battle.scene中创建BattleRoot节点挂上BattleManager组件。把PlayerGroup下的角色节点拖到playerActors数组把EnemyGroup下的角色节点拖到enemyActors数组。第二步给每个角色节点挂BattleActor组件填上基本的属性比如角色 AHP 120MP 30攻击 25防御 8速度 14 角色 B敌人HP 80MP 20攻击 18防御 5速度 10第三步在UILayer下挂BattleUI组件。把BattleUI的battleManager属性拖到BattleManager节点上。配置好技能按钮预制体和 HP/日志 Label。第四步写一个临时按钮或者直接在BattleManager节点的start里调用startBattle()。运行场景。如果一切正常控制台输出会是这样[Battle] 战斗开始 [Battle] 战士 使用 火球术 [Battle] 史莱姆 受到 42 点伤害暴击 [Battle] 敌人回合史莱姆 [Battle] 史莱姆 使用 撞击 [Battle] 战士 受到 12 点伤害 [Battle] 战士 使用 普通攻击 ...7.2 验证点至少验证以下四个行为缺一不可行动顺序速度更快的角色先行动。你可以在日志里看到行动者名字的顺序和速度一致。技能结算伤害值与伤害公式计算出的结果一致。用计算器手动算一遍确保没有多算或少算。玩家输入屏蔽进入SkillResolving后快速点击技能按钮不会产生第二次技能释放。胜负判定把敌人的 HP 改成 1让玩家打一次战斗应该进入BattleEnd并输出“战斗胜利”。如果某个验证点失败不要急着改代码。先确认当前状态机处于哪个阶段。例如玩家输入屏蔽失败大概率是_onPlayerCommand里的阶段判断缺失或者 UI 没有在结算阶段禁用按钮。核心逻辑层的if (this._phase ! CombatPhase.PlayerTurn)就是兜底保障。8. 常见问题与排查思路问题现象可能原因排查方式解决方案战斗卡在等待玩家输入没有任何输出PlayerCommand事件没有被发送或监听器在错误节点上在_waitPlayerAction里加日志打印当前行动者和_resolvePlayerInput是否为 null检查BattleUI是否监听了EnterPlayerTurn点击按钮后是否调用了battleManager.node.emit(PlayerCommand, ...)玩家连续点击技能造成重复结算UI 在SkillResolving阶段没有禁用按钮或逻辑层缺少阶段判断查看日志中是否同一回合出现两次同技能结算在 UI 的_onEnterPlayerTurn生成按钮在SkillResolved后清空按钮在_onPlayerCommand中增加阶段判断敌人回合永远不结束_processEnemyTurn中的异步流程没有返回或_enterPhase没有被递归调用在_processEnemyTurn末尾加日志检查_processEnemyTurn是否走到了await之后确认 BattleManager 的_battleFinished没有被错误设置伤害数值和公式预期不符防御减伤公式被多次套用浮点数取整时机不对用固定数值写单元测试或手动计算统一先在DamageCalculator中计算总伤害再在外部只做 HP 扣除不要在多个地方重复计算节点销毁后异步流程还在运行战斗场景切换时_delay回调仍在执行访问了已销毁节点在onDestroy中设置_battleFinished true并在异步方法中判断this.isValid在BattleManager.onDestroy中清理事件监听设置结束标志每次await后检查this.isValid目标选择为空导致技能未释放玩家没有选择目标或目标已经死亡查看_resolvePrimaryTarget是否返回 null在 UI 中为存活敌人生成可点击目标技能结算前校验目标存活状态胜负判定时机不正确比如角色死后还能行动行动顺序和死亡状态没有同步刷新确认_buildTurnOrder是否在回合开始前过滤了死亡角色每次进入新回合前重新计算行动顺序或至少过滤存活单位排查的时候最有效的工具是日志。我建议在 BattleManager 里增加一个_log方法把所有阶段切换和关键事件都打印出来。回合制战斗的流程比帧同步类玩法更容易追踪因为它的状态跳转是线性的日志一多问题位置立刻清晰。9. 工程化建议与后续扩展方向9.1 保持逻辑层独立BattleManager 不要引用任何 UI 节点。UI 需要更新时通过事件通知。这样做的最大好处是以后你可以把同一个 BattleManager 用到不同的战斗场景里甚至可以在一套代码里实现战斗回放因为逻辑层不依赖任何特定节点。9.2 技能效果用数据配置而不是堆 if当你开始加 Buff、中毒、灼烧这类持续效果时建议把技能效果抽象成一组可执行的数据结构。比如SkillEffect可以描述“对目标附加中毒每回合扣 5% 最大生命持续 3 回合”。BattleManager 在回合结束阶段统一处理持续效果。继续用硬编码 if 也能实现但会越写越长后面几乎无法维护。9.3 慎重处理异步动画本文代码里的_delay是简化版的演出等待。实际项目中动画播放和逻辑推进之间有一个经典冲突逻辑太快或太慢。我的建议是让战斗逻辑永远不依赖动画回调而是在动画开始时记录一个“结算事件”动画结束只是让流程继续但流程本身已经完成。这样播放动画和战斗逻辑可以解耦你甚至可以先不发动画只靠日志跑通。9.4 注意对象销毁与事件清理在onDestroy中要主动清理 BattleManager 上的事件监听尤其是使用this.node.on的地方。Cocos Creator 在节点销毁时会自动清理节点自身的事件监听但如果你监听了其他对象的节点事件或者使用了EventTarget全局事件就要手动处理。9.5 后续扩展路径这个原型跑通之后下一步可以按这个顺序扩展目标选择 UI点击敌人节点选择目标而不是默认选第一个。Buff/Debuff 系统设计状态效果在回合开始和回合结束时结算。技能动画队列用动画播放队列控制演出顺序。多技能配置把skills.json扩展成带冷却、消耗类型、元素属性的完整配置表。战斗日志面板UI 中的 LogPanel 记录完整历史。服务端同步如果做联机回合制把 BattleManager 的核心规则搬到服务端客户端只负责输入和表现。回合制游戏是一个非常适合练习“状态机 事件驱动”的项目类型。它的规则边界清晰状态数量有限但又足够复杂能让你体会架构设计带来的长期收益。先跑通最小循环再逐步加内容这是我认为最靠谱的做独立项目的节奏。

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

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

免费获取报价 →
↑