这次我们来看 UE5 C 从零开始做 CRPG 系列中战斗系统的第 7.16 课战斗流程3。做 CRPG 的同学基本都会遇到同一个问题角色能走到敌人面前、技能能放出来但走到一半流程就乱了——技能打出去了伤害没结算敌人血条空了还能继续行动回合切换时 UI 卡住。这一课要解决的就是战斗流程里最容易被拖进度的一段从技能命中到伤害结算再到胜负判定和战斗收尾。本文会把战斗流程第三阶段的工程要点拆开讲包括阶段状态机的 C 落地、伤害结算链路的顺序、战斗结束的事件广播、调试验证方法和常见问题排查。适合正在用 UE5 C 做回合制或 CRPG 战斗系统的开发者也适合已经看完前几课、想把战斗流程部分真正接起来的同学。这里没有课程视频里的逐行演示但我会把这一段最核心的工程知识给全。1. 核心能力与课程定位速览先对这一课的内容边界和能力范围做一个快速梳理方便你判断要不要继续往下读。能力项说明技术栈UE5 C少量蓝图辅助表现层章节定位战斗系统模块第 16 小节战斗流程3核心环节技能释放后的结算链路、状态变更、胜负判定、战斗收尾前置要求熟悉 UE 的 Actor/Component、接口、委托和基础 C 语法适合人群做 CRPG/BAS/回合制战斗的开发者需要把战斗流程做扎实工程价值从“能打架”升级到“流程可控、可调试、可扩展”运行环境满足 UE5 编辑器基本硬件要求即可实际开销取决于资产复杂度这一课不讨论技能表现怎么做、UI 怎么画得华丽重点放在战斗逻辑层。逻辑层稳定后往上面挂特效、动画、UI 都不容易出问题。2. 战斗流程的整体拆解与第三讲定位CRPG 战斗流程从玩家进入战斗到战斗结束可以拆成下面的链路战前准备生成战斗单位、初始化属性、设置初始位置。回合开始刷新行动力/魔法/技能冷却广播回合开始。行动选择玩家输入指令AI 决策确定本回合动作。技能执行播放动画、生成特效、触发命中。伤害结算计算防御、暴击、属性加减。状态更新Buff 持续时间、死亡标记、仇恨更新。胜负判定检查我方/敌方是否全灭是否满足撤退条件。战斗结束结算掉落、经验、关闭 UI、恢复地图控制。第 7.16 课落实到工程上通常负责“技能执行之后的半条链路”也就是上面第 5、第 6、第 7、第 8 步。为什么这个部分容易写乱因为前三步是顺序执行的只要指令正确流程就能往前走但从第 5 步开始每一步都可能被中途打断——暴击触发了额外效果、敌人死了要立刻从战斗单位里移除、Buff 反弹伤害导致敌方也掉血。如果不做统一管理你会在多个 Actor 里互相调用函数到最后根本分不清伤害是谁算的、阶段由谁推进。更稳妥的做法是给战斗流程一个明确的“阶段驱动者”。所有阶段切换都从这个驱动者发出其他系统只监听事件。后面第 3 章会给出一个参考实现。3. 战斗流程阶段状态机的 C 落地先给一个通用的阶段枚举覆盖从战斗开始到结束的完整状态。如果你的项目只需要一部分可以删减但建议保留结算、更新、判定三个独立阶段。// BattlePhase.h #pragma once UENUM(BlueprintType) enum class EBattlePhase : uint8 { None 0, StartBattle, TurnStart, SelectAction, ExecuteAction, ResolveDamage, UpdateState, CheckEnd, Victory, Defeat };阶段管理建议放到一个 ActorComponent 上而不是堆在 PlayerController 或 GameMode 里。战斗管理器只负责切阶段、发事件不关心具体表现。下面是一个最小可用的管理器头文件// BattleManagerComponent.h #pragma once #include CoreMinimal.h #include Components/ActorComponent.h #include BattlePhase.h #include BattleManagerComponent.generated.h DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnBattlePhaseChanged, EBattlePhase, OldPhase, EBattlePhase, NewPhase); UCLASS(ClassGroup(Combat), meta(BlueprintSpawnableComponent)) class CRPG_API UBattleManagerComponent : public UActorComponent { GENERATED_BODY() public: UBattleManagerComponent(); UPROPERTY(BlueprintReadOnly, CategoryBattle) EBattlePhase CurrentPhase; UPROPERTY(BlueprintAssignable, CategoryBattle) FOnBattlePhaseChanged OnBattlePhaseChanged; UFUNCTION(BlueprintCallable, CategoryBattle) void SetPhase(EBattlePhase NewPhase); protected: virtual void BeginPlay() override; private: void HandlePhaseEnter(EBattlePhase InPhase); };实现里的核心是SetPhase和HandlePhaseEnter。进入每个阶段时要先广播事件再执行该阶段的逻辑。这样蓝图和 UI 可以通过监听OnBattlePhaseChanged做表现层响应不会和逻辑层耦合。// BattleManagerComponent.cpp #include BattleManagerComponent.h UBattleManagerComponent::UBattleManagerComponent() { CurrentPhase EBattlePhase::None; PrimaryComponentTick.bCanEverTick false; } void UBattleManagerComponent::BeginPlay() { Super::BeginPlay(); } void UBattleManagerComponent::SetPhase(EBattlePhase NewPhase) { if (CurrentPhase NewPhase) { return; } EBattlePhase OldPhase CurrentPhase; CurrentPhase NewPhase; OnBattlePhaseChanged.Broadcast(OldPhase, NewPhase); HandlePhaseEnter(NewPhase); } void UBattleManagerComponent::HandlePhaseEnter(EBattlePhase InPhase) { switch (InPhase) { case EBattlePhase::StartBattle: // 初始化所有战斗单位 break; case EBattlePhase::TurnStart: // 刷新行动力、技能冷却 break; case EBattlePhase::SelectAction: // 玩家指令或 AI 决策 break; case EBattlePhase::ExecuteAction: // 执行技能逻辑 break; case EBattlePhase::ResolveDamage: // 逐个结算伤害 break; case EBattlePhase::UpdateState: // 处理 Buff 持续时间和死亡 break; case EBattlePhase::CheckEnd: // 判定战斗是否结束 break; default: break; } }关键点在于SetPhase是唯一能切阶段的入口。不要让伤害计算逻辑自己顺手把阶段改了。所有阶段推进都回到战斗管理器否则项目大了以后你会在十几个类里到处搜索“CurrentPhase ”根本改不动。4. 结算链路从命中到属性变化战斗流程3里最值得静下心写的是伤害结算顺序。CRPG 的伤害往往不是单纯“攻击力减防御力”中间会夹着命中率、暴击、属性抗性、Buff 加成、浮动范围。如果顺序不对玩家会觉得伤害忽高忽低且很难定位问题。建议的结算顺序是预计算攻击加成 - 命中判定 - 基础伤害 - 防御减伤 - 随机浮动 - 暴击判定 - 最终伤害。下面用函数库的方式给一个参考框架// CombatFunctionLibrary.h #pragma once #include CoreMinimal.h #include Kismet/BlueprintFunctionLibrary.h #include CombatFunctionLibrary.generated.h USTRUCT(BlueprintType) struct FCombatAttributes { GENERATED_BODY() UPROPERTY(EditAnywhere, BlueprintReadWrite) float Attack 0.f; UPROPERTY(EditAnywhere, BlueprintReadWrite) float Armor 0.f; UPROPERTY(EditAnywhere, BlueprintReadWrite) float HitRate 1.f; UPROPERTY(EditAnywhere, BlueprintReadWrite) float CritRate 0.f; UPROPERTY(EditAnywhere, BlueprintReadWrite) int32 CritMultiplier 2; }; UCLASS() class CRPG_API UCombatFunctionLibrary : public UBlueprintFunctionLibrary { GENERATED_BODY() public: UFUNCTION(BlueprintPure, CategoryCombat) static float CalculateDamage( const FCombatAttributes Attacker, const FCombatAttributes Defender, float SkillMultiplier); };函数实现如下#include CombatFunctionLibrary.h float UCombatFunctionLibrary::CalculateDamage( const FCombatAttributes Attacker, const FCombatAttributes Defender, float SkillMultiplier) { // 1. 基础伤害 攻击力 * 技能倍率 float BaseDamage Attacker.Attack * SkillMultiplier; // 2. 减伤最低保留 1 点伤害 float ReducedDamage FMath::Max(1.f, BaseDamage - Defender.Armor); // 3. 随机浮动范围 0.9 ~ 1.1 float FinalDamage ReducedDamage * FMath::FRandRange(0.9f, 1.1f); // 4. 暴击判定 if (FMath::FRand() Attacker.CritRate) { FinalDamage * Attacker.CritMultiplier; } return FMath::FloorToInt(FinalDamage); }运行时应该把“是否命中”和“伤害计算”分开。未命中也要走一次结算链路只是最终伤害为 0并且需要广播一个 Miss 事件给 UI。不要把未命中逻辑写在技能蓝图里否则每次加新技能都要复制一遍。伤害结算的阶段设计要明确结算开始时遍历所有等待结算的技能结果。技能结果建议用独立数据结构每次执行技能时生成而不是让单位直接改血量。这样方便做“多段伤害”“反击”“吸血”一类扩展。struct FSkillResult { TWeakObjectPtrAActor Target; float Damage 0.f; bool bHit false; bool bCritical false; TArrayFName AppliedTags; };当前回合所有技能都执行完后进入ResolveDamage阶段统一把这些结果应用到对应单位上。这样做的好处是战斗回放、跳过动画、自动战斗都能通过同一套结算逻辑跑不会出现“动画没播完所以伤害没生效”的死循环。5. 战斗结束胜负判定、掉落与事件广播实现到CheckEnd阶段时很多项目会写出一堆if判断塞在蓝图里。这里建议在 C 层做一件事用接口抽象“可战斗单位”而不是直接操作蓝图类。接口至少包含当前数值、是否死亡、能否继续行动三个能力定义。// ICombatable.h #pragma once #include UObject/Interface.h #include CombatFunctionLibrary.h #include ICombatable.generated.h UINTERFACE(MinimalAPI, Blueprintable) class UCombatable : public UInterface { GENERATED_BODY() }; class CRPG_API ICombatable { GENERATED_BODY() public: UFUNCTION(BlueprintNativeEvent, BlueprintCallable) float GetCurrentHP(); UFUNCTION(BlueprintNativeEvent, BlueprintCallable) bool IsDead() const; UFUNCTION(BlueprintNativeEvent, BlueprintCallable) void ApplyDamage(float Damage); };接口定义好之后胜负判定算法只需要通过接口遍历所有战斗单位bool UBattleManagerComponent::CheckTeamAlive(const TArrayAActor* TeamActors) { for (AActor* Actor : TeamActors) { if (Actor nullptr || !Actor-ImplementsUCombatable()) { continue; } if (!ICombatable::Execute_IsDead(Actor)) { return true; } } return false; }战斗结束的触发时机很关键。不要在每次伤害结算后立刻判定而是统一在CheckEnd阶段判定否则一个 AOE 技能打掉多个敌人时会出现“先判定一次失败、又判定一次胜利”的边界问题。战斗结束后的流程分两条线胜利线关闭输入 - 播放胜利演出 - 计算经验 - 生成掉落 - 打开结算 UI - 退出战斗地图。失败线关闭输入 - 播放失败演出 - 显示失败面板 - 回到据点或加载存档。这里要特别注意结束事件不要循环广播。战斗管理器可以维护一个bBattleEnded标志位进入结束分支后直接置空当前阶段避免后续 Tick 或延迟节点再次触发结算逻辑。一个稳定的广播接口设计如下DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams( FOnBattleEnded, bool, bVictory, const TArrayAActor*, LivingAllies, const TArrayAActor*, LivingEnemies);蓝图层只需要绑定这个事件就可以显示结算面板、播放胜利 BGM、保存战斗结果。任务系统也可以通过这个事件监听“某场战斗是否胜利”不需要额外写轮询。6. 功能测试与效果验证战斗流程这种逻辑密集模块不能只靠眼睛看“好像打赢了”就算通过。建议按以下六个测试用例逐项验证。测试项操作步骤预期结果普通攻击流程玩家选中敌人执行普通攻击动画播放完成后血条减少伤害数字正确未命中流程设置低命中率发起攻击伤害数字显示 Miss目标不掉血暴击流程设置高暴击率发起多次攻击暴击伤害为普通伤害的设定倍率多目标技能流程释放 AOE 技能命中 3 个敌人3 个敌人依次进入结算同一回合内全部结算敌人死亡流程将敌人血量降到 0敌人从战斗单位列表中移除不再参与后续回合战斗结束流程消灭全部敌人进入结算 UI退出战斗模式每个用例都要能回答三个问题阶段是否执行到了预期位置、数值是否符合计算公式、页面或输入状态是否被正确恢复。如果只有手动测试建议至少做以下验证# 命令行启动并打开指定地图 UnrealEditor-Cmd.exe ProjectName.uproject /Game/Maps/BattleTest -game -log配合日志确认阶段切换在SetPhase里临时加打印UE_LOG(LogTemp, Log, TEXT(Battle Phase - %d), (int32)NewPhase);阶段日志要保留到项目上线前后面排查存档错乱和自动战斗问题时非常有帮助。7. 性能观察与稳定性检查战斗流程本身的计算量不大但如果项目用蓝图一层层嵌套节点卡顿仍然会出现在“每回合刷新、每个单位遍历、每次 UI 刷新”这些高频路径上。这里给出几个常见的观察和优化角度。第一个是 Tick 频率。战斗管理器和战斗单位不要在 Tick 里反复检查阶段变化。用事件驱动来推进流程阶段变化只由SetPhase触发。第二个是遍历次数。胜负判定只需要在CheckEnd阶段遍历一次不需要每帧检测。如果项目里通过AllActors接口搜索全部可战斗单位要改成战斗管理器自己维护一个参战单位数组。UPROPERTY() TArrayAActor* AllyActors; UPROPERTY() TArrayAActor* EnemyActors;第三个是临时对象。每次技能结算都新建一个FSkillResult结构体问题不大但不要在结算循环里大量NewObject生成状态对象。状态类对象尽量复用或者用静态函数处理纯数值计算。编辑器内可以用stat unit和stat game查看帧耗时分布。如果结算阶段出现明显峰值优先检查是不是在结算过程中播放了大量特效或者同步生成了大量 UI 组件。如果是要把表现层的响应延迟到下一个帧或使用异步加载。实际运行表现还取决于项目的资产复杂度。更稳妥的判断是在编辑器里打完一场 5 人对 5 人的战斗观察结算瞬间是否有卡顿打包后在同一台设备上再跑一遍对比差异。逻辑层代码只要不出现明显的 N1 遍历问题就不会成为瓶颈。8. 常见问题与排查方法战斗流程的报错通常不是编译失败而是逻辑卡住。下面这张表整理了本阶段最高频的几个问题。问题现象可能原因排查方式解决方案技能释放后没有伤害没有进入 ResolveDamage 阶段或技能结果没有加入结算队列查看阶段日志确认 SetPhase 是否被调用在技能执行完成后统一调用结算入口伤害数字重复跳结算逻辑同时被技能蓝图和 C 各执行了一次在结算函数入口加 Debug 打印统一结算入口蓝图只负责表现敌人死亡后还能行动死亡单位没有从参战数组移除或行动判定没有检查 IsDead打印回合开始时的单位列表进入 TurnStart 前过滤 Dead 单位回合切换时 UI 卡住UI 等待某个事件但事件广播在阶段切换前就发出了查看事件触发顺序先切阶段再广播事件表现层统一监听读档后战斗状态丢失战斗管理器没有保存当前阶段和相关单位 HP检查存档字段是否包含战斗数据将当前阶段和单位状态列入存档字段战斗胜利无法结束判定条件只检查所有敌人是否为 0没有处理逃逸或中立单位查看 CheckEnd 分支是否走到明确胜利条件加入最大回合数上限自动战斗卡死自动战斗跳过表现动画但结算依赖动画通知查看技能执行是否等待动画事件结算逻辑改为独立于动画表现的时间轴遇到这类问题第一件事永远是看阶段日志。能确认当前停在哪个阶段问题就缩小了一半。战斗中建议常开日志发布时需要保留关键节点。9. 战斗流程最佳实践与工程建议战斗流程3这一课里工程整洁度比“把功能跑通”更重要。这里给出一组经过验证的开发建议。第一用状态机管理阶段但状态机内部不做业务逻辑的具体实现。阶段只负责“知道当前该干什么”和“切换后通知谁”。真正做事的是各个战斗单位、技能组件和数据表。第二数据驱动技能和属性。把攻击倍率、命中率、暴击率放到 DataTable 里而不是每个技能一张蓝图。这个阶段你会频繁调整数值如果用源码硬编码每次改动都要重新编译用 DataTable 只需要改一行配置。技能表结构可以参考USTRUCT(BlueprintType) struct FSkillRow : public FTableRowBase { GENERATED_BODY() UPROPERTY(EditAnywhere, BlueprintReadWrite) FName SkillID; UPROPERTY(EditAnywhere, BlueprintReadWrite) float Multiplier 1.f; UPROPERTY(EditAnywhere, BlueprintReadWrite) float HitRate 1.f; UPROPERTY(EditAnywhere, BlueprintReadWrite) float Cost 10.f; UPROPERTY(EditAnywhere, BlueprintReadWrite) bool bAOE false; };第三保持一条稳定的“标准战斗数据流”技能执行 - 生成结果 - 结算 - 广播 - UI 表现。不要在数据流中途插入一次性特殊逻辑。遇到特殊技能想办法做成技巧或标签而不是在各处加if判技能名。第四战斗管理器维护一个bBattleEnded标志位所有结束分支只执行一次。这个标志位要随存档一起保存防止玩家在结算界面退出游戏后重进战斗仍处于已结束状态。第五给战斗系统做独立的“测试启动入口”。比如关卡蓝图按下快捷键直接进入指定敌人配置的战斗不要每次手动从地图走到 BOSS 面前。缺少这个入口后续反复验证会非常浪费时间。第六合法合规和数据安全在游戏项目里同样重要。玩家存档中的战斗状态涉及隐私数据本地存档注意做好权限控制如果涉及联机对战或云端存档要避免泄露玩家角色 ID、伤害数值等原始信息。这一课是单人 CRPG 为主但如果后续扩展联机功能所有战斗结算必须由服务器权威计算不能信任客户端上报的伤害数字。10. 总结战斗流程3这一课最核心的工作量集中在三件事把流程拆成明确阶段、把所有阶段切换收敛到战斗管理器、把伤害结算的先后顺序固定下来。前两件事决定了你的战斗系统能不能继续加新技能、新状态第三件事决定了玩家体感顺不顺。最容易踩的坑是复位问题阶段切了但输入没关、UI 没复位、敌人列表没清理。建议每一次结束战斗后都用CheckEnd统一打扫战场而不是在胜利面板里零零散散地处理。刚接触这一课的同学建议从最小闭环开始先跑通“玩家攻击 - 敌人掉血 - 敌人死亡 - 战斗胜利”这一根线再把命中、暴击、Buff、AOE 一个个挂进去。先保证流程完整再谈扩展。等到你发现新技能只需要加一行配置、新状态只需要加一个监听事件时战斗流程这条管道就算真正立住了。这一课的代码和设计思路值得反复对照实际项目落地建议先收藏备用。