资讯动态

UE5 C++实战:用状态机驱动回合制战斗流程

发布时间:2026/10/17 10:14:39 来源:尧图企业网站定制
UE 5 C从零开始做CRPG第7.16课【战斗系统】- 16 - 战斗流程3前面我们花了整整两节课把战斗流程的框架和核心节点梳理了一遍从战斗开始时的初始化到回合切换的入口再到角色行动前的前置判定。很多朋友跟着做下来框架能跑通但一旦加入技能、伤害、死亡这些“真实逻辑”流程就开始失控走着走着状态丢死、动画不播、回合卡住、甚至直接崩溃。这其实不是UE的锅而是我们对战斗流程的“状态管理”和“任务拆分”还差一口气。本节课我们继续沿着第7.14课和第7.15课的思路把战斗流程的第三站走完。本课会聚焦以下内容把战斗流程从“顺序执行”升级为“状态机驱动”实现回合结束、战斗胜利、战斗失败的完整判定在C里统一处理伤害、死亡、经验掉落解决AI行动与主角行动“抢占回合”的问题用调试命令和日志帮你快速定位流程卡住的每一个环节。如果你是从零跟到这一课的建议先把前两课的代码准备好尤其是FCombatContext和UCombatComponent的结构。如果你中途跳进来的也没有关系本课所有代码都会标注完整文件路径你也可以把它当成一份“UE5 C回合制战斗框架”的实战笔记来读。1. 战斗流程为什么容易写崩先不急着写代码我们来想一个问题为什么回合制战斗系统总是比即时战斗系统更容易在流程上出Bug原因很简单回合制战斗是“状态多、节点多、等待时间长”。一行代码从开始执行到结束中间可能要经过用户输入、技能动画、伤害跳动、角色死亡、经验结算等多个阶段。如果你用“顺序调用”的方式去写比如在玩家的ExecuteTurn()里直接调DealDamage()再调PlayAnim()再调EndTurn()那么任何一个协程、动画通知、AI定时器稍微慢一拍整个链条就会断掉。所以工程上更通用的做法是把战斗流程抽象成几组状态让每个状态只负责一件事情状态之间通过事件或委托来切换。这也就是我们常说的“有限状态机FSM”思路。空闲状态等待当前行动者指令行动状态正在播放动画、结算伤害、弹出数值切换状态清理数据、切换回合、切换行动者结束状态判定战斗结束条件处理胜利失败。我们这节课就是要亲手把上面的四个状态写成一个可运行的C战斗流程。2. 环境准备与版本说明本人课程案例基于以下环境你不需要完全一致但至少要知道差异可能会导致哪些问题操作系统Windows 10/11引擎版本Unreal Engine 5.3开发语言C17IDERider 或 Visual Studio 2022项目类型空白C项目已启用UE默认的First Person或TopDown模板均可插件不需要额外付费插件会用到UE自带的结构体、委托、定时器。如果你的引擎版本是5.0、5.1、5.2大部分代码可以直接用但个别头文件的命名空间可能有细微差异比如FGenericPlatformMisc之类。遇到编译报错优先检查引擎版本对应的API文档不要盲目改代码。3. 拆解战斗流程的三个核心模块在写实战代码之前我们先在逻辑上把战斗流程拆成三个模块理解每个模块的职责边界。3.1 战斗上下文数据的中转站我们之前已经设计了一个FCombatContext结构体用来在战斗流程中传递临时数据比如目标引用、技能ID、伤害值。在这个模块里我们只需要确保它能在战斗中存活并且能够追踪当前行动者、目标列表、战斗结果。3.2 战斗管理器状态的发动机战斗管理器是所有状态的中枢。它负责初始化战斗决定当前轮次应该由谁行动分发行动事件监听行动结束事件判定胜负。3.3 战斗角色行动的真正执行者角色必须能接收“开始行动”的指令然后选择技能、目标、播放动画、结算伤害。战斗管理器不关心角色内部怎么执行它只要收到“行动结束”的回调即可。顺着这个思路我们就可以从根本上规避“一个函数做所有事”的问题。4. 完整实战战斗流程状态机实现接下来我们直接进入C实现。为了方便你对照代码位置我会把文件路径和完整代码一并给出。4.1 项目结构设计先创建一个新的类用于承担战斗流程控制。建议你的项目结构如下Source/ │ ├── CRPGGame/ │ ├── Public/ │ │ ├── Combat/ │ │ │ ├── CombatComponent.h │ │ │ ├── CombatContext.h │ │ │ └── CombatFlowState.h │ │ ├── Abilities/ │ │ │ └── GameplayAbilityBase.h │ │ └── Characters/ │ │ └── RPGCharacterBase.h │ └── Private/ │ ├── Combat/ │ │ ├── CombatComponent.cpp │ │ ├── CombatContext.cpp │ │ └── CombatFlowState.cpp │ ├── Abilities/ │ │ └── GameplayAbilityBase.cpp │ └── Characters/ │ └── RPGCharacterBase.cpp本节课只用添加一个CombatFlowState.h/.cpp用来声明战斗流程状态机。4.2 定义战斗状态枚举我们先定义战斗流程的各个状态这样可以随时以“状态”为单位去调试。// 文件路径Source/CRPGGame/Public/Combat/CombatFlowState.h #pragma once UENUM(BlueprintType) enum class ECombatPhase : uint8 { None, // 战斗开始播放入场演出 Intro, // 等待当前行动者做出选择 AwaitingInput, // 行动者正在执行某技能 ExecutingAction, // 技能执行完毕统一结算伤害 ResolvingEffects, // 判定是否有人死亡 CheckingDeath, // 切换回合/切换行动者 SwitchTurn, // 战斗结束 CombatEnd, // 战斗胜利 Victory, // 战斗失败 Defeat };这个枚举不仅用于逻辑判断还可以直接暴露给蓝图以后做技能演出与UMG界面同步都会方便很多。4.3 编写战斗流控制类FCombatFlowState可以设计成纯C结构体也可以设计成UObject。考虑到它需要监听委托、管理定时器我建议使用UObject让引擎帮你管理生命周期。// 文件路径Source/CRPGGame/Public/Combat/CombatFlowState.h #pragma once #include CoreMinimal.h #include UObject/NoExportTypes.h #include CombatFlowState.generated.h class ARPGCharacterBase; class UCombatComponent; UCLASS() class UCombatFlowState : public UObject { GENERATED_BODY() public: UCombatFlowState(); // 初始化战斗 void StartCombat(); // 切换到下一个状态 void MoveToPhase(ECombatPhase NextPhase); // 当前控制器 ECombatPhase GetCurrentPhase() const { return CurrentPhase; } protected: // 当前战斗阶段 ECombatPhase CurrentPhase; // 回合计数器 int32 TurnCount; // 当前行动者索引 int32 CurrentActorIndex; // 所有参战角色 UPROPERTY() TArrayARPGCharacterBase* Combatants; // 绑定到角色的行动结束回调 void BindToAllCombatants(); // 播放入场动画 void PlayIntro(); // 请求下一个行动者的输入 void RequestInput(); // 执行当前行动者的技能 void ExecuteCurrentAction(); // 结算所有伤害 void ResolveEffects(); // 检查死亡 void CheckDeath(); };下面是实现文件。需要注意这里大量使用了GetWorld()-GetTimerManager()在UE5中可以直接使用FTimerManager来控制战斗流程的延迟等待也就是回合制战斗的节奏感来源。// 文件路径Source/CRPGGame/Private/Combat/CombatFlowState.cpp #include Combat/CombatFlowState.h #include Characters/RPGCharacterBase.h #include Combat/CombatComponent.h #include TimerManager.h UCombatFlowState::UCombatFlowState() : CurrentPhase(ECombatPhase::None) , TurnCount(0) , CurrentActorIndex(0) { } void UCombatFlowState::StartCombat() { if (CurrentPhase ! ECombatPhase::None) { // 防止重复初始化 return; } TurnCount 0; CurrentActorIndex 0; BindToAllCombatants(); MoveToPhase(ECombatPhase::Intro); } void UCombatFlowState::BindToAllCombatants() { // 假设战斗组件已经在外部设置好了参战列表 // 如果参战角色为空说明战斗初始化未完成 if (Combatants.Num() 0) { // 这里应该从外部注入而不是自己到处找角色 UE_LOG(LogTemp, Error, TEXT([CombatFlow] 没有可用的参战角色)); return; } for (ARPGCharacterBase* Character : Combatants) { if (Character) { // 每一帧进行行动结算是非常危险的用委托更安全 Character-OnActionCompleted.AddDynamic(this, UCombatFlowState::OnActionCompleted); } } } void UCombatFlowState::MoveToPhase(ECombatPhase NextPhase) { CurrentPhase NextPhase; switch (NextPhase) { case ECombatPhase::Intro: PlayIntro(); break; case ECombatPhase::AwaitingInput: RequestInput(); break; case ECombatPhase::ExecutingAction: ExecuteCurrentAction(); break; case ECombatPhase::ResolvingEffects: ResolveEffects(); break; case ECombatPhase::CheckingDeath: CheckDeath(); break; case ECombatPhase::SwitchTurn: // 切换回合时自动进入下一轮 TurnCount; CurrentActorIndex (CurrentActorIndex 1) % Combatants.Num(); MoveToPhase(ECombatPhase::AwaitingInput); break; case ECombatPhase::Victory: // 战斗胜利通知外部 UE_LOG(LogTemp, Log, TEXT([CombatFlow] 战斗胜利)); break; case ECombatPhase::Defeat: // 战斗失败通知外部 UE_LOG(LogTemp, Log, TEXT([CombatFlow] 战斗失败)); break; default: break; } } void UCombatFlowState::PlayIntro() { // 播放一段入场动画或者可以直接跳过 UE_LOG(LogTemp, Log, TEXT([CombatFlow] 战斗开始第 %d 回合), TurnCount 1); // 如果不需要播放演出直接进入等待输入 GetWorld()-GetTimerManager().SetTimerForNextTick(this, [this]() { MoveToPhase(ECombatPhase::AwaitingInput); }); } void UCombatFlowState::RequestInput() { ARPGCharacterBase* CurrentActor GetCurrentActor(); if (!CurrentActor) { UE_LOG(LogTemp, Error, TEXT([CombatFlow] 当前行动者为空无法继续战斗流程)); MoveToPhase(ECombatPhase::Defeat); return; } // 先给行动者一个事件UI可以据此显示技能菜单 CurrentActor-NotifyTurnStarted(); // 假设玩家或AI会在角色内部调用 BeginAction 来正式行动 // 战斗流程在这里什么都不用做等待行动结束回调 } void UCombatFlowState::ExecuteCurrentAction() { ARPGCharacterBase* CurrentActor GetCurrentActor(); if (!CurrentActor) { MoveToPhase(ECombatPhase::Defeat); return; } // 这里实际调用的是之前课程写好的技能释放逻辑 // 技能释放完以后会调用 OnActionCompleted 回调 CurrentActor-ExecuteSelectedAction(); } void UCombatFlowState::ResolveEffects() { // 这里是先结算伤害再检查死亡所以单独拆出一个阶段 for (ARPGCharacterBase* Combatant : Combatants) { if (Combatant) { Combatant-ApplyPendingDamage(); } } MoveToPhase(ECombatPhase::CheckingDeath); } void UCombatFlowState::CheckDeath() { TArrayARPGCharacterBase* AliveCombatants; for (ARPGCharacterBase* Combatant : Combatants) { if (Combatant Combatant-IsAlive()) { AliveCombatants.Add(Combatant); } else if (Combatant) { // 死亡角色可以播放死亡动画或直接从逻辑列表移除 Combatant-Die(); } } // 场景可以简化玩家和敌人两组 bool bPlayerDead false; bool bEnemyDead false; for (ARPGCharacterBase* Combatant : AliveCombatants) { if (Combatant-IsPlayerControlled()) { bPlayerDead false; // 有玩家活着 } else { bEnemyDead false; // 有敌人活着 } } // 这里要用更严谨的方式遍历并统计而不是上面这种两个变量的粗暴写法 // 下面会在代码优化部分再给一版更安全的 if (bPlayerDead) { MoveToPhase(ECombatPhase::Defeat); return; } if (bEnemyDead) { MoveToPhase(ECombatPhase::Victory); return; } MoveToPhase(ECombatPhase::SwitchTurn); } void UCombatFlowState::OnActionCompleted(ARPGCharacterBase* Character) { // 收到当前行动者的完成回调后直接进入结算阶段 if (Character GetCurrentActor()) { MoveToPhase(ECombatPhase::ResolvingEffects); } else { // 防止回调重复触发 UE_LOG(LogWarning, TEXT([CombatFlow] 收到非当前行动者的完成回调忽略。)); } } ARPGCharacterBase* UCombatFlowState::GetCurrentActor() const { if (Combatants.IsValidIndex(CurrentActorIndex)) { return Combatants[CurrentActorIndex]; } return nullptr; }4.4 把战斗流程挂载到战斗组件接下来我们需要在战斗组件中创建并持有UCombatFlowState。把战斗组件看作是战斗系统的门面而战斗状态机是内部执行器。// 文件路径Source/CRPGGame/Public/Combat/CombatComponent.h #pragma once #include CoreMinimal.h #include Components/ActorComponent.h #include CombatFlowState.h #include CombatComponent.generated.h class ARPGCharacterBase; UCLASS( ClassGroup(Custom), meta(BlueprintSpawnableComponent) ) class UCombatComponent : public UActorComponent { GENERATED_BODY() public: UCombatComponent(); // 初始化战斗外部调用 void StartBattle(const TArrayARPGCharacterBase* InCombatants); protected: UPROPERTY() UCombatFlowState* CombatFlowState; };实现文件// 文件路径Source/CRPGGame/Private/Combat/CombatComponent.cpp #include Combat/CombatComponent.h #include Characters/RPGCharacterBase.h UCombatComponent::UCombatComponent() { PrimaryComponentTick.bCanEverTick false; } void UCombatComponent::StartBattle(const TArrayARPGCharacterBase* InCombatants) { if (!CombatFlowState) { CombatFlowState NewObjectUCombatFlowState(this); } // 这里需要把参战角色列表塞给状态机 CombatFlowState-SetCombatants(InCombatants); CombatFlowState-StartCombat(); }由于CombatFlowState.h中的Combatants目前是protected我们需要添加一个公共方法// 在 CombatFlowState.h 中添加 public: void SetCombatants(const TArrayARPGCharacterBase* InCombatants) { Combatants InCombatants; }4.5 角色端的行动完成回调因为战斗流程依赖角色的OnActionCompleted委托所以我们还要在角色基类中声明它。这样玩家按下技能按钮后角色执行完技能主动广播这个消息战斗流程就知道可以走到下一步了。// 文件路径Source/CRPGGame/Public/Characters/RPGCharacterBase.h #pragma once #include CoreMinimal.h #include GameFramework/Character.h #include RPGCharacterBase.generated.h DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnActionCompleted, ARPGCharacterBase*, Character); UCLASS() class ARPGCharacterBase : public ACharacter { GENERATED_BODY() public: ARPGCharacterBase(); // 当前角色开始行动 void NotifyTurnStarted(); // 当前角色执行选中的技能 void ExecuteSelectedAction(); // 是否存活 bool IsAlive() const { return CurrentHP 0; } // 结算待处理伤害 void ApplyPendingDamage(); // 死亡处理 void Die(); // 是否由玩家控制 bool IsPlayerControlled() const { return bPlayerControlled; } UPROPERTY(BlueprintAssignable) FOnActionCompleted OnActionCompleted; protected: UPROPERTY(EditAnywhere, BlueprintReadOnly, CategoryRPG|Stats) float MaxHP; UPROPERTY(BlueprintReadOnly, CategoryRPG|Stats) float CurrentHP; UPROPERTY(EditAnywhere, BlueprintReadOnly, CategoryRPG|Stats) float PendingDamage; UPROPERTY(EditAnywhere, BlueprintReadOnly, CategoryRPG|AI) bool bPlayerControlled; };实现文件关键代码// 文件路径Source/CRPGGame/Private/Characters/RPGCharacterBase.cpp #include Characters/RPGCharacterBase.h #include TimerManager.h ARPGCharacterBase::ARPGCharacterBase() { MaxHP 100.0f; CurrentHP MaxHP; PendingDamage 0.0f; } void ARPGCharacterBase::NotifyTurnStarted() { // 这里可以让UI显示“你的回合” UE_LOG(LogTemp, Log, TEXT(角色 %s 开始行动), *GetName()); // 实际项目中玩家角色会等待输入AI角色会在这里启动AI逻辑 // 我们暂时直接调用了 ExecuteSelectedAction方便流程演示 } void ARPGCharacterBase::ExecuteSelectedAction() { // 这里可能是根据技能ID计算伤害 // 简单起见直接对第一个敌方目标造成10点伤害 PendingDamage 10.0f; // 模拟技能动画耗时为2秒 GetWorldTimerManager().SetTimerForNextTick(this, [this]() { // 执行完动作后广播完成 OnActionCompleted.Broadcast(this); }); } void ARPGCharacterBase::ApplyPendingDamage() { CurrentHP - PendingDamage; PendingDamage 0.0f; if (CurrentHP 0.0f) { CurrentHP 0.0f; } UE_LOG(LogTemp, Log, TEXT(角色 %s 当前血量%f), *GetName(), CurrentHP); } void ARPGCharacterBase::Die() { // 播放死亡动画、停止AI逻辑等 UE_LOG(LogTemp, Log, TEXT(角色 %s 已死亡), *GetName()); }到此为止一个从“开始战斗”到“回合切换”再到“胜利/失败”的最小可运行闭环就出来了。你可以先在游戏模式里调用UCombatComponent::StartBattle()传入几个角色然后跑起来看日志输出。5. 运行与验证从日志看流程是否正常写完了代码我们先不着急加动画和UI用日志来验证状态机的流动是否顺畅。在ARPGCharacterBase::ExecuteSelectedAction()中我们暂时把技能执行写成了立刻结算如果运行正常你应该在输出日志中看到类似下面的信息[CombatFlow] 战斗开始第 1 回合 角色 BP_Player_0 开始行动 角色 BP_Player_0 当前血量100.000000 角色 BP_Enemy_0 当前血量90.000000 [CombatFlow] 战斗胜利如果日志顺序不对比如角色还没有行动就进入了胜利判定优先检查委托绑定是否正确。OnActionCompleted是否被调用了多次。战斗组件是否在所有角色生成之后才调用StartBattle。另外UCombatFlowState::CheckDeath()里我故意写了一个粗糙的后缀实际项目中不能那样判断。我们应该统计人数而不是用两个bool变量自我欺骗。下面给出改进方案。6. 加固死亡判定别在战斗流程里写弱智逻辑死亡判定是整个战斗流程里最容易出逻辑漏洞的地方。我们用更严谨的方式重写CheckDeath()。void UCombatFlowState::CheckDeath() { bool bAnyPlayerAlive false; bool bAnyEnemyAlive false; for (ARPGCharacterBase* Combatant : Combatants) { if (!Combatant) { continue; } if (Combatant-IsAlive()) { if (Combatant-IsPlayerControlled()) { bAnyPlayerAlive true; } else { bAnyEnemyAlive true; } } else { // 如果角色已经死了只播一次死亡动画防止重复 Combatant-Die(); } } if (!bAnyPlayerAlive) { MoveToPhase(ECombatPhase::Defeat); return; } if (!bAnyEnemyAlive) { MoveToPhase(ECombatPhase::Victory); return; } MoveToPhase(ECombatPhase::SwitchTurn); }这样写最大的好处是哪怕以后战斗列表中混入了中立单位或友军单位也能通过IsPlayerControlled()进行区分。如果你以后要做“NPC助战”或者“玩家与玩家对战”只需要再扩展一个阵营枚举即可。7. 常见问题与排查思路战斗流程中的问题往往呈“牵一发动全身”的连锁反应。如果你在调试时遇到下面这些情况可以直接照表排查。7.1 战斗流程卡在AwaingInput不继续问题现象常见原因解决思路一直等待输入不进入执行行动阶段OnActionCompleted没有被广播检查角色类是否调用了Broadcast确认不是广播给其他角色了流程走了两次AwaitingInputSwitchTurn状态被重复触发在SwitchTurn状态加一个bSwitched标记或者检查外部是否手动调用了MoveToPhase敌人行动结束后玩家跟着一起动委托绑定了所有角色但广播被错误地转发在OnActionCompleted里严格判断Character GetCurrentActor()7.2 战斗胜利后仍然能切换回合这种情况通常是因为MoveToPhase(ECombatPhase::Victory)之后外部UI或输入系统还在发放“行动确认”指令。建议在战斗流程进入Victory或Defeat后关闭玩家输入立即清空所有待执行的定时器发布一个OnBattleEnd的蓝图事件UI收到后彻底锁定按钮。7.3 定时器陷阱回合制战斗离不开定时器。但UE的FTimerManager有一个典型坑如果你在定时器回调里调用MoveToPhase而回调对象已经被销毁就会导致白指针崩溃。解决办法使用WeakObjectPtr或在状态机内部持有外部的有效引用尤其在场景切换时先清空所有定时器使用FTimerHandle保存句柄在战斗结束时调用ClearTimer。7.4 数组索引越界CurrentActorIndex在数组长度变化时容易踩坑比如战斗中突然加入了新角色或者角色死亡后被移出Combatants。建议死亡角色不要直接从数组中Remove而是保留Alive标志索引递增时使用取模运算数组长度变化时在SwitchTurn状态里重新计算索引。8. 最佳实践与工程建议战斗流程是这个项目里最容易变成“屎山”的部分。为了让你后面几个课时的开发能更顺畅这里有几条基于实际经验的最佳实践建议现在就养成习惯。8.1 用数据驱动替代硬编码我们在ExecuteSelectedAction()里直接写死伤害数值这在演示阶段没问题但到了项目中期你会想把技能ID、伤害、动画资源、特效资源全部整理成DataTable或PrimaryDataAsset。建议你尽早把技能信息抽出来不要让角色类自己算伤害。8.2 流程控制别交给蓝图蓝图适合做表现层比如播放动画、弹出飘字、切换UI但不要用蓝图节点去串联“流程状态”。蓝图流程一旦断点太多很难Debug。C里每行代码都能断点而且有清晰的调用栈推荐用C控制流程用蓝图标出表现层。8.3 日志系统和调试命令战斗流程是非常典型的需要“追踪状态变化”的逻辑。建议在UCombatFlowState::MoveToPhase里打一条日志最好带上当前回合数、当前行动者索引、新旧状态。UE_LOG(LogTemp, Log, TEXT([CombatFlow] Turn %d, ActorIndex %d, Phase %s - %s), TurnCount, CurrentActorIndex, *UEnum::GetValueAsString(PreviousPhase), *UEnum::GetValueAsString(NextPhase));然后在项目里注册一个CheatManager扩展通过控制台命令直接进入指定阶段这样你就能快速复现流程问题而不需要反复点击按钮。8.4 最小权限原则战斗流程不需要让每个角色都能操作全局状态。比如角色只应该知道自己何时行动不应该直接调用MoveToPhase。一切调度都由UCombatFlowState来完成。这样就算以后加入网络同步也只要在这一个类里做处理不会满世界找改动点。8.5 单元测试与模拟如果你有信心可以把战斗流程写成一个可脱离引擎的纯C类然后在项目启动时用Automation框架跑一次模拟战斗。UE官方提供AutomationSpec库可以让你在编辑器Automation模式下执行战斗逻辑验证流程是否按照预期跑完。这在大型CRPG项目里极其有用尤其是你后面加入更多角色技能之后。9. 小结与下一步学习路线本节课我们完成了战斗流程3的核心任务使用状态机替代顺序执行完成从开始战斗到胜利失败判定的闭环通过委托和定时器实现回合制战斗的节奏控制加固了死亡判定逻辑避免常见需求混乱。接下来你的战斗流程已经具备最基本的可玩性。下一步可以从下面三个方向继续扩展加入技能选择UI把AwaitingInput阶段从“自动执行”改成“等待玩家点击技能按钮”加入更多角色数值比如攻击力、防御力、暴击率、命中率让伤害结算变得有趣实现敌人AI让敌人在NotifyTurnStarted后主动选择技能和目标。这里再给你一个提醒战斗流程不是一锤子买卖你可能在加入新系统时频繁调整状态机的结构。这时候不要舍不得改动一定要记住“状态清晰、回调唯一、日志完整”这三条铁律你的战斗系统会越做越稳。如果这节内容对你有帮助可以把它收藏起来后面几节课我们会在战斗流程上继续叠加“技能系统”和“养成系统”到时候需要回看的核心代码都在这里了。动手改一改、跑一跑你才能真正把这些逻辑变成自己的东西。加油

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

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

免费获取报价 →
↑