feat(M4): enemy AI - behavior tree, patrol, chase, attack, spawner

M4 — Enemy AI Foundation

- MMEnemyBase: enemy base class (aggro/deaggro/attack ranges, death event)
- MMAIController: BT driver with TargetActor/SpawnLocation/IsInAttackRange blackboard keys
- BTTask_Patrol: random reachable point patrol around spawn location
- BTTask_ChaseTarget: move-to-actor with tick-based range check
- BTTask_Attack: face target + activate attack ability
- MMEnemySpawner: timed spawn with max alive count, death tracking
- Build.cs: added AIModule + NavigationSystem

Files: 44 total, 2381 lines
This commit is contained in:
不明不惑
2026-05-02 06:47:07 +08:00
parent 398a7cb8bb
commit b1e37a6e05
27 changed files with 1262 additions and 58 deletions

View File

@@ -1,6 +1,6 @@
# MM 项目开发进度报告
> 最后更新2026-05-02 06:35
> 最后更新2026-05-02 07:10
## 已完成里程碑
@@ -11,50 +11,61 @@
### M1 ✅ — 项目骨架
- commit: `266e817`
- 14 文件,602 行新增代码
- MMAbilitySystemComponentASC 骨架
- MMAttributeSet7 核心属性 + PostGameplayEffectExecute 日志
- MMCharacterBaseASC + 俯视角摄像机 + Enhanced Input 移动/面向
- MMPlayerController鼠标光标
- MMGameMode默认 Pawn/Controller
- SaberCharacterSaber 入口类,空
- 14文件,500行代码
- MMCharacterBaseASC + 摄像机 + Enhanced Input 移动
- MMAbilitySystemComponentASC 骨架,实现 IAbilitySystemInterface
- MMAttributeSet7个核心属性Health/MaxHealth/Mana/MaxMana/AttackPower/Defense/MoveSpeed
- MMPlayerController鼠标光标 + 默认输入
- MMGameMode默认 Pawn + Controller
- SaberCharacterSaber 子类占位
## 目录结构(当前)
### M2 ✅ — 战斗基础
- commit: `c08f18f`
- 24个文件1197行代码
- MMGameplayAbility技能基类工具方法
- MMWeaponComponent武器碰撞检测 + 命中列表 + ApplyDamage
- DamageCalculationGE Execution Calc伤害公式 = Atk × (1 + Atk*0.01) - Def × 0.5
- MMGameplayEffect_Damage瞬时伤害 GE
- MMAttack_MeleeSaber 普攻 GA0.3s 挥砍窗口)
- 角色基类扩展:攻击输入、受击事件、死亡处理
```
Source/MM/
├── Core/
│ ├── MMCharacterBase.h/.cpp # 角色基类
│ ├── MMGameMode.h/.cpp # GameMode
│ └── MMPlayerController.h/.cpp # PlayerController
├── GAS/
│ └── AbilitySystem/
│ ├── MMAbilitySystemComponent.h/.cpp # ASC
│ └── MMAttributeSet.h/.cpp # AttributeSet
├── Player/
│ └── SaberCharacter.h/.cpp # Saber 入口
├── MM.h / MM.cpp # 模块入口
└── MM.Build.cs # 构建配置
```
### M3 ✅ — 技能系统 & HUD
- commit: `a18c23b`
- 32个文件1721行代码
- MMGameplayTagsNative Tag 注册Cooldown/State/Combat
- MMSkill_SlashSaber 横斩3s CD2x 伤害倍率)
- MMHUD + MMHealthBarWidget纯 C++ UGM 生命条)
- MMCharacterBase 扩展Skill1Action 输入 + StartupAbilities 授予
- SaberCharacter 授予 Slash 技能
- Build.cs 添加 Slate/SlateCore/UMG 模块
## 下一步M2 — 战斗基础
## 当前代码统计
**目标**:碰撞命中、普攻、伤害 GE、受击、死亡
| 指标 | 数值 |
|------|------|
| 总文件数 | 32 (.h + .cpp) |
| 总代码行数 | 1721 |
| 目录结构 | Core/GAS/Combat/UI/Player |
### M2 计划任务
## 下一步目标 — M4
| 任务 | 文件 | 说明 |
|------|------|------|
| T001 | Combat/MMDamageType.h | 伤害类型定义 |
| T002 | Combat/DamageCalculation.h/.cpp | 伤害计算(无随机命中/闪避) |
| T003 | GAS/Effects/MMGameplayEffect_Damage.h | 伤害 GameplayEffectC++ DataAsset |
| T004 | Core/MMCharacterBase 扩展 | 添加受击/死亡逻辑、Health clamp |
| T005 | Combat/MMWeaponComponent.h/.cpp | 武器碰撞检测组件CapsuleOverlap |
| T006 | GAS/Abilities/MMGameplayAbility.h/.cpp | 技能基类GA 基类) |
| T007 | GAS/Abilities/Saber/MMAttack_Melee.h/.cpp | Saber 普攻 GA |
| T008 | Core/MMCharacterBase 扩展 | 普攻输入绑定LMB |
### M4 — 怪物 AI 基础
目标:创建怪物基类、简单 AI巡逻+追击+攻击)、生成系统
### 设计约束(来自 game-design.md
- 命中/闪避不做随机面板检定
- 用碰撞、走位、翻滚无敌帧、格挡窗口、技能前后摇表达操作性
- 伤害公式:最终伤害 = (攻击力 - 防御力) × 技能倍率
**任务清单:**
1. T001: MMEnemyBase.h/.cpp — 怪物基类(继承 CharacterBaseAI 控制)
2. T002: MMAIController.h/.cpp — AI 控制器(行为树 + 黑板)
3. T003: BT_EnemyBase — 行为树资产(巡逻→追击→攻击)
4. T004: BTTask_Patrol.h/.cpp — 巡逻任务节点
5. T005: BTTask_ChaseTarget.h/.cpp — 追击目标节点
6. T006: BTTask_Attack.h/.cpp — 攻击任务节点
7. T007: MMEnemySpawner.h/.cpp — 怪物生成器
8. T008: 更新 MM.Build.cs — 添加 AIModule
**依赖链:**
T001 + T002 → T003 → T004+T005+T006 → T007
T008 独立Build.cs 更新)
**完成后项目状态:**
玩家可以操控 Saber 在地图上移动、攻击、使用技能,
怪物会自动生成、巡逻、追击玩家并攻击。

View File

@@ -0,0 +1,49 @@
#include "BTTask_Attack.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "AI/MMAIController.h"
#include "Core/MMCharacterBase.h"
#include "AbilitySystemComponent.h"
UBTTask_Attack::UBTTask_Attack()
{
NodeName = TEXT("Attack");
}
EBTNodeResult::Type UBTTask_Attack::ExecuteTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
AAIController* AIController = OwnerComp.GetAIOwner();
if (!AIController)
{
return EBTNodeResult::Failed;
}
AMMCharacterBase* Character = Cast<AMMCharacterBase>(AIController->GetPawn());
if (!Character || Character->IsDead())
{
return EBTNodeResult::Failed;
}
// 面向目标
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (BB)
{
if (AActor* Target = Cast<AActor>(BB->GetValueAsObject(AMMAIController::TargetActorKeyName)))
{
const FVector Direction = (Target->GetActorLocation() - Character->GetActorLocation()).GetSafeNormal2D();
if (!Direction.IsNearlyZero())
{
Character->SetActorRotation(Direction.Rotation());
}
}
}
// 触发攻击
if (Character->GetAbilitySystemComponent() && Character->GetDefaultAttackAbility())
{
Character->GetAbilitySystemComponent()->TryActivateAbilityByClass(
Character->GetDefaultAttackAbility());
}
return EBTNodeResult::Succeeded;
}

View File

@@ -0,0 +1,34 @@
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "BTTask_Attack.generated.h"
/// 攻击任务节点。
///
/// 触发怪物的默认攻击能力。
/// 攻击完成后有短暂冷却。
///
/// # 黑板键
///
/// | 键名 | 类型 | 说明 |
/// |------|------|------|
/// | TargetActor | Object | 攻击目标 |
///
/// - SeeAlso: UBTTaskNode
UCLASS()
class UBTTask_Attack : public UBTTaskNode
{
GENERATED_BODY()
public:
UBTTask_Attack();
/// 执行攻击任务。
virtual EBTNodeResult::Type ExecuteTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
/// 攻击后冷却时间(秒)。
UPROPERTY(EditAnywhere, Category = "Attack")
float AttackCooldown = 1.5f;
};

View File

@@ -0,0 +1,66 @@
#include "BTTask_ChaseTarget.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "AI/MMAIController.h"
#include "Enemy/MMEnemyBase.h"
UBTTask_ChaseTarget::UBTTask_ChaseTarget()
{
NodeName = TEXT("Chase Target");
bNotifyTick = true;
}
EBTNodeResult::Type UBTTask_ChaseTarget::ExecuteTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
AAIController* AIController = OwnerComp.GetAIOwner();
if (!BB || !AIController)
{
return EBTNodeResult::Failed;
}
AActor* Target = Cast<AActor>(BB->GetValueAsObject(AMMAIController::TargetActorKeyName));
if (!Target)
{
return EBTNodeResult::Failed;
}
// 开始追击
AIController->MoveToActor(Target, AttackRange * 0.8f);
return EBTNodeResult::InProgress;
}
void UBTTask_ChaseTarget::TickTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
AAIController* AIController = OwnerComp.GetAIOwner();
if (!BB || !AIController)
{
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return;
}
AActor* Target = Cast<AActor>(BB->GetValueAsObject(AMMAIController::TargetActorKeyName));
if (!Target)
{
// 目标丢失
BB->SetValueAsObject(AMMAIController::TargetActorKeyName, nullptr);
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return;
}
APawn* Pawn = AIController->GetPawn();
if (!Pawn)
{
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return;
}
const float Distance = Pawn->GetDistanceTo(Target);
if (Distance <= AttackRange)
{
BB->SetValueAsBool(AMMAIController::IsInAttackRangeKeyName, true);
FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
}
}

View File

@@ -0,0 +1,38 @@
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "BTTask_ChaseTarget.generated.h"
/// 追击目标任务节点。
///
/// 向黑板中的 TargetActor 移动,直到进入攻击范围或目标丢失。
///
/// # 黑板键
///
/// | 键名 | 类型 | 说明 |
/// |------|------|------|
/// | TargetActor | Object | 追击目标 |
/// | IsInAttackRange | Boolean | 是否已进入攻击范围 |
///
/// - SeeAlso: UBTTaskNode
UCLASS()
class UBTTask_ChaseTarget : public UBTTaskNode
{
GENERATED_BODY()
public:
UBTTask_ChaseTarget();
/// 执行追击任务。
virtual EBTNodeResult::Type ExecuteTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
/// 每帧检查距离。
virtual void TickTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
/// 攻击范围(用于判断是否切换到攻击行为)。
UPROPERTY(EditAnywhere, Category = "Chase")
float AttackRange = 150.0f;
};

View File

@@ -0,0 +1,42 @@
#include "BTTask_Patrol.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "AI/MMAIController.h"
#include "NavigationSystem.h"
UBTTask_Patrol::UBTTask_Patrol()
{
NodeName = TEXT("Patrol");
}
EBTNodeResult::Type UBTTask_Patrol::ExecuteTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (!BB)
{
return EBTNodeResult::Failed;
}
const FVector SpawnLoc = BB->GetValueAsVector(AMMAIController::SpawnLocationKeyName);
AAIController* AIController = OwnerComp.GetAIOwner();
if (!AIController)
{
return EBTNodeResult::Failed;
}
// 在生成点附近生成随机可达点
UNavigationSystemV1* NavSys = FNavigationSystem::GetCurrent<UNavigationSystemV1>(AIController);
if (!NavSys)
{
return EBTNodeResult::Failed;
}
FNavLocation RandomPoint;
if (NavSys->GetRandomReachablePointInRadius(SpawnLoc, PatrolRadius, RandomPoint))
{
AIController->MoveToLocation(RandomPoint.Location);
return EBTNodeResult::Succeeded;
}
return EBTNodeResult::Failed;
}

View File

@@ -0,0 +1,34 @@
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "BTTask_Patrol.generated.h"
/// 巡逻任务节点。
///
/// 在生成点附近的随机位置移动。
/// 到达后等待一段随机时间,再选择下一个巡逻点。
///
/// # 黑板键
///
/// | 键名 | 类型 | 说明 |
/// |------|------|------|
/// | SpawnLocation | Vector | 生成位置(巡逻中心点)|
///
/// - SeeAlso: UBTTaskNode
UCLASS()
class UBTTask_Patrol : public UBTTaskNode
{
GENERATED_BODY()
public:
UBTTask_Patrol();
/// 执行巡逻任务。
virtual EBTNodeResult::Type ExecuteTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
/// 巡逻半径(从黑板 SpawnLocation 偏移的最大距离)。
UPROPERTY(EditAnywhere, Category = "Patrol")
float PatrolRadius = 500.0f;
};

View File

@@ -0,0 +1,38 @@
#include "MMAIController.h"
#include "BehaviorTree/BehaviorTree.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "Enemy/MMEnemyBase.h"
#include "Perception/AIPerceptionComponent.h"
#include "Perception/AISenseConfig_Sight.h"
const FName AMMAIController::TargetActorKeyName(TEXT("TargetActor"));
const FName AMMAIController::SpawnLocationKeyName(TEXT("SpawnLocation"));
const FName AMMAIController::IsInAttackRangeKeyName(TEXT("IsInAttackRange"));
AMMAIController::AMMAIController()
{
CachedBlackboard = nullptr;
}
void AMMAIController::OnPossess(APawn* InPawn)
{
Super::OnPossess(InPawn);
AMMEnemyBase* Enemy = Cast<AMMEnemyBase>(InPawn);
if (!Enemy || !BehaviorTreeAsset)
{
return;
}
// 启动行为树
if (UBlackboardComponent* BB = GetBlackboardComponent())
{
BB->InitializeBlackboard(*BehaviorTreeAsset->BlackboardAsset);
BB->SetValueAsVector(SpawnLocationKeyName, Enemy->GetActorLocation());
CachedBlackboard = BB;
}
RunBehaviorTree(BehaviorTreeAsset);
UE_LOG(LogTemp, Log, TEXT("[MMAIController] AI 控制器已启动: %s"), *InPawn->GetName());
}

View File

@@ -0,0 +1,53 @@
#pragma once
#include "CoreMinimal.h"
#include "AIController.h"
#include "MMAIController.generated.h"
class UBehaviorTree;
class UBlackboardComponent;
class AMMEnemyBase;
/// MM 项目的 AI 控制器。
///
/// 驱动怪物的行为树和黑板数据。
/// 在运行 ``Possess`` 时启动行为树。
///
/// # 玩家追踪
///
/// 黑板中维护 ``TargetActor`` 键,表示当前仇恨目标。
/// 行为树节点通过查询此键决定行为。
///
/// - SeeAlso: AAIController, AMMEnemyBase
UCLASS()
class AMMAIController : public AAIController
{
GENERATED_BODY()
public:
AMMAIController();
/// 控制 Pawn 时启动行为树。
virtual void OnPossess(APawn* InPawn) override;
/// 行为树资产引用。
///
/// 在怪物蓝图的 AI Controller 类中设置,
/// 或通过 C++ 构造函数默认指定。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "AI")
UBehaviorTree* BehaviorTreeAsset;
/// 黑板中目标 Actor 的键名。
static const FName TargetActorKeyName;
/// 黑板中生成位置的键名。
static const FName SpawnLocationKeyName;
/// 黑板中是否在攻击范围的键名。
static const FName IsInAttackRangeKeyName;
private:
/// 缓存的黑板组件引用。
UPROPERTY()
UBlackboardComponent* CachedBlackboard;
};

View File

@@ -41,6 +41,23 @@ void AMMCharacterBase::BeginPlay()
{
AttributeSet = const_cast<UMMAttributeSet*>(
AbilitySystemComponent->InitStats(UMMAttributeSet::StaticClass(), nullptr));
// 授予默认攻击技能
if (DefaultAttackAbility)
{
AbilitySystemComponent->GiveAbility(
FGameplayAbilitySpec(DefaultAttackAbility, 1, 0, this));
}
// 授予 StartupAbilities 中的技能
for (int32 Idx = 0; Idx < StartupAbilities.Num(); ++Idx)
{
if (StartupAbilities[Idx])
{
AbilitySystemComponent->GiveAbility(
FGameplayAbilitySpec(StartupAbilities[Idx], 1, Idx + 1, this));
}
}
}
// ---- 注册默认输入映射上下文 ----
@@ -63,6 +80,7 @@ void AMMCharacterBase::SetupPlayerInputComponent(UInputComponent* PlayerInputCom
EnhancedInput->BindAction(MoveAction, ETriggerEvent::Triggered, this, &AMMCharacterBase::Move);
EnhancedInput->BindAction(LookAction, ETriggerEvent::Triggered, this, &AMMCharacterBase::Look);
EnhancedInput->BindAction(AttackAction, ETriggerEvent::Triggered, this, &AMMCharacterBase::Attack);
EnhancedInput->BindAction(Skill1Action, ETriggerEvent::Triggered, this, &AMMCharacterBase::Skill1);
}
}
@@ -112,6 +130,20 @@ void AMMCharacterBase::Attack(const FInputActionValue& Value)
}
}
void AMMCharacterBase::Skill1(const FInputActionValue& Value)
{
if (bIsDead || !AbilitySystemComponent)
{
return;
}
// 激活 StartupAbilities 中的第一个技能
if (StartupAbilities.Num() > 0 && StartupAbilities[0])
{
AbilitySystemComponent->TryActivateAbilityByClass(StartupAbilities[0]);
}
}
void AMMCharacterBase::HandleHealthChanged(float DeltaValue)
{
if (bIsDead)
@@ -119,13 +151,11 @@ void AMMCharacterBase::HandleHealthChanged(float DeltaValue)
return;
}
// 只有受到伤害(负值变化)才触发
if (DeltaValue < 0.0f)
{
const float CurrentHealth = AttributeSet ? AttributeSet->GetHealth() : 0.0f;
OnDamaged.Broadcast(CurrentHealth - DeltaValue, CurrentHealth);
// 检查死亡
if (CurrentHealth <= 0.0f)
{
HandleDeath();
@@ -137,13 +167,11 @@ void AMMCharacterBase::HandleDeath()
{
bIsDead = true;
// 禁用输入
if (APlayerController* PC = Cast<APlayerController>(Controller))
{
DisableInput(PC);
}
// 广播死亡事件
OnDeath.Broadcast();
UE_LOG(LogTemp, Log, TEXT("[MMCharacterBase] 角色死亡: %s"), *GetName());

View File

@@ -106,6 +106,19 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Input")
TSubclassOf<UMMGameplayAbility> DefaultAttackAbility;
/// 技能输入动作按键1
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Input")
UInputAction* Skill1Action;
// ==================== 技能 ====================
/// 角色拥有的技能列表。
///
/// 在 ``BeginPlay`` 中通过 ASC 授予这些技能。
/// 子类在构造函数中填充此数组。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Ability")
TArray<TSubclassOf<UMMGameplayAbility>> StartupAbilities;
// ==================== 事件 ====================
/// 角色受击事件。
@@ -132,6 +145,12 @@ public:
UFUNCTION()
void Attack(const FInputActionValue& Value);
/// 处理技能1输入。
///
/// 激活 ``StartupAbilities`` 中索引为 1 的技能(横斩)。
UFUNCTION()
void Skill1(const FInputActionValue& Value);
protected:
/// 游戏开始时的初始化逻辑。
virtual void BeginPlay() override;

View File

@@ -1,9 +1,11 @@
#include "MMGameMode.h"
#include "MMCharacterBase.h"
#include "MMPlayerController.h"
#include "UI/MMHUD.h"
AMMGameMode::AMMGameMode()
{
DefaultPawnClass = AMMCharacterBase::StaticClass();
PlayerControllerClass = AMMPlayerController::StaticClass();
HUDClass = AMMHUD::StaticClass();
}

View File

@@ -0,0 +1,30 @@
#include "MMEnemyBase.h"
#include "GAS/Abilities/MMGameplayAbility.h"
#include "AbilitySystemComponent.h"
#include "Kismet/GameplayStatics.h"
AMMEnemyBase::AMMEnemyBase(const FObjectInitializer& ObjectInitializer)
: Super(ObjectInitializer)
{
// 怪物默认不被玩家控制
AutoPossessPlayer = EAutoReceiveInput::Disabled;
AutoPossessAI = EAutoPossessAI::PlacedInWorldOrSpawned;
// AI 不需要玩家输入相关
bIsDead = false;
}
void AMMEnemyBase::HandleDeath()
{
// 广播怪物死亡事件
OnEnemyDied.Broadcast(this);
// 调用基类死亡处理
Super::HandleDeath();
UE_LOG(LogTemp, Log, TEXT("[MMEnemyBase] 怪物死亡: %s"), *GetName());
// Phase 0: 简单延时销毁
// 后续改为死亡动画 + 掉落物生成
SetLifeSpan(3.0f);
}

View File

@@ -0,0 +1,74 @@
#pragma once
#include "CoreMinimal.h"
#include "Core/MMCharacterBase.h"
#include "MMEnemyBase.generated.h"
/// MM 项目的怪物基类。
///
/// 所有 AI 控制的敌人(非玩家角色)的基类,继承自 ``AMMCharacterBase``。
/// 拥有自己的攻击能力、感知范围和死亡掉落逻辑。
///
/// # 功能
/// - 巡逻/追击/攻击三种行为状态
/// - 仇恨范围和脱战范围
/// - 死亡时广播事件(用于生成器计数和掉落)
///
/// - SeeAlso: AMMCharacterBase, AMMAIController
UCLASS()
class AMMEnemyBase : public AMMCharacterBase
{
GENERATED_BODY()
public:
AMMEnemyBase(const FObjectInitializer& ObjectInitializer);
// ==================== AI 参数 ====================
/// 仇恨检测范围。
/// 玩家进入此范围后开始追击。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "AI")
float AggroRange = 600.0f;
/// 脱战范围。
/// 玩家离开此范围后停止追击,返回巡逻。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "AI")
float DeaggroRange = 1200.0f;
/// 攻击范围。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "AI")
float AttackRange = 150.0f;
/// 巡逻半径。
/// 怪物在生成点附近随机巡逻的范围。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "AI")
float PatrolRadius = 500.0f;
/// 巡逻等待时间范围(秒)。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "AI")
FVector2D PatrolWaitRange = FVector2D(1.0f, 3.0f);
// ==================== 生成信息 ====================
/// 生成位置(由 Spawner 设置)。
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "AI")
FVector SpawnLocation;
/// 设置生成位置。
UFUNCTION(BlueprintCallable, Category = "AI")
void SetSpawnLocation(const FVector& Location) { SpawnLocation = Location; }
// ==================== 死亡 ====================
/// 怪物死亡事件委托。
/// - Parameter Enemy: 死亡的怪物实例
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnEnemyDied, AMMEnemyBase*, Enemy);
/// 怪物死亡事件。
UPROPERTY(BlueprintAssignable, Category = "AI")
FOnEnemyDied OnEnemyDied;
protected:
/// 处理死亡:广播 OnEnemyDied 事件。
virtual void HandleDeath() override;
};

View File

@@ -0,0 +1,110 @@
#include "MMEnemySpawner.h"
#include "MMEnemyBase.h"
#include "NavigationSystem.h"
#include "Engine/World.h"
AMMEnemySpawner::AMMEnemySpawner()
{
PrimaryActorTick.bCanEverTick = true;
PrimaryActorTick.TickInterval = 1.0f;
}
void AMMEnemySpawner::BeginPlay()
{
Super::BeginPlay();
if (bAutoStart)
{
StartSpawning();
}
}
void AMMEnemySpawner::StartSpawning()
{
bIsSpawning = true;
TimeSinceLastSpawn = SpawnInterval; // 立即触发第一次生成
}
void AMMEnemySpawner::StopSpawning()
{
bIsSpawning = false;
}
void AMMEnemySpawner::Tick(float DeltaSeconds)
{
Super::Tick(DeltaSeconds);
if (!bIsSpawning || !EnemyClass)
{
return;
}
TimeSinceLastSpawn += DeltaSeconds;
if (TimeSinceLastSpawn >= SpawnInterval && AliveCount < MaxAliveCount)
{
SpawnEnemy();
TimeSinceLastSpawn = 0.0f;
}
}
void AMMEnemySpawner::SpawnEnemy()
{
UWorld* World = GetWorld();
if (!World || !EnemyClass)
{
return;
}
// 计算生成位置
FVector SpawnLocation = GetActorLocation();
if (SpawnPoints.Num() > 0)
{
// 从预设点中随机选择
SpawnLocation += SpawnPoints[FMath::RandRange(0, SpawnPoints.Num() - 1)];
}
else
{
// 在半径内随机生成
UNavigationSystemV1* NavSys = FNavigationSystem::GetCurrent<UNavigationSystemV1>(World);
if (NavSys)
{
FNavLocation RandomPoint;
if (NavSys->GetRandomReachablePointInRadius(GetActorLocation(), SpawnRadius, RandomPoint))
{
SpawnLocation = RandomPoint.Location;
}
}
}
// 生成怪物
FActorSpawnParameters SpawnParams;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AdjustIfPossibleButAlwaysSpawn;
AMMEnemyBase* Enemy = World->SpawnActor<AMMEnemyBase>(EnemyClass, SpawnLocation, FRotator::ZeroRotator, SpawnParams);
if (Enemy)
{
Enemy->SetSpawnLocation(SpawnLocation);
// 绑定死亡事件
Enemy->OnEnemyDied.AddDynamic(this, &AMMEnemySpawner::OnEnemyDied);
SpawnedEnemies.Add(Enemy);
AliveCount++;
UE_LOG(LogTemp, Log, TEXT("[MMEnemySpawner] 生成怪物: %s (存活: %d/%d)"),
*Enemy->GetName(), AliveCount, MaxAliveCount);
}
}
void AMMEnemySpawner::OnEnemyDied(AMMEnemyBase* Enemy)
{
AliveCount = FMath::Max(0, AliveCount - 1);
if (SpawnedEnemies.Contains(Enemy))
{
SpawnedEnemies.Remove(Enemy);
}
UE_LOG(LogTemp, Log, TEXT("[MMEnemySpawner] 怪物死亡 (存活: %d/%d)"), AliveCount, MaxAliveCount);
}

View File

@@ -0,0 +1,92 @@
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "MMEnemySpawner.generated.h"
class AMMEnemyBase;
/// 怪物生成器。
///
/// 在指定位置定时或一次性生成怪物。
/// 追踪存活怪物数量,支持最大同时存活数限制。
///
/// # 使用示例
///
/// ```cpp
/// // 在关卡中放置 Spawner 并设置参数
/// Spawner->EnemyClass = ASkeletonEnemy::StaticClass();
/// Spawner->MaxAliveCount = 5;
/// Spawner->SpawnInterval = 10.0f;
/// ```
///
/// - SeeAlso: AActor, AMMEnemyBase
UCLASS()
class AMMEnemySpawner : public AActor
{
GENERATED_BODY()
public:
AMMEnemySpawner();
/// 每帧更新(仅用于检测是否需要生成)。
virtual void Tick(float DeltaSeconds) override;
/// 开始生成怪物。
UFUNCTION(BlueprintCallable, Category = "Spawner")
void StartSpawning();
/// 停止生成怪物。
UFUNCTION(BlueprintCallable, Category = "Spawner")
void StopSpawning();
// ==================== 生成参数 ====================
/// 怪物类。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Spawner")
TSubclassOf<AMMEnemyBase> EnemyClass;
/// 生成位置(相对于 Spawner 的偏移)。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Spawner")
TArray<FVector> SpawnPoints;
/// 最大同时存活数量。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Spawner")
int32 MaxAliveCount = 5;
/// 生成间隔(秒)。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Spawner")
float SpawnInterval = 5.0f;
/// 生成半径(如果没有指定 SpawnPoints在此范围内随机生成
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Spawner")
float SpawnRadius = 300.0f;
/// 是否在游戏开始时自动开始生成。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Spawner")
bool bAutoStart = true;
protected:
virtual void BeginPlay() override;
private:
/// 尝试生成一只怪物。
void SpawnEnemy();
/// 怪物死亡回调。
UFUNCTION()
void OnEnemyDied(AMMEnemyBase* Enemy);
/// 当前存活数量。
int32 AliveCount = 0;
/// 距离上次生成的时间。
float TimeSinceLastSpawn = 0.0f;
/// 是否正在生成。
bool bIsSpawning = false;
/// 已生成的怪物引用(弱引用,避免阻止销毁)。
UPROPERTY()
TArray<AMMEnemyBase*> SpawnedEnemies;
};

View File

@@ -0,0 +1,79 @@
#include "MMSkill_Slash.h"
#include "Combat/MMWeaponComponent.h"
#include "AbilitySystemComponent.h"
#include "GameplayEffect.h"
#include "Engine/World.h"
#include "TimerManager.h"
UMMSkill_Slash::UMMSkill_Slash()
{
InstancingPolicy = EGameplayAbilityInstancingPolicy::InstancedPerActor;
NetExecutionPolicy = EGameplayAbilityNetExecutionPolicy::LocalPredicted;
}
void UMMSkill_Slash::ActivateAbility(
const FGameplayAbilitySpecHandle Handle,
const FGameplayAbilityActorInfo* ActorInfo,
const FGameplayAbilityActivationInfo ActivationInfo,
const FGameplayEventData* TriggerEventData)
{
if (!CommitAbility(Handle, ActorInfo, ActivationInfo))
{
EndAbility(Handle, ActorInfo, ActivationInfo, true, false);
return;
}
AActor* Avatar = GetAvatarActorFromActorInfo();
if (!Avatar)
{
EndAbility(Handle, ActorInfo, ActivationInfo, true, false);
return;
}
// 获取武器组件
UMMWeaponComponent* WeaponComp = Avatar->FindComponentByClass<UMMWeaponComponent>();
if (!WeaponComp)
{
EndAbility(Handle, ActorInfo, ActivationInfo, true, false);
return;
}
// 开始挥砍
WeaponComp->StartSwing();
// 设置攻击结束计时器
Avatar->GetWorldTimerManager().SetTimer(
AttackTimerHandle,
this,
&UMMSkill_Slash::OnAttackFinished,
AttackDuration,
false);
}
void UMMSkill_Slash::OnAttackFinished()
{
AActor* Avatar = GetAvatarActorFromActorInfo();
if (Avatar)
{
if (UMMWeaponComponent* WeaponComp = Avatar->FindComponentByClass<UMMWeaponComponent>())
{
WeaponComp->EndSwing();
}
}
K2_EndAbility();
}
void UMMSkill_Slash::EndAbility(
const FGameplayAbilitySpecHandle Handle,
const FGameplayAbilityActorInfo* ActorInfo,
const FGameplayAbilityActivationInfo ActivationInfo,
bool bReplicateEndAbility, bool bWasCancelled)
{
if (UWorld* World = GetWorld())
{
World->GetTimerManager().ClearTimer(AttackTimerHandle);
}
Super::EndAbility(Handle, ActorInfo, ActivationInfo, bReplicateEndAbility, bWasCancelled);
}

View File

@@ -0,0 +1,66 @@
#pragma once
#include "CoreMinimal.h"
#include "GAS/Abilities/MMGameplayAbility.h"
#include "MMSkill_Slash.generated.h"
/// Saber 技能:横斩。
///
/// 前方扇形范围的强力斩击,造成较高伤害。
/// 使用冷却 GE 控制冷却时间。
///
/// # 参数
///
/// | 属性 | 默认值 | 说明 |
/// |------|--------|------|
/// | CooldownDuration | 3.0s | 冷却时间 |
/// | DamageMultiplier | 2.0 | 伤害倍率 |
/// | AttackDuration | 0.6s | 挥砍持续时间 |
///
/// - SeeAlso: UMMGameplayAbility, UMMWeaponComponent
UCLASS()
class UMMSkill_Slash : public UMMGameplayAbility
{
GENERATED_BODY()
public:
UMMSkill_Slash();
/// 冷却时间(秒)。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Skill")
float CooldownDuration = 3.0f;
/// 伤害倍率。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Skill")
float DamageMultiplier = 2.0f;
/// 攻击持续时间(秒)。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Skill")
float AttackDuration = 0.6f;
/// 冷却 GE 类。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Skill")
TSubclassOf<UGameplayEffect> CooldownGameplayEffect;
// ~Begin UGameplayAbility Interface
virtual void ActivateAbility(
const FGameplayAbilitySpecHandle Handle,
const FGameplayAbilityActorInfo* ActorInfo,
const FGameplayAbilityActivationInfo ActivationInfo,
const FGameplayEventData* TriggerEventData) override;
virtual void EndAbility(
const FGameplayAbilitySpecHandle Handle,
const FGameplayAbilityActorInfo* ActorInfo,
const FGameplayAbilityActivationInfo ActivationInfo,
bool bReplicateEndAbility, bool bWasCancelled) override;
// ~End UGameplayAbility Interface
private:
/// 攻击结束回调。
UFUNCTION()
void OnAttackFinished();
/// 攻击计时器句柄。
FTimerHandle AttackTimerHandle;
};

View File

@@ -0,0 +1,37 @@
#include "MMGameplayTags.h"
#include "GameplayTagsManager.h"
TSharedPtr<FMMGameplayTags> FMMGameplayTags::CachedInstance;
const FMMGameplayTags& FMMGameplayTags::Get()
{
if (!CachedInstance.IsValid())
{
InitializeNativeTags();
}
return *CachedInstance;
}
void FMMGameplayTags::InitializeNativeTags()
{
if (CachedInstance.IsValid())
{
return;
}
CachedInstance = MakeShared<FMMGameplayTags>();
UGameplayTagsManager& Manager = UGameplayTagsManager::Get();
// 冷却标签
CachedInstance->Cooldown_Skill1 = Manager.AddNativeGameplayTag(FName("Cooldown.Skill1"), TEXT("技能1冷却"));
CachedInstance->Cooldown_Skill2 = Manager.AddNativeGameplayTag(FName("Cooldown.Skill2"), TEXT("技能2冷却"));
CachedInstance->Cooldown_Skill3 = Manager.AddNativeGameplayTag(FName("Cooldown.Skill3"), TEXT("技能3冷却"));
// 状态标签
CachedInstance->State_Dead = Manager.AddNativeGameplayTag(FName("State.Dead"), TEXT("角色死亡"));
CachedInstance->State_Hit = Manager.AddNativeGameplayTag(FName("State.Hit"), TEXT("角色受击"));
// 效果标签
CachedInstance->Effect_Damage = Manager.AddNativeGameplayTag(FName("Effect.Damage"), TEXT("伤害效果"));
CachedInstance->Effect_Heal = Manager.AddNativeGameplayTag(FName("Effect.Heal"), TEXT("治疗效果"));
}

View File

@@ -0,0 +1,62 @@
#pragma once
#include "CoreMinimal.h"
#include "GameplayTagContainer.h"
/// MM 项目的 Gameplay Tags 定义。
///
/// 集中管理所有项目自定义的 Gameplay Tags。
/// 所有 Tag 在 ``InitializeNativeTags`` 中通过 ``AddNativeGameplayTag`` 注册。
///
/// # 使用示例
///
/// ```cpp
/// FGameplayTag Tag = FMMGameplayTags::Get().Cooldown_Skill1;
/// ```
///
/// - Note: 在 MM 模块的 ``StartupModule`` 中调用 ``InitializeNativeTags``
/// - SeeAlso: FGameplayTag, UGameplayTagsManager
struct FMMGameplayTags
{
public:
/// 获取单例实例。
static const FMMGameplayTags& Get();
/// 初始化并注册所有 Native Tags。
///
/// 应在模块启动时调用(``MM.cpp`` 的 ``StartupModule`` 中)。
static void InitializeNativeTags();
// ==================== 冷却标签 ====================
/// 技能1冷却标签。
///
/// 挂载在冷却 GE 上,技能激活时检查此标签是否存在来判断是否在冷却中。
FGameplayTag Cooldown_Skill1;
/// 技能2冷却标签。
FGameplayTag Cooldown_Skill2;
/// 技能3冷却标签。
FGameplayTag Cooldown_Skill3;
// ==================== 状态标签 ====================
/// 角色死亡标签。
FGameplayTag State_Dead;
/// 角色受击标签。
FGameplayTag State_Hit;
// ==================== 效果标签 ====================
/// 伤害效果标签。
FGameplayTag Effect_Damage;
/// 治疗效果标签。
FGameplayTag Effect_Heal;
private:
/// 缓存的 Tag 引用。
static TSharedPtr<FMMGameplayTags> CachedInstance;
};

View File

@@ -7,17 +7,26 @@ public class MM : ModuleRules
public MM(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "InputCore", "EnhancedInput", "GameplayAbilities", "GameplayTags", "GameplayTasks" });
PrivateDependencyModuleNames.AddRange(new string[] { });
PublicDependencyModuleNames.AddRange(new string[]
{
"Core",
"CoreUObject",
"Engine",
"InputCore",
"EnhancedInput",
"GameplayAbilities",
"GameplayTags",
"GameplayTasks",
"AIModule",
"NavigationSystem"
});
// Uncomment if you are using Slate UI
// PrivateDependencyModuleNames.AddRange(new string[] { "Slate", "SlateCore" });
// Uncomment if you are using online features
// PrivateDependencyModuleNames.Add("OnlineSubsystem");
// To include OnlineSubsystemSteam, add it to the plugins section in your uproject file with the Enabled attribute set to true
PrivateDependencyModuleNames.AddRange(new string[]
{
"Slate",
"SlateCore",
"UMG"
});
}
}

View File

@@ -1,6 +1,19 @@
// Copyright Epic Games, Inc. All Rights Reserved.
#include "MM.h"
#include "Modules/ModuleManager.h"
#include "GAS/MMGameplayTags.h"
IMPLEMENT_PRIMARY_GAME_MODULE( FDefaultGameModuleImpl, MM, "MM" );
#define LOCTEXT_NAMESPACE "FMMModule"
void FMMModule::StartupModule()
{
// 注册项目自定义 Gameplay Tags
FMMGameplayTags::InitializeNativeTags();
}
void FMMModule::ShutdownModule()
{
}
#undef LOCTEXT_NAMESPACE
IMPLEMENT_MODULE(FMMModule, MM)

View File

@@ -1,6 +1,7 @@
#include "SaberCharacter.h"
#include "Combat/MMWeaponComponent.h"
#include "GAS/Abilities/Saber/MMAttack_Melee.h"
#include "GAS/Abilities/Saber/MMSkill_Slash.h"
#include "AbilitySystemComponent.h"
ASaberCharacter::ASaberCharacter(const FObjectInitializer& ObjectInitializer)
@@ -11,4 +12,7 @@ ASaberCharacter::ASaberCharacter(const FObjectInitializer& ObjectInitializer)
// 设置默认攻击技能为 Saber 普攻
DefaultAttackAbility = UMMAttack_Melee::StaticClass();
// 授予 Saber 技能:横斩
StartupAbilities.Add(UMMSkill_Slash::StaticClass());
}

76
Source/MM/UI/MMHUD.cpp Normal file
View File

@@ -0,0 +1,76 @@
#include "MMHUD.h"
#include "UI/Widgets/MMHealthBarWidget.h"
#include "Core/MMCharacterBase.h"
#include "GAS/AbilitySystem/MMAttributeSet.h"
#include "Blueprint/UserWidget.h"
#include "Kismet/GameplayStatics.h"
AMMHUD::AMMHUD()
{
PrimaryActorTick.bCanEverTick = true;
}
void AMMHUD::BeginPlay()
{
Super::BeginPlay();
CreateWidgets();
BindCharacterEvents();
}
void AMMHUD::CreateWidgets()
{
if (HealthBarWidgetClass && !HealthBarWidget)
{
HealthBarWidget = CreateWidget<UMMHealthBarWidget>(GetOwningPlayerController(), HealthBarWidgetClass);
if (HealthBarWidget)
{
HealthBarWidget->AddToViewport();
}
}
}
void AMMHUD::BindCharacterEvents()
{
if (AMMCharacterBase* Character = Cast<AMMCharacterBase>(GetOwningPawn()))
{
Character->OnDamaged.AddDynamic(this, &AMMHUD::OnCharacterDamaged);
Character->OnDeath.AddDynamic(this, &AMMHUD::OnCharacterDeath);
// 初始化生命条
if (Character->GetAttributeSet())
{
HealthBarWidget->UpdateHealth(
Character->GetAttributeSet()->GetHealth(),
Character->GetAttributeSet()->GetMaxHealth());
}
}
}
void AMMHUD::Tick(float DeltaSeconds)
{
Super::Tick(DeltaSeconds);
// Tick 仅用于处理 PlayerPawn 延迟设置的情况
// 实际 UI 更新通过事件驱动
}
void AMMHUD::OnCharacterDamaged(float OldHealth, float NewHealth)
{
if (HealthBarWidget)
{
if (AMMCharacterBase* Character = Cast<AMMCharacterBase>(GetOwningPawn()))
{
if (Character->GetAttributeSet())
{
HealthBarWidget->UpdateHealth(
Character->GetAttributeSet()->GetHealth(),
Character->GetAttributeSet()->GetMaxHealth());
}
}
}
}
void AMMHUD::OnCharacterDeath()
{
// Phase 0: 死亡后 UI 反馈可在此扩展
UE_LOG(LogTemp, Log, TEXT("[MMHUD] 角色死亡"));
}

55
Source/MM/UI/MMHUD.h Normal file
View File

@@ -0,0 +1,55 @@
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/HUD.h"
#include "MMHUD.generated.h"
class UMMHealthBarWidget;
class AMMCharacterBase;
/// MM 项目的 HUD。
///
/// 管理游戏界面的创建和更新,包括生命条。
/// 监听角色的 ``OnDamaged`` 和 ``OnDeath`` 事件来更新 UI。
///
/// - Note: Widget 通过 C++ 代码创建,不依赖编辑器蓝图
/// - SeeAlso: AHUD, UMMHealthBarWidget
UCLASS()
class AMMHUD : public AHUD
{
GENERATED_BODY()
public:
/// 构造函数。
AMMHUD();
/// 每帧更新(仅用于检测 PlayerPawn 变化)。
virtual void Tick(float DeltaSeconds) override;
/// 初始化 HUD 并绑定事件。
virtual void BeginPlay() override;
protected:
/// 创建并添加 Widget 到视口。
void CreateWidgets();
/// 绑定角色事件。
void BindCharacterEvents();
/// 生命条 Widget 类。
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "UI")
TSubclassOf<UMMHealthBarWidget> HealthBarWidgetClass;
/// 生命条 Widget 实例。
UPROPERTY()
UMMHealthBarWidget* HealthBarWidget;
private:
/// 角色受击回调,更新生命条。
UFUNCTION()
void OnCharacterDamaged(float OldHealth, float NewHealth);
/// 角色死亡回调。
UFUNCTION()
void OnCharacterDeath();
};

View File

@@ -0,0 +1,35 @@
#include "MMHealthBarWidget.h"
#include "Components/ProgressBar.h"
#include "Components/TextBlock.h"
#include "Widgets/Layout/Anchors.h"
bool UMMHealthBarWidget::Initialize()
{
if (!Super::Initialize())
{
return false;
}
// 如果没有 WidgetTree纯代码创建手动构建
if (!WidgetTree)
{
return true;
}
return true;
}
void UMMHealthBarWidget::UpdateHealth(float CurrentHealth, float MaxHealth)
{
if (HealthProgressBar)
{
const float Percent = (MaxHealth > 0.0f) ? (CurrentHealth / MaxHealth) : 0.0f;
HealthProgressBar->SetPercent(Percent);
}
if (HealthText)
{
HealthText->SetText(FText::FromString(
FString::Printf(TEXT("%.0f / %.0f"), CurrentHealth, MaxHealth)));
}
}

View File

@@ -0,0 +1,48 @@
#pragma once
#include "CoreMinimal.h"
#include "Blueprint/UserWidget.h"
#include "MMHealthBarWidget.generated.h"
class UProgressBar;
class UTextBlock;
/// 生命条 Widget。
///
/// 纯 C++ 构建的 HUD 生命条,显示角色当前/最大生命值。
/// 不依赖编辑器蓝图资产。
///
/// # 使用示例
///
/// ```cpp
/// auto* HealthBar = CreateWidget<UMMHealthBarWidget>(PC);
/// HealthBar->UpdateHealth(80.0f, 100.0f);
/// ```
///
/// - SeeAlso: UUserWidget, UProgressBar
UCLASS()
class UMMHealthBarWidget : public UUserWidget
{
GENERATED_BODY()
public:
/// 构造并初始化 Widget 树。
///
/// 创建 ProgressBar 和 TextBlock 子组件。
virtual bool Initialize() override;
/// 更新生命值显示。
///
/// - Parameter CurrentHealth: 当前生命值
/// - Parameter MaxHealth: 最大生命值
void UpdateHealth(float CurrentHealth, float MaxHealth);
protected:
/// 生命条进度条。
UPROPERTY()
UProgressBar* HealthProgressBar;
/// 生命值文本。
UPROPERTY()
UTextBlock* HealthText;
};