☰
Cocos Creator回合制战斗框架:用状态机理清战斗流程
2026/10/21 2:49:39 网站建设 项目流程

回合制 RPG 看起来简单,真正动手做的时候,卡住你的往往不是美术和动画,而是“现在该轮到谁行动”“玩家点完技能之后下一步去哪”这类流程问题。如果你准备用 Cocos Creator 做一款回合制游戏,这篇文章想给你一个明确判断:回合制战斗的骨架不是特效,不是 UI,而是一个有限状态机。下面我会用 TypeScript 在 Cocos Creator 里从零搭一套可运行的回合制战斗框架,包括状态流转、伤害公式、技能配置、玩家输入和敌方 AI,最后落到工程结构和常见排错上。

很多第一次做回合制项目的开发者,习惯把战斗流程写在一个大update()里,用一堆 boolean 变量判断“能不能点击、有没有结算、要不要换回合”。刚开始只有两个角色的时候确实能跑,一旦加入技能动画、Buff、多个敌人,流程就会开始错乱:重复点击技能导致结算两次、敌人行动没结束玩家又能操作、动画还没播放完 HP 就已经扣完了。这不是偶然的 Bug,而是架构上的问题。回合制游戏的复杂度,恰恰集中在“流程状态转移”和“结算顺序”上,这两件事需要被显式管理。

我推荐的方案是:BattleManager 作为唯一决策者,用状态机管理回合阶段,用事件解耦 UI 和表现层,用数据配置驱动技能扩展。这篇教程适合已经开始接触 Cocos Creator、想认真做一个回合制原型,或者做到一半发现流程越来越难维护的开发者。读完你会得到一个清晰的工程结构,可以在上面继续加角色、加技能、加 Buff,而不需要推翻重来。

1. 这篇文章真正要解决的问题

先说一个常见的开发轨迹。刚开始你会觉得“回合制不就是你打我一下,我打你一下吗”,于是很快就把玩家攻击、敌人 AI、扣血逻辑都写在场景脚本里。跑通第一次之后,你开始加技能、加暴击、加防御,于是update()里的 if 越来越多,脚本之间互相调用也越来越乱。等到你想加一个“中毒每回合扣血”的效果时,会发现几乎每个系统都要被改动一遍。

这个问题的本质是:你把回合流程、游戏规则和表现细节混在了一起。具体到代码里通常有四种坏味道:

  1. 流程状态分裂:谁在行动、能不能操作、是否在结算,这些信息分散在多个组件的多个变量里。
  2. UI 和逻辑耦合:技能按钮点击后直接修改 HP 文本、播放粒子特效,没有统一的结算入口。
  3. 技能扩展困难:每加一个技能就要改战斗循环,甚至要改 UI 代码。
  4. 异步时序混乱:动画播放结束才结算,或者结算完了动画还没结束,两种顺序都没有被约束。

这篇文章要解决的,就是把回合制战斗里的“规则”和“表演”拆开。规则由 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.ts
  • scenes存放战斗场景。
  • 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:敌方行动决策逻辑,根据当前敌人属性选择一个技能。

这些模块之间的调用关系要单向流动。最常见的数据流是这样的:

  1. BattleManager 进入PlayerTurn阶段,发事件通知 UI 显示技能按钮。
  2. 玩家点击技能按钮,BattleUI 收集技能 ID 和目标 ID,发送PlayerCommand事件。
  3. BattleManager 收到事件后,关闭监听、解除输入等待,进入SkillResolving阶段。
  4. BattleManager 调用DamageCalculator计算伤害,调用BattleActor.takeDamage写入结果。
  5. BattleManager 发出“技能结算完成”事件,UI 更新血条和日志。
  6. 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): Promise<void> { 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(): Promise<void> { return new Promise<void>((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(): Promise<void> { 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(): Promise<void> { 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): Promise<void> { // 实际项目中在这里播放动画,并通过动画结束事件 resolve return this._delay(0.4); } private _delay(seconds: number): Promise<void> { 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组件,填上基本的属性,比如:

角色 A:HP 120,MP 30,攻击 25,防御 8,速度 14 角色 B(敌人):HP 80,MP 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 验证点

至少验证以下四个行为,缺一不可:

  1. 行动顺序:速度更快的角色先行动。你可以在日志里看到行动者名字的顺序和速度一致。
  2. 技能结算:伤害值与伤害公式计算出的结果一致。用计算器手动算一遍,确保没有多算或少算。
  3. 玩家输入屏蔽:进入SkillResolving后快速点击技能按钮,不会产生第二次技能释放。
  4. 胜负判定:把敌人的 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 后续扩展路径

这个原型跑通之后,下一步可以按这个顺序扩展:

  1. 目标选择 UI:点击敌人节点选择目标,而不是默认选第一个。
  2. Buff/Debuff 系统:设计状态效果,在回合开始和回合结束时结算。
  3. 技能动画队列:用动画播放队列控制演出顺序。
  4. 多技能配置:把skills.json扩展成带冷却、消耗类型、元素属性的完整配置表。
  5. 战斗日志面板:UI 中的 LogPanel 记录完整历史。
  6. 服务端同步:如果做联机回合制,把 BattleManager 的核心规则搬到服务端,客户端只负责输入和表现。

回合制游戏是一个非常适合练习“状态机 + 事件驱动”的项目类型。它的规则边界清晰,状态数量有限,但又足够复杂,能让你体会架构设计带来的长期收益。先跑通最小循环,再逐步加内容,这是我认为最靠谱的做独立项目的节奏。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询