Compare commits

...

2 Commits

Author SHA1 Message Date
909583a1bb Fix PS_AI_Behavior compilation errors for UE 5.5
- Remove NavigationSystem from .uplugin Plugins (it's a module, not a plugin)
- Fix UInterface naming: IPS_AI_Behavior -> IPS_AI_Behavior_Interface (UHT requirement)
- Fix TWeakObjectPtr<AActor> TArray not Blueprint-compatible (remove BlueprintReadOnly)
- Fix UseBlackboard TObjectPtr ref: use raw pointer intermediary
- Fix FEdMode::HandleClick signature: FInputClick -> FViewportClick (UE 5.5)
- Fix SplineNetwork: use OnWorldBeginPlay(UWorld&) override instead of delegate
- Fix PerceptionComponent: remove const from methods calling non-const GetActorsPerception
- Fix EQS SetScore: use 5-arg float overload (Score, FilterMin, FilterMax)
- Fix BTTask_FindCover: add missing Definitions.h include, fix const World
- Fix BTTask_FollowSpline: replace AddWeakLambda with polling in TickTask
- Fix CoverPoint: initialize Color before switch, add default case
- Export LogPS_AI_Behavior with PS_AI_BEHAVIOR_API for cross-module visibility
- Remove unused variables (WorldOrigin, WorldDirection)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 08:41:42 +01:00
5d5b85380a Add PS_AI_Behavior plugin: NPC behavior system with BT, EQS, splines, cover, and editor tools
New plugin providing a complete NPC behavior framework:

- Personality system: score-based traits (Courage, Aggressivity, Caution, Loyalty, Discipline)
  with PersonalityProfile data assets and runtime-modifiable traits
- NPC types: Civilian, Enemy, Protector with team affiliation and perception
- IPS_AI_Behavior interface: decoupled bridge between plugin and host project
  (type, hostility, team, movement speed, state notifications)
- AIController: auto Blackboard setup, BT launch, team ID mapping,
  GetTeamAttitudeTowards (Civilian<->Protector friendly), soft ConvAgent binding
- Perception: sight/hearing/damage senses, target priority system per profile,
  multi-player support, ClassifyActor helper
- BT nodes: UpdateThreat, EvaluateReaction services; CheckTrait decorator;
  Patrol, FleeFrom, FindCover, Attack, FollowSpline, FindAndFollowSpline tasks
- Combat: CombatComponent with range, cooldown, damage, NetMulticast attack events
- Spline navigation: SplinePath actors (typed), SplineNetwork subsystem with
  junction detection, SplineFollowerComponent for fluid movement
- Cover points: manually placed CoverPoint actors (Cover/HidingSpot),
  occupancy system, hybrid BTTask (manual + procedural fallback),
  EQS generator for cover point queries
- Editor tools: SplineEdMode with interactive placement, cover point tool,
  SplinePanel (list, create, validate, snap-to-ground), SplineVisualizer
  (junctions, direction arrows, distance markers)
- Replication: CurrentState, ThreatLevel, CurrentTarget, CurrentSpline replicated
  for multiplayer; server-authoritative AI with client cosmetic callbacks
- Movement speed per state: configurable in PersonalityProfile, applied via interface

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 08:27:20 +01:00
65 changed files with 7456 additions and 0 deletions

View File

@ -40,6 +40,10 @@
{
"Name": "AudioCapture",
"Enabled": true
},
{
"Name": "PS_AI_Behavior",
"Enabled": true
}
]
}

View File

@ -0,0 +1,511 @@
# PS_AI_Behavior — Plan d'implémentation V1
## Vue d'ensemble
Plugin UE5.5 pour gérer les comportements de NPCs (civils et ennemis) via Behavior Trees, EQS et un système de personnalité à scores. Navigation sur NavMesh, détection d'ennemis, combat basique, fuite, couverture.
Dépendance optionnelle vers PS_AI_ConvAgent (détectée à l'exécution, pas de link-time dependency).
---
## 1. Structure du plugin
```
Plugins/PS_AI_Behavior/
├── PS_AI_Behavior.uplugin
├── Config/
│ └── DefaultPS_AI_Behavior.ini
├── Content/
│ ├── BehaviorTrees/
│ │ ├── BT_Civilian.uasset (BT civils)
│ │ └── BT_Enemy.uasset (BT ennemis)
│ ├── EQS/
│ │ ├── EQS_FindCover.uasset (trouver couverture)
│ │ ├── EQS_FindFleePoint.uasset (point de fuite)
│ │ └── EQS_FindPatrolPoint.uasset (point de patrouille)
│ └── Data/
│ ├── DA_Trait_Coward.uasset (exemple Data Asset)
│ └── DA_Trait_Aggressive.uasset
└── Source/
├── PS_AI_Behavior/ (module Runtime)
│ ├── PS_AI_Behavior.Build.cs
│ ├── Public/
│ │ ├── PS_AI_Behavior.h (module def)
│ │ ├── PS_AI_Behavior_Definitions.h (enums, structs, log category)
│ │ ├── PS_AI_Behavior_Settings.h (Project Settings)
│ │ │
│ │ ├── PS_AI_Behavior_AIController.h (AIController principal)
│ │ ├── PS_AI_Behavior_PersonalityComponent.h (traits de personnalité)
│ │ ├── PS_AI_Behavior_PerceptionComponent.h (wrapper AIPerception)
│ │ ├── PS_AI_Behavior_CombatComponent.h (état combat)
│ │ ├── PS_AI_Behavior_PersonalityProfile.h (Data Asset profil)
│ │ │
│ │ ├── BT/ (BT Tasks, Services, Decorators)
│ │ │ ├── PS_AI_Behavior_BTTask_FindCover.h
│ │ │ ├── PS_AI_Behavior_BTTask_FleeFrom.h
│ │ │ ├── PS_AI_Behavior_BTTask_Attack.h
│ │ │ ├── PS_AI_Behavior_BTTask_Patrol.h
│ │ │ ├── PS_AI_Behavior_BTService_UpdateThreat.h
│ │ │ ├── PS_AI_Behavior_BTService_EvaluateReaction.h
│ │ │ └── PS_AI_Behavior_BTDecorator_CheckTrait.h
│ │ │
│ │ └── EQS/
│ │ ├── PS_AI_Behavior_EQSContext_Threat.h
│ │ └── PS_AI_Behavior_EQSTest_CoverQuality.h
│ │
│ └── Private/
│ ├── PS_AI_Behavior.cpp
│ ├── PS_AI_Behavior_Settings.cpp
│ ├── PS_AI_Behavior_AIController.cpp
│ ├── PS_AI_Behavior_PersonalityComponent.cpp
│ ├── PS_AI_Behavior_PerceptionComponent.cpp
│ ├── PS_AI_Behavior_CombatComponent.cpp
│ ├── PS_AI_Behavior_PersonalityProfile.cpp
│ ├── BT/
│ │ ├── PS_AI_Behavior_BTTask_FindCover.cpp
│ │ ├── PS_AI_Behavior_BTTask_FleeFrom.cpp
│ │ ├── PS_AI_Behavior_BTTask_Attack.cpp
│ │ ├── PS_AI_Behavior_BTTask_Patrol.cpp
│ │ ├── PS_AI_Behavior_BTService_UpdateThreat.cpp
│ │ ├── PS_AI_Behavior_BTService_EvaluateReaction.cpp
│ │ └── PS_AI_Behavior_BTDecorator_CheckTrait.cpp
│ └── EQS/
│ ├── PS_AI_Behavior_EQSContext_Threat.cpp
│ └── PS_AI_Behavior_EQSTest_CoverQuality.cpp
└── PS_AI_BehaviorEditor/ (module Editor — futur, pas V1)
├── PS_AI_BehaviorEditor.Build.cs
└── ...
```
---
## 2. Classes principales
### 2.1 Definitions (`PS_AI_Behavior_Definitions.h`)
```cpp
// Log category
DECLARE_LOG_CATEGORY_EXTERN(LogPS_AI_Behavior, Log, All);
// Type de NPC
UENUM(BlueprintType)
enum class EPS_AI_Behavior_NPCType : uint8
{
Civilian,
Enemy,
Neutral
};
// État comportemental haut-niveau
UENUM(BlueprintType)
enum class EPS_AI_Behavior_State : uint8
{
Idle,
Patrol,
Alerted,
Combat,
Fleeing,
TakingCover,
Dead
};
// Axes de personnalité (scores 0.0 → 1.0)
UENUM(BlueprintType)
enum class EPS_AI_Behavior_TraitAxis : uint8
{
Courage, // 0 = lâche, 1 = téméraire
Aggressivity, // 0 = pacifique, 1 = violent
Loyalty, // 0 = égoïste, 1 = dévoué
Caution, // 0 = imprudent, 1 = prudent
Discipline // 0 = indiscipliné, 1 = discipliné
};
// Struct pour un trait + valeur
USTRUCT(BlueprintType)
struct FPS_AI_Behavior_TraitScore
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, meta=(ClampMin=0.0, ClampMax=1.0))
float Value = 0.5f;
};
```
### 2.2 PersonalityProfile — Data Asset (`PS_AI_Behavior_PersonalityProfile.h`)
```cpp
UCLASS(BlueprintType)
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_PersonalityProfile : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Personality")
FText ProfileName;
// Scores par axe : TMap<EPS_AI_Behavior_TraitAxis, float>
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Personality")
TMap<EPS_AI_Behavior_TraitAxis, float> TraitScores;
// Seuils de réaction
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Reaction Thresholds",
meta=(ClampMin=0.0, ClampMax=1.0))
float FleeThreshold = 0.6f; // Threat level au-delà duquel on fuit (modulé par Courage)
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Reaction Thresholds",
meta=(ClampMin=0.0, ClampMax=1.0))
float AttackThreshold = 0.4f; // Threat level au-delà duquel on attaque (modulé par Aggressivity)
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Behavior")
EPS_AI_Behavior_NPCType DefaultNPCType = EPS_AI_Behavior_NPCType::Civilian;
// Behavior Tree à utiliser (peut être overridé par l'AIController)
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category="Behavior")
TSoftObjectPtr<UBehaviorTree> DefaultBehaviorTree;
// Helper
float GetTrait(EPS_AI_Behavior_TraitAxis Axis) const;
};
```
### 2.3 PersonalityComponent (`PS_AI_Behavior_PersonalityComponent.h`)
Attaché au Pawn. Fournit l'accès runtime aux traits, modifie les seuils dynamiquement.
```cpp
UCLASS(ClassGroup="PS AI Behavior", meta=(BlueprintSpawnableComponent))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_PersonalityComponent : public UActorComponent
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Personality")
UPS_AI_Behavior_PersonalityProfile* Profile;
// Runtime overrides (initialisés depuis Profile au BeginPlay)
UPROPERTY(BlueprintReadWrite, Category="Personality|Runtime")
TMap<EPS_AI_Behavior_TraitAxis, float> RuntimeTraits;
// Threat level perçu (mis à jour par BTService_UpdateThreat)
UPROPERTY(BlueprintReadWrite, Category="Personality|Runtime")
float PerceivedThreatLevel = 0.0f;
// Décision finale basée sur traits + threat
UFUNCTION(BlueprintCallable, Category="PS AI Behavior|Personality")
EPS_AI_Behavior_State EvaluateReaction() const;
UFUNCTION(BlueprintCallable, Category="PS AI Behavior|Personality")
float GetTrait(EPS_AI_Behavior_TraitAxis Axis) const;
UFUNCTION(BlueprintCallable, Category="PS AI Behavior|Personality")
void ModifyTrait(EPS_AI_Behavior_TraitAxis Axis, float Delta);
};
```
**Logique `EvaluateReaction()`** :
```
EffectiveCourage = RuntimeTraits[Courage] * (1 - PerceivedThreatLevel * 0.5)
if PerceivedThreatLevel > FleeThreshold * (1 + EffectiveCourage) → Fleeing
if PerceivedThreatLevel > AttackThreshold * (1 - Aggressivity) → Combat
if PerceivedThreatLevel > 0.1 → Alerted
else → Idle/Patrol
```
### 2.4 AIController (`PS_AI_Behavior_AIController.h`)
```cpp
UCLASS()
class PS_AI_BEHAVIOR_API APS_AI_Behavior_AIController : public AAIController
{
GENERATED_BODY()
public:
APS_AI_Behavior_AIController();
// Blackboard keys (nom constants)
static const FName BB_State; // EPS_AI_Behavior_State
static const FName BB_ThreatActor; // UObject*
static const FName BB_ThreatLocation; // FVector
static const FName BB_ThreatLevel; // float
static const FName BB_CoverLocation; // FVector
static const FName BB_PatrolIndex; // int32
static const FName BB_HomeLocation; // FVector
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Behavior")
UBehaviorTree* BehaviorTreeAsset;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Behavior")
UBlackboardData* BlackboardAsset;
// Patrol waypoints (set par level designer ou spawner)
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Patrol")
TArray<FVector> PatrolPoints;
protected:
virtual void OnPossess(APawn* InPawn) override;
virtual void OnUnPossess() override;
// Auto-détection optionnelle de PS_AI_ConvAgent
void TryBindConversationAgent();
};
```
**`OnPossess`** :
1. Trouve `PersonalityComponent` sur le Pawn
2. Crée/initialise le Blackboard
3. Lit `DefaultBehaviorTree` du ProfileData (ou utilise `BehaviorTreeAsset`)
4. Lance `RunBehaviorTree()`
5. Appelle `TryBindConversationAgent()`
**`TryBindConversationAgent()`** :
- Via `FindComponentByClass` (pas de include direct, utilise `FindObject` ou interface)
- Si trouvé : bind OnAgentActionRequested pour injecter des actions dans le BT
### 2.5 PerceptionComponent (`PS_AI_Behavior_PerceptionComponent.h`)
Wrapper configuré autour de `UAIPerceptionComponent` :
```cpp
UCLASS(ClassGroup="PS AI Behavior", meta=(BlueprintSpawnableComponent))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_PerceptionComponent : public UAIPerceptionComponent
{
GENERATED_BODY()
public:
UPS_AI_Behavior_PerceptionComponent();
// Pré-configure : Sight (60m, 90° FOV) + Hearing (30m) + Damage
virtual void BeginPlay() override;
UFUNCTION(BlueprintCallable, Category="PS AI Behavior|Perception")
AActor* GetHighestThreat() const;
UFUNCTION(BlueprintCallable, Category="PS AI Behavior|Perception")
float CalculateThreatLevel() const;
protected:
UFUNCTION()
void OnPerceptionUpdated(const TArray<AActor*>& UpdatedActors);
};
```
### 2.6 CombatComponent (`PS_AI_Behavior_CombatComponent.h`)
Gère l'état combat, les distances, le cooldown d'attaque :
```cpp
UCLASS(ClassGroup="PS AI Behavior", meta=(BlueprintSpawnableComponent))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_CombatComponent : public UActorComponent
{
GENERATED_BODY()
public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Combat")
float AttackRange = 200.0f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Combat")
float AttackCooldown = 1.5f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category="Combat")
float AttackDamage = 20.0f;
UFUNCTION(BlueprintCallable, Category="PS AI Behavior|Combat")
bool CanAttack() const;
UFUNCTION(BlueprintCallable, Category="PS AI Behavior|Combat")
void ExecuteAttack(AActor* Target);
UFUNCTION(BlueprintCallable, Category="PS AI Behavior|Combat")
bool IsInAttackRange(AActor* Target) const;
};
```
---
## 3. Behavior Tree — Nodes
### 3.1 Services (tournent en continu)
**BTService_UpdateThreat** :
- Lit `PerceptionComponent::CalculateThreatLevel()`
- Écrit `BB_ThreatLevel`, `BB_ThreatActor`, `BB_ThreatLocation`
- Met à jour `PersonalityComponent::PerceivedThreatLevel`
**BTService_EvaluateReaction** :
- Appelle `PersonalityComponent::EvaluateReaction()`
- Écrit `BB_State` dans le Blackboard
- Le BT utilise des decorators pour brancher sur cet état
### 3.2 Tasks
**BTTask_Patrol** :
- Lit `BB_PatrolIndex`, navigue vers `PatrolPoints[idx]`
- Au succès, incrémente l'index (cyclique)
- Supporte pause aléatoire aux waypoints
**BTTask_FleeFrom** :
- Lit `BB_ThreatLocation`
- Utilise EQS `EQS_FindFleePoint` (direction opposée à la menace)
- `MoveTo()` vers le point trouvé
**BTTask_FindCover** :
- Lance EQS `EQS_FindCover` (scoring : distance menace, line-of-sight block, distance au NPC)
- Navigue vers le meilleur point
- Écrit `BB_CoverLocation`
**BTTask_Attack** :
- Vérifie `CombatComponent::CanAttack()`
- Si hors range : `MoveTo(Target)`
- Si in range : `CombatComponent::ExecuteAttack(Target)`
### 3.3 Decorators
**BTDecorator_CheckTrait** :
- Paramètres : `TraitAxis`, `ComparisonOp` (>, <, ==), `Threshold`
- Lit le trait depuis `PersonalityComponent`
- Exemple : "Exécuter seulement si Courage > 0.5"
---
## 4. EQS
### EQS_FindCover
- **Generator** : Points sur grille autour du NPC (rayon 15m)
- **Tests** :
- Distance à la menace (préfère mi-distance, pas trop loin)
- Trace visibility (préfère les points non-visibles depuis la menace)
- Distance au NPC (préfère les points proches)
- `EQSTest_CoverQuality` (custom) : raycasts multiples pour évaluer la qualité de couverture
### EQS_FindFleePoint
- **Generator** : Points sur donut (rayon 10-25m)
- **Tests** :
- Dot product direction (opposé à la menace : score max)
- PathExistence (doit être atteignable sur NavMesh)
- Distance à la menace (préfère loin)
### EQS_FindPatrolPoint
- **Generator** : Points depuis la liste PatrolPoints de l'AIController
- **Tests** :
- Distance au NPC (préfère le plus proche non-visité)
### EQSContext_Threat
- Renvoie l'acteur/location de `BB_ThreatActor` / `BB_ThreatLocation`
---
## 5. Intégration optionnelle PS_AI_ConvAgent
**Mécanisme** : Pas de `#include` direct. L'AIController utilise `FindComponentByClass` avec le nom de classe via UObject reflection :
```cpp
void APS_AI_Behavior_AIController::TryBindConversationAgent()
{
// Soft reference — no link-time dependency
UActorComponent* ConvComp = GetPawn()->FindComponentByClass(
LoadClass<UActorComponent>(nullptr,
TEXT("/Script/PS_AI_ConvAgent.PS_AI_ConvAgent_ElevenLabsComponent")));
if (ConvComp)
{
// Bind to OnAgentActionRequested via dynamic delegate
// Actions from conversation can inject BT state changes
}
}
```
Cela permet :
- Un NPC conversationnel qui reçoit "Fuis !" via ElevenLabs → injecte State=Fleeing dans le BT
- Aucune dépendance de compilation
---
## 6. Build.cs — Dépendances
```csharp
// PS_AI_Behavior.Build.cs
PublicDependencyModuleNames.AddRange(new string[] {
"Core", "CoreUObject", "Engine",
"AIModule", // AAIController, BehaviorTree, Blackboard
"GameplayTasks", // UGameplayTask (requis par BT tasks)
"NavigationSystem", // NavMesh queries
});
PrivateDependencyModuleNames.AddRange(new string[] {
"Settings", // ISettingsModule
});
// PAS de dépendance vers PS_AI_ConvAgent
```
---
## 7. Ordre d'implémentation (étapes)
### Étape 1 — Squelette plugin
- [ ] Créer la structure de fichiers du plugin
- [ ] `.uplugin`, `Build.cs`, module class
- [ ] `Definitions.h` (enums, structs, log category)
- [ ] `Settings.h/cpp` (settings vides pour l'instant)
- [ ] Ajouter au `.uproject`
- [ ] **Vérification** : compile sans erreur
### Étape 2 — Personality System
- [ ] `PersonalityProfile` (Data Asset)
- [ ] `PersonalityComponent` avec `EvaluateReaction()`
- [ ] **Vérification** : peut créer un Data Asset dans l'éditeur, lire les traits en BP
### Étape 3 — AIController + Perception
- [ ] `PS_AI_Behavior_AIController` avec Blackboard setup
- [ ] `PS_AI_Behavior_PerceptionComponent` (sight + hearing)
- [ ] `BlackboardData` asset par défaut
- [ ] **Vérification** : un NPC spawné détecte les acteurs proches
### Étape 4 — BT Services + Decorators
- [ ] `BTService_UpdateThreat`
- [ ] `BTService_EvaluateReaction`
- [ ] `BTDecorator_CheckTrait`
- [ ] **Vérification** : le Blackboard se met à jour en jeu
### Étape 5 — BT Tasks (Navigation)
- [ ] `BTTask_Patrol`
- [ ] `BTTask_FleeFrom`
- [ ] `BTTask_FindCover`
- [ ] **Vérification** : NPC patrouille et fuit
### Étape 6 — Combat
- [ ] `CombatComponent`
- [ ] `BTTask_Attack`
- [ ] **Vérification** : NPC ennemi attaque le joueur
### Étape 7 — EQS
- [ ] `EQSContext_Threat`
- [ ] `EQSTest_CoverQuality`
- [ ] Assets EQS dans Content/
- [ ] **Vérification** : NPC trouve des couvertures intelligemment
### Étape 8 — Intégration ConvAgent (optionnelle)
- [ ] `TryBindConversationAgent()` soft binding
- [ ] Test avec un NPC qui a les deux plugins
---
## 8. Résumé des fichiers à créer
| # | Fichier | Rôle |
|---|---------|------|
| 1 | `PS_AI_Behavior.uplugin` | Plugin descriptor |
| 2 | `PS_AI_Behavior.Build.cs` | Module dependencies |
| 3 | `PS_AI_Behavior.h / .cpp` | Module class (register settings) |
| 4 | `PS_AI_Behavior_Definitions.h` | Enums, structs, log |
| 5 | `PS_AI_Behavior_Settings.h / .cpp` | Project settings |
| 6 | `PS_AI_Behavior_PersonalityProfile.h / .cpp` | Data Asset |
| 7 | `PS_AI_Behavior_PersonalityComponent.h / .cpp` | Personality runtime |
| 8 | `PS_AI_Behavior_AIController.h / .cpp` | AIController |
| 9 | `PS_AI_Behavior_PerceptionComponent.h / .cpp` | AI Perception |
| 10 | `PS_AI_Behavior_CombatComponent.h / .cpp` | Combat state |
| 11 | `BT/PS_AI_Behavior_BTTask_Patrol.h / .cpp` | Patrol task |
| 12 | `BT/PS_AI_Behavior_BTTask_FleeFrom.h / .cpp` | Flee task |
| 13 | `BT/PS_AI_Behavior_BTTask_FindCover.h / .cpp` | Cover task |
| 14 | `BT/PS_AI_Behavior_BTTask_Attack.h / .cpp` | Attack task |
| 15 | `BT/PS_AI_Behavior_BTService_UpdateThreat.h / .cpp` | Threat service |
| 16 | `BT/PS_AI_Behavior_BTService_EvaluateReaction.h / .cpp` | Reaction service |
| 17 | `BT/PS_AI_Behavior_BTDecorator_CheckTrait.h / .cpp` | Trait decorator |
| 18 | `EQS/PS_AI_Behavior_EQSContext_Threat.h / .cpp` | EQS context |
| 19 | `EQS/PS_AI_Behavior_EQSTest_CoverQuality.h / .cpp` | EQS test |
**Total : ~38 fichiers C++ (19 paires h/cpp) + 1 .uplugin + 1 .Build.cs**

View File

@ -0,0 +1,34 @@
{
"FileVersion": 3,
"Version": 1,
"VersionName": "1.0.0",
"FriendlyName": "PS AI Behavior",
"Description": "NPC behavior system using Behavior Trees, EQS, and personality-driven reactions for civilians and enemies.",
"Category": "AI",
"CreatedBy": "Asterion",
"CanContainContent": true,
"IsBetaVersion": true,
"Modules": [
{
"Name": "PS_AI_Behavior",
"Type": "Runtime",
"LoadingPhase": "PreDefault",
"PlatformAllowList": [
"Win64",
"Mac",
"Linux"
]
},
{
"Name": "PS_AI_BehaviorEditor",
"Type": "UncookedOnly",
"LoadingPhase": "Default",
"PlatformAllowList": [
"Win64",
"Mac",
"Linux"
]
}
],
"Plugins": []
}

View File

@ -0,0 +1,27 @@
// Copyright Asterion. All Rights Reserved.
using UnrealBuildTool;
public class PS_AI_Behavior : ModuleRules
{
public PS_AI_Behavior(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[]
{
"Core",
"CoreUObject",
"Engine",
"InputCore",
"AIModule",
"GameplayTasks",
"NavigationSystem",
});
PrivateDependencyModuleNames.AddRange(new string[]
{
"DeveloperSettings",
});
}
}

View File

@ -0,0 +1,43 @@
// Copyright Asterion. All Rights Reserved.
#include "BT/PS_AI_Behavior_BTDecorator_CheckTrait.h"
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_PersonalityComponent.h"
UPS_AI_Behavior_BTDecorator_CheckTrait::UPS_AI_Behavior_BTDecorator_CheckTrait()
{
NodeName = TEXT("Check Trait");
}
bool UPS_AI_Behavior_BTDecorator_CheckTrait::CalculateRawConditionValue(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC) return false;
UPS_AI_Behavior_PersonalityComponent* Personality = AIC->GetPersonalityComponent();
if (!Personality) return false;
const float TraitValue = Personality->GetTrait(TraitAxis);
switch (Comparison)
{
case EPS_AI_Behavior_ComparisonOp::GreaterThan: return TraitValue > Threshold;
case EPS_AI_Behavior_ComparisonOp::GreaterOrEqual: return TraitValue >= Threshold;
case EPS_AI_Behavior_ComparisonOp::LessThan: return TraitValue < Threshold;
case EPS_AI_Behavior_ComparisonOp::LessOrEqual: return TraitValue <= Threshold;
case EPS_AI_Behavior_ComparisonOp::Equal: return FMath::IsNearlyEqual(TraitValue, Threshold, 0.01f);
default: return false;
}
}
FString UPS_AI_Behavior_BTDecorator_CheckTrait::GetStaticDescription() const
{
const UEnum* AxisEnum = StaticEnum<EPS_AI_Behavior_TraitAxis>();
const UEnum* OpEnum = StaticEnum<EPS_AI_Behavior_ComparisonOp>();
return FString::Printf(TEXT("Trait: %s %s %.2f"),
*AxisEnum->GetDisplayNameTextByValue(static_cast<int64>(TraitAxis)).ToString(),
*OpEnum->GetDisplayNameTextByValue(static_cast<int64>(Comparison)).ToString(),
Threshold);
}

View File

@ -0,0 +1,40 @@
// Copyright Asterion. All Rights Reserved.
#include "BT/PS_AI_Behavior_BTService_EvaluateReaction.h"
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_PersonalityComponent.h"
#include "PS_AI_Behavior_Definitions.h"
#include "BehaviorTree/BlackboardComponent.h"
UPS_AI_Behavior_BTService_EvaluateReaction::UPS_AI_Behavior_BTService_EvaluateReaction()
{
NodeName = TEXT("Evaluate Reaction");
Interval = 0.5f;
RandomDeviation = 0.1f;
}
void UPS_AI_Behavior_BTService_EvaluateReaction::TickNode(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC) return;
UPS_AI_Behavior_PersonalityComponent* Personality = AIC->GetPersonalityComponent();
if (!Personality) return;
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (!BB) return;
// Evaluate and apply the reaction
const EPS_AI_Behavior_State NewState = Personality->ApplyReaction();
// Write to Blackboard
AIC->SetBehaviorState(NewState);
}
FString UPS_AI_Behavior_BTService_EvaluateReaction::GetStaticDescription() const
{
return TEXT("Evaluates NPC reaction from personality + threat.\nWrites: BehaviorState.");
}

View File

@ -0,0 +1,70 @@
// Copyright Asterion. All Rights Reserved.
#include "BT/PS_AI_Behavior_BTService_UpdateThreat.h"
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_PerceptionComponent.h"
#include "PS_AI_Behavior_PersonalityComponent.h"
#include "PS_AI_Behavior_Settings.h"
#include "PS_AI_Behavior_Definitions.h"
#include "BehaviorTree/BlackboardComponent.h"
UPS_AI_Behavior_BTService_UpdateThreat::UPS_AI_Behavior_BTService_UpdateThreat()
{
NodeName = TEXT("Update Threat");
Interval = 0.3f;
RandomDeviation = 0.05f;
}
void UPS_AI_Behavior_BTService_UpdateThreat::TickNode(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC) return;
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (!BB) return;
UPS_AI_Behavior_PerceptionComponent* Perception = AIC->GetBehaviorPerception();
if (!Perception) return;
// Calculate current threat
const float RawThreat = Perception->CalculateThreatLevel();
// Get current stored threat for decay
const float StoredThreat = BB->GetValueAsFloat(PS_AI_Behavior_BB::ThreatLevel);
// Apply decay when no threat, or take the max of new vs decayed
const UPS_AI_Behavior_Settings* Settings = GetDefault<UPS_AI_Behavior_Settings>();
const float DecayedThreat = FMath::Max(0.0f, StoredThreat - Settings->ThreatDecayRate * DeltaSeconds);
const float FinalThreat = FMath::Max(RawThreat, DecayedThreat);
BB->SetValueAsFloat(PS_AI_Behavior_BB::ThreatLevel, FinalThreat);
// Update threat actor and location
AActor* ThreatActor = Perception->GetHighestThreatActor();
if (ThreatActor)
{
BB->SetValueAsObject(PS_AI_Behavior_BB::ThreatActor, ThreatActor);
BB->SetValueAsVector(PS_AI_Behavior_BB::ThreatLocation, ThreatActor->GetActorLocation());
}
else if (FinalThreat <= 0.01f)
{
// Clear threat data when fully decayed
BB->ClearValue(PS_AI_Behavior_BB::ThreatActor);
BB->ClearValue(PS_AI_Behavior_BB::ThreatLocation);
}
// Sync to PersonalityComponent
UPS_AI_Behavior_PersonalityComponent* Personality = AIC->GetPersonalityComponent();
if (Personality)
{
Personality->PerceivedThreatLevel = FinalThreat;
}
}
FString UPS_AI_Behavior_BTService_UpdateThreat::GetStaticDescription() const
{
return TEXT("Updates Blackboard threat data from perception.\nWrites: ThreatActor, ThreatLocation, ThreatLevel.");
}

View File

@ -0,0 +1,147 @@
// Copyright Asterion. All Rights Reserved.
#include "BT/PS_AI_Behavior_BTTask_Attack.h"
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_CombatComponent.h"
#include "PS_AI_Behavior_Definitions.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "Navigation/PathFollowingComponent.h"
UPS_AI_Behavior_BTTask_Attack::UPS_AI_Behavior_BTTask_Attack()
{
NodeName = TEXT("Attack");
bNotifyTick = true;
bNotifyTaskFinished = true;
}
EBTNodeResult::Type UPS_AI_Behavior_BTTask_Attack::ExecuteTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC || !AIC->GetPawn()) return EBTNodeResult::Failed;
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (!BB) return EBTNodeResult::Failed;
// Get threat actor
AActor* Target = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor));
if (!Target)
{
return EBTNodeResult::Failed;
}
// Get combat component
UPS_AI_Behavior_CombatComponent* Combat =
AIC->GetPawn()->FindComponentByClass<UPS_AI_Behavior_CombatComponent>();
if (!Combat)
{
UE_LOG(LogPS_AI_Behavior, Warning,
TEXT("[%s] Attack task: no CombatComponent on Pawn."), *AIC->GetName());
return EBTNodeResult::Failed;
}
// Try to attack immediately if in range
if (Combat->IsInAttackRange(Target))
{
if (Combat->CanAttack())
{
Combat->ExecuteAttack(Target);
return EBTNodeResult::Succeeded;
}
// In range but on cooldown — wait
return EBTNodeResult::InProgress;
}
// Out of range — move toward target
const EPathFollowingRequestResult::Type Result = AIC->MoveToActor(
Target, Combat->AttackRange * 0.8f, /*bUsePathfinding=*/true,
/*bAllowStrafe=*/true);
if (Result == EPathFollowingRequestResult::Failed)
{
return EBTNodeResult::Failed;
}
FAttackMemory* Memory = reinterpret_cast<FAttackMemory*>(NodeMemory);
Memory->bMovingToTarget = true;
return EBTNodeResult::InProgress;
}
void UPS_AI_Behavior_BTTask_Attack::TickTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC || !AIC->GetPawn())
{
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return;
}
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
AActor* Target = BB ? Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor)) : nullptr;
if (!Target)
{
AIC->StopMovement();
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return;
}
UPS_AI_Behavior_CombatComponent* Combat =
AIC->GetPawn()->FindComponentByClass<UPS_AI_Behavior_CombatComponent>();
if (!Combat)
{
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return;
}
// Check if we can attack now
if (Combat->IsInAttackRange(Target))
{
FAttackMemory* Memory = reinterpret_cast<FAttackMemory*>(NodeMemory);
if (Memory->bMovingToTarget)
{
AIC->StopMovement();
Memory->bMovingToTarget = false;
}
if (Combat->CanAttack())
{
Combat->ExecuteAttack(Target);
FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
}
// Else: wait for cooldown (stay InProgress)
}
else
{
// Still moving — check if movement failed
FAttackMemory* Memory = reinterpret_cast<FAttackMemory*>(NodeMemory);
if (Memory->bMovingToTarget && AIC->GetMoveStatus() == EPathFollowingStatus::Idle)
{
// Movement ended but not in range — try again
const EPathFollowingRequestResult::Type Result = AIC->MoveToActor(
Target, Combat->AttackRange * 0.8f, /*bUsePathfinding=*/true,
/*bAllowStrafe=*/true);
if (Result == EPathFollowingRequestResult::Failed)
{
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
}
}
}
}
EBTNodeResult::Type UPS_AI_Behavior_BTTask_Attack::AbortTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (AIC)
{
AIC->StopMovement();
}
return EBTNodeResult::Aborted;
}
FString UPS_AI_Behavior_BTTask_Attack::GetStaticDescription() const
{
return TEXT("Move to threat and attack via CombatComponent.");
}

View File

@ -0,0 +1,152 @@
// Copyright Asterion. All Rights Reserved.
#include "BT/PS_AI_Behavior_BTTask_FindAndFollowSpline.h"
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_SplineFollowerComponent.h"
#include "PS_AI_Behavior_SplineNetwork.h"
#include "PS_AI_Behavior_SplinePath.h"
#include "PS_AI_Behavior_PersonalityComponent.h"
#include "PS_AI_Behavior_Definitions.h"
#include "Navigation/PathFollowingComponent.h"
UPS_AI_Behavior_BTTask_FindAndFollowSpline::UPS_AI_Behavior_BTTask_FindAndFollowSpline()
{
NodeName = TEXT("Find & Start Spline");
bNotifyTick = true;
bNotifyTaskFinished = true;
}
EBTNodeResult::Type UPS_AI_Behavior_BTTask_FindAndFollowSpline::ExecuteTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC || !AIC->GetPawn()) return EBTNodeResult::Failed;
UPS_AI_Behavior_SplineFollowerComponent* Follower =
AIC->GetPawn()->FindComponentByClass<UPS_AI_Behavior_SplineFollowerComponent>();
if (!Follower)
{
UE_LOG(LogPS_AI_Behavior, Warning,
TEXT("[%s] FindAndFollowSpline: no SplineFollowerComponent."), *AIC->GetName());
return EBTNodeResult::Failed;
}
// Determine NPC type
EPS_AI_Behavior_NPCType NPCType = EPS_AI_Behavior_NPCType::Civilian;
UPS_AI_Behavior_PersonalityComponent* Personality = AIC->GetPersonalityComponent();
if (Personality)
{
NPCType = Personality->GetNPCType();
}
// Find closest spline
UPS_AI_Behavior_SplineNetwork* Network =
GetWorld()->GetSubsystem<UPS_AI_Behavior_SplineNetwork>();
if (!Network)
{
UE_LOG(LogPS_AI_Behavior, Warning,
TEXT("[%s] FindAndFollowSpline: SplineNetwork subsystem not available."), *AIC->GetName());
return EBTNodeResult::Failed;
}
APS_AI_Behavior_SplinePath* ClosestSpline = nullptr;
float DistAlongSpline = 0.0f;
if (!Network->FindClosestSpline(
AIC->GetPawn()->GetActorLocation(), NPCType, MaxSearchDistance,
ClosestSpline, DistAlongSpline))
{
UE_LOG(LogPS_AI_Behavior, Verbose,
TEXT("[%s] FindAndFollowSpline: no accessible spline within %.0fcm."),
*AIC->GetName(), MaxSearchDistance);
return EBTNodeResult::Failed;
}
// Check if we need to walk to the spline first
const FVector SplinePoint = ClosestSpline->GetWorldLocationAtDistance(DistAlongSpline);
const float GapToSpline = FVector::Dist(AIC->GetPawn()->GetActorLocation(), SplinePoint);
if (bWalkToSpline && GapToSpline > AcceptanceRadius)
{
// Walk to spline first via NavMesh
const EPathFollowingRequestResult::Type Result = AIC->MoveToLocation(
SplinePoint, AcceptanceRadius, /*bStopOnOverlap=*/true,
/*bUsePathfinding=*/true, /*bProjectDestinationToNavigation=*/true,
/*bCanStrafe=*/false);
if (Result == EPathFollowingRequestResult::Failed)
{
// Can't reach via NavMesh — try starting anyway (snap)
Follower->StartFollowingAtDistance(ClosestSpline, DistAlongSpline);
return EBTNodeResult::Succeeded;
}
if (Result == EPathFollowingRequestResult::AlreadyAtGoal)
{
Follower->StartFollowingAtDistance(ClosestSpline, DistAlongSpline);
return EBTNodeResult::Succeeded;
}
// Store the spline to connect to after reaching it
Follower->CurrentSpline = ClosestSpline;
Follower->CurrentDistance = DistAlongSpline;
FFindSplineMemory* Memory = reinterpret_cast<FFindSplineMemory*>(NodeMemory);
Memory->bMovingToSpline = true;
return EBTNodeResult::InProgress;
}
// Close enough — start immediately
Follower->StartFollowingAtDistance(ClosestSpline, DistAlongSpline);
return EBTNodeResult::Succeeded;
}
void UPS_AI_Behavior_BTTask_FindAndFollowSpline::TickTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
FFindSplineMemory* Memory = reinterpret_cast<FFindSplineMemory*>(NodeMemory);
if (!Memory->bMovingToSpline) return;
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC || !AIC->GetPawn())
{
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return;
}
// Check if we've reached the spline
if (AIC->GetMoveStatus() == EPathFollowingStatus::Idle)
{
Memory->bMovingToSpline = false;
UPS_AI_Behavior_SplineFollowerComponent* Follower =
AIC->GetPawn()->FindComponentByClass<UPS_AI_Behavior_SplineFollowerComponent>();
if (Follower && Follower->CurrentSpline)
{
Follower->StartFollowingAtDistance(
Follower->CurrentSpline, Follower->CurrentDistance);
FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
}
else
{
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
}
}
}
EBTNodeResult::Type UPS_AI_Behavior_BTTask_FindAndFollowSpline::AbortTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
AAIController* AIC = OwnerComp.GetAIOwner();
if (AIC)
{
AIC->StopMovement();
}
return EBTNodeResult::Aborted;
}
FString UPS_AI_Behavior_BTTask_FindAndFollowSpline::GetStaticDescription() const
{
return FString::Printf(TEXT("Find nearest spline (max %.0fcm) and start following%s"),
MaxSearchDistance, bWalkToSpline ? TEXT(" (walk to)") : TEXT(""));
}

View File

@ -0,0 +1,271 @@
// Copyright Asterion. All Rights Reserved.
#include "BT/PS_AI_Behavior_BTTask_FindCover.h"
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_CoverPoint.h"
#include "PS_AI_Behavior_PersonalityComponent.h"
#include "PS_AI_Behavior_Definitions.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "NavigationSystem.h"
#include "Navigation/PathFollowingComponent.h"
#include "CollisionQueryParams.h"
#include "Engine/World.h"
#include "EngineUtils.h"
UPS_AI_Behavior_BTTask_FindCover::UPS_AI_Behavior_BTTask_FindCover()
{
NodeName = TEXT("Find Cover");
bNotifyTick = true;
bNotifyTaskFinished = true;
}
EBTNodeResult::Type UPS_AI_Behavior_BTTask_FindCover::ExecuteTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC || !AIC->GetPawn()) return EBTNodeResult::Failed;
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (!BB) return EBTNodeResult::Failed;
UWorld* World = GetWorld();
if (!World) return EBTNodeResult::Failed;
const FVector NpcLoc = AIC->GetPawn()->GetActorLocation();
const FVector ThreatLoc = BB->GetValueAsVector(PS_AI_Behavior_BB::ThreatLocation);
if (ThreatLoc.IsZero())
{
return EBTNodeResult::Failed;
}
// Determine NPC type for accessibility filtering
EPS_AI_Behavior_NPCType NPCType = EPS_AI_Behavior_NPCType::Any;
UPS_AI_Behavior_PersonalityComponent* Personality = AIC->GetPersonalityComponent();
if (Personality)
{
NPCType = Personality->GetNPCType();
}
// ─── Phase 1: Search manual CoverPoints ─────────────────────────────
float ManualScore = -1.0f;
APS_AI_Behavior_CoverPoint* BestManualPoint =
FindBestManualCoverPoint(World, NpcLoc, ThreatLoc, NPCType, ManualScore);
FVector BestCoverPos = FVector::ZeroVector;
float BestScore = -1.0f;
APS_AI_Behavior_CoverPoint* ChosenPoint = nullptr;
if (BestManualPoint)
{
BestCoverPos = BestManualPoint->GetActorLocation();
BestScore = ManualScore + ManualPointBonus; // Bonus for manual placement
ChosenPoint = BestManualPoint;
}
// ─── Phase 2: Procedural fallback (if allowed) ──────────────────────
if (!bUseManualPointsOnly)
{
UNavigationSystemV1* NavSys = FNavigationSystem::GetCurrent<UNavigationSystemV1>(World);
if (NavSys)
{
for (int32 i = 0; i < NumCandidates; ++i)
{
const float Angle = (360.0f / NumCandidates) * i;
const float Dist = FMath::RandRange(SearchRadius * 0.3f, SearchRadius);
const FVector Dir = FVector::ForwardVector.RotateAngleAxis(Angle, FVector::UpVector);
const FVector Candidate = NpcLoc + Dir * Dist;
FNavLocation NavLoc;
if (!NavSys->ProjectPointToNavigation(Candidate, NavLoc, FVector(300.0f, 300.0f, 200.0f)))
{
continue;
}
const float Score = EvaluateCoverQuality(World, NavLoc.Location, ThreatLoc, NpcLoc);
if (Score > BestScore)
{
BestScore = Score;
BestCoverPos = NavLoc.Location;
ChosenPoint = nullptr; // Procedural, no actor
}
}
}
}
if (BestScore < 0.1f)
{
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] FindCover: no suitable cover found."), *AIC->GetName());
return EBTNodeResult::Failed;
}
// ─── Claim and write to Blackboard ──────────────────────────────────
if (ChosenPoint)
{
ChosenPoint->Claim(AIC->GetPawn());
BB->SetValueAsObject(PS_AI_Behavior_BB::CoverPoint, ChosenPoint);
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] FindCover: using manual %s '%s' (score %.2f)"),
*AIC->GetName(),
ChosenPoint->PointType == EPS_AI_Behavior_CoverPointType::Cover ? TEXT("Cover") : TEXT("HidingSpot"),
*ChosenPoint->GetName(), BestScore);
}
else
{
BB->ClearValue(PS_AI_Behavior_BB::CoverPoint);
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] FindCover: using procedural cover (score %.2f)"),
*AIC->GetName(), BestScore);
}
BB->SetValueAsVector(PS_AI_Behavior_BB::CoverLocation, BestCoverPos);
// Navigate to cover
const EPathFollowingRequestResult::Type Result = AIC->MoveToLocation(
BestCoverPos, AcceptanceRadius, /*bStopOnOverlap=*/true,
/*bUsePathfinding=*/true, /*bProjectDestinationToNavigation=*/true,
/*bCanStrafe=*/false);
if (Result == EPathFollowingRequestResult::Failed)
{
return EBTNodeResult::Failed;
}
if (Result == EPathFollowingRequestResult::AlreadyAtGoal)
{
return EBTNodeResult::Succeeded;
}
FCoverMemory* Memory = reinterpret_cast<FCoverMemory*>(NodeMemory);
Memory->bMoveRequested = true;
return EBTNodeResult::InProgress;
}
void UPS_AI_Behavior_BTTask_FindCover::TickTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
FCoverMemory* Memory = reinterpret_cast<FCoverMemory*>(NodeMemory);
if (!Memory->bMoveRequested) return;
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC) { FinishLatentTask(OwnerComp, EBTNodeResult::Failed); return; }
if (AIC->GetMoveStatus() == EPathFollowingStatus::Idle)
{
Memory->bMoveRequested = false;
FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
}
}
EBTNodeResult::Type UPS_AI_Behavior_BTTask_FindCover::AbortTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (AIC)
{
AIC->StopMovement();
// Release any claimed cover point
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (BB)
{
APS_AI_Behavior_CoverPoint* Point =
Cast<APS_AI_Behavior_CoverPoint>(BB->GetValueAsObject(PS_AI_Behavior_BB::CoverPoint));
if (Point)
{
Point->Release(AIC->GetPawn());
BB->ClearValue(PS_AI_Behavior_BB::CoverPoint);
}
}
}
return EBTNodeResult::Aborted;
}
// ─── Manual CoverPoint Search ───────────────────────────────────────────────
APS_AI_Behavior_CoverPoint* UPS_AI_Behavior_BTTask_FindCover::FindBestManualCoverPoint(
const UWorld* World, const FVector& NpcLoc, const FVector& ThreatLoc,
EPS_AI_Behavior_NPCType NPCType, float& OutScore) const
{
APS_AI_Behavior_CoverPoint* BestPoint = nullptr;
OutScore = -1.0f;
for (TActorIterator<APS_AI_Behavior_CoverPoint> It(const_cast<UWorld*>(World)); It; ++It)
{
APS_AI_Behavior_CoverPoint* Point = *It;
if (!Point || !Point->bEnabled) continue;
// Type filter
if (Point->PointType != CoverPointType) continue;
// NPC type accessibility
if (!Point->IsAccessibleTo(NPCType)) continue;
// Availability
if (!Point->HasRoom()) continue;
// Distance check
const float Dist = FVector::Dist(NpcLoc, Point->GetActorLocation());
if (Dist > SearchRadius) continue;
// Evaluate quality against current threat
float Score = Point->EvaluateAgainstThreat(ThreatLoc);
// Distance bonus — closer to NPC is better
Score += FMath::GetMappedRangeValueClamped(
FVector2D(0.0f, SearchRadius), FVector2D(0.15f, 0.0f), Dist);
if (Score > OutScore)
{
OutScore = Score;
BestPoint = Point;
}
}
return BestPoint;
}
// ─── Procedural Cover Quality ───────────────────────────────────────────────
float UPS_AI_Behavior_BTTask_FindCover::EvaluateCoverQuality(
const UWorld* World, const FVector& CandidatePos,
const FVector& ThreatLoc, const FVector& NpcLoc) const
{
float Score = 0.0f;
const FVector TraceStart = CandidatePos + FVector(0, 0, MinCoverHeight);
const FVector TraceEnd = ThreatLoc + FVector(0, 0, 100.0f);
FHitResult Hit;
FCollisionQueryParams Params(SCENE_QUERY_STAT(CoverCheck), true);
if (World->LineTraceSingleByChannel(Hit, TraceStart, TraceEnd, ECC_Visibility, Params))
{
Score += 0.5f;
}
const FVector TraceStartLow = CandidatePos + FVector(0, 0, MinCoverHeight * 0.5f);
if (World->LineTraceSingleByChannel(Hit, TraceStartLow, TraceEnd, ECC_Visibility, Params))
{
Score += 0.15f;
}
const float DistFromThreat = FVector::Dist(CandidatePos, ThreatLoc);
const float DistFromNpc = FVector::Dist(CandidatePos, NpcLoc);
Score += FMath::GetMappedRangeValueClamped(
FVector2D(300.0f, 1500.0f), FVector2D(0.0f, 0.2f), DistFromThreat);
Score += FMath::GetMappedRangeValueClamped(
FVector2D(0.0f, SearchRadius), FVector2D(0.15f, 0.0f), DistFromNpc);
return Score;
}
FString UPS_AI_Behavior_BTTask_FindCover::GetStaticDescription() const
{
return FString::Printf(TEXT("Find cover within %.0fcm\nManual %s + Procedural (%d candidates)\nBonus: +%.0f%%"),
SearchRadius,
CoverPointType == EPS_AI_Behavior_CoverPointType::Cover ? TEXT("Cover") : TEXT("Hiding"),
NumCandidates,
ManualPointBonus * 100.0f);
}

View File

@ -0,0 +1,135 @@
// Copyright Asterion. All Rights Reserved.
#include "BT/PS_AI_Behavior_BTTask_FleeFrom.h"
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_Definitions.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "NavigationSystem.h"
#include "Navigation/PathFollowingComponent.h"
UPS_AI_Behavior_BTTask_FleeFrom::UPS_AI_Behavior_BTTask_FleeFrom()
{
NodeName = TEXT("Flee From Threat");
bNotifyTick = true;
bNotifyTaskFinished = true;
}
EBTNodeResult::Type UPS_AI_Behavior_BTTask_FleeFrom::ExecuteTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC || !AIC->GetPawn()) return EBTNodeResult::Failed;
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (!BB) return EBTNodeResult::Failed;
const FVector ThreatLoc = BB->GetValueAsVector(PS_AI_Behavior_BB::ThreatLocation);
const FVector Origin = AIC->GetPawn()->GetActorLocation();
// If no valid threat location, fail
if (ThreatLoc.IsZero())
{
return EBTNodeResult::Failed;
}
FVector FleePoint;
if (!FindFleePoint(Origin, ThreatLoc, FleePoint))
{
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Flee: could not find valid flee point."), *AIC->GetName());
return EBTNodeResult::Failed;
}
const EPathFollowingRequestResult::Type Result = AIC->MoveToLocation(
FleePoint, AcceptanceRadius, /*bStopOnOverlap=*/true,
/*bUsePathfinding=*/true, /*bProjectDestinationToNavigation=*/true,
/*bCanStrafe=*/false);
if (Result == EPathFollowingRequestResult::Failed)
{
return EBTNodeResult::Failed;
}
if (Result == EPathFollowingRequestResult::AlreadyAtGoal)
{
return EBTNodeResult::Succeeded;
}
FFleeMemory* Memory = reinterpret_cast<FFleeMemory*>(NodeMemory);
Memory->bMoveRequested = true;
return EBTNodeResult::InProgress;
}
void UPS_AI_Behavior_BTTask_FleeFrom::TickTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
FFleeMemory* Memory = reinterpret_cast<FFleeMemory*>(NodeMemory);
if (!Memory->bMoveRequested) return;
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC) { FinishLatentTask(OwnerComp, EBTNodeResult::Failed); return; }
if (AIC->GetMoveStatus() == EPathFollowingStatus::Idle)
{
Memory->bMoveRequested = false;
FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
}
}
EBTNodeResult::Type UPS_AI_Behavior_BTTask_FleeFrom::AbortTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (AIC)
{
AIC->StopMovement();
}
return EBTNodeResult::Aborted;
}
bool UPS_AI_Behavior_BTTask_FleeFrom::FindFleePoint(
const FVector& Origin, const FVector& ThreatLoc, FVector& OutFleePoint) const
{
UNavigationSystemV1* NavSys = FNavigationSystem::GetCurrent<UNavigationSystemV1>(GetWorld());
if (!NavSys) return false;
// Direction away from threat
FVector FleeDir = (Origin - ThreatLoc).GetSafeNormal2D();
if (FleeDir.IsNearlyZero())
{
FleeDir = FVector::ForwardVector; // Fallback
}
// Try multiple angles to find a valid navmesh point
const int32 NumAttempts = 8;
const float AngleStep = 45.0f;
float BestDistFromThreat = 0.0f;
for (int32 i = 0; i < NumAttempts; ++i)
{
// Spread from directly away, rotating by increments
const float Angle = (i % 2 == 0 ? 1.0f : -1.0f) * (i / 2) * AngleStep;
const FVector RotatedDir = FleeDir.RotateAngleAxis(Angle, FVector::UpVector);
const float FleeDist = FMath::RandRange(MinFleeDistance, MaxFleeDistance);
const FVector CandidatePoint = Origin + RotatedDir * FleeDist;
// Project onto NavMesh
FNavLocation NavLoc;
if (NavSys->ProjectPointToNavigation(CandidatePoint, NavLoc, FVector(500.0f, 500.0f, 250.0f)))
{
const float DistFromThreat = FVector::Dist(NavLoc.Location, ThreatLoc);
if (DistFromThreat > BestDistFromThreat)
{
BestDistFromThreat = DistFromThreat;
OutFleePoint = NavLoc.Location;
}
}
}
return BestDistFromThreat > 0.0f;
}
FString UPS_AI_Behavior_BTTask_FleeFrom::GetStaticDescription() const
{
return FString::Printf(TEXT("Flee %.0f-%.0fcm from threat"),
MinFleeDistance, MaxFleeDistance);
}

View File

@ -0,0 +1,127 @@
// Copyright Asterion. All Rights Reserved.
#include "BT/PS_AI_Behavior_BTTask_FollowSpline.h"
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_SplineFollowerComponent.h"
#include "PS_AI_Behavior_SplinePath.h"
#include "PS_AI_Behavior_Definitions.h"
UPS_AI_Behavior_BTTask_FollowSpline::UPS_AI_Behavior_BTTask_FollowSpline()
{
NodeName = TEXT("Follow Spline");
bNotifyTick = true;
bNotifyTaskFinished = true;
}
EBTNodeResult::Type UPS_AI_Behavior_BTTask_FollowSpline::ExecuteTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
AAIController* AIC = OwnerComp.GetAIOwner();
if (!AIC || !AIC->GetPawn()) return EBTNodeResult::Failed;
UPS_AI_Behavior_SplineFollowerComponent* Follower =
AIC->GetPawn()->FindComponentByClass<UPS_AI_Behavior_SplineFollowerComponent>();
if (!Follower) return EBTNodeResult::Failed;
if (!Follower->CurrentSpline)
{
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] FollowSpline: no current spline set."),
*AIC->GetName());
return EBTNodeResult::Failed;
}
// Optional random direction
if (bRandomDirection)
{
Follower->bMovingForward = FMath::RandBool();
}
// Start or resume following
if (!Follower->bIsFollowing)
{
Follower->ResumeFollowing();
}
// Initialize memory — TickTask will poll bIsFollowing to detect end-of-spline
FFollowMemory* Memory = reinterpret_cast<FFollowMemory*>(NodeMemory);
Memory->Elapsed = 0.0f;
Memory->bEndReached = false;
return EBTNodeResult::InProgress;
}
void UPS_AI_Behavior_BTTask_FollowSpline::TickTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
FFollowMemory* Memory = reinterpret_cast<FFollowMemory*>(NodeMemory);
// Check if spline end was reached (poll bIsFollowing — set to false by SplineFollowerComponent)
AAIController* AICCheck = OwnerComp.GetAIOwner();
if (AICCheck && AICCheck->GetPawn())
{
UPS_AI_Behavior_SplineFollowerComponent* FollowerCheck =
AICCheck->GetPawn()->FindComponentByClass<UPS_AI_Behavior_SplineFollowerComponent>();
if (FollowerCheck && !FollowerCheck->bIsFollowing)
{
FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
return;
}
}
// Time limit check
if (MaxFollowTime > 0.0f)
{
Memory->Elapsed += DeltaSeconds;
if (Memory->Elapsed >= MaxFollowTime)
{
// Pause following (don't stop — can resume later)
AAIController* AIC = OwnerComp.GetAIOwner();
if (AIC && AIC->GetPawn())
{
UPS_AI_Behavior_SplineFollowerComponent* Follower =
AIC->GetPawn()->FindComponentByClass<UPS_AI_Behavior_SplineFollowerComponent>();
if (Follower)
{
Follower->PauseFollowing();
}
}
FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
return;
}
}
// Verify pawn is still valid
AAIController* AIC = OwnerComp.GetAIOwner();
if (!AIC || !AIC->GetPawn())
{
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return;
}
}
EBTNodeResult::Type UPS_AI_Behavior_BTTask_FollowSpline::AbortTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
AAIController* AIC = OwnerComp.GetAIOwner();
if (AIC && AIC->GetPawn())
{
UPS_AI_Behavior_SplineFollowerComponent* Follower =
AIC->GetPawn()->FindComponentByClass<UPS_AI_Behavior_SplineFollowerComponent>();
if (Follower)
{
Follower->PauseFollowing();
}
}
return EBTNodeResult::Aborted;
}
FString UPS_AI_Behavior_BTTask_FollowSpline::GetStaticDescription() const
{
if (MaxFollowTime > 0.0f)
{
return FString::Printf(TEXT("Follow current spline (max %.1fs%s)"),
MaxFollowTime, bRandomDirection ? TEXT(", random dir") : TEXT(""));
}
return FString::Printf(TEXT("Follow current spline%s"),
bRandomDirection ? TEXT(" (random dir)") : TEXT(""));
}

View File

@ -0,0 +1,130 @@
// Copyright Asterion. All Rights Reserved.
#include "BT/PS_AI_Behavior_BTTask_Patrol.h"
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_Definitions.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "Navigation/PathFollowingComponent.h"
UPS_AI_Behavior_BTTask_Patrol::UPS_AI_Behavior_BTTask_Patrol()
{
NodeName = TEXT("Patrol");
bNotifyTick = true;
bNotifyTaskFinished = true;
}
EBTNodeResult::Type UPS_AI_Behavior_BTTask_Patrol::ExecuteTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC) return EBTNodeResult::Failed;
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (!BB) return EBTNodeResult::Failed;
// Check we have patrol points
if (AIC->PatrolPoints.Num() == 0)
{
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Patrol task: no patrol points defined."), *AIC->GetName());
return EBTNodeResult::Failed;
}
FPatrolMemory* Memory = reinterpret_cast<FPatrolMemory*>(NodeMemory);
Memory->bIsWaiting = false;
Memory->bMoveRequested = false;
Memory->WaitRemaining = 0.0f;
// Get current patrol index
const int32 PatrolIdx = BB->GetValueAsInt(PS_AI_Behavior_BB::PatrolIndex);
const int32 SafeIdx = PatrolIdx % AIC->PatrolPoints.Num();
const FVector Destination = AIC->PatrolPoints[SafeIdx];
// Issue move request
const EPathFollowingRequestResult::Type Result = AIC->MoveToLocation(
Destination, AcceptanceRadius, /*bStopOnOverlap=*/true,
/*bUsePathfinding=*/true, /*bProjectDestinationToNavigation=*/true,
/*bCanStrafe=*/false);
if (Result == EPathFollowingRequestResult::Failed)
{
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Patrol: MoveTo failed for point %d."),
*AIC->GetName(), SafeIdx);
return EBTNodeResult::Failed;
}
if (Result == EPathFollowingRequestResult::AlreadyAtGoal)
{
// Already there — start wait
Memory->bIsWaiting = true;
Memory->WaitRemaining = FMath::RandRange(MinWaitTime, MaxWaitTime);
// Advance patrol index
BB->SetValueAsInt(PS_AI_Behavior_BB::PatrolIndex, (SafeIdx + 1) % AIC->PatrolPoints.Num());
return EBTNodeResult::InProgress;
}
Memory->bMoveRequested = true;
return EBTNodeResult::InProgress;
}
void UPS_AI_Behavior_BTTask_Patrol::TickTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
FPatrolMemory* Memory = reinterpret_cast<FPatrolMemory*>(NodeMemory);
if (Memory->bIsWaiting)
{
Memory->WaitRemaining -= DeltaSeconds;
if (Memory->WaitRemaining <= 0.0f)
{
FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
}
return;
}
if (Memory->bMoveRequested)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC) { FinishLatentTask(OwnerComp, EBTNodeResult::Failed); return; }
const EPathFollowingStatus::Type MoveStatus = AIC->GetMoveStatus();
if (MoveStatus == EPathFollowingStatus::Idle)
{
// Move completed — start wait at waypoint
Memory->bMoveRequested = false;
Memory->bIsWaiting = true;
Memory->WaitRemaining = FMath::RandRange(MinWaitTime, MaxWaitTime);
// Advance patrol index
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (BB)
{
const int32 PatrolIdx = BB->GetValueAsInt(PS_AI_Behavior_BB::PatrolIndex);
const int32 NumPoints = AIC->PatrolPoints.Num();
if (NumPoints > 0)
{
BB->SetValueAsInt(PS_AI_Behavior_BB::PatrolIndex, (PatrolIdx + 1) % NumPoints);
}
}
}
}
}
EBTNodeResult::Type UPS_AI_Behavior_BTTask_Patrol::AbortTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (AIC)
{
AIC->StopMovement();
}
return EBTNodeResult::Aborted;
}
FString UPS_AI_Behavior_BTTask_Patrol::GetStaticDescription() const
{
return FString::Printf(TEXT("Patrol (wait %.1f-%.1fs, radius %.0fcm)"),
MinWaitTime, MaxWaitTime, AcceptanceRadius);
}

View File

@ -0,0 +1,41 @@
// Copyright Asterion. All Rights Reserved.
#include "EQS/PS_AI_Behavior_EQSContext_Threat.h"
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_Definitions.h"
#include "EnvironmentQuery/EnvQueryTypes.h"
#include "EnvironmentQuery/Items/EnvQueryItemType_Actor.h"
#include "EnvironmentQuery/Items/EnvQueryItemType_Point.h"
#include "BehaviorTree/BlackboardComponent.h"
void UPS_AI_Behavior_EQSContext_Threat::ProvideContext(
FEnvQueryInstance& QueryInstance, FEnvQueryContextData& ContextData) const
{
// Get the querier's AIController
const AActor* QuerierActor = Cast<AActor>(QueryInstance.Owner.Get());
if (!QuerierActor) return;
const APawn* QuerierPawn = Cast<APawn>(QuerierActor);
if (!QuerierPawn) return;
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(QuerierPawn->GetController());
if (!AIC) return;
UBlackboardComponent* BB = AIC->GetBlackboardComponent();
if (!BB) return;
// Try to provide the threat actor first
AActor* ThreatActor = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor));
if (ThreatActor)
{
UEnvQueryItemType_Actor::SetContextHelper(ContextData, ThreatActor);
return;
}
// Fall back to threat location
const FVector ThreatLoc = BB->GetValueAsVector(PS_AI_Behavior_BB::ThreatLocation);
if (!ThreatLoc.IsZero())
{
UEnvQueryItemType_Point::SetContextHelper(ContextData, ThreatLoc);
}
}

View File

@ -0,0 +1,86 @@
// Copyright Asterion. All Rights Reserved.
#include "EQS/PS_AI_Behavior_EQSGenerator_CoverPoints.h"
#include "PS_AI_Behavior_CoverPoint.h"
#include "PS_AI_Behavior_PersonalityComponent.h"
#include "PS_AI_Behavior_Interface.h"
#include "EnvironmentQuery/EnvQueryTypes.h"
#include "EnvironmentQuery/Items/EnvQueryItemType_Actor.h"
#include "EngineUtils.h"
#include "GameFramework/Pawn.h"
UPS_AI_Behavior_EQSGenerator_CoverPoints::UPS_AI_Behavior_EQSGenerator_CoverPoints()
{
ItemType = UEnvQueryItemType_Actor::StaticClass();
}
void UPS_AI_Behavior_EQSGenerator_CoverPoints::GenerateItems(FEnvQueryInstance& QueryInstance) const
{
const UObject* QueryOwner = QueryInstance.Owner.Get();
if (!QueryOwner) return;
const AActor* QuerierActor = Cast<AActor>(QueryOwner);
if (!QuerierActor) return;
const UWorld* World = QuerierActor->GetWorld();
if (!World) return;
const FVector QuerierLoc = QuerierActor->GetActorLocation();
// Determine NPC type for accessibility check
EPS_AI_Behavior_NPCType NPCType = EPS_AI_Behavior_NPCType::Any;
const APawn* QuerierPawn = Cast<APawn>(QuerierActor);
if (QuerierPawn)
{
if (QuerierPawn->Implements<UPS_AI_Behavior_Interface>())
{
NPCType = IPS_AI_Behavior_Interface::Execute_GetBehaviorNPCType(const_cast<APawn*>(QuerierPawn));
}
else if (const auto* PC = QuerierPawn->FindComponentByClass<UPS_AI_Behavior_PersonalityComponent>())
{
NPCType = PC->GetNPCType();
}
}
// Collect matching cover points
TArray<AActor*> FoundPoints;
for (TActorIterator<APS_AI_Behavior_CoverPoint> It(const_cast<UWorld*>(World)); It; ++It)
{
APS_AI_Behavior_CoverPoint* Point = *It;
if (!Point || !Point->bEnabled) continue;
// Type filter
if (Point->PointType != PointTypeFilter) continue;
// NPC type accessibility
if (!Point->IsAccessibleTo(NPCType)) continue;
// Availability
if (bOnlyAvailable && !Point->HasRoom()) continue;
// Distance
if (FVector::Dist(QuerierLoc, Point->GetActorLocation()) > MaxDistance) continue;
FoundPoints.Add(Point);
}
// Add items to the query
for (AActor* Point : FoundPoints)
{
QueryInstance.AddItemData<UEnvQueryItemType_Actor>(Point);
}
}
FText UPS_AI_Behavior_EQSGenerator_CoverPoints::GetDescriptionTitle() const
{
return FText::FromString(FString::Printf(TEXT("Cover Points (%s)"),
PointTypeFilter == EPS_AI_Behavior_CoverPointType::Cover ? TEXT("Cover") : TEXT("Hiding")));
}
FText UPS_AI_Behavior_EQSGenerator_CoverPoints::GetDescriptionDetails() const
{
return FText::FromString(FString::Printf(
TEXT("Max dist: %.0f, Available only: %s"),
MaxDistance, bOnlyAvailable ? TEXT("Yes") : TEXT("No")));
}

View File

@ -0,0 +1,89 @@
// Copyright Asterion. All Rights Reserved.
#include "EQS/PS_AI_Behavior_EQSTest_CoverQuality.h"
#include "EQS/PS_AI_Behavior_EQSContext_Threat.h"
#include "EnvironmentQuery/EnvQueryTypes.h"
#include "EnvironmentQuery/Items/EnvQueryItemType_VectorBase.h"
#include "CollisionQueryParams.h"
#include "Engine/World.h"
UPS_AI_Behavior_EQSTest_CoverQuality::UPS_AI_Behavior_EQSTest_CoverQuality()
{
Cost = EEnvTestCost::High; // Uses raycasts
ValidItemType = UEnvQueryItemType_VectorBase::StaticClass();
SetWorkOnFloatValues(true);
}
void UPS_AI_Behavior_EQSTest_CoverQuality::RunTest(FEnvQueryInstance& QueryInstance) const
{
UObject* QueryOwner = QueryInstance.Owner.Get();
if (!QueryOwner) return;
// Get threat locations from context
TArray<FVector> ThreatLocations;
if (!QueryInstance.PrepareContext(UPS_AI_Behavior_EQSContext_Threat::StaticClass(), ThreatLocations))
{
return;
}
if (ThreatLocations.Num() == 0)
{
return;
}
const FVector ThreatLoc = ThreatLocations[0];
const UWorld* World = GEngine->GetWorldFromContextObject(QueryOwner, EGetWorldErrorMode::LogAndReturnNull);
if (!World) return;
FCollisionQueryParams TraceParams(SCENE_QUERY_STAT(CoverQualityEQS), true);
// Compute height steps
TArray<float> TraceHeights;
if (NumTraceHeights == 1)
{
TraceHeights.Add((MinTraceHeight + MaxTraceHeight) * 0.5f);
}
else
{
for (int32 i = 0; i < NumTraceHeights; ++i)
{
const float Alpha = static_cast<float>(i) / (NumTraceHeights - 1);
TraceHeights.Add(FMath::Lerp(MinTraceHeight, MaxTraceHeight, Alpha));
}
}
for (FEnvQueryInstance::ItemIterator It(this, QueryInstance); It; ++It)
{
const FVector CandidatePos = GetItemLocation(QueryInstance, It.GetIndex());
float BlockedCount = 0.0f;
for (float Height : TraceHeights)
{
const FVector TraceStart = CandidatePos + FVector(0, 0, Height);
const FVector TraceEnd = ThreatLoc + FVector(0, 0, 150.0f); // Approx eye height
FHitResult Hit;
if (World->LineTraceSingleByChannel(Hit, TraceStart, TraceEnd,
ECC_Visibility, TraceParams))
{
BlockedCount += 1.0f;
}
}
// Score: ratio of blocked traces (0.0 = fully exposed, 1.0 = fully covered)
const float Score = BlockedCount / static_cast<float>(TraceHeights.Num());
It.SetScore(TestPurpose, FilterType, Score, 0.0f, 1.0f);
}
}
FText UPS_AI_Behavior_EQSTest_CoverQuality::GetDescriptionTitle() const
{
return FText::FromString(TEXT("Cover Quality (vs Threat)"));
}
FText UPS_AI_Behavior_EQSTest_CoverQuality::GetDescriptionDetails() const
{
return FText::FromString(FString::Printf(
TEXT("%d traces from %.0f to %.0fcm height"),
NumTraceHeights, MinTraceHeight, MaxTraceHeight));
}

View File

@ -0,0 +1,20 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior.h"
#include "PS_AI_Behavior_Definitions.h"
#define LOCTEXT_NAMESPACE "FPS_AI_BehaviorModule"
IMPLEMENT_MODULE(FPS_AI_BehaviorModule, PS_AI_Behavior)
void FPS_AI_BehaviorModule::StartupModule()
{
UE_LOG(LogPS_AI_Behavior, Log, TEXT("PS_AI_Behavior module started."));
}
void FPS_AI_BehaviorModule::ShutdownModule()
{
UE_LOG(LogPS_AI_Behavior, Log, TEXT("PS_AI_Behavior module shut down."));
}
#undef LOCTEXT_NAMESPACE

View File

@ -0,0 +1,357 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_Interface.h"
#include "PS_AI_Behavior_PerceptionComponent.h"
#include "PS_AI_Behavior_PersonalityComponent.h"
#include "PS_AI_Behavior_PersonalityProfile.h"
#include "BehaviorTree/BehaviorTree.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "BehaviorTree/BlackboardData.h"
#include "BehaviorTree/Blackboard/BlackboardKeyType_Enum.h"
#include "BehaviorTree/Blackboard/BlackboardKeyType_Float.h"
#include "BehaviorTree/Blackboard/BlackboardKeyType_Int.h"
#include "BehaviorTree/Blackboard/BlackboardKeyType_Object.h"
#include "BehaviorTree/Blackboard/BlackboardKeyType_Vector.h"
APS_AI_Behavior_AIController::APS_AI_Behavior_AIController()
{
// Create our perception component
BehaviorPerception = CreateDefaultSubobject<UPS_AI_Behavior_PerceptionComponent>(TEXT("BehaviorPerception"));
SetPerceptionComponent(*BehaviorPerception);
}
void APS_AI_Behavior_AIController::OnPossess(APawn* InPawn)
{
Super::OnPossess(InPawn);
if (!InPawn)
{
return;
}
// Find PersonalityComponent on the pawn
PersonalityComp = InPawn->FindComponentByClass<UPS_AI_Behavior_PersonalityComponent>();
if (!PersonalityComp)
{
UE_LOG(LogPS_AI_Behavior, Warning,
TEXT("[%s] No PersonalityComponent found on Pawn '%s' — using defaults."),
*GetName(), *InPawn->GetName());
}
// Auto-assign Team ID from IPS_AI_Behavior interface (preferred) or PersonalityComponent
if (TeamId == FGenericTeamId::NoTeam)
{
EPS_AI_Behavior_NPCType NPCType = EPS_AI_Behavior_NPCType::Any;
if (InPawn->Implements<UPS_AI_Behavior_Interface>())
{
// Use the interface — the host project controls the storage
NPCType = IPS_AI_Behavior_Interface::Execute_GetBehaviorNPCType(InPawn);
// Also check if the interface provides a specific TeamId
const uint8 InterfaceTeamId = IPS_AI_Behavior_Interface::Execute_GetBehaviorTeamId(InPawn);
if (InterfaceTeamId != FGenericTeamId::NoTeam)
{
TeamId = InterfaceTeamId;
}
}
else if (PersonalityComp)
{
// Fallback: get from PersonalityProfile
NPCType = PersonalityComp->GetNPCType();
}
// If interface didn't set a specific TeamId, derive from NPCType
if (TeamId == FGenericTeamId::NoTeam)
{
switch (NPCType)
{
case EPS_AI_Behavior_NPCType::Civilian:
TeamId = 1;
break;
case EPS_AI_Behavior_NPCType::Enemy:
// Check if infiltrated (hostile=false → disguised as civilian)
if (InPawn->Implements<UPS_AI_Behavior_Interface>() &&
!IPS_AI_Behavior_Interface::Execute_IsBehaviorHostile(InPawn))
{
TeamId = 1; // Disguised as Civilian
}
else
{
TeamId = 2;
}
break;
case EPS_AI_Behavior_NPCType::Protector:
TeamId = 3;
break;
default:
TeamId = FGenericTeamId::NoTeam; // 255 → Neutral to everyone
break;
}
}
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Auto-assigned TeamId=%d from NPCType=%s"),
*GetName(), TeamId, *UEnum::GetValueAsString(NPCType));
}
SetupBlackboard();
StartBehavior();
TryBindConversationAgent();
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Possessed Pawn '%s' — BT started, TeamId=%d."),
*GetName(), *InPawn->GetName(), TeamId);
}
void APS_AI_Behavior_AIController::OnUnPossess()
{
// Stop the behavior tree
UBrainComponent* Brain = GetBrainComponent();
if (Brain)
{
Brain->StopLogic(TEXT("Unpossessed"));
}
PersonalityComp = nullptr;
Super::OnUnPossess();
}
void APS_AI_Behavior_AIController::SetupBlackboard()
{
// Create a runtime Blackboard Data if none is assigned
if (!BlackboardAsset)
{
BlackboardAsset = NewObject<UBlackboardData>(this, TEXT("RuntimeBlackboardData"));
// State (stored as uint8 enum)
FBlackboardEntry StateEntry;
StateEntry.EntryName = PS_AI_Behavior_BB::State;
StateEntry.KeyType = NewObject<UBlackboardKeyType_Enum>(BlackboardAsset);
BlackboardAsset->Keys.Add(StateEntry);
// ThreatActor
FBlackboardEntry ThreatActorEntry;
ThreatActorEntry.EntryName = PS_AI_Behavior_BB::ThreatActor;
ThreatActorEntry.KeyType = NewObject<UBlackboardKeyType_Object>(BlackboardAsset);
Cast<UBlackboardKeyType_Object>(ThreatActorEntry.KeyType)->BaseClass = AActor::StaticClass();
BlackboardAsset->Keys.Add(ThreatActorEntry);
// ThreatLocation
FBlackboardEntry ThreatLocEntry;
ThreatLocEntry.EntryName = PS_AI_Behavior_BB::ThreatLocation;
ThreatLocEntry.KeyType = NewObject<UBlackboardKeyType_Vector>(BlackboardAsset);
BlackboardAsset->Keys.Add(ThreatLocEntry);
// ThreatLevel
FBlackboardEntry ThreatLevelEntry;
ThreatLevelEntry.EntryName = PS_AI_Behavior_BB::ThreatLevel;
ThreatLevelEntry.KeyType = NewObject<UBlackboardKeyType_Float>(BlackboardAsset);
BlackboardAsset->Keys.Add(ThreatLevelEntry);
// CoverLocation
FBlackboardEntry CoverEntry;
CoverEntry.EntryName = PS_AI_Behavior_BB::CoverLocation;
CoverEntry.KeyType = NewObject<UBlackboardKeyType_Vector>(BlackboardAsset);
BlackboardAsset->Keys.Add(CoverEntry);
// CoverPoint (Object — APS_AI_Behavior_CoverPoint)
FBlackboardEntry CoverPointEntry;
CoverPointEntry.EntryName = PS_AI_Behavior_BB::CoverPoint;
CoverPointEntry.KeyType = NewObject<UBlackboardKeyType_Object>(BlackboardAsset);
Cast<UBlackboardKeyType_Object>(CoverPointEntry.KeyType)->BaseClass = AActor::StaticClass();
BlackboardAsset->Keys.Add(CoverPointEntry);
// PatrolIndex
FBlackboardEntry PatrolEntry;
PatrolEntry.EntryName = PS_AI_Behavior_BB::PatrolIndex;
PatrolEntry.KeyType = NewObject<UBlackboardKeyType_Int>(BlackboardAsset);
BlackboardAsset->Keys.Add(PatrolEntry);
// HomeLocation
FBlackboardEntry HomeEntry;
HomeEntry.EntryName = PS_AI_Behavior_BB::HomeLocation;
HomeEntry.KeyType = NewObject<UBlackboardKeyType_Vector>(BlackboardAsset);
BlackboardAsset->Keys.Add(HomeEntry);
// CurrentSpline (Object — APS_AI_Behavior_SplinePath)
FBlackboardEntry SplineEntry;
SplineEntry.EntryName = PS_AI_Behavior_BB::CurrentSpline;
SplineEntry.KeyType = NewObject<UBlackboardKeyType_Object>(BlackboardAsset);
Cast<UBlackboardKeyType_Object>(SplineEntry.KeyType)->BaseClass = AActor::StaticClass();
BlackboardAsset->Keys.Add(SplineEntry);
// SplineProgress (float 0-1)
FBlackboardEntry SplineProgressEntry;
SplineProgressEntry.EntryName = PS_AI_Behavior_BB::SplineProgress;
SplineProgressEntry.KeyType = NewObject<UBlackboardKeyType_Float>(BlackboardAsset);
BlackboardAsset->Keys.Add(SplineProgressEntry);
}
UBlackboardComponent* RawBBComp = nullptr;
UseBlackboard(BlackboardAsset, RawBBComp);
Blackboard = RawBBComp;
// Initialize home location to pawn's spawn position
if (Blackboard && GetPawn())
{
Blackboard->SetValueAsVector(PS_AI_Behavior_BB::HomeLocation, GetPawn()->GetActorLocation());
Blackboard->SetValueAsInt(PS_AI_Behavior_BB::PatrolIndex, 0);
Blackboard->SetValueAsEnum(PS_AI_Behavior_BB::State,
static_cast<uint8>(EPS_AI_Behavior_State::Idle));
}
}
void APS_AI_Behavior_AIController::StartBehavior()
{
UBehaviorTree* BTToRun = BehaviorTreeAsset;
// Fallback: get from personality profile
if (!BTToRun && PersonalityComp && PersonalityComp->Profile)
{
BTToRun = PersonalityComp->Profile->DefaultBehaviorTree.LoadSynchronous();
}
if (BTToRun)
{
RunBehaviorTree(BTToRun);
}
else
{
UE_LOG(LogPS_AI_Behavior, Warning,
TEXT("[%s] No BehaviorTree assigned and none in PersonalityProfile — NPC will be inert."),
*GetName());
}
}
void APS_AI_Behavior_AIController::SetBehaviorState(EPS_AI_Behavior_State NewState)
{
if (Blackboard)
{
Blackboard->SetValueAsEnum(PS_AI_Behavior_BB::State, static_cast<uint8>(NewState));
}
}
EPS_AI_Behavior_State APS_AI_Behavior_AIController::GetBehaviorState() const
{
if (Blackboard)
{
return static_cast<EPS_AI_Behavior_State>(
Blackboard->GetValueAsEnum(PS_AI_Behavior_BB::State));
}
return EPS_AI_Behavior_State::Idle;
}
// ─── Team / Affiliation ─────────────────────────────────────────────────────
void APS_AI_Behavior_AIController::SetTeamId(uint8 NewTeamId)
{
TeamId = NewTeamId;
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] TeamId set to %d"), *GetName(), TeamId);
}
FGenericTeamId APS_AI_Behavior_AIController::GetGenericTeamId() const
{
return FGenericTeamId(TeamId);
}
void APS_AI_Behavior_AIController::SetGenericTeamId(const FGenericTeamId& InTeamId)
{
TeamId = InTeamId.GetId();
}
ETeamAttitude::Type APS_AI_Behavior_AIController::GetTeamAttitudeTowards(const AActor& Other) const
{
const uint8 OtherTeamId = FGenericTeamId::NoTeam;
// Try to get the other actor's team ID
const APawn* OtherPawn = Cast<APawn>(&Other);
if (!OtherPawn)
{
OtherPawn = Cast<APawn>(Other.GetInstigator());
}
uint8 OtherTeam = FGenericTeamId::NoTeam;
if (OtherPawn)
{
// Check via AIController first
if (const AAIController* OtherAIC = Cast<AAIController>(OtherPawn->GetController()))
{
OtherTeam = OtherAIC->GetGenericTeamId().GetId();
}
// Check via IPS_AI_Behavior interface
else if (OtherPawn->Implements<UPS_AI_Behavior_Interface>())
{
OtherTeam = IPS_AI_Behavior_Interface::Execute_GetBehaviorTeamId(const_cast<APawn*>(OtherPawn));
}
}
// NoTeam (255) → Neutral
if (TeamId == FGenericTeamId::NoTeam || OtherTeam == FGenericTeamId::NoTeam)
{
return ETeamAttitude::Neutral;
}
// Same team → Friendly
if (TeamId == OtherTeam)
{
return ETeamAttitude::Friendly;
}
// ─── Custom cross-team attitudes ────────────────────────────────────
// Civilian (1) ↔ Protector (3) → Friendly
if ((TeamId == 1 && OtherTeam == 3) || (TeamId == 3 && OtherTeam == 1))
{
return ETeamAttitude::Friendly;
}
// Everything else → Hostile
return ETeamAttitude::Hostile;
}
// ─── ConvAgent Integration ──────────────────────────────────────────────────
void APS_AI_Behavior_AIController::TryBindConversationAgent()
{
APawn* MyPawn = GetPawn();
if (!MyPawn) return;
// Soft lookup via reflection — no compile dependency on PS_AI_ConvAgent
static UClass* ConvAgentClass = nullptr;
if (!ConvAgentClass)
{
ConvAgentClass = LoadClass<UActorComponent>(nullptr,
TEXT("/Script/PS_AI_ConvAgent.PS_AI_ConvAgent_ElevenLabsComponent"));
}
if (!ConvAgentClass)
{
// PS_AI_ConvAgent plugin not loaded — that's fine
return;
}
UActorComponent* ConvComp = MyPawn->FindComponentByClass(ConvAgentClass);
if (!ConvComp)
{
return;
}
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] Found PS_AI_ConvAgent_ElevenLabsComponent on Pawn — binding for conversation-driven actions."),
*GetName());
// Bind to OnAgentActionRequested delegate via reflection
FMulticastDelegateProperty* ActionDelegate = CastField<FMulticastDelegateProperty>(
ConvAgentClass->FindPropertyByName(TEXT("OnAgentActionRequested")));
if (ActionDelegate)
{
// The delegate binding would inject conversation-driven state changes
// into the behavior tree via Blackboard writes.
// Full implementation depends on PS_AI_ConvAgent's delegate signature.
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] OnAgentActionRequested delegate found — conversation actions can drive behavior."),
*GetName());
}
}

View File

@ -0,0 +1,115 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior_CombatComponent.h"
#include "PS_AI_Behavior_Definitions.h"
#include "GameFramework/DamageType.h"
#include "Engine/DamageEvents.h"
#include "Kismet/GameplayStatics.h"
#include "Net/UnrealNetwork.h"
UPS_AI_Behavior_CombatComponent::UPS_AI_Behavior_CombatComponent()
{
PrimaryComponentTick.bCanEverTick = true;
PrimaryComponentTick.TickInterval = 0.1f; // Don't need per-frame
SetIsReplicatedByDefault(true);
}
void UPS_AI_Behavior_CombatComponent::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(UPS_AI_Behavior_CombatComponent, CurrentTarget);
}
void UPS_AI_Behavior_CombatComponent::BeginPlay()
{
Super::BeginPlay();
if (!DamageTypeClass)
{
DamageTypeClass = UDamageType::StaticClass();
}
}
void UPS_AI_Behavior_CombatComponent::TickComponent(
float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
// Cooldown only ticks on server (BT runs server-only)
if (GetOwner() && GetOwner()->HasAuthority() && CooldownRemaining > 0.0f)
{
CooldownRemaining = FMath::Max(0.0f, CooldownRemaining - DeltaTime);
}
}
bool UPS_AI_Behavior_CombatComponent::CanAttack() const
{
return CooldownRemaining <= 0.0f;
}
bool UPS_AI_Behavior_CombatComponent::IsInAttackRange(AActor* Target) const
{
if (!Target || !GetOwner())
{
return false;
}
const float Dist = FVector::Dist(GetOwner()->GetActorLocation(), Target->GetActorLocation());
return Dist <= AttackRange;
}
bool UPS_AI_Behavior_CombatComponent::ExecuteAttack(AActor* Target)
{
// Only execute on server (authority)
if (!GetOwner() || !GetOwner()->HasAuthority())
{
return false;
}
if (!CanAttack() || !Target)
{
return false;
}
if (!IsInAttackRange(Target))
{
return false;
}
// Apply damage (server-only — UE5 damage system is server-authoritative)
UGameplayStatics::ApplyDamage(
Target,
AttackDamage,
GetOwner()->GetInstigatorController(),
GetOwner(),
DamageTypeClass);
// Start cooldown
CooldownRemaining = AttackCooldown;
CurrentTarget = Target; // Replicated → clients see the target
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Attacked '%s' for %.1f damage."),
*GetOwner()->GetName(), *Target->GetName(), AttackDamage);
// Multicast cosmetic event to all clients (VFX, sound, anims)
Multicast_OnAttackExecuted(Target);
return true;
}
void UPS_AI_Behavior_CombatComponent::Multicast_OnAttackExecuted_Implementation(AActor* Target)
{
// Fires on server AND all clients
OnAttackExecuted.Broadcast(Target);
}
void UPS_AI_Behavior_CombatComponent::NotifyDamageReceived(float Damage, AActor* DamageInstigator)
{
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Received %.1f damage from '%s'."),
*GetOwner()->GetName(), Damage,
DamageInstigator ? *DamageInstigator->GetName() : TEXT("Unknown"));
OnDamageReceived.Broadcast(Damage, DamageInstigator);
}

View File

@ -0,0 +1,194 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior_CoverPoint.h"
#include "Components/ArrowComponent.h"
#include "Components/BillboardComponent.h"
#include "Engine/World.h"
#include "CollisionQueryParams.h"
APS_AI_Behavior_CoverPoint::APS_AI_Behavior_CoverPoint()
{
PrimaryActorTick.bCanEverTick = false;
bNetLoadOnClient = true;
USceneComponent* Root = CreateDefaultSubobject<USceneComponent>(TEXT("Root"));
RootComponent = Root;
#if WITH_EDITORONLY_DATA
ArrowComp = CreateDefaultSubobject<UArrowComponent>(TEXT("Arrow"));
ArrowComp->SetupAttachment(Root);
ArrowComp->SetArrowSize(0.5f);
ArrowComp->SetArrowLength(80.0f);
ArrowComp->SetRelativeLocation(FVector(0, 0, 50.0f));
ArrowComp->bIsScreenSizeScaled = false;
SpriteComp = CreateDefaultSubobject<UBillboardComponent>(TEXT("Sprite"));
SpriteComp->SetupAttachment(Root);
SpriteComp->SetRelativeLocation(FVector(0, 0, 80.0f));
SpriteComp->bIsScreenSizeScaled = true;
SpriteComp->ScreenSize = 0.0025f;
#endif
}
void APS_AI_Behavior_CoverPoint::BeginPlay()
{
Super::BeginPlay();
// Cleanup stale occupant refs
CurrentOccupants.RemoveAll([](const TWeakObjectPtr<AActor>& Ptr) { return !Ptr.IsValid(); });
}
bool APS_AI_Behavior_CoverPoint::IsAccessibleTo(EPS_AI_Behavior_NPCType NPCType) const
{
if (!bEnabled) return false;
if (AllowedNPCType == EPS_AI_Behavior_NPCType::Any) return true;
return AllowedNPCType == NPCType;
}
bool APS_AI_Behavior_CoverPoint::HasRoom() const
{
if (!bEnabled) return false;
// Cleanup stale refs
int32 ValidCount = 0;
for (const TWeakObjectPtr<AActor>& Occ : CurrentOccupants)
{
if (Occ.IsValid()) ++ValidCount;
}
return ValidCount < MaxOccupants;
}
bool APS_AI_Behavior_CoverPoint::Claim(AActor* Occupant)
{
if (!Occupant || !bEnabled) return false;
// Already claimed by this occupant?
for (const TWeakObjectPtr<AActor>& Occ : CurrentOccupants)
{
if (Occ.Get() == Occupant) return true;
}
// Cleanup stale
CurrentOccupants.RemoveAll([](const TWeakObjectPtr<AActor>& Ptr) { return !Ptr.IsValid(); });
if (CurrentOccupants.Num() >= MaxOccupants)
{
return false;
}
CurrentOccupants.Add(Occupant);
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[CoverPoint %s] Claimed by '%s' (%d/%d occupants)"),
*GetName(), *Occupant->GetName(), CurrentOccupants.Num(), MaxOccupants);
return true;
}
void APS_AI_Behavior_CoverPoint::Release(AActor* Occupant)
{
if (!Occupant) return;
const int32 Removed = CurrentOccupants.RemoveAll([Occupant](const TWeakObjectPtr<AActor>& Ptr)
{
return !Ptr.IsValid() || Ptr.Get() == Occupant;
});
if (Removed > 0)
{
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[CoverPoint %s] Released by '%s' (%d/%d occupants)"),
*GetName(), *Occupant->GetName(), CurrentOccupants.Num(), MaxOccupants);
}
}
FVector APS_AI_Behavior_CoverPoint::GetCoverDirection() const
{
return GetActorForwardVector();
}
float APS_AI_Behavior_CoverPoint::EvaluateAgainstThreat(const FVector& ThreatLocation) const
{
if (!bEnabled) return 0.0f;
const UWorld* World = GetWorld();
if (!World) return Quality;
const FVector CoverPos = GetActorLocation();
float Score = Quality * 0.5f; // Manual quality = half the score
// Raycast check at crouch and standing heights
FCollisionQueryParams Params(SCENE_QUERY_STAT(CoverPointEval), true);
Params.AddIgnoredActor(this);
const float Heights[] = { 60.0f, 100.0f, 170.0f };
int32 BlockedCount = 0;
for (float H : Heights)
{
FHitResult Hit;
const FVector TraceStart = CoverPos + FVector(0, 0, H);
const FVector TraceEnd = ThreatLocation + FVector(0, 0, 150.0f);
if (World->LineTraceSingleByChannel(Hit, TraceStart, TraceEnd, ECC_Visibility, Params))
{
++BlockedCount;
}
}
// Raycast score = other half
const float RaycastScore = static_cast<float>(BlockedCount) / UE_ARRAY_COUNT(Heights);
Score += RaycastScore * 0.5f;
return FMath::Clamp(Score, 0.0f, 1.0f);
}
void APS_AI_Behavior_CoverPoint::SetEnabled(bool bNewEnabled)
{
bEnabled = bNewEnabled;
if (!bEnabled)
{
// Release all occupants
for (const TWeakObjectPtr<AActor>& Occ : CurrentOccupants)
{
// Occupants will re-evaluate in their next BT tick
}
CurrentOccupants.Empty();
}
UpdateVisualization();
}
void APS_AI_Behavior_CoverPoint::UpdateVisualization()
{
#if WITH_EDITORONLY_DATA
if (!ArrowComp) return;
FLinearColor Color = FLinearColor::White;
switch (PointType)
{
case EPS_AI_Behavior_CoverPointType::Cover:
Color = FLinearColor(0.2f, 0.5f, 1.0f); // Blue
break;
case EPS_AI_Behavior_CoverPointType::HidingSpot:
Color = FLinearColor(1.0f, 0.85f, 0.0f); // Yellow
break;
default:
break;
}
if (!bEnabled)
{
Color *= 0.3f; // Dimmed when disabled
}
ArrowComp->SetArrowColor(Color);
#endif
}
#if WITH_EDITOR
void APS_AI_Behavior_CoverPoint::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent)
{
Super::PostEditChangeProperty(PropertyChangedEvent);
UpdateVisualization();
}
#endif

View File

@ -0,0 +1,5 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior_Definitions.h"
DEFINE_LOG_CATEGORY(LogPS_AI_Behavior);

View File

@ -0,0 +1,7 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior_Interface.h"
// UInterface boilerplate — no default implementation needed.
// All functions are BlueprintNativeEvent and must be implemented
// by the class that declares "implements IPS_AI_Behavior".

View File

@ -0,0 +1,326 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior_PerceptionComponent.h"
#include "PS_AI_Behavior_Interface.h"
#include "PS_AI_Behavior_PersonalityComponent.h"
#include "PS_AI_Behavior_PersonalityProfile.h"
#include "PS_AI_Behavior_Settings.h"
#include "Perception/AISenseConfig_Sight.h"
#include "Perception/AISenseConfig_Hearing.h"
#include "Perception/AISenseConfig_Damage.h"
#include "Perception/AISense_Sight.h"
#include "Perception/AISense_Hearing.h"
#include "Perception/AISense_Damage.h"
#include "GameFramework/Pawn.h"
#include "AIController.h"
UPS_AI_Behavior_PerceptionComponent::UPS_AI_Behavior_PerceptionComponent()
{
// Senses are configured in BeginPlay after settings are available
}
void UPS_AI_Behavior_PerceptionComponent::BeginPlay()
{
ConfigureSenses();
Super::BeginPlay();
OnPerceptionUpdated.AddDynamic(this, &UPS_AI_Behavior_PerceptionComponent::HandlePerceptionUpdated);
}
void UPS_AI_Behavior_PerceptionComponent::ConfigureSenses()
{
const UPS_AI_Behavior_Settings* Settings = GetDefault<UPS_AI_Behavior_Settings>();
// ─── Sight ──────────────────────────────────────────────────────────
UAISenseConfig_Sight* SightConfig = NewObject<UAISenseConfig_Sight>(this);
SightConfig->SightRadius = Settings->DefaultSightRadius;
SightConfig->LoseSightRadius = Settings->DefaultSightRadius * 1.2f;
SightConfig->PeripheralVisionAngleDegrees = Settings->DefaultSightHalfAngle;
SightConfig->SetMaxAge(Settings->PerceptionMaxAge);
SightConfig->AutoSuccessRangeFromLastSeenLocation = 500.0f;
// Detect ALL affiliations — target filtering is handled by TargetPriority
// in GetHighestThreatActor(), not at the perception level.
// This is necessary because an Enemy needs to *see* Civilians to target them.
SightConfig->DetectionByAffiliation.bDetectEnemies = true;
SightConfig->DetectionByAffiliation.bDetectNeutrals = true;
SightConfig->DetectionByAffiliation.bDetectFriendlies = true;
ConfigureSense(*SightConfig);
// ─── Hearing ────────────────────────────────────────────────────────
UAISenseConfig_Hearing* HearingConfig = NewObject<UAISenseConfig_Hearing>(this);
HearingConfig->HearingRange = Settings->DefaultHearingRange;
HearingConfig->SetMaxAge(Settings->PerceptionMaxAge);
HearingConfig->DetectionByAffiliation.bDetectEnemies = true;
HearingConfig->DetectionByAffiliation.bDetectNeutrals = true;
HearingConfig->DetectionByAffiliation.bDetectFriendlies = true;
ConfigureSense(*HearingConfig);
// ─── Damage ─────────────────────────────────────────────────────────
UAISenseConfig_Damage* DamageConfig = NewObject<UAISenseConfig_Damage>(this);
DamageConfig->SetMaxAge(Settings->PerceptionMaxAge * 2.0f); // Damage memories last longer
ConfigureSense(*DamageConfig);
// Sight is the dominant sense
SetDominantSense(UAISense_Sight::StaticClass());
}
void UPS_AI_Behavior_PerceptionComponent::HandlePerceptionUpdated(const TArray<AActor*>& UpdatedActors)
{
// Placeholder — BTService_UpdateThreat does the heavy lifting.
// This callback can be used for immediate alert reactions.
}
// ─── Actor Classification ───────────────────────────────────────────────────
EPS_AI_Behavior_TargetType UPS_AI_Behavior_PerceptionComponent::ClassifyActor(const AActor* Actor)
{
if (!Actor) return EPS_AI_Behavior_TargetType::Civilian; // Safe default
// Check if player-controlled
const APawn* Pawn = Cast<APawn>(Actor);
if (Pawn && Pawn->IsPlayerControlled())
{
return EPS_AI_Behavior_TargetType::Player;
}
// Check via IPS_AI_Behavior interface
if (Actor->Implements<UPS_AI_Behavior_Interface>())
{
const EPS_AI_Behavior_NPCType NPCType =
IPS_AI_Behavior_Interface::Execute_GetBehaviorNPCType(const_cast<AActor*>(Actor));
switch (NPCType)
{
case EPS_AI_Behavior_NPCType::Civilian: return EPS_AI_Behavior_TargetType::Civilian;
case EPS_AI_Behavior_NPCType::Enemy: return EPS_AI_Behavior_TargetType::Enemy;
case EPS_AI_Behavior_NPCType::Protector: return EPS_AI_Behavior_TargetType::Protector;
default: break;
}
}
// Fallback: check PersonalityComponent
if (Pawn)
{
if (const auto* PersonalityComp = Pawn->FindComponentByClass<UPS_AI_Behavior_PersonalityComponent>())
{
switch (PersonalityComp->GetNPCType())
{
case EPS_AI_Behavior_NPCType::Civilian: return EPS_AI_Behavior_TargetType::Civilian;
case EPS_AI_Behavior_NPCType::Enemy: return EPS_AI_Behavior_TargetType::Enemy;
case EPS_AI_Behavior_NPCType::Protector: return EPS_AI_Behavior_TargetType::Protector;
default: break;
}
}
}
return EPS_AI_Behavior_TargetType::Civilian;
}
// ─── Target Selection ───────────────────────────────────────────────────────
AActor* UPS_AI_Behavior_PerceptionComponent::GetHighestThreatActor()
{
// Get priority from PersonalityProfile if available
TArray<EPS_AI_Behavior_TargetType> Priority;
const AActor* Owner = GetOwner();
if (Owner)
{
// Owner is the AIController, get the Pawn
const AAIController* AIC = Cast<AAIController>(Owner);
const APawn* MyPawn = AIC ? AIC->GetPawn() : Cast<APawn>(const_cast<AActor*>(Owner));
if (MyPawn)
{
if (const auto* PersonalityComp = MyPawn->FindComponentByClass<UPS_AI_Behavior_PersonalityComponent>())
{
if (PersonalityComp->Profile)
{
Priority = PersonalityComp->Profile->TargetPriority;
}
}
}
}
return GetHighestThreatActor(Priority);
}
AActor* UPS_AI_Behavior_PerceptionComponent::GetHighestThreatActor(
const TArray<EPS_AI_Behavior_TargetType>& TargetPriority)
{
// Gather all perceived actors from all senses
TArray<AActor*> PerceivedActors;
GetCurrentlyPerceivedActors(nullptr, PerceivedActors); // All senses at once
if (PerceivedActors.Num() == 0)
{
return nullptr;
}
const AActor* Owner = GetOwner();
if (!Owner)
{
return nullptr;
}
// Use default priority if none provided
static const TArray<EPS_AI_Behavior_TargetType> DefaultPriority = {
EPS_AI_Behavior_TargetType::Protector,
EPS_AI_Behavior_TargetType::Player,
EPS_AI_Behavior_TargetType::Civilian,
};
const TArray<EPS_AI_Behavior_TargetType>& ActivePriority =
TargetPriority.Num() > 0 ? TargetPriority : DefaultPriority;
const FVector OwnerLoc = Owner->GetActorLocation();
AActor* BestThreat = nullptr;
float BestScore = -1.0f;
for (AActor* Actor : PerceivedActors)
{
if (!Actor || Actor == Owner) continue;
// Skip self (when owner is AIController, also skip own pawn)
const AAIController* AIC = Cast<AAIController>(Owner);
if (AIC && Actor == AIC->GetPawn()) continue;
// ─── Classify this actor ────────────────────────────────────────
const EPS_AI_Behavior_TargetType ActorType = ClassifyActor(Actor);
// Check if this target type is in our priority list at all
const int32 PriorityIndex = ActivePriority.IndexOfByKey(ActorType);
if (PriorityIndex == INDEX_NONE)
{
// Not a valid target for this NPC
continue;
}
// ─── Score calculation ──────────────────────────────────────────
float Score = 0.0f;
// Priority rank bonus: higher priority = much higher score
// Max priority entries = ~4, so (4 - index) * 100 gives clear separation
Score += (ActivePriority.Num() - PriorityIndex) * 100.0f;
// Damage sense override: actor that hit us gets a massive bonus
// (bypasses priority — self-defense)
FActorPerceptionBlueprintInfo Info;
if (GetActorsPerception(Actor, Info))
{
for (const FAIStimulus& Stimulus : Info.LastSensedStimuli)
{
if (!Stimulus.IsValid()) continue;
if (Stimulus.Type == UAISense::GetSenseID<UAISense_Damage>())
{
Score += 500.0f; // Self-defense: always prioritize attacker
}
else if (Stimulus.Type == UAISense::GetSenseID<UAISense_Sight>())
{
Score += 10.0f;
}
else
{
Score += 5.0f; // Hearing
}
}
}
// Distance: closer targets score higher (0-20 range)
const float Dist = FVector::Dist(OwnerLoc, Actor->GetActorLocation());
Score += FMath::GetMappedRangeValueClamped(
FVector2D(0.0f, 6000.0f), FVector2D(20.0f, 0.0f), Dist);
if (Score > BestScore)
{
BestScore = Score;
BestThreat = Actor;
}
}
return BestThreat;
}
float UPS_AI_Behavior_PerceptionComponent::CalculateThreatLevel()
{
const AActor* Owner = GetOwner();
if (!Owner) return 0.0f;
const FVector OwnerLoc = Owner->GetActorLocation();
float TotalThreat = 0.0f;
TArray<AActor*> PerceivedActors;
GetCurrentlyPerceivedActors(nullptr, PerceivedActors); // All senses
for (AActor* Actor : PerceivedActors)
{
if (!Actor) continue;
float ActorThreat = 0.0f;
const float Dist = FVector::Dist(OwnerLoc, Actor->GetActorLocation());
// Closer = more threatening
ActorThreat += FMath::GetMappedRangeValueClamped(
FVector2D(0.0f, 5000.0f), FVector2D(0.5f, 0.05f), Dist);
// Sense-based multiplier
FActorPerceptionBlueprintInfo Info;
if (GetActorsPerception(Actor, Info))
{
for (const FAIStimulus& Stimulus : Info.LastSensedStimuli)
{
if (!Stimulus.IsValid()) continue;
if (Stimulus.Type == UAISense::GetSenseID<UAISense_Damage>())
{
ActorThreat += 0.6f; // Being hit = big threat spike
}
else if (Stimulus.Type == UAISense::GetSenseID<UAISense_Sight>())
{
ActorThreat += 0.2f;
}
else if (Stimulus.Type == UAISense::GetSenseID<UAISense_Hearing>())
{
ActorThreat += 0.1f;
}
}
}
TotalThreat += ActorThreat;
}
// Clamp to reasonable range (can exceed 1.0 for multiple threats)
return FMath::Min(TotalThreat, 2.0f);
}
bool UPS_AI_Behavior_PerceptionComponent::GetThreatLocation(FVector& OutLocation)
{
AActor* Threat = GetHighestThreatActor();
if (Threat)
{
OutLocation = Threat->GetActorLocation();
return true;
}
// Fallback: check last known stimulus location
TArray<AActor*> KnownActors;
GetKnownPerceivedActors(nullptr, KnownActors);
if (KnownActors.Num() > 0 && KnownActors[0])
{
FActorPerceptionBlueprintInfo Info;
if (GetActorsPerception(KnownActors[0], Info))
{
for (const FAIStimulus& Stimulus : Info.LastSensedStimuli)
{
if (Stimulus.IsValid())
{
OutLocation = Stimulus.StimulusLocation;
return true;
}
}
}
}
return false;
}

View File

@ -0,0 +1,189 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior_PersonalityComponent.h"
#include "PS_AI_Behavior_Interface.h"
#include "PS_AI_Behavior_PersonalityProfile.h"
#include "Net/UnrealNetwork.h"
UPS_AI_Behavior_PersonalityComponent::UPS_AI_Behavior_PersonalityComponent()
{
PrimaryComponentTick.bCanEverTick = false;
SetIsReplicatedByDefault(true);
}
void UPS_AI_Behavior_PersonalityComponent::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(UPS_AI_Behavior_PersonalityComponent, CurrentState);
DOREPLIFETIME(UPS_AI_Behavior_PersonalityComponent, PerceivedThreatLevel);
}
void UPS_AI_Behavior_PersonalityComponent::BeginPlay()
{
Super::BeginPlay();
// Initialize runtime traits from profile
if (Profile)
{
RuntimeTraits = Profile->TraitScores;
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Personality initialized from profile '%s' (%s)"),
*GetOwner()->GetName(),
*Profile->ProfileName.ToString(),
*UEnum::GetValueAsString(Profile->NPCType));
}
else
{
// Defaults — all traits at 0.5
for (uint8 i = 0; i <= static_cast<uint8>(EPS_AI_Behavior_TraitAxis::Discipline); ++i)
{
RuntimeTraits.Add(static_cast<EPS_AI_Behavior_TraitAxis>(i), 0.5f);
}
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] No PersonalityProfile assigned — using default traits."),
*GetOwner()->GetName());
}
}
float UPS_AI_Behavior_PersonalityComponent::GetTrait(EPS_AI_Behavior_TraitAxis Axis) const
{
const float* Found = RuntimeTraits.Find(Axis);
return Found ? *Found : 0.5f;
}
void UPS_AI_Behavior_PersonalityComponent::ModifyTrait(EPS_AI_Behavior_TraitAxis Axis, float Delta)
{
float& Value = RuntimeTraits.FindOrAdd(Axis, 0.5f);
Value = FMath::Clamp(Value + Delta, 0.0f, 1.0f);
}
EPS_AI_Behavior_State UPS_AI_Behavior_PersonalityComponent::EvaluateReaction() const
{
if (CurrentState == EPS_AI_Behavior_State::Dead)
{
return EPS_AI_Behavior_State::Dead;
}
const float Courage = GetTrait(EPS_AI_Behavior_TraitAxis::Courage);
const float Aggressivity = GetTrait(EPS_AI_Behavior_TraitAxis::Aggressivity);
const float Caution = GetTrait(EPS_AI_Behavior_TraitAxis::Caution);
// Get thresholds from profile (or use defaults)
float FleeThresh = Profile ? Profile->FleeThreshold : 0.5f;
float AttackThresh = Profile ? Profile->AttackThreshold : 0.4f;
float AlertThresh = Profile ? Profile->AlertThreshold : 0.15f;
// Modulate thresholds by personality:
// - High courage raises the flee threshold (harder to scare)
// - High aggressivity lowers the attack threshold (quicker to fight)
// - High caution lowers the flee threshold (quicker to run)
const float EffectiveFleeThresh = FleeThresh * (0.5f + Courage * 0.5f) * (1.5f - Caution * 0.5f);
const float EffectiveAttackThresh = AttackThresh * (1.5f - Aggressivity * 0.5f);
// Decision cascade
if (PerceivedThreatLevel >= EffectiveFleeThresh && Courage < 0.7f)
{
return EPS_AI_Behavior_State::Fleeing;
}
if (PerceivedThreatLevel >= EffectiveAttackThresh && Aggressivity > 0.3f)
{
// Cautious NPCs prefer cover over direct combat
if (Caution > 0.6f)
{
return EPS_AI_Behavior_State::TakingCover;
}
return EPS_AI_Behavior_State::Combat;
}
if (PerceivedThreatLevel >= AlertThresh)
{
return EPS_AI_Behavior_State::Alerted;
}
// No threat — maintain patrol or idle
return (CurrentState == EPS_AI_Behavior_State::Patrol)
? EPS_AI_Behavior_State::Patrol
: EPS_AI_Behavior_State::Idle;
}
EPS_AI_Behavior_State UPS_AI_Behavior_PersonalityComponent::ApplyReaction()
{
const EPS_AI_Behavior_State NewState = EvaluateReaction();
if (NewState != CurrentState)
{
const EPS_AI_Behavior_State OldState = CurrentState;
CurrentState = NewState; // Replicated → OnRep fires on clients
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] State: %s -> %s (Threat: %.2f)"),
*GetOwner()->GetName(),
*UEnum::GetValueAsString(OldState),
*UEnum::GetValueAsString(NewState),
PerceivedThreatLevel);
HandleStateChanged(OldState, NewState);
}
return CurrentState;
}
void UPS_AI_Behavior_PersonalityComponent::ForceState(EPS_AI_Behavior_State NewState)
{
if (NewState != CurrentState)
{
const EPS_AI_Behavior_State OldState = CurrentState;
CurrentState = NewState; // Replicated → OnRep fires on clients
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] State forced: %s -> %s"),
*GetOwner()->GetName(),
*UEnum::GetValueAsString(OldState),
*UEnum::GetValueAsString(NewState));
HandleStateChanged(OldState, NewState);
}
}
void UPS_AI_Behavior_PersonalityComponent::OnRep_CurrentState(EPS_AI_Behavior_State OldState)
{
// On clients: fire delegate only (speed is set by CMC replication)
OnBehaviorStateChanged.Broadcast(OldState, CurrentState);
// Also notify the Pawn via interface (for client-side cosmetics)
AActor* Owner = GetOwner();
if (Owner && Owner->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_OnBehaviorStateChanged(Owner, CurrentState, OldState);
}
}
void UPS_AI_Behavior_PersonalityComponent::HandleStateChanged(
EPS_AI_Behavior_State OldState, EPS_AI_Behavior_State NewState)
{
// 1. Broadcast delegate (server)
OnBehaviorStateChanged.Broadcast(OldState, NewState);
AActor* Owner = GetOwner();
if (!Owner) return;
// 2. Set movement speed via interface
if (Owner->Implements<UPS_AI_Behavior_Interface>())
{
float NewSpeed = Profile ? Profile->GetSpeedForState(NewState) : 150.0f;
IPS_AI_Behavior_Interface::Execute_SetBehaviorMovementSpeed(Owner, NewSpeed);
// 3. Notify the Pawn of the state change
IPS_AI_Behavior_Interface::Execute_OnBehaviorStateChanged(Owner, NewState, OldState);
}
}
EPS_AI_Behavior_NPCType UPS_AI_Behavior_PersonalityComponent::GetNPCType() const
{
// Prefer the IPS_AI_Behavior interface on the owning actor
AActor* Owner = GetOwner();
if (Owner && Owner->Implements<UPS_AI_Behavior_Interface>())
{
return IPS_AI_Behavior_Interface::Execute_GetBehaviorNPCType(Owner);
}
// Fallback: read from PersonalityProfile
return Profile ? Profile->NPCType : EPS_AI_Behavior_NPCType::Civilian;
}

View File

@ -0,0 +1,44 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior_PersonalityProfile.h"
UPS_AI_Behavior_PersonalityProfile::UPS_AI_Behavior_PersonalityProfile()
{
// Initialize all trait axes to a neutral 0.5
TraitScores.Add(EPS_AI_Behavior_TraitAxis::Courage, 0.5f);
TraitScores.Add(EPS_AI_Behavior_TraitAxis::Aggressivity, 0.5f);
TraitScores.Add(EPS_AI_Behavior_TraitAxis::Loyalty, 0.5f);
TraitScores.Add(EPS_AI_Behavior_TraitAxis::Caution, 0.5f);
TraitScores.Add(EPS_AI_Behavior_TraitAxis::Discipline, 0.5f);
// Default target priority: Protector first, then Player, then Civilian
TargetPriority.Add(EPS_AI_Behavior_TargetType::Protector);
TargetPriority.Add(EPS_AI_Behavior_TargetType::Player);
TargetPriority.Add(EPS_AI_Behavior_TargetType::Civilian);
// Default speeds per state (cm/s)
SpeedPerState.Add(EPS_AI_Behavior_State::Idle, 0.0f);
SpeedPerState.Add(EPS_AI_Behavior_State::Patrol, 150.0f);
SpeedPerState.Add(EPS_AI_Behavior_State::Alerted, 200.0f);
SpeedPerState.Add(EPS_AI_Behavior_State::Combat, 350.0f);
SpeedPerState.Add(EPS_AI_Behavior_State::Fleeing, 500.0f);
SpeedPerState.Add(EPS_AI_Behavior_State::TakingCover, 400.0f);
SpeedPerState.Add(EPS_AI_Behavior_State::Dead, 0.0f);
}
float UPS_AI_Behavior_PersonalityProfile::GetTrait(EPS_AI_Behavior_TraitAxis Axis) const
{
const float* Found = TraitScores.Find(Axis);
return Found ? *Found : 0.5f;
}
float UPS_AI_Behavior_PersonalityProfile::GetSpeedForState(EPS_AI_Behavior_State State) const
{
const float* Found = SpeedPerState.Find(State);
return Found ? *Found : DefaultWalkSpeed;
}
FPrimaryAssetId UPS_AI_Behavior_PersonalityProfile::GetPrimaryAssetId() const
{
return FPrimaryAssetId(TEXT("PersonalityProfile"), GetFName());
}

View File

@ -0,0 +1,7 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior_Settings.h"
UPS_AI_Behavior_Settings::UPS_AI_Behavior_Settings()
{
}

View File

@ -0,0 +1,311 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior_SplineFollowerComponent.h"
#include "PS_AI_Behavior_SplinePath.h"
#include "PS_AI_Behavior_SplineNetwork.h"
#include "PS_AI_Behavior_PersonalityComponent.h"
#include "Components/SplineComponent.h"
#include "GameFramework/Character.h"
#include "GameFramework/CharacterMovementComponent.h"
#include "Net/UnrealNetwork.h"
UPS_AI_Behavior_SplineFollowerComponent::UPS_AI_Behavior_SplineFollowerComponent()
{
PrimaryComponentTick.bCanEverTick = true;
PrimaryComponentTick.TickGroup = TG_PrePhysics;
SetIsReplicatedByDefault(true);
// Each NPC walks at a slightly different speed for natural look
SpeedVariation = FMath::RandRange(0.85f, 1.15f);
}
void UPS_AI_Behavior_SplineFollowerComponent::GetLifetimeReplicatedProps(
TArray<FLifetimeProperty>& OutLifetimeProps) const
{
Super::GetLifetimeReplicatedProps(OutLifetimeProps);
DOREPLIFETIME(UPS_AI_Behavior_SplineFollowerComponent, CurrentSpline);
DOREPLIFETIME(UPS_AI_Behavior_SplineFollowerComponent, bIsFollowing);
}
bool UPS_AI_Behavior_SplineFollowerComponent::StartFollowing(
APS_AI_Behavior_SplinePath* Spline, bool bForward)
{
if (!Spline || !GetOwner())
{
return false;
}
// Snap to closest point on spline
float Dist = 0.0f;
FVector ClosestPoint;
Spline->GetClosestPointOnSpline(GetOwner()->GetActorLocation(), Dist, ClosestPoint);
return StartFollowingAtDistance(Spline, Dist, bForward);
}
bool UPS_AI_Behavior_SplineFollowerComponent::StartFollowingAtDistance(
APS_AI_Behavior_SplinePath* Spline, float StartDistance, bool bForward)
{
if (!Spline)
{
return false;
}
APS_AI_Behavior_SplinePath* OldSpline = CurrentSpline;
CurrentSpline = Spline;
CurrentDistance = FMath::Clamp(StartDistance, 0.0f, Spline->GetSplineLength());
bMovingForward = bForward;
bIsFollowing = true;
LastHandledJunctionIndex = -1;
if (OldSpline && OldSpline != Spline)
{
OnSplineChanged.Broadcast(OldSpline, Spline);
}
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Started following spline '%s' at d=%.0f (%s)"),
*GetOwner()->GetName(), *Spline->GetName(), CurrentDistance,
bForward ? TEXT("forward") : TEXT("reverse"));
return true;
}
void UPS_AI_Behavior_SplineFollowerComponent::StopFollowing()
{
bIsFollowing = false;
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Stopped following spline."),
GetOwner() ? *GetOwner()->GetName() : TEXT("?"));
}
void UPS_AI_Behavior_SplineFollowerComponent::PauseFollowing()
{
bIsFollowing = false;
}
void UPS_AI_Behavior_SplineFollowerComponent::ResumeFollowing()
{
if (CurrentSpline)
{
bIsFollowing = true;
}
}
void UPS_AI_Behavior_SplineFollowerComponent::SwitchToSpline(
APS_AI_Behavior_SplinePath* NewSpline, float DistanceOnNew, bool bNewForward)
{
if (!NewSpline) return;
APS_AI_Behavior_SplinePath* OldSpline = CurrentSpline;
CurrentSpline = NewSpline;
CurrentDistance = FMath::Clamp(DistanceOnNew, 0.0f, NewSpline->GetSplineLength());
bMovingForward = bNewForward;
LastHandledJunctionIndex = -1;
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Switched to spline '%s' at d=%.0f"),
GetOwner() ? *GetOwner()->GetName() : TEXT("?"), *NewSpline->GetName(), CurrentDistance);
if (OldSpline != NewSpline)
{
OnSplineChanged.Broadcast(OldSpline, NewSpline);
}
}
float UPS_AI_Behavior_SplineFollowerComponent::GetEffectiveSpeed() const
{
float Speed = DefaultWalkSpeed;
if (CurrentSpline && CurrentSpline->SplineWalkSpeed > 0.0f)
{
Speed = CurrentSpline->SplineWalkSpeed;
}
return Speed * SpeedVariation;
}
float UPS_AI_Behavior_SplineFollowerComponent::GetProgress() const
{
if (!CurrentSpline) return 0.0f;
const float Len = CurrentSpline->GetSplineLength();
return (Len > 0.0f) ? (CurrentDistance / Len) : 0.0f;
}
void UPS_AI_Behavior_SplineFollowerComponent::TickComponent(
float DeltaTime, ELevelTick TickType, FActorComponentTickFunction* ThisTickFunction)
{
Super::TickComponent(DeltaTime, TickType, ThisTickFunction);
if (!bIsFollowing || !CurrentSpline || !GetOwner())
{
return;
}
const float SplineLen = CurrentSpline->GetSplineLength();
if (SplineLen <= 0.0f)
{
return;
}
// ─── Advance along spline ───────────────────────────────────────────
const float Speed = GetEffectiveSpeed();
const float Delta = Speed * DeltaTime;
if (bMovingForward)
{
CurrentDistance += Delta;
}
else
{
CurrentDistance -= Delta;
}
// ─── End of spline handling ─────────────────────────────────────────
if (CurrentDistance >= SplineLen)
{
if (CurrentSpline->SplineComp && CurrentSpline->SplineComp->IsClosedLoop())
{
CurrentDistance = FMath::Fmod(CurrentDistance, SplineLen);
}
else if (bReverseAtEnd)
{
CurrentDistance = SplineLen - (CurrentDistance - SplineLen);
bMovingForward = false;
}
else
{
CurrentDistance = SplineLen;
bIsFollowing = false;
OnSplineEndReached.Broadcast(CurrentSpline);
return;
}
}
else if (CurrentDistance <= 0.0f)
{
if (CurrentSpline->SplineComp && CurrentSpline->SplineComp->IsClosedLoop())
{
CurrentDistance = SplineLen + CurrentDistance;
}
else if (bReverseAtEnd)
{
CurrentDistance = -CurrentDistance;
bMovingForward = true;
}
else
{
CurrentDistance = 0.0f;
bIsFollowing = false;
OnSplineEndReached.Broadcast(CurrentSpline);
return;
}
}
// ─── Move the pawn ──────────────────────────────────────────────────
const FVector TargetLocation = CurrentSpline->GetWorldLocationAtDistance(CurrentDistance);
const FRotator TargetRotation = CurrentSpline->GetWorldRotationAtDistance(CurrentDistance);
AActor* Owner = GetOwner();
const FVector CurrentLocation = Owner->GetActorLocation();
const FRotator CurrentRotation = Owner->GetActorRotation();
// Use Character movement if available for proper physics/collision
ACharacter* Character = Cast<ACharacter>(Owner);
if (Character && Character->GetCharacterMovement())
{
// Compute velocity to reach the spline point
FVector DesiredVelocity = (TargetLocation - CurrentLocation) / FMath::Max(DeltaTime, 0.001f);
// Clamp to avoid teleporting on large frame spikes
const float MaxVel = Speed * 3.0f;
if (DesiredVelocity.SizeSquared() > MaxVel * MaxVel)
{
DesiredVelocity = DesiredVelocity.GetSafeNormal() * MaxVel;
}
Character->GetCharacterMovement()->RequestDirectMove(DesiredVelocity, /*bForceMaxSpeed=*/false);
}
else
{
// Direct placement for non-Characters
Owner->SetActorLocation(TargetLocation);
}
// Smooth rotation — flip if going backward
FRotator FinalTargetRot = TargetRotation;
if (!bMovingForward)
{
FinalTargetRot.Yaw += 180.0f;
}
const FRotator SmoothedRot = FMath::RInterpConstantTo(
CurrentRotation, FinalTargetRot, DeltaTime, RotationInterpSpeed);
Owner->SetActorRotation(FRotator(0.0f, SmoothedRot.Yaw, 0.0f)); // Only yaw
// ─── Junction handling ──────────────────────────────────────────────
HandleJunctions();
}
void UPS_AI_Behavior_SplineFollowerComponent::HandleJunctions()
{
if (!CurrentSpline) return;
const TArray<FPS_AI_Behavior_SplineJunction>& Junctions = CurrentSpline->Junctions;
if (Junctions.Num() == 0) return;
for (int32 i = 0; i < Junctions.Num(); ++i)
{
if (i == LastHandledJunctionIndex)
{
continue;
}
const FPS_AI_Behavior_SplineJunction& J = Junctions[i];
const float DistToJunction = FMath::Abs(J.DistanceOnThisSpline - CurrentDistance);
// Are we approaching this junction?
if (DistToJunction <= JunctionDetectionDistance)
{
// Check direction: only handle junctions ahead of us
if (bMovingForward && J.DistanceOnThisSpline < CurrentDistance)
{
continue; // Junction is behind us
}
if (!bMovingForward && J.DistanceOnThisSpline > CurrentDistance)
{
continue; // Junction is behind us
}
LastHandledJunctionIndex = i;
OnApproachingJunction.Broadcast(CurrentSpline, i, DistToJunction);
if (bAutoChooseAtJunction)
{
// Use SplineNetwork subsystem to choose
UPS_AI_Behavior_SplineNetwork* Network =
GetWorld()->GetSubsystem<UPS_AI_Behavior_SplineNetwork>();
if (!Network) break;
// Get NPC type and caution from personality
EPS_AI_Behavior_NPCType NPCType = EPS_AI_Behavior_NPCType::Civilian;
float Caution = 0.5f;
UPS_AI_Behavior_PersonalityComponent* Personality =
GetOwner()->FindComponentByClass<UPS_AI_Behavior_PersonalityComponent>();
if (Personality)
{
NPCType = Personality->GetNPCType();
Caution = Personality->GetTrait(EPS_AI_Behavior_TraitAxis::Caution);
}
APS_AI_Behavior_SplinePath* ChosenSpline = Network->ChooseSplineAtJunction(
CurrentSpline, i, NPCType, FVector::ZeroVector, Caution);
if (ChosenSpline && ChosenSpline != CurrentSpline)
{
SwitchToSpline(ChosenSpline, J.DistanceOnOtherSpline, bMovingForward);
}
}
break; // Only handle one junction per tick
}
}
}

View File

@ -0,0 +1,257 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior_SplineNetwork.h"
#include "PS_AI_Behavior_SplinePath.h"
#include "Components/SplineComponent.h"
#include "EngineUtils.h"
#include "Engine/World.h"
// ─── Subsystem Lifecycle ────────────────────────────────────────────────────
void UPS_AI_Behavior_SplineNetwork::Initialize(FSubsystemCollectionBase& Collection)
{
Super::Initialize(Collection);
}
void UPS_AI_Behavior_SplineNetwork::Deinitialize()
{
AllSplines.Empty();
TotalJunctions = 0;
Super::Deinitialize();
}
void UPS_AI_Behavior_SplineNetwork::OnWorldBeginPlay(UWorld& InWorld)
{
Super::OnWorldBeginPlay(InWorld);
RebuildNetwork();
}
// ─── Network Build ──────────────────────────────────────────────────────────
void UPS_AI_Behavior_SplineNetwork::RebuildNetwork()
{
UWorld* World = GetWorld();
if (!World) return;
AllSplines.Empty();
TotalJunctions = 0;
// Gather all spline paths
for (TActorIterator<APS_AI_Behavior_SplinePath> It(World); It; ++It)
{
AllSplines.Add(*It);
It->Junctions.Empty(); // Reset
}
UE_LOG(LogPS_AI_Behavior, Log, TEXT("SplineNetwork: Found %d spline paths. Computing junctions..."),
AllSplines.Num());
// Junction detection tolerance (cm) — how close two splines must be to form a junction
constexpr float JunctionTolerance = 150.0f;
// Detect junctions for all pairs
for (int32 i = 0; i < AllSplines.Num(); ++i)
{
for (int32 j = i + 1; j < AllSplines.Num(); ++j)
{
if (AllSplines[i] && AllSplines[j])
{
DetectJunctions(AllSplines[i], AllSplines[j], JunctionTolerance);
}
}
}
// Sort junctions by distance along spline for each path
for (APS_AI_Behavior_SplinePath* Spline : AllSplines)
{
if (Spline)
{
Spline->Junctions.Sort([](const FPS_AI_Behavior_SplineJunction& A,
const FPS_AI_Behavior_SplineJunction& B)
{
return A.DistanceOnThisSpline < B.DistanceOnThisSpline;
});
}
}
UE_LOG(LogPS_AI_Behavior, Log, TEXT("SplineNetwork: %d junctions detected."), TotalJunctions);
}
void UPS_AI_Behavior_SplineNetwork::DetectJunctions(
APS_AI_Behavior_SplinePath* SplineA,
APS_AI_Behavior_SplinePath* SplineB,
float Tolerance)
{
if (!SplineA || !SplineB) return;
if (!SplineA->SplineComp || !SplineB->SplineComp) return;
const float LengthA = SplineA->GetSplineLength();
const float LengthB = SplineB->GetSplineLength();
if (LengthA <= 0.0f || LengthB <= 0.0f) return;
// Sample SplineA at regular intervals and check proximity to SplineB
const float SampleStep = FMath::Max(50.0f, LengthA / 200.0f); // At least every 50cm
const float ToleranceSq = Tolerance * Tolerance;
// Track the last junction distance to avoid duplicates (merge nearby junctions)
float LastJunctionDistA = -Tolerance * 3.0f;
for (float DistA = 0.0f; DistA <= LengthA; DistA += SampleStep)
{
// Skip if too close to last detected junction
if (DistA - LastJunctionDistA < Tolerance * 2.0f)
{
continue;
}
const FVector PointA = SplineA->GetWorldLocationAtDistance(DistA);
// Find closest point on SplineB
float DistB = 0.0f;
FVector PointB = FVector::ZeroVector;
const float Gap = SplineB->GetClosestPointOnSpline(PointA, DistB, PointB);
if (Gap <= Tolerance)
{
// Found a junction!
const FVector JunctionLoc = (PointA + PointB) * 0.5f;
// Add junction to SplineA
FPS_AI_Behavior_SplineJunction JunctionOnA;
JunctionOnA.OtherSpline = SplineB;
JunctionOnA.DistanceOnThisSpline = DistA;
JunctionOnA.DistanceOnOtherSpline = DistB;
JunctionOnA.WorldLocation = JunctionLoc;
SplineA->Junctions.Add(JunctionOnA);
// Add mirror junction to SplineB
FPS_AI_Behavior_SplineJunction JunctionOnB;
JunctionOnB.OtherSpline = SplineA;
JunctionOnB.DistanceOnThisSpline = DistB;
JunctionOnB.DistanceOnOtherSpline = DistA;
JunctionOnB.WorldLocation = JunctionLoc;
SplineB->Junctions.Add(JunctionOnB);
++TotalJunctions;
LastJunctionDistA = DistA;
UE_LOG(LogPS_AI_Behavior, Verbose,
TEXT("SplineNetwork: Junction at (%.0f, %.0f, %.0f) between '%s' (d=%.0f) and '%s' (d=%.0f), gap=%.1fcm"),
JunctionLoc.X, JunctionLoc.Y, JunctionLoc.Z,
*SplineA->GetName(), DistA,
*SplineB->GetName(), DistB,
Gap);
}
}
}
// ─── Queries ────────────────────────────────────────────────────────────────
bool UPS_AI_Behavior_SplineNetwork::FindClosestSpline(
const FVector& WorldLocation, EPS_AI_Behavior_NPCType NPCType,
float MaxDistance, APS_AI_Behavior_SplinePath*& OutSpline, float& OutDistance) const
{
OutSpline = nullptr;
float BestGap = MaxDistance;
for (APS_AI_Behavior_SplinePath* Spline : AllSplines)
{
if (!Spline || !Spline->IsAccessibleTo(NPCType))
{
continue;
}
float Dist = 0.0f;
FVector ClosestPoint;
const float Gap = Spline->GetClosestPointOnSpline(WorldLocation, Dist, ClosestPoint);
if (Gap < BestGap)
{
BestGap = Gap;
OutSpline = Spline;
OutDistance = Dist;
}
}
return OutSpline != nullptr;
}
TArray<APS_AI_Behavior_SplinePath*> UPS_AI_Behavior_SplineNetwork::GetSplinesForCategory(
EPS_AI_Behavior_NPCType Category) const
{
TArray<APS_AI_Behavior_SplinePath*> Result;
for (APS_AI_Behavior_SplinePath* Spline : AllSplines)
{
if (Spline && Spline->IsAccessibleTo(Category))
{
Result.Add(Spline);
}
}
return Result;
}
APS_AI_Behavior_SplinePath* UPS_AI_Behavior_SplineNetwork::ChooseSplineAtJunction(
APS_AI_Behavior_SplinePath* CurrentSpline, int32 JunctionIndex,
EPS_AI_Behavior_NPCType NPCType,
const FVector& ThreatLocation, float CautionScore) const
{
if (!CurrentSpline || !CurrentSpline->Junctions.IsValidIndex(JunctionIndex))
{
return CurrentSpline;
}
const FPS_AI_Behavior_SplineJunction& Junction = CurrentSpline->Junctions[JunctionIndex];
APS_AI_Behavior_SplinePath* OtherSpline = Junction.OtherSpline.Get();
// If other spline is invalid or not accessible, stay
if (!OtherSpline || !OtherSpline->IsAccessibleTo(NPCType))
{
return CurrentSpline;
}
// Score each option
float CurrentScore = 0.0f;
float OtherScore = 0.0f;
// Priority
CurrentScore += CurrentSpline->Priority * 10.0f;
OtherScore += OtherSpline->Priority * 10.0f;
// Randomness for natural behavior (less random if disciplined)
const float RandomRange = FMath::Lerp(30.0f, 5.0f, CautionScore);
CurrentScore += FMath::RandRange(-RandomRange, RandomRange);
OtherScore += FMath::RandRange(-RandomRange, RandomRange);
// Threat avoidance (if threat is present)
if (!ThreatLocation.IsZero())
{
const FVector JunctionLoc = Junction.WorldLocation;
// How far along each spline leads away from threat
// Sample a point ahead on each spline
const float SampleAhead = 500.0f;
const float CurrentDist = Junction.DistanceOnThisSpline;
const float CurrentLen = CurrentSpline->GetSplineLength();
const FVector CurrentAhead = CurrentSpline->GetWorldLocationAtDistance(
FMath::Min(CurrentDist + SampleAhead, CurrentLen));
const float CurrentDistFromThreat = FVector::Dist(CurrentAhead, ThreatLocation);
const float OtherDist = Junction.DistanceOnOtherSpline;
const float OtherLen = OtherSpline->GetSplineLength();
const FVector OtherAhead = OtherSpline->GetWorldLocationAtDistance(
FMath::Min(OtherDist + SampleAhead, OtherLen));
const float OtherDistFromThreat = FVector::Dist(OtherAhead, ThreatLocation);
// Cautious NPCs heavily favor paths away from threat
const float ThreatWeight = 20.0f * CautionScore;
CurrentScore += (CurrentDistFromThreat / 100.0f) * ThreatWeight;
OtherScore += (OtherDistFromThreat / 100.0f) * ThreatWeight;
}
// Slight bias toward continuing on current spline (inertia)
CurrentScore += 5.0f;
return (OtherScore > CurrentScore) ? OtherSpline : CurrentSpline;
}

View File

@ -0,0 +1,141 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior_SplinePath.h"
#include "Components/SplineComponent.h"
APS_AI_Behavior_SplinePath::APS_AI_Behavior_SplinePath()
{
PrimaryActorTick.bCanEverTick = false;
SplineComp = CreateDefaultSubobject<USplineComponent>(TEXT("SplineComp"));
RootComponent = SplineComp;
// Defaults for a nice visible path
SplineComp->SetDrawDebug(true);
SplineComp->SetUnselectedSplineSegmentColor(FLinearColor::Green);
}
void APS_AI_Behavior_SplinePath::BeginPlay()
{
Super::BeginPlay();
UpdateSplineVisualization();
}
bool APS_AI_Behavior_SplinePath::IsAccessibleTo(EPS_AI_Behavior_NPCType NPCType) const
{
// Any spline → accessible to all
if (SplineCategory == EPS_AI_Behavior_NPCType::Any)
{
return true;
}
// Exact match
if (SplineCategory == NPCType)
{
return true;
}
// Protectors can also use Civilian splines (allies)
if (SplineCategory == EPS_AI_Behavior_NPCType::Civilian
&& NPCType == EPS_AI_Behavior_NPCType::Protector)
{
return true;
}
return false;
}
float APS_AI_Behavior_SplinePath::GetClosestPointOnSpline(
const FVector& WorldLocation, float& OutDistance, FVector& OutWorldPoint) const
{
if (!SplineComp) return MAX_FLT;
const float InputKey = SplineComp->FindInputKeyClosestToWorldLocation(WorldLocation);
OutDistance = SplineComp->GetDistanceAlongSplineAtSplineInputKey(InputKey);
OutWorldPoint = SplineComp->GetLocationAtDistanceAlongSpline(OutDistance, ESplineCoordinateSpace::World);
return FVector::Dist(WorldLocation, OutWorldPoint);
}
TArray<FPS_AI_Behavior_SplineJunction> APS_AI_Behavior_SplinePath::GetUpcomingJunctions(
float CurrentDistance, float LookAheadDist, bool bForward) const
{
TArray<FPS_AI_Behavior_SplineJunction> Result;
for (const FPS_AI_Behavior_SplineJunction& J : Junctions)
{
if (bForward)
{
if (J.DistanceOnThisSpline > CurrentDistance
&& J.DistanceOnThisSpline <= CurrentDistance + LookAheadDist)
{
Result.Add(J);
}
}
else
{
if (J.DistanceOnThisSpline < CurrentDistance
&& J.DistanceOnThisSpline >= CurrentDistance - LookAheadDist)
{
Result.Add(J);
}
}
}
return Result;
}
float APS_AI_Behavior_SplinePath::GetSplineLength() const
{
return SplineComp ? SplineComp->GetSplineLength() : 0.0f;
}
FVector APS_AI_Behavior_SplinePath::GetWorldLocationAtDistance(float Distance) const
{
if (!SplineComp) return FVector::ZeroVector;
return SplineComp->GetLocationAtDistanceAlongSpline(Distance, ESplineCoordinateSpace::World);
}
FRotator APS_AI_Behavior_SplinePath::GetWorldRotationAtDistance(float Distance) const
{
if (!SplineComp) return FRotator::ZeroRotator;
return SplineComp->GetRotationAtDistanceAlongSpline(Distance, ESplineCoordinateSpace::World);
}
void APS_AI_Behavior_SplinePath::UpdateSplineVisualization()
{
if (!SplineComp) return;
FLinearColor Color;
switch (SplineCategory)
{
case EPS_AI_Behavior_NPCType::Civilian:
Color = FLinearColor::Green;
break;
case EPS_AI_Behavior_NPCType::Enemy:
Color = FLinearColor::Red;
break;
case EPS_AI_Behavior_NPCType::Protector:
Color = FLinearColor(0.2f, 0.4f, 1.0f); // Blue
break;
case EPS_AI_Behavior_NPCType::Any:
default:
Color = FLinearColor(1.0f, 0.7f, 0.0f); // Orange
break;
}
SplineComp->SetUnselectedSplineSegmentColor(Color);
SplineComp->SetSelectedSplineSegmentColor(FLinearColor::White);
}
#if WITH_EDITOR
void APS_AI_Behavior_SplinePath::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent)
{
Super::PostEditChangeProperty(PropertyChangedEvent);
if (PropertyChangedEvent.GetPropertyName() == GET_MEMBER_NAME_CHECKED(APS_AI_Behavior_SplinePath, SplineCategory))
{
UpdateSplineVisualization();
}
}
#endif

View File

@ -0,0 +1,48 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTDecorator.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_BTDecorator_CheckTrait.generated.h"
/** Comparison operator for trait checks. */
UENUM(BlueprintType)
enum class EPS_AI_Behavior_ComparisonOp : uint8
{
GreaterThan UMETA(DisplayName = ">"),
GreaterOrEqual UMETA(DisplayName = ">="),
LessThan UMETA(DisplayName = "<"),
LessOrEqual UMETA(DisplayName = "<="),
Equal UMETA(DisplayName = "=="),
};
/**
* BT Decorator: Checks a personality trait against a threshold.
* Use to gate branches: e.g. "Only attack if Courage > 0.5".
*/
UCLASS(meta = (DisplayName = "PS AI: Check Trait"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTDecorator_CheckTrait : public UBTDecorator
{
GENERATED_BODY()
public:
UPS_AI_Behavior_BTDecorator_CheckTrait();
/** Which personality axis to check. */
UPROPERTY(EditAnywhere, Category = "Trait Check")
EPS_AI_Behavior_TraitAxis TraitAxis = EPS_AI_Behavior_TraitAxis::Courage;
/** Comparison operator. */
UPROPERTY(EditAnywhere, Category = "Trait Check")
EPS_AI_Behavior_ComparisonOp Comparison = EPS_AI_Behavior_ComparisonOp::GreaterThan;
/** Threshold value to compare against. */
UPROPERTY(EditAnywhere, Category = "Trait Check", meta = (ClampMin = "0.0", ClampMax = "1.0"))
float Threshold = 0.5f;
protected:
virtual bool CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const override;
virtual FString GetStaticDescription() const override;
};

View File

@ -0,0 +1,26 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTService.h"
#include "PS_AI_Behavior_BTService_EvaluateReaction.generated.h"
/**
* BT Service: Evaluates the NPC's reaction based on personality traits and threat level.
* Calls PersonalityComponent::ApplyReaction() and writes the resulting state to the Blackboard.
*
* Should be placed alongside or below BTService_UpdateThreat in the tree.
*/
UCLASS(meta = (DisplayName = "PS AI: Evaluate Reaction"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTService_EvaluateReaction : public UBTService
{
GENERATED_BODY()
public:
UPS_AI_Behavior_BTService_EvaluateReaction();
protected:
virtual void TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
virtual FString GetStaticDescription() const override;
};

View File

@ -0,0 +1,28 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTService.h"
#include "PS_AI_Behavior_BTService_UpdateThreat.generated.h"
/**
* BT Service: Updates threat information in the Blackboard.
* Queries PerceptionComponent for the highest threat actor and threat level.
* Writes: BB_ThreatActor, BB_ThreatLocation, BB_ThreatLevel.
* Also updates PersonalityComponent::PerceivedThreatLevel.
*
* Place on the root node of any behavior tree that needs threat awareness.
*/
UCLASS(meta = (DisplayName = "PS AI: Update Threat"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTService_UpdateThreat : public UBTService
{
GENERATED_BODY()
public:
UPS_AI_Behavior_BTService_UpdateThreat();
protected:
virtual void TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
virtual FString GetStaticDescription() const override;
};

View File

@ -0,0 +1,35 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "PS_AI_Behavior_BTTask_Attack.generated.h"
/**
* BT Task: Move to and attack the threat actor.
* If out of range, moves toward the target. If in range, executes attack via CombatComponent.
* Succeeds after one attack, fails if target is lost or unreachable.
*/
UCLASS(meta = (DisplayName = "PS AI: Attack"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTTask_Attack : public UBTTaskNode
{
GENERATED_BODY()
public:
UPS_AI_Behavior_BTTask_Attack();
protected:
virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
virtual void TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
virtual EBTNodeResult::Type AbortTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
virtual FString GetStaticDescription() const override;
private:
struct FAttackMemory
{
bool bMovingToTarget = false;
};
virtual uint16 GetInstanceMemorySize() const override { return sizeof(FAttackMemory); }
};

View File

@ -0,0 +1,50 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "PS_AI_Behavior_BTTask_FindAndFollowSpline.generated.h"
/**
* BT Task: Find the nearest accessible spline and start following it.
* Uses the SplineNetwork subsystem to find the closest spline matching the NPC's type.
* Then activates the SplineFollowerComponent.
*
* Succeeds immediately after starting use BTTask_FollowSpline to actually follow.
* Or use this as a setup node in a Sequence before BTTask_FollowSpline.
*/
UCLASS(meta = (DisplayName = "PS AI: Find & Start Spline"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTTask_FindAndFollowSpline : public UBTTaskNode
{
GENERATED_BODY()
public:
UPS_AI_Behavior_BTTask_FindAndFollowSpline();
/** Maximum distance to search for a spline (cm). */
UPROPERTY(EditAnywhere, Category = "Spline", meta = (ClampMin = "100.0"))
float MaxSearchDistance = 3000.0f;
/** If true, move toward the closest spline point before starting. */
UPROPERTY(EditAnywhere, Category = "Spline")
bool bWalkToSpline = true;
/** Acceptance radius for reaching the spline starting point (cm). */
UPROPERTY(EditAnywhere, Category = "Spline", meta = (ClampMin = "10.0", EditCondition = "bWalkToSpline"))
float AcceptanceRadius = 100.0f;
protected:
virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
virtual void TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
virtual EBTNodeResult::Type AbortTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
virtual FString GetStaticDescription() const override;
private:
struct FFindSplineMemory
{
bool bMovingToSpline = false;
};
virtual uint16 GetInstanceMemorySize() const override { return sizeof(FFindSplineMemory); }
};

View File

@ -0,0 +1,93 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_BTTask_FindCover.generated.h"
class APS_AI_Behavior_CoverPoint;
/**
* BT Task: Find a cover position and navigate to it.
*
* First checks for manually placed CoverPoint actors in range.
* If none found (or bUseManualPointsOnly is false), falls back to
* procedural raycast-based cover finding.
*
* Writes CoverLocation and CoverPoint to the Blackboard on success.
*/
UCLASS(meta = (DisplayName = "PS AI: Find Cover"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTTask_FindCover : public UBTTaskNode
{
GENERATED_BODY()
public:
UPS_AI_Behavior_BTTask_FindCover();
/** Search radius around the NPC for cover candidates (cm). */
UPROPERTY(EditAnywhere, Category = "Cover", meta = (ClampMin = "200.0"))
float SearchRadius = 1500.0f;
/** Number of procedural candidate points to evaluate. */
UPROPERTY(EditAnywhere, Category = "Cover|Procedural", meta = (ClampMin = "4", ClampMax = "32"))
int32 NumCandidates = 12;
/** Acceptance radius for reaching cover (cm). */
UPROPERTY(EditAnywhere, Category = "Cover", meta = (ClampMin = "10.0"))
float AcceptanceRadius = 80.0f;
/** Minimum height of geometry considered as cover (cm). */
UPROPERTY(EditAnywhere, Category = "Cover|Procedural", meta = (ClampMin = "30.0"))
float MinCoverHeight = 90.0f;
/**
* Cover point type to search for (Cover for enemies, HidingSpot for civilians).
*/
UPROPERTY(EditAnywhere, Category = "Cover|Manual Points")
EPS_AI_Behavior_CoverPointType CoverPointType = EPS_AI_Behavior_CoverPointType::Cover;
/**
* Bonus score added to manual CoverPoints over procedural candidates.
* Higher = manual points are strongly preferred.
*/
UPROPERTY(EditAnywhere, Category = "Cover|Manual Points", meta = (ClampMin = "0.0", ClampMax = "1.0"))
float ManualPointBonus = 0.3f;
/**
* If true, only use manually placed CoverPoints never procedural.
* If false (default), manual points are preferred but procedural is fallback.
*/
UPROPERTY(EditAnywhere, Category = "Cover|Manual Points")
bool bUseManualPointsOnly = false;
protected:
virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
virtual void TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
virtual EBTNodeResult::Type AbortTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
virtual FString GetStaticDescription() const override;
private:
struct FCoverMemory
{
bool bMoveRequested = false;
};
virtual uint16 GetInstanceMemorySize() const override { return sizeof(FCoverMemory); }
/**
* Evaluate cover quality at a given position.
* Returns 0.0 (bad) to 1.0 (excellent cover).
*/
float EvaluateCoverQuality(const UWorld* World, const FVector& CandidatePos,
const FVector& ThreatLoc, const FVector& NpcLoc) const;
/**
* Search for the best manual CoverPoint in range.
* Returns the best point and its score, or nullptr if none found.
*/
APS_AI_Behavior_CoverPoint* FindBestManualCoverPoint(
const UWorld* World, const FVector& NpcLoc, const FVector& ThreatLoc,
EPS_AI_Behavior_NPCType NPCType, float& OutScore) const;
};

View File

@ -0,0 +1,50 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "PS_AI_Behavior_BTTask_FleeFrom.generated.h"
/**
* BT Task: Flee away from the current threat.
* Finds a point in the opposite direction of ThreatLocation and navigates to it.
* Can optionally use an EQS query for smarter flee-point selection.
*/
UCLASS(meta = (DisplayName = "PS AI: Flee From Threat"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTTask_FleeFrom : public UBTTaskNode
{
GENERATED_BODY()
public:
UPS_AI_Behavior_BTTask_FleeFrom();
/** Minimum flee distance from threat (cm). */
UPROPERTY(EditAnywhere, Category = "Flee", meta = (ClampMin = "200.0"))
float MinFleeDistance = 1000.0f;
/** Maximum flee distance from threat (cm). */
UPROPERTY(EditAnywhere, Category = "Flee", meta = (ClampMin = "500.0"))
float MaxFleeDistance = 2500.0f;
/** Acceptance radius for the flee destination (cm). */
UPROPERTY(EditAnywhere, Category = "Flee", meta = (ClampMin = "10.0"))
float AcceptanceRadius = 150.0f;
protected:
virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
virtual void TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
virtual EBTNodeResult::Type AbortTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
virtual FString GetStaticDescription() const override;
private:
struct FFleeMemory
{
bool bMoveRequested = false;
};
virtual uint16 GetInstanceMemorySize() const override { return sizeof(FFleeMemory); }
/** Find a navmesh-projected point away from the threat. */
bool FindFleePoint(const FVector& Origin, const FVector& ThreatLoc, FVector& OutFleePoint) const;
};

View File

@ -0,0 +1,56 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "PS_AI_Behavior_BTTask_FollowSpline.generated.h"
/**
* BT Task: Follow the current spline path.
* Uses the SplineFollowerComponent on the Pawn.
*
* This task runs InProgress while the NPC moves along the spline.
* It succeeds when the end of the spline is reached (if not looping/reversing).
* It can be aborted to interrupt spline movement.
*
* To start on a specific spline, use BTTask_FindAndFollowSpline first,
* or set CurrentSpline via Blueprint/code before this task runs.
*/
UCLASS(meta = (DisplayName = "PS AI: Follow Spline"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTTask_FollowSpline : public UBTTaskNode
{
GENERATED_BODY()
public:
UPS_AI_Behavior_BTTask_FollowSpline();
/**
* Maximum time (seconds) this task will follow the spline before succeeding.
* 0 = no time limit (run until end of spline or abort).
*/
UPROPERTY(EditAnywhere, Category = "Spline", meta = (ClampMin = "0.0"))
float MaxFollowTime = 0.0f;
/**
* If true, picks a random direction when starting.
* If false, continues in the current direction.
*/
UPROPERTY(EditAnywhere, Category = "Spline")
bool bRandomDirection = false;
protected:
virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
virtual void TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
virtual EBTNodeResult::Type AbortTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
virtual FString GetStaticDescription() const override;
private:
struct FFollowMemory
{
float Elapsed = 0.0f;
bool bEndReached = false;
};
virtual uint16 GetInstanceMemorySize() const override { return sizeof(FFollowMemory); }
};

View File

@ -0,0 +1,52 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "PS_AI_Behavior_BTTask_Patrol.generated.h"
/**
* BT Task: Navigate to the next patrol waypoint.
* Reads PatrolIndex from Blackboard, navigates to the corresponding point in
* the AIController's PatrolPoints array, then increments the index (cyclic).
*
* Optional random wait at each waypoint.
*/
UCLASS(meta = (DisplayName = "PS AI: Patrol"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTTask_Patrol : public UBTTaskNode
{
GENERATED_BODY()
public:
UPS_AI_Behavior_BTTask_Patrol();
/** Acceptance radius for reaching a waypoint (cm). */
UPROPERTY(EditAnywhere, Category = "Patrol", meta = (ClampMin = "10.0"))
float AcceptanceRadius = 100.0f;
/** Minimum wait time at each waypoint (seconds). */
UPROPERTY(EditAnywhere, Category = "Patrol", meta = (ClampMin = "0.0"))
float MinWaitTime = 1.0f;
/** Maximum wait time at each waypoint (seconds). */
UPROPERTY(EditAnywhere, Category = "Patrol", meta = (ClampMin = "0.0"))
float MaxWaitTime = 4.0f;
protected:
virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
virtual void TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
virtual EBTNodeResult::Type AbortTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
virtual FString GetStaticDescription() const override;
private:
/** Per-instance memory. */
struct FPatrolMemory
{
float WaitRemaining = 0.0f;
bool bIsWaiting = false;
bool bMoveRequested = false;
};
virtual uint16 GetInstanceMemorySize() const override { return sizeof(FPatrolMemory); }
};

View File

@ -0,0 +1,22 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "EnvironmentQuery/EnvQueryContext.h"
#include "PS_AI_Behavior_EQSContext_Threat.generated.h"
/**
* EQS Context: Returns the current threat actor (or its last known location).
* Reads from the Blackboard keys ThreatActor / ThreatLocation.
* Use in EQS queries as the "Threat" context for distance/visibility tests.
*/
UCLASS(meta = (DisplayName = "PS AI: Threat"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_EQSContext_Threat : public UEnvQueryContext
{
GENERATED_BODY()
public:
virtual void ProvideContext(FEnvQueryInstance& QueryInstance,
FEnvQueryContextData& ContextData) const override;
};

View File

@ -0,0 +1,41 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "EnvironmentQuery/EnvQueryGenerator.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_EQSGenerator_CoverPoints.generated.h"
/**
* EQS Generator: returns all CoverPoint actors in the level as query items.
* Filters by: type (Cover/HidingSpot), NPC type accessibility, availability (HasRoom),
* and max distance from querier.
*
* Use with EQSTest_CoverQuality for scoring, or with standard distance/trace tests.
*/
UCLASS(meta = (DisplayName = "PS AI: Cover Points"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_EQSGenerator_CoverPoints : public UEnvQueryGenerator
{
GENERATED_BODY()
public:
UPS_AI_Behavior_EQSGenerator_CoverPoints();
/** Filter by cover point type. */
UPROPERTY(EditDefaultsOnly, Category = "Generator")
EPS_AI_Behavior_CoverPointType PointTypeFilter = EPS_AI_Behavior_CoverPointType::Cover;
/** Maximum distance from querier to include a cover point (cm). */
UPROPERTY(EditDefaultsOnly, Category = "Generator", meta = (ClampMin = "100.0"))
float MaxDistance = 3000.0f;
/** Only include points that have room for another occupant. */
UPROPERTY(EditDefaultsOnly, Category = "Generator")
bool bOnlyAvailable = true;
protected:
virtual void GenerateItems(FEnvQueryInstance& QueryInstance) const override;
virtual FText GetDescriptionTitle() const override;
virtual FText GetDescriptionDetails() const override;
};

View File

@ -0,0 +1,40 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "EnvironmentQuery/EnvQueryTest.h"
#include "PS_AI_Behavior_EQSTest_CoverQuality.generated.h"
/**
* EQS Test: Evaluates how well a candidate point provides cover from a threat context.
* Performs raycasts at multiple heights to assess visual concealment.
* Higher score = better cover.
*
* Use with EQSContext_Threat as the context for the "threat from" parameter.
*/
UCLASS(meta = (DisplayName = "PS AI: Cover Quality"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_EQSTest_CoverQuality : public UEnvQueryTest
{
GENERATED_BODY()
public:
UPS_AI_Behavior_EQSTest_CoverQuality();
/** Number of raycasts from candidate to threat at different heights. */
UPROPERTY(EditDefaultsOnly, Category = "Cover", meta = (ClampMin = "1", ClampMax = "5"))
int32 NumTraceHeights = 3;
/** Minimum height for the lowest trace (cm above ground). */
UPROPERTY(EditDefaultsOnly, Category = "Cover", meta = (ClampMin = "10.0"))
float MinTraceHeight = 50.0f;
/** Maximum height for the highest trace (cm above ground). */
UPROPERTY(EditDefaultsOnly, Category = "Cover", meta = (ClampMin = "50.0"))
float MaxTraceHeight = 180.0f;
protected:
virtual void RunTest(FEnvQueryInstance& QueryInstance) const override;
virtual FText GetDescriptionTitle() const override;
virtual FText GetDescriptionDetails() const override;
};

View File

@ -0,0 +1,12 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "Modules/ModuleManager.h"
class FPS_AI_BehaviorModule : public IModuleInterface
{
public:
virtual void StartupModule() override;
virtual void ShutdownModule() override;
};

View File

@ -0,0 +1,116 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "AIController.h"
#include "GenericTeamAgentInterface.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_AIController.generated.h"
class UPS_AI_Behavior_PerceptionComponent;
class UPS_AI_Behavior_PersonalityComponent;
class UBehaviorTree;
class UBlackboardData;
class UBlackboardComponent;
/**
* Base AI Controller for the PS AI Behavior system.
* Manages Blackboard setup, Behavior Tree execution, perception, and patrol waypoints.
* Automatically discovers PersonalityComponent on the possessed Pawn.
*
* Optionally detects PS_AI_ConvAgent_ElevenLabsComponent at runtime (no compile dependency).
*/
UCLASS(BlueprintType, Blueprintable)
class PS_AI_BEHAVIOR_API APS_AI_Behavior_AIController : public AAIController
{
GENERATED_BODY()
public:
APS_AI_Behavior_AIController();
// ─── Team / Affiliation ─────────────────────────────────────────────
/**
* Team ID determines perception affiliation (Enemy/Friendly/Neutral).
* Auto-assigned from NPCType at possession if left at 255 (NoTeam):
* - Civilian = Team 1
* - Enemy = Team 2
* - Neutral = 255 (NoTeam perceived as Neutral by everyone)
*
* Two NPCs with the SAME Team ID Friendly (ignored by perception).
* Two NPCs with DIFFERENT Team IDs Enemy (detected by perception).
* A NPC with Team ID 255 Neutral to everyone.
*
* You can override this in Blueprint or per-instance in the editor.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Team")
uint8 TeamId = FGenericTeamId::NoTeam;
/** Set the team ID at runtime. */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Team")
void SetTeamId(uint8 NewTeamId);
// ─── IGenericTeamAgentInterface (inherited from AAIController) ────
virtual FGenericTeamId GetGenericTeamId() const override;
virtual void SetGenericTeamId(const FGenericTeamId& InTeamId) override;
virtual ETeamAttitude::Type GetTeamAttitudeTowards(const AActor& Other) const override;
// ─── Configuration ──────────────────────────────────────────────────
/** Behavior Tree to run. If null, uses the Profile's DefaultBehaviorTree. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Behavior")
TObjectPtr<UBehaviorTree> BehaviorTreeAsset;
/** Blackboard Data asset. If null, a default one is created at runtime. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Behavior")
TObjectPtr<UBlackboardData> BlackboardAsset;
/** Patrol waypoints — set by level designer, spawner, or Blueprint. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Patrol")
TArray<FVector> PatrolPoints;
// ─── Component Access ───────────────────────────────────────────────
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior")
UPS_AI_Behavior_PerceptionComponent* GetBehaviorPerception() const { return BehaviorPerception; }
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior")
UPS_AI_Behavior_PersonalityComponent* GetPersonalityComponent() const { return PersonalityComp; }
// ─── Blackboard Helpers ─────────────────────────────────────────────
/** Write the current behavior state to the Blackboard. */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Blackboard")
void SetBehaviorState(EPS_AI_Behavior_State NewState);
/** Read the current behavior state from the Blackboard. */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Blackboard")
EPS_AI_Behavior_State GetBehaviorState() const;
protected:
virtual void OnPossess(APawn* InPawn) override;
virtual void OnUnPossess() override;
/** Our custom perception component — created in constructor. */
UPROPERTY(VisibleAnywhere, Category = "Components")
TObjectPtr<UPS_AI_Behavior_PerceptionComponent> BehaviorPerception;
/** Cached ref to the Pawn's PersonalityComponent. */
UPROPERTY(Transient)
TObjectPtr<UPS_AI_Behavior_PersonalityComponent> PersonalityComp;
private:
/** Initialize Blackboard with required keys. */
void SetupBlackboard();
/** Start the Behavior Tree (from asset or profile). */
void StartBehavior();
/**
* Attempt to bind to PS_AI_ConvAgent_ElevenLabsComponent if present on the Pawn.
* Uses UObject reflection no compile-time dependency on PS_AI_ConvAgent.
*/
void TryBindConversationAgent();
};

View File

@ -0,0 +1,103 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "PS_AI_Behavior_CombatComponent.generated.h"
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnAttackExecuted, AActor*, Target);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnDamageReceived, float, Damage, AActor*, Instigator);
/**
* Manages NPC combat state: attack range, cooldown, damage dealing.
*
* Replication: CurrentTarget is replicated so clients know who the NPC
* is fighting. ExecuteAttack fires a NetMulticast for cosmetic effects.
*
* Attach to the NPC Pawn alongside PersonalityComponent.
*/
UCLASS(ClassGroup = "PS AI Behavior", meta = (BlueprintSpawnableComponent, DisplayName = "PS AI Behavior - Combat"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_CombatComponent : public UActorComponent
{
GENERATED_BODY()
public:
UPS_AI_Behavior_CombatComponent();
// ─── Configuration ──────────────────────────────────────────────────
/** Maximum distance at which the NPC can attack (cm). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Combat", meta = (ClampMin = "50.0"))
float AttackRange = 200.0f;
/** Cooldown between attacks (seconds). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Combat", meta = (ClampMin = "0.1"))
float AttackCooldown = 1.5f;
/** Base damage per attack. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Combat", meta = (ClampMin = "0.0"))
float AttackDamage = 20.0f;
/** Damage type class for applying damage. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Combat")
TSubclassOf<UDamageType> DamageTypeClass;
// ─── Runtime State ──────────────────────────────────────────────────
/** Current attack target — replicated so clients can show targeting visuals. */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Replicated, Category = "Combat|Runtime")
TObjectPtr<AActor> CurrentTarget;
// ─── Delegates ──────────────────────────────────────────────────────
/** Fired on ALL machines (server + clients) when an attack is executed. */
UPROPERTY(BlueprintAssignable, Category = "PS AI Behavior|Combat")
FOnAttackExecuted OnAttackExecuted;
/** Fired on server when damage is received. */
UPROPERTY(BlueprintAssignable, Category = "PS AI Behavior|Combat")
FOnDamageReceived OnDamageReceived;
// ─── API ────────────────────────────────────────────────────────────
/** Whether the NPC can currently attack (cooldown elapsed). Server-only. */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Combat")
bool CanAttack() const;
/** Whether the target is within attack range. */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Combat")
bool IsInAttackRange(AActor* Target) const;
/**
* Execute an attack on the target. Applies damage (server), triggers
* cooldown, and multicasts cosmetic event to all clients.
* @return True if the attack was executed.
*/
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Combat")
bool ExecuteAttack(AActor* Target);
/**
* Called when this NPC takes damage. Updates threat perception.
* Hook this into the owning Actor's OnTakeAnyDamage.
*/
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Combat")
void NotifyDamageReceived(float Damage, AActor* DamageInstigator);
// ─── Replication ────────────────────────────────────────────────────
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
protected:
virtual void BeginPlay() override;
virtual void TickComponent(float DeltaTime, ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction) override;
/** Multicast: notify all clients that an attack happened (for VFX, sound, anims). */
UFUNCTION(NetMulticast, Unreliable)
void Multicast_OnAttackExecuted(AActor* Target);
private:
/** Time remaining before next attack is allowed. Server-only. */
float CooldownRemaining = 0.0f;
};

View File

@ -0,0 +1,131 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_CoverPoint.generated.h"
class UArrowComponent;
class UBillboardComponent;
/**
* A manually placed strategic point in the level.
*
* - **Cover**: positioned behind walls/barricades for enemies in combat.
* The arrow shows the direction the NPC will face (toward the threat).
*
* - **Hiding Spot**: under desks, in closets, behind cars for panicking civilians.
*
* Features:
* - Occupancy system: only one NPC per point (configurable max).
* - Quality score: manually set by the level designer (0.0-1.0).
* - Crouch flag: NPC should crouch at this cover.
* - Editor: color-coded (blue=Cover, yellow=HidingSpot), arrow shows facing.
*/
UCLASS(BlueprintType, Blueprintable, meta = (DisplayName = "PS AI Cover Point"))
class PS_AI_BEHAVIOR_API APS_AI_Behavior_CoverPoint : public AActor
{
GENERATED_BODY()
public:
APS_AI_Behavior_CoverPoint();
// ─── Components ─────────────────────────────────────────────────────
#if WITH_EDITORONLY_DATA
UPROPERTY(VisibleAnywhere, Category = "Components")
TObjectPtr<UBillboardComponent> SpriteComp;
UPROPERTY(VisibleAnywhere, Category = "Components")
TObjectPtr<UArrowComponent> ArrowComp;
#endif
// ─── Configuration ──────────────────────────────────────────────────
/** Type of this point. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cover Point")
EPS_AI_Behavior_CoverPointType PointType = EPS_AI_Behavior_CoverPointType::Cover;
/**
* Manual quality score set by the level designer.
* 0.0 = poor cover, 1.0 = excellent cover.
* Combined with runtime raycast verification.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cover Point", meta = (ClampMin = "0.0", ClampMax = "1.0"))
float Quality = 0.7f;
/** Maximum number of NPCs that can occupy this point simultaneously. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cover Point", meta = (ClampMin = "1", ClampMax = "4"))
int32 MaxOccupants = 1;
/** NPC should crouch when using this cover point. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cover Point")
bool bCrouch = true;
/**
* Optional: restrict this point to specific NPC types.
* Any = all NPC types can use it. Otherwise, matches the NPC's type.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cover Point")
EPS_AI_Behavior_NPCType AllowedNPCType = EPS_AI_Behavior_NPCType::Any;
/**
* Whether this point is currently enabled. Disabled points are ignored.
* Useful for scripted scenarios (e.g. barricade destroyed disable cover).
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Cover Point")
bool bEnabled = true;
// ─── Runtime (server-only) ──────────────────────────────────────────
/** Current occupants. Managed by the BT / EQS. */
UPROPERTY(Transient)
TArray<TWeakObjectPtr<AActor>> CurrentOccupants;
// ─── API ────────────────────────────────────────────────────────────
/** Can this point be used by the given NPC type? */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Cover")
bool IsAccessibleTo(EPS_AI_Behavior_NPCType NPCType) const;
/** Is there room for one more occupant? */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Cover")
bool HasRoom() const;
/** Try to claim this point for an NPC. Returns true if successful. */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Cover")
bool Claim(AActor* Occupant);
/** Release this point (NPC leaves cover). */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Cover")
void Release(AActor* Occupant);
/** Get the facing direction (forward vector of the actor). */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Cover")
FVector GetCoverDirection() const;
/**
* Evaluate cover quality at runtime with a raycast check against a threat.
* Combines manual Quality score with actual line-of-sight blockage.
* @param ThreatLocation Where the threat is.
* @return Combined score 0.0 to 1.0.
*/
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Cover")
float EvaluateAgainstThreat(const FVector& ThreatLocation) const;
/** Enable/disable at runtime (e.g. barricade destroyed). */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Cover")
void SetEnabled(bool bNewEnabled);
#if WITH_EDITOR
virtual void PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) override;
#endif
protected:
virtual void BeginPlay() override;
private:
void UpdateVisualization();
};

View File

@ -0,0 +1,98 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "PS_AI_Behavior_Definitions.generated.h"
// ─── Log Category ───────────────────────────────────────────────────────────
PS_AI_BEHAVIOR_API DECLARE_LOG_CATEGORY_EXTERN(LogPS_AI_Behavior, Log, All);
// ─── API Macro ──────────────────────────────────────────────────────────────
// Defined by UBT from module name; redeclare for clarity
#ifndef PS_AI_BEHAVIOR_API
#define PS_AI_BEHAVIOR_API
#endif
// ─── Enums ──────────────────────────────────────────────────────────────────
/**
* Type of NPC determines team affiliation, spline access, and default behavior.
* Also used on splines to restrict which NPCs can walk on them.
* "Any" means accessible to all types (splines only not a valid NPC type).
*/
UENUM(BlueprintType)
enum class EPS_AI_Behavior_NPCType : uint8
{
Civilian UMETA(DisplayName = "Civilian", ToolTip = "Non-hostile civilians"),
Enemy UMETA(DisplayName = "Enemy", ToolTip = "Hostile NPCs"),
Protector UMETA(DisplayName = "Protector", ToolTip = "Police/guards, allied with Civilians"),
Any UMETA(DisplayName = "Any", ToolTip = "Splines only: accessible to all types"),
};
/** High-level behavioral state written to the Blackboard. */
UENUM(BlueprintType)
enum class EPS_AI_Behavior_State : uint8
{
Idle UMETA(DisplayName = "Idle"),
Patrol UMETA(DisplayName = "Patrol"),
Alerted UMETA(DisplayName = "Alerted"),
Combat UMETA(DisplayName = "Combat"),
Fleeing UMETA(DisplayName = "Fleeing"),
TakingCover UMETA(DisplayName = "Taking Cover"),
Dead UMETA(DisplayName = "Dead"),
};
/**
* Target type for combat priority.
* Includes Player which is not an NPC type but is a valid target.
*/
UENUM(BlueprintType)
enum class EPS_AI_Behavior_TargetType : uint8
{
Player UMETA(DisplayName = "Player", ToolTip = "Human-controlled character"),
Civilian UMETA(DisplayName = "Civilian", ToolTip = "Non-hostile NPC"),
Protector UMETA(DisplayName = "Protector", ToolTip = "Police/guard NPC"),
Enemy UMETA(DisplayName = "Enemy", ToolTip = "Hostile NPC (same faction — rare)"),
};
/**
* Type of strategic point placed manually in the level.
* Cover = enemies use it for tactical combat cover.
* HidingSpot = civilians use it to hide when panicking.
*/
UENUM(BlueprintType)
enum class EPS_AI_Behavior_CoverPointType : uint8
{
Cover UMETA(DisplayName = "Cover", ToolTip = "Tactical cover for enemies (behind walls, barricades)"),
HidingSpot UMETA(DisplayName = "Hiding Spot", ToolTip = "Civilian hiding place (under desks, in closets, behind cars)"),
};
/** Personality trait axes — each scored 0.0 (low) to 1.0 (high). */
UENUM(BlueprintType)
enum class EPS_AI_Behavior_TraitAxis : uint8
{
Courage UMETA(DisplayName = "Courage", ToolTip = "0 = coward, 1 = fearless"),
Aggressivity UMETA(DisplayName = "Aggressivity", ToolTip = "0 = peaceful, 1 = violent"),
Loyalty UMETA(DisplayName = "Loyalty", ToolTip = "0 = selfish, 1 = devoted"),
Caution UMETA(DisplayName = "Caution", ToolTip = "0 = reckless, 1 = prudent"),
Discipline UMETA(DisplayName = "Discipline", ToolTip = "0 = undisciplined, 1 = disciplined"),
};
// ─── Blackboard Key Names ───────────────────────────────────────────────────
namespace PS_AI_Behavior_BB
{
inline const FName State = TEXT("BehaviorState");
inline const FName ThreatActor = TEXT("ThreatActor");
inline const FName ThreatLocation = TEXT("ThreatLocation");
inline const FName ThreatLevel = TEXT("ThreatLevel");
inline const FName CoverLocation = TEXT("CoverLocation");
inline const FName CoverPoint = TEXT("CoverPoint");
inline const FName PatrolIndex = TEXT("PatrolIndex");
inline const FName HomeLocation = TEXT("HomeLocation");
inline const FName CurrentSpline = TEXT("CurrentSpline");
inline const FName SplineProgress = TEXT("SplineProgress");
}

View File

@ -0,0 +1,114 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "UObject/Interface.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_Interface.generated.h"
/**
* General-purpose interface for the PS AI Behavior plugin.
*
* Implement this on your Pawn/Character classes so the behavior system can
* query and modify NPC identity, hostility, and team affiliation without
* any compile-time dependency on your project's class hierarchy.
*
* Implementable in C++ (BlueprintNativeEvent) or Blueprint (BlueprintImplementableEvent).
*
* Example C++ implementation on your Character:
*
* class AMyCharacter : public ACharacter, public IPS_AI_Behavior
* {
* EPS_AI_Behavior_NPCType MyType = EPS_AI_Behavior_NPCType::Civilian;
* bool bHostile = false;
*
* virtual EPS_AI_Behavior_NPCType GetBehaviorNPCType_Implementation() const override { return MyType; }
* virtual void SetBehaviorNPCType_Implementation(EPS_AI_Behavior_NPCType T) override { MyType = T; }
* virtual bool IsBehaviorHostile_Implementation() const override { return bHostile; }
* virtual void SetBehaviorHostile_Implementation(bool b) override { bHostile = b; }
* virtual uint8 GetBehaviorTeamId_Implementation() const override { return bHostile ? 2 : 1; }
* };
*/
UINTERFACE(BlueprintType, Blueprintable, meta = (DisplayName = "PS AI Behavior Interface"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_Interface : public UInterface
{
GENERATED_BODY()
};
class PS_AI_BEHAVIOR_API IPS_AI_Behavior_Interface
{
GENERATED_BODY()
public:
// ─── NPC Type ───────────────────────────────────────────────────────
/** Get this NPC's type (Civilian, Enemy, Protector). */
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS AI Behavior")
EPS_AI_Behavior_NPCType GetBehaviorNPCType() const;
/** Set this NPC's type. Called by gameplay logic or plugin actions. */
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS AI Behavior")
void SetBehaviorNPCType(EPS_AI_Behavior_NPCType NewType);
// ─── Hostility ──────────────────────────────────────────────────────
/**
* Is this NPC currently hostile?
* An infiltrated Enemy with IsHostile=false appears as Civilian to the perception system.
* When SetHostile(true) is called, the NPC reveals itself and TeamId changes.
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS AI Behavior")
bool IsBehaviorHostile() const;
/**
* Set hostility state. Typically called by gameplay scripts or ConvAgent actions.
* Implementors should update their TeamId accordingly.
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS AI Behavior")
void SetBehaviorHostile(bool bNewHostile);
// ─── Team ───────────────────────────────────────────────────────────
/**
* Get the Team ID for perception affiliation.
* Convention: Civilian=1, Enemy=2, Protector=3, NoTeam=255.
* Infiltrated enemies return 1 (Civilian) until SetHostile(true).
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS AI Behavior")
uint8 GetBehaviorTeamId() const;
// ─── Movement ───────────────────────────────────────────────────────
/**
* Request the Pawn to change its movement speed.
* Called by the behavior system when the NPC's state changes
* (e.g. panicking civilian runs, cautious enemy crouches slowly).
*
* The Pawn implements this however it wants typically by setting
* CharacterMovementComponent::MaxWalkSpeed.
*
* @param NewSpeed Desired walk speed in cm/s.
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS AI Behavior")
void SetBehaviorMovementSpeed(float NewSpeed);
/**
* Get the Pawn's current movement speed (cm/s).
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS AI Behavior")
float GetBehaviorMovementSpeed() const;
/**
* Notify the Pawn that the behavioral state changed.
* The Pawn can use this to trigger animations, voice lines, VFX, etc.
* Called on the server the Pawn is responsible for replicating
* any cosmetic effects if needed.
*
* @param NewState The new behavioral state.
* @param OldState The previous behavioral state.
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS AI Behavior")
void OnBehaviorStateChanged(EPS_AI_Behavior_State NewState, EPS_AI_Behavior_State OldState);
};

View File

@ -0,0 +1,73 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Perception/AIPerceptionComponent.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_PerceptionComponent.generated.h"
/**
* Pre-configured AI Perception component for the behavior system.
* Sets up Sight, Hearing, and Damage senses with defaults from plugin settings.
* Provides helpers to query the highest threat and compute a threat level.
*
* Automatically added by PS_AI_Behavior_AIController you don't need to add it manually.
*/
UCLASS(ClassGroup = "PS AI Behavior", meta = (DisplayName = "PS AI Behavior - Perception"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_PerceptionComponent : public UAIPerceptionComponent
{
GENERATED_BODY()
public:
UPS_AI_Behavior_PerceptionComponent();
// ─── Queries ────────────────────────────────────────────────────────
/**
* Get the actor that represents the highest threat, considering target priority.
* Scoring: priority rank (from PersonalityProfile) > damage sense > proximity.
*
* @param TargetPriority Ordered list of target types (first = highest priority).
* If empty, uses default [Protector, Player, Civilian].
* @return The most threatening actor, or nullptr if none perceived.
*/
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Perception")
AActor* GetHighestThreatActor(const TArray<EPS_AI_Behavior_TargetType>& TargetPriority);
/** Convenience overload — reads priority from the Pawn's PersonalityProfile. */
AActor* GetHighestThreatActor();
/**
* Compute an aggregate threat level from all currently perceived hostile stimuli.
* Returns 0.0 (no threat) to 1.0+ (extreme danger).
*/
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Perception")
float CalculateThreatLevel();
/**
* Get the location of the last known threat stimulus.
* @param OutLocation Filled with the threat location if any threat exists.
* @return True if a threat was found.
*/
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Perception")
bool GetThreatLocation(FVector& OutLocation);
protected:
virtual void BeginPlay() override;
UFUNCTION()
void HandlePerceptionUpdated(const TArray<AActor*>& UpdatedActors);
/**
* Classify an actor as a TargetType.
* Uses IsPlayerControlled() for Player, IPS_AI_Behavior interface or
* PersonalityComponent for NPC type.
*/
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Perception")
static EPS_AI_Behavior_TargetType ClassifyActor(const AActor* Actor);
private:
/** Configure sight, hearing, and damage senses from plugin settings. */
void ConfigureSenses();
};

View File

@ -0,0 +1,121 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "Net/UnrealNetwork.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_PersonalityComponent.generated.h"
class UPS_AI_Behavior_PersonalityProfile;
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnBehaviorStateChanged, EPS_AI_Behavior_State, OldState, EPS_AI_Behavior_State, NewState);
/**
* Manages an NPC's personality traits at runtime.
* Reads from a PersonalityProfile data asset, maintains runtime-modifiable trait scores,
* and evaluates the NPC's behavioral reaction to perceived threats.
*
* Replication: CurrentState and PerceivedThreatLevel are replicated to all clients
* so that animations and HUD can reflect the NPC's current behavior.
*
* Attach to the NPC Pawn/Character.
*/
UCLASS(ClassGroup = "PS AI Behavior", meta = (BlueprintSpawnableComponent, DisplayName = "PS AI Behavior - Personality"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_PersonalityComponent : public UActorComponent
{
GENERATED_BODY()
public:
UPS_AI_Behavior_PersonalityComponent();
// ─── Configuration ──────────────────────────────────────────────────
/** Personality profile data asset. Set in the editor per NPC archetype. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Personality")
TObjectPtr<UPS_AI_Behavior_PersonalityProfile> Profile;
// ─── Runtime State ──────────────────────────────────────────────────
/**
* Runtime trait scores initialized from Profile at BeginPlay.
* Can be modified during gameplay (e.g. NPC becomes more courageous over time).
* Server-only: traits drive AI decisions which run on server.
*/
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = "Personality|Runtime")
TMap<EPS_AI_Behavior_TraitAxis, float> RuntimeTraits;
/**
* Current perceived threat level (0.0 = safe, 1.0 = maximum danger).
* Written by BTService_UpdateThreat on the server.
* Replicated for client HUD/debug display.
*/
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Replicated, Category = "Personality|Runtime")
float PerceivedThreatLevel = 0.0f;
/**
* Current behavioral state replicated with OnRep to fire delegate on clients.
* Only written on the server (by BT or ForceState).
*/
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, ReplicatedUsing = OnRep_CurrentState, Category = "Personality|Runtime")
EPS_AI_Behavior_State CurrentState = EPS_AI_Behavior_State::Idle;
// ─── Delegates ──────────────────────────────────────────────────────
/** Fired when the behavioral state changes (on server AND clients via OnRep). */
UPROPERTY(BlueprintAssignable, Category = "PS AI Behavior|Personality")
FOnBehaviorStateChanged OnBehaviorStateChanged;
// ─── API ────────────────────────────────────────────────────────────
/**
* Evaluate the NPC's reaction based on current traits and perceived threat.
* Returns the recommended behavioral state. Server-only.
*/
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Personality")
EPS_AI_Behavior_State EvaluateReaction() const;
/**
* Evaluate and apply the reaction updates CurrentState and fires delegate if changed.
* Server-only: state is replicated to clients via OnRep.
* @return The new behavioral state.
*/
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Personality")
EPS_AI_Behavior_State ApplyReaction();
/** Get a runtime trait value (returns 0.5 if undefined). */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Personality")
float GetTrait(EPS_AI_Behavior_TraitAxis Axis) const;
/** Modify a runtime trait by delta, clamped to [0, 1]. Server-only. */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Personality")
void ModifyTrait(EPS_AI_Behavior_TraitAxis Axis, float Delta);
/** Force a specific state (e.g. from conversation agent action). Server-only. */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Personality")
void ForceState(EPS_AI_Behavior_State NewState);
/** Get the NPC type from the interface or profile. */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Personality")
EPS_AI_Behavior_NPCType GetNPCType() const;
// ─── Replication ────────────────────────────────────────────────────
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
protected:
virtual void BeginPlay() override;
UFUNCTION()
void OnRep_CurrentState(EPS_AI_Behavior_State OldState);
private:
/**
* Central handler for state transitions. Called on server when state changes.
* - Broadcasts the delegate
* - Calls IPS_AI_Behavior::SetBehaviorMovementSpeed on the Pawn
* - Calls IPS_AI_Behavior::OnBehaviorStateChanged on the Pawn
*/
void HandleStateChanged(EPS_AI_Behavior_State OldState, EPS_AI_Behavior_State NewState);
};

View File

@ -0,0 +1,121 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Engine/DataAsset.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_PersonalityProfile.generated.h"
class UBehaviorTree;
/**
* Data Asset defining an NPC's personality profile.
* Contains trait scores, reaction thresholds, and default behavior tree.
* Create one per archetype (e.g. "Coward Civilian", "Aggressive Guard").
*/
UCLASS(BlueprintType)
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_PersonalityProfile : public UPrimaryDataAsset
{
GENERATED_BODY()
public:
UPS_AI_Behavior_PersonalityProfile();
// ─── Identity ───────────────────────────────────────────────────────
/** Human-readable profile name (e.g. "Cowardly Villager"). */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality")
FText ProfileName;
/** NPC type — determines base behavior tree selection. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality")
EPS_AI_Behavior_NPCType NPCType = EPS_AI_Behavior_NPCType::Civilian;
// ─── Trait Scores ───────────────────────────────────────────────────
/** Personality trait scores. Each axis ranges from 0.0 to 1.0. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality|Traits",
meta = (ClampMin = "0.0", ClampMax = "1.0"))
TMap<EPS_AI_Behavior_TraitAxis, float> TraitScores;
// ─── Reaction Thresholds ────────────────────────────────────────────
/**
* Base threat level above which the NPC considers fleeing.
* Actual threshold is modulated at runtime by Courage trait.
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality|Reaction",
meta = (ClampMin = "0.0", ClampMax = "1.0"))
float FleeThreshold = 0.5f;
/**
* Base threat level above which the NPC engages in combat.
* Actual threshold is modulated at runtime by Aggressivity trait.
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality|Reaction",
meta = (ClampMin = "0.0", ClampMax = "1.0"))
float AttackThreshold = 0.4f;
/**
* Base threat level above which the NPC becomes alerted (but not yet fleeing/attacking).
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality|Reaction",
meta = (ClampMin = "0.0", ClampMax = "1.0"))
float AlertThreshold = 0.15f;
// ─── Target Priority (Combat) ───────────────────────────────────────
/**
* Target selection priority for combat, in order of preference.
* First entry = highest priority target type.
*
* Example for a terrorist: [Player, Protector, Civilian]
* Example for a thief: [Civilian, Player] (avoids Protectors)
* Example for a rival gang: [Enemy, Protector, Player]
*
* If empty, defaults to: [Protector, Player, Civilian].
* Target types not in the list will not be attacked.
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality|Combat")
TArray<EPS_AI_Behavior_TargetType> TargetPriority;
// ─── Movement Speed per State ──────────────────────────────────────
/**
* Movement speed (cm/s) for each behavioral state.
* The behavior system calls IPS_AI_Behavior::SetBehaviorMovementSpeed()
* on the Pawn when the state changes.
*
* States not in this map use DefaultWalkSpeed.
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality|Movement")
TMap<EPS_AI_Behavior_State, float> SpeedPerState;
/**
* Base walk speed (cm/s) used when the current state is not in SpeedPerState.
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality|Movement", meta = (ClampMin = "0.0"))
float DefaultWalkSpeed = 150.0f;
// ─── Behavior ───────────────────────────────────────────────────────
/** Default Behavior Tree for this personality archetype. Can be overridden on the AIController. */
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Behavior")
TSoftObjectPtr<UBehaviorTree> DefaultBehaviorTree;
// ─── Helpers ────────────────────────────────────────────────────────
/**
* Get the score for a given trait axis. Returns 0.5 if the axis is not defined.
*/
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Personality")
float GetTrait(EPS_AI_Behavior_TraitAxis Axis) const;
/** Get the speed for a given state. Returns DefaultWalkSpeed if state not in SpeedPerState. */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Personality")
float GetSpeedForState(EPS_AI_Behavior_State State) const;
/** UPrimaryDataAsset interface */
virtual FPrimaryAssetId GetPrimaryAssetId() const override;
};

View File

@ -0,0 +1,54 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Engine/DeveloperSettings.h"
#include "PS_AI_Behavior_Settings.generated.h"
/**
* Project-wide settings for the PS AI Behavior plugin.
* Accessible via Project Settings -> Plugins -> PS AI Behavior.
*/
UCLASS(config = Game, defaultconfig, meta = (DisplayName = "PS AI Behavior"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_Settings : public UDeveloperSettings
{
GENERATED_BODY()
public:
UPS_AI_Behavior_Settings();
// ─── General ────────────────────────────────────────────────────────
/** Enable verbose logging for the behavior plugin. */
UPROPERTY(config, EditAnywhere, Category = "General")
bool bVerboseLogging = false;
// ─── Perception Defaults ────────────────────────────────────────────
/** Default sight radius for NPC perception (cm). */
UPROPERTY(config, EditAnywhere, Category = "Perception", meta = (ClampMin = 100.0, ClampMax = 10000.0))
float DefaultSightRadius = 6000.0f;
/** Default sight half-angle (degrees). */
UPROPERTY(config, EditAnywhere, Category = "Perception", meta = (ClampMin = 10.0, ClampMax = 180.0))
float DefaultSightHalfAngle = 45.0f;
/** Default hearing range (cm). */
UPROPERTY(config, EditAnywhere, Category = "Perception", meta = (ClampMin = 100.0, ClampMax = 10000.0))
float DefaultHearingRange = 3000.0f;
/** Seconds before a perceived stimulus is forgotten. */
UPROPERTY(config, EditAnywhere, Category = "Perception", meta = (ClampMin = 1.0, ClampMax = 60.0))
float PerceptionMaxAge = 10.0f;
// ─── Threat ─────────────────────────────────────────────────────────
/** Threat level decay rate per second when no threat is visible. */
UPROPERTY(config, EditAnywhere, Category = "Threat", meta = (ClampMin = 0.0, ClampMax = 2.0))
float ThreatDecayRate = 0.15f;
// ─── Section Name ───────────────────────────────────────────────────
virtual FName GetCategoryName() const override { return TEXT("Plugins"); }
};

View File

@ -0,0 +1,169 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Components/ActorComponent.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_SplineFollowerComponent.generated.h"
class APS_AI_Behavior_SplinePath;
struct FPS_AI_Behavior_SplineJunction;
DECLARE_DYNAMIC_MULTICAST_DELEGATE_ThreeParams(FOnApproachingJunction,
APS_AI_Behavior_SplinePath*, CurrentSpline,
int32, JunctionIndex,
float, DistanceToJunction);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_TwoParams(FOnSplineChanged,
APS_AI_Behavior_SplinePath*, OldSpline,
APS_AI_Behavior_SplinePath*, NewSpline);
DECLARE_DYNAMIC_MULTICAST_DELEGATE_OneParam(FOnSplineEndReached,
APS_AI_Behavior_SplinePath*, Spline);
/**
* Drives smooth NPC movement along spline paths.
* Handles:
* - Fluid motion with rotation interpolation
* - Automatic junction detection and spline switching
* - Speed variation based on spline settings
* - Forward/reverse travel on bidirectional splines
*
* Attach to the NPC Pawn. Works with or without the AI Controller.
*/
UCLASS(ClassGroup = "PS AI Behavior", meta = (BlueprintSpawnableComponent, DisplayName = "PS AI Behavior - Spline Follower"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_SplineFollowerComponent : public UActorComponent
{
GENERATED_BODY()
public:
UPS_AI_Behavior_SplineFollowerComponent();
// ─── Replication ────────────────────────────────────────────────────
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
// ─── Configuration ──────────────────────────────────────────────────
/** Walk speed along spline (cm/s). If the spline has its own speed, this is overridden. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spline Follower", meta = (ClampMin = "10.0"))
float DefaultWalkSpeed = 150.0f;
/** How far ahead to look for junctions (cm). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spline Follower", meta = (ClampMin = "50.0"))
float JunctionDetectionDistance = 300.0f;
/** How quickly the NPC rotates to face the spline direction (degrees/sec). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spline Follower", meta = (ClampMin = "30.0"))
float RotationInterpSpeed = 360.0f;
/**
* Whether to auto-choose a spline at junctions.
* If false, OnApproachingJunction fires and you must call SwitchToSpline manually.
* If true, uses SplineNetwork::ChooseSplineAtJunction automatically.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spline Follower")
bool bAutoChooseAtJunction = true;
/**
* If true, on reaching the end of a non-looped spline, reverse direction.
* If false, stop and fire OnSplineEndReached.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spline Follower")
bool bReverseAtEnd = false;
// ─── Runtime State ──────────────────────────────────────────────────
/**
* Currently followed spline. Replicated so clients know which spline the NPC is on.
* Null if not following any. Movement itself is synced via CMC.
*/
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Replicated, Category = "Spline Follower|Runtime")
TObjectPtr<APS_AI_Behavior_SplinePath> CurrentSpline;
/** Current distance along the spline (cm). Server-only, not replicated (CMC handles position). */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Spline Follower|Runtime")
float CurrentDistance = 0.0f;
/** True if moving in the positive direction along the spline. */
UPROPERTY(VisibleAnywhere, BlueprintReadWrite, Category = "Spline Follower|Runtime")
bool bMovingForward = true;
/** Is the follower actively moving? Replicated for client animation state. */
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Replicated, Category = "Spline Follower|Runtime")
bool bIsFollowing = false;
// ─── Delegates ──────────────────────────────────────────────────────
/** Fired when approaching a junction. Use to make custom spline selection. */
UPROPERTY(BlueprintAssignable, Category = "PS AI Behavior|Spline Follower")
FOnApproachingJunction OnApproachingJunction;
/** Fired when the NPC switches to a different spline. */
UPROPERTY(BlueprintAssignable, Category = "PS AI Behavior|Spline Follower")
FOnSplineChanged OnSplineChanged;
/** Fired when the NPC reaches the end of a spline (if bReverseAtEnd is false). */
UPROPERTY(BlueprintAssignable, Category = "PS AI Behavior|Spline Follower")
FOnSplineEndReached OnSplineEndReached;
// ─── API ────────────────────────────────────────────────────────────
/**
* Start following the given spline from the closest point.
* @param Spline The spline to follow.
* @param bForward Direction of travel.
* @return True if successfully started.
*/
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Spline Follower")
bool StartFollowing(APS_AI_Behavior_SplinePath* Spline, bool bForward = true);
/**
* Start following the given spline from a specific distance.
*/
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Spline Follower")
bool StartFollowingAtDistance(APS_AI_Behavior_SplinePath* Spline, float StartDistance, bool bForward = true);
/** Stop following the current spline. */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Spline Follower")
void StopFollowing();
/** Pause/resume without losing state. */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Spline Follower")
void PauseFollowing();
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Spline Follower")
void ResumeFollowing();
/**
* Switch to another spline at a junction point.
* @param NewSpline The spline to switch to.
* @param DistanceOnNew Distance along the new spline to start from.
* @param bNewForward Direction on the new spline.
*/
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Spline Follower")
void SwitchToSpline(APS_AI_Behavior_SplinePath* NewSpline, float DistanceOnNew, bool bNewForward = true);
/** Get the effective walk speed (considering spline override). */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Spline Follower")
float GetEffectiveSpeed() const;
/** Get progress as a 0-1 ratio. */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Spline Follower")
float GetProgress() const;
protected:
virtual void TickComponent(float DeltaTime, ELevelTick TickType,
FActorComponentTickFunction* ThisTickFunction) override;
private:
/** Check for upcoming junctions and handle them. */
void HandleJunctions();
/** Index of the junction we already handled (to avoid re-triggering). */
int32 LastHandledJunctionIndex = -1;
/** Speed multiplier for variety (set randomly on spawn). */
float SpeedVariation = 1.0f;
};

View File

@ -0,0 +1,108 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Subsystems/WorldSubsystem.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_SplineNetwork.generated.h"
class APS_AI_Behavior_SplinePath;
/**
* World Subsystem that manages the network of spline paths.
* At BeginPlay, scans all SplinePath actors, detects intersections between them,
* and populates their Junction arrays.
*
* Provides queries for NPCs to find the nearest accessible spline, pick a path
* at a junction, etc.
*/
UCLASS()
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_SplineNetwork : public UWorldSubsystem
{
GENERATED_BODY()
public:
// ─── UWorldSubsystem ────────────────────────────────────────────────
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
virtual void Deinitialize() override;
// ─── Network Build ──────────────────────────────────────────────────
/**
* Scan the world for all SplinePath actors and compute junctions.
* Called automatically after world initialization. Can be called again
* if splines are added/removed at runtime.
*/
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|SplineNetwork")
void RebuildNetwork();
// ─── Queries ────────────────────────────────────────────────────────
/**
* Find the closest accessible spline for the given NPC type.
* @param WorldLocation The NPC's current position.
* @param NPCType Filter: only return splines accessible to this type.
* @param MaxDistance Maximum snap distance (cm). Default = 2000.
* @param OutSpline The closest spline (if found).
* @param OutDistance Distance along the spline to the closest point.
* @return True if a spline was found within MaxDistance.
*/
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|SplineNetwork")
bool FindClosestSpline(const FVector& WorldLocation,
EPS_AI_Behavior_NPCType NPCType, float MaxDistance,
APS_AI_Behavior_SplinePath*& OutSpline, float& OutDistance) const;
/**
* Get all splines of a given category.
*/
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|SplineNetwork")
TArray<APS_AI_Behavior_SplinePath*> GetSplinesForCategory(
EPS_AI_Behavior_NPCType Category) const;
/**
* Choose the best spline to switch to at a junction.
* Considers spline priority, NPC personality (Caution avoids main roads),
* and optional bias away from a threat location.
*
* @param CurrentSpline The spline the NPC is currently on.
* @param JunctionIndex Index into CurrentSpline->Junctions.
* @param NPCType NPC type filter.
* @param ThreatLocation Optional: bias away from this point. ZeroVector = ignore.
* @param CautionScore Optional: NPC's caution trait (0-1). Higher = prefer quieter paths.
* @return The chosen spline (could be the same if staying is best).
*/
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|SplineNetwork")
APS_AI_Behavior_SplinePath* ChooseSplineAtJunction(
APS_AI_Behavior_SplinePath* CurrentSpline, int32 JunctionIndex,
EPS_AI_Behavior_NPCType NPCType,
const FVector& ThreatLocation = FVector::ZeroVector,
float CautionScore = 0.5f) const;
/** Total number of splines in the network. */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|SplineNetwork")
int32 GetSplineCount() const { return AllSplines.Num(); }
/** Total number of junctions detected. */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|SplineNetwork")
int32 GetJunctionCount() const { return TotalJunctions; }
private:
/** All registered spline paths. */
UPROPERTY()
TArray<TObjectPtr<APS_AI_Behavior_SplinePath>> AllSplines;
/** Cached junction count. */
int32 TotalJunctions = 0;
/**
* Detect junctions between two splines by sampling one and projecting onto the other.
* Tolerance = max distance between splines to consider a junction.
*/
void DetectJunctions(APS_AI_Behavior_SplinePath* SplineA,
APS_AI_Behavior_SplinePath* SplineB, float Tolerance);
/** UWorldSubsystem override — called when world begins play. */
virtual void OnWorldBeginPlay(UWorld& InWorld) override;
};

View File

@ -0,0 +1,144 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_SplinePath.generated.h"
class USplineComponent;
/**
* A junction (intersection) between two splines.
* Stored by the SplineNetwork subsystem after scanning overlaps.
*/
USTRUCT(BlueprintType)
struct FPS_AI_Behavior_SplineJunction
{
GENERATED_BODY()
/** The other spline at this junction. */
UPROPERTY(BlueprintReadOnly, Category = "Spline")
TWeakObjectPtr<class APS_AI_Behavior_SplinePath> OtherSpline;
/** Distance along THIS spline where the junction is. */
UPROPERTY(BlueprintReadOnly, Category = "Spline")
float DistanceOnThisSpline = 0.0f;
/** Distance along the OTHER spline where the junction is. */
UPROPERTY(BlueprintReadOnly, Category = "Spline")
float DistanceOnOtherSpline = 0.0f;
/** World location of the junction. */
UPROPERTY(BlueprintReadOnly, Category = "Spline")
FVector WorldLocation = FVector::ZeroVector;
};
/**
* Spline path actor place in the level to define NPC navigation paths.
* Think of it as a sidewalk, patrol route, or corridor.
*
* - Set SplineCategory to Civilian, Enemy, Protector, or Any to control access.
* - Splines can overlap/intersect. The SplineNetwork subsystem detects junctions
* and lets NPCs switch between paths at those points.
* - Supports bidirectional travel by default.
*/
UCLASS(BlueprintType, Blueprintable, meta = (DisplayName = "PS AI Spline Path"))
class PS_AI_BEHAVIOR_API APS_AI_Behavior_SplinePath : public AActor
{
GENERATED_BODY()
public:
APS_AI_Behavior_SplinePath();
// ─── Components ─────────────────────────────────────────────────────
UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Category = "Spline")
TObjectPtr<USplineComponent> SplineComp;
// ─── Configuration ──────────────────────────────────────────────────
/**
* Which NPC type is allowed on this spline.
* Civilian = civilians + protectors, Enemy = enemies only,
* Protector = protectors only, Any = all types.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spline")
EPS_AI_Behavior_NPCType SplineCategory = EPS_AI_Behavior_NPCType::Any;
/** If true, NPCs can travel in both directions on this spline. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spline")
bool bBidirectional = true;
/**
* Base walk speed on this spline (cm/s). 0 = use NPC's default speed.
* Useful for making NPCs walk slower on narrow sidewalks.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spline", meta = (ClampMin = "0.0"))
float SplineWalkSpeed = 0.0f;
/**
* Priority when multiple splines are available at a junction.
* Higher = more likely to be chosen. 0 = default.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spline")
int32 Priority = 0;
// ─── Junctions (populated at runtime by SplineNetwork) ──────────────
/** All junctions on this spline, sorted by distance along spline. */
UPROPERTY(BlueprintReadOnly, Category = "Spline|Junctions")
TArray<FPS_AI_Behavior_SplineJunction> Junctions;
// ─── API ────────────────────────────────────────────────────────────
/** Can the given NPC type use this spline? */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Spline")
bool IsAccessibleTo(EPS_AI_Behavior_NPCType NPCType) const;
/**
* Get the closest point on this spline to a world location.
* @param WorldLocation The reference point.
* @param OutDistance Distance along the spline to the closest point.
* @param OutWorldPoint World location of the closest point on the spline.
* @return Distance from WorldLocation to the closest spline point.
*/
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Spline")
float GetClosestPointOnSpline(const FVector& WorldLocation,
float& OutDistance, FVector& OutWorldPoint) const;
/**
* Get all junctions within a distance range on this spline.
* @param CurrentDistance Current distance along the spline.
* @param LookAheadDist How far ahead to look for junctions.
* @param bForward Travel direction (true = increasing distance).
* @return Array of upcoming junctions.
*/
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Spline")
TArray<FPS_AI_Behavior_SplineJunction> GetUpcomingJunctions(
float CurrentDistance, float LookAheadDist, bool bForward) const;
/** Total spline length. */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Spline")
float GetSplineLength() const;
/** Get world location at a distance along the spline. */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Spline")
FVector GetWorldLocationAtDistance(float Distance) const;
/** Get world rotation at a distance along the spline. */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Spline")
FRotator GetWorldRotationAtDistance(float Distance) const;
#if WITH_EDITOR
virtual void PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) override;
#endif
protected:
virtual void BeginPlay() override;
private:
/** Update spline color in editor based on category. */
void UpdateSplineVisualization();
};

View File

@ -0,0 +1,33 @@
// Copyright Asterion. All Rights Reserved.
using UnrealBuildTool;
public class PS_AI_BehaviorEditor : ModuleRules
{
public PS_AI_BehaviorEditor(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[]
{
"Core",
"CoreUObject",
"Engine",
"InputCore",
"UnrealEd",
"PS_AI_Behavior",
});
PrivateDependencyModuleNames.AddRange(new string[]
{
"Slate",
"SlateCore",
"EditorStyle",
"EditorFramework",
"PropertyEditor",
"LevelEditor",
"EditorSubsystem",
"ComponentVisualizers",
});
}
}

View File

@ -0,0 +1,134 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_BehaviorEditor.h"
#include "PS_AI_Behavior_SplineEdMode.h"
#include "PS_AI_Behavior_SplineVisualizer.h"
#include "SPS_AI_Behavior_SplinePanel.h"
#include "PS_AI_Behavior_Definitions.h"
#include "EditorModeRegistry.h"
#include "UnrealEdGlobals.h"
#include "Editor/UnrealEdEngine.h"
#include "LevelEditor.h"
#include "Components/SplineComponent.h"
#include "Widgets/Docking/SDockTab.h"
#include "Framework/Docking/TabManager.h"
#define LOCTEXT_NAMESPACE "FPS_AI_BehaviorEditorModule"
IMPLEMENT_MODULE(FPS_AI_BehaviorEditorModule, PS_AI_BehaviorEditor)
void FPS_AI_BehaviorEditorModule::StartupModule()
{
UE_LOG(LogPS_AI_Behavior, Log, TEXT("PS_AI_BehaviorEditor module started."));
RegisterEdMode();
RegisterVisualizer();
RegisterSplinePanel();
}
void FPS_AI_BehaviorEditorModule::ShutdownModule()
{
UnregisterSplinePanel();
UnregisterVisualizer();
UnregisterEdMode();
UE_LOG(LogPS_AI_Behavior, Log, TEXT("PS_AI_BehaviorEditor module shut down."));
}
// ─── EdMode Registration ────────────────────────────────────────────────────
void FPS_AI_BehaviorEditorModule::RegisterEdMode()
{
FEditorModeRegistry::Get().RegisterMode<FPS_AI_Behavior_SplineEdMode>(
FPS_AI_Behavior_SplineEdMode::EM_SplineEdModeId,
LOCTEXT("SplineEdModeName", "PS AI Spline"),
FSlateIcon(), // TODO: custom icon
true // Visible in toolbar
);
}
void FPS_AI_BehaviorEditorModule::UnregisterEdMode()
{
FEditorModeRegistry::Get().UnregisterMode(FPS_AI_Behavior_SplineEdMode::EM_SplineEdModeId);
}
// ─── Component Visualizer Registration ──────────────────────────────────────
void FPS_AI_BehaviorEditorModule::RegisterVisualizer()
{
if (GUnrealEd)
{
TSharedPtr<FPS_AI_Behavior_SplineVisualizer> Visualizer = MakeShareable(new FPS_AI_Behavior_SplineVisualizer);
GUnrealEd->RegisterComponentVisualizer(USplineComponent::StaticClass()->GetFName(), Visualizer);
// Note: This registers for ALL USplineComponents. The visualizer checks
// if the owner is a SplinePath before drawing anything extra.
}
}
void FPS_AI_BehaviorEditorModule::UnregisterVisualizer()
{
if (GUnrealEd)
{
GUnrealEd->UnregisterComponentVisualizer(USplineComponent::StaticClass()->GetFName());
}
}
// ─── Detail Customizations ──────────────────────────────────────────────────
void FPS_AI_BehaviorEditorModule::RegisterDetailCustomizations()
{
// TODO: Register detail customization for APS_AI_Behavior_SplinePath
}
void FPS_AI_BehaviorEditorModule::UnregisterDetailCustomizations()
{
// TODO: Unregister detail customizations
}
// ─── Spline Panel Tab ───────────────────────────────────────────────────────
void FPS_AI_BehaviorEditorModule::RegisterSplinePanel()
{
FGlobalTabmanager::Get()->RegisterNomadTabSpawner(
SPS_AI_Behavior_SplinePanel::TabId,
FOnSpawnTab::CreateLambda([](const FSpawnTabArgs& Args) -> TSharedRef<SDockTab>
{
return SNew(SDockTab)
.TabRole(ETabRole::NomadTab)
.Label(LOCTEXT("SplinePanelTabLabel", "PS AI Spline Network"))
[
SNew(SPS_AI_Behavior_SplinePanel)
];
}))
.SetDisplayName(LOCTEXT("SplinePanelDisplayName", "PS AI Spline Network"))
.SetMenuType(ETabSpawnerMenuType::Hidden);
// Add to Window menu via Level Editor
FLevelEditorModule& LevelEditor = FModuleManager::LoadModuleChecked<FLevelEditorModule>("LevelEditor");
TSharedPtr<FExtender> MenuExtender = MakeShareable(new FExtender);
MenuExtender->AddMenuExtension(
"WindowLayout",
EExtensionHook::After,
nullptr,
FMenuExtensionDelegate::CreateLambda([](FMenuBuilder& MenuBuilder)
{
MenuBuilder.AddMenuEntry(
LOCTEXT("SplinePanelMenuEntry", "PS AI Spline Network"),
LOCTEXT("SplinePanelMenuTooltip", "Open the PS AI Spline Network management panel"),
FSlateIcon(),
FUIAction(FExecuteAction::CreateLambda([]()
{
FGlobalTabmanager::Get()->TryInvokeTab(SPS_AI_Behavior_SplinePanel::TabId);
}))
);
}));
LevelEditor.GetMenuExtensibilityManager()->AddExtender(MenuExtender);
}
void FPS_AI_BehaviorEditorModule::UnregisterSplinePanel()
{
FGlobalTabmanager::Get()->UnregisterNomadTabSpawner(SPS_AI_Behavior_SplinePanel::TabId);
}
#undef LOCTEXT_NAMESPACE

View File

@ -0,0 +1,433 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior_SplineEdMode.h"
#include "PS_AI_Behavior_SplineEdModeToolkit.h"
#include "PS_AI_Behavior_SplinePath.h"
#include "PS_AI_Behavior_SplineNetwork.h"
#include "PS_AI_Behavior_CoverPoint.h"
#include "Components/SplineComponent.h"
#include "EditorModeManager.h"
#include "Engine/World.h"
#include "EngineUtils.h"
#include "Editor.h"
#include "Toolkits/ToolkitManager.h"
#include "DrawDebugHelpers.h"
#include "CollisionQueryParams.h"
const FEditorModeID FPS_AI_Behavior_SplineEdMode::EM_SplineEdModeId = TEXT("EM_PS_AI_BehaviorSpline");
FPS_AI_Behavior_SplineEdMode::FPS_AI_Behavior_SplineEdMode()
{
}
FPS_AI_Behavior_SplineEdMode::~FPS_AI_Behavior_SplineEdMode()
{
}
void FPS_AI_Behavior_SplineEdMode::Enter()
{
FEdMode::Enter();
// Create toolkit (toolbar widget)
if (!Toolkit.IsValid())
{
Toolkit = MakeShareable(new FPS_AI_Behavior_SplineEdModeToolkit);
Toolkit->Init(Owner->GetToolkitHost());
}
}
void FPS_AI_Behavior_SplineEdMode::Exit()
{
// Finalize any in-progress spline
if (ActiveSpline)
{
FinalizeCurrentSpline();
}
if (Toolkit.IsValid())
{
FToolkitManager::Get().CloseToolkit(Toolkit.ToSharedRef());
Toolkit.Reset();
}
FEdMode::Exit();
}
bool FPS_AI_Behavior_SplineEdMode::HandleClick(
FEditorViewportClient* InViewportClient, HHitProxy* HitProxy, const FViewportClick& Click)
{
if (Click.GetKey() != EKeys::LeftMouseButton)
{
return false;
}
UWorld* World = GetWorld();
if (!World) return false;
// Get click location via line trace from camera
FViewport* Viewport = InViewportClient->Viewport;
if (!Viewport) return false;
const int32 HitX = Viewport->GetMouseX();
const int32 HitY = Viewport->GetMouseY();
FSceneViewFamilyContext ViewFamily(FSceneViewFamily::ConstructionValues(
Viewport, InViewportClient->GetScene(),
InViewportClient->EngineShowFlags));
FSceneView* View = InViewportClient->CalcSceneView(&ViewFamily);
// Deproject mouse to world
FVector2D MousePos(HitX, HitY);
FVector RayOrigin, RayDirection;
FSceneView::DeprojectScreenToWorld(
MousePos, View->UnconstrainedViewRect,
View->ViewMatrices.GetInvViewProjectionMatrix(),
RayOrigin, RayDirection);
// Line trace to find ground
FHitResult Hit;
FCollisionQueryParams Params(SCENE_QUERY_STAT(SplineEdModeClick), true);
const FVector TraceEnd = RayOrigin + RayDirection * 100000.0f;
if (!World->LineTraceSingleByChannel(Hit, RayOrigin, TraceEnd, ECC_WorldStatic, Params))
{
return false; // No ground hit
}
FVector ClickLocation = Hit.ImpactPoint;
// Ctrl+Click on existing spline → select for extension
if (Click.IsControlDown())
{
// Check if we hit a SplinePath
AActor* HitActor = Hit.GetActor();
APS_AI_Behavior_SplinePath* HitSpline = Cast<APS_AI_Behavior_SplinePath>(HitActor);
if (!HitSpline)
{
// Check nearby splines
for (TActorIterator<APS_AI_Behavior_SplinePath> It(World); It; ++It)
{
float Dist = 0.0f;
FVector ClosestPt;
if ((*It)->GetClosestPointOnSpline(ClickLocation, Dist, ClosestPt) < 200.0f)
{
HitSpline = *It;
break;
}
}
}
if (HitSpline)
{
SelectSplineForExtension(HitSpline);
return true;
}
return false;
}
// Snap to ground
if (bSnapToGround)
{
SnapToGround(ClickLocation);
}
// ─── Route to active tool ───────────────────────────────────────────
switch (ActiveTool)
{
case EPS_AI_Behavior_EdModeTool::Spline:
AddPointToSpline(ClickLocation);
break;
case EPS_AI_Behavior_EdModeTool::CoverPoint:
{
// Cover point faces toward the camera (typical workflow)
const FVector CamLoc = InViewportClient->GetViewLocation();
const FVector DirToCamera = (CamLoc - ClickLocation).GetSafeNormal2D();
const FRotator Facing = DirToCamera.Rotation();
APS_AI_Behavior_CoverPoint* NewPoint = PlaceCoverPoint(ClickLocation, Facing);
if (NewPoint)
{
GEditor->SelectNone(true, true);
GEditor->SelectActor(NewPoint, true, true);
}
}
break;
}
return true;
}
bool FPS_AI_Behavior_SplineEdMode::InputKey(
FEditorViewportClient* ViewportClient, FViewport* Viewport,
FKey Key, EInputEvent Event)
{
if (Event != IE_Pressed)
{
return false;
}
// Enter → finalize current spline
if (Key == EKeys::Enter || Key == EKeys::SpaceBar)
{
if (ActiveSpline && PointCount >= 2)
{
FinalizeCurrentSpline();
return true;
}
}
// Escape → cancel current spline or exit mode
if (Key == EKeys::Escape)
{
if (ActiveSpline)
{
// Delete the in-progress spline
ActiveSpline->Destroy();
ActiveSpline = nullptr;
PointCount = 0;
return true;
}
}
// Delete → delete selected spline
if (Key == EKeys::Delete)
{
if (ActiveSpline)
{
ActiveSpline->Destroy();
ActiveSpline = nullptr;
PointCount = 0;
return true;
}
}
return false;
}
void FPS_AI_Behavior_SplineEdMode::Render(
const FSceneView* View, FViewport* Viewport, FPrimitiveDrawInterface* PDI)
{
FEdMode::Render(View, Viewport, PDI);
if (!bShowJunctionPreview) return;
UWorld* World = GetWorld();
if (!World) return;
// Draw junction spheres for all splines in the level
for (TActorIterator<APS_AI_Behavior_SplinePath> It(World); It; ++It)
{
for (const FPS_AI_Behavior_SplineJunction& J : (*It)->Junctions)
{
// Yellow sphere at junction
PDI->DrawPoint(J.WorldLocation, FLinearColor::Yellow, 12.0f, SDPG_Foreground);
}
}
// Draw arrow heads on splines to show direction
for (TActorIterator<APS_AI_Behavior_SplinePath> It(World); It; ++It)
{
APS_AI_Behavior_SplinePath* Spline = *It;
if (!Spline->SplineComp) continue;
const float Length = Spline->GetSplineLength();
if (Length <= 0.0f) continue;
// Draw arrows every 500cm
const float ArrowSpacing = 500.0f;
for (float Dist = ArrowSpacing; Dist < Length; Dist += ArrowSpacing)
{
const FVector Pos = Spline->GetWorldLocationAtDistance(Dist);
const FVector Dir = Spline->SplineComp->GetDirectionAtDistanceAlongSpline(
Dist, ESplineCoordinateSpace::World);
// Draw direction line
const FVector ArrowEnd = Pos + Dir * 60.0f;
PDI->DrawLine(Pos, ArrowEnd, FLinearColor::White, SDPG_Foreground, 2.0f);
// Arrow head
const FVector Right = FVector::CrossProduct(Dir, FVector::UpVector).GetSafeNormal();
PDI->DrawLine(ArrowEnd, ArrowEnd - Dir * 25.0f + Right * 15.0f,
FLinearColor::White, SDPG_Foreground, 2.0f);
PDI->DrawLine(ArrowEnd, ArrowEnd - Dir * 25.0f - Right * 15.0f,
FLinearColor::White, SDPG_Foreground, 2.0f);
}
}
}
bool FPS_AI_Behavior_SplineEdMode::IsCompatibleWith(FEditorModeID OtherModeID) const
{
return true; // Compatible with all other modes
}
// ─── Spline Building ────────────────────────────────────────────────────────
APS_AI_Behavior_SplinePath* FPS_AI_Behavior_SplineEdMode::SpawnNewSpline(const FVector& FirstPoint)
{
UWorld* World = GetWorld();
if (!World) return nullptr;
FActorSpawnParameters SpawnParams;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
APS_AI_Behavior_SplinePath* NewSpline = World->SpawnActor<APS_AI_Behavior_SplinePath>(
APS_AI_Behavior_SplinePath::StaticClass(), FTransform(FirstPoint), SpawnParams);
if (NewSpline)
{
NewSpline->SplineCategory = CurrentSplineType;
// Clear default spline points and set first point
NewSpline->SplineComp->ClearSplinePoints(false);
NewSpline->SplineComp->AddSplineWorldPoint(FirstPoint);
NewSpline->SplineComp->UpdateSpline();
// Label in outliner
const FString TypeName = UEnum::GetDisplayValueAsText(CurrentSplineType).ToString();
NewSpline->SetActorLabel(FString::Printf(TEXT("SplinePath_%s"), *TypeName));
// Register with undo
GEditor->BeginTransaction(FText::FromString(TEXT("Create Spline Path")));
NewSpline->Modify();
GEditor->EndTransaction();
UE_LOG(LogPS_AI_Behavior, Log, TEXT("EdMode: Created new %s spline at (%.0f, %.0f, %.0f)"),
*TypeName, FirstPoint.X, FirstPoint.Y, FirstPoint.Z);
}
return NewSpline;
}
void FPS_AI_Behavior_SplineEdMode::AddPointToSpline(const FVector& WorldLocation)
{
if (!ActiveSpline)
{
// First click — spawn new spline
ActiveSpline = SpawnNewSpline(WorldLocation);
PointCount = ActiveSpline ? 1 : 0;
return;
}
// Add point to existing spline
GEditor->BeginTransaction(FText::FromString(TEXT("Add Spline Point")));
ActiveSpline->Modify();
ActiveSpline->SplineComp->AddSplineWorldPoint(WorldLocation);
ActiveSpline->SplineComp->UpdateSpline();
++PointCount;
GEditor->EndTransaction();
}
void FPS_AI_Behavior_SplineEdMode::FinalizeCurrentSpline()
{
if (!ActiveSpline) return;
if (PointCount < 2)
{
// Not enough points — delete
ActiveSpline->Destroy();
ActiveSpline = nullptr;
PointCount = 0;
return;
}
UE_LOG(LogPS_AI_Behavior, Log, TEXT("EdMode: Finalized spline '%s' with %d points, length %.0fcm"),
*ActiveSpline->GetActorLabel(), PointCount, ActiveSpline->GetSplineLength());
// Rebuild network to detect new junctions
RebuildNetworkPreview();
ActiveSpline = nullptr;
PointCount = 0;
}
void FPS_AI_Behavior_SplineEdMode::SelectSplineForExtension(APS_AI_Behavior_SplinePath* Spline)
{
if (!Spline) return;
// Finalize any current spline first
if (ActiveSpline && ActiveSpline != Spline)
{
FinalizeCurrentSpline();
}
ActiveSpline = Spline;
PointCount = Spline->SplineComp ? Spline->SplineComp->GetNumberOfSplinePoints() : 0;
UE_LOG(LogPS_AI_Behavior, Log, TEXT("EdMode: Selected spline '%s' for extension (%d existing points)"),
*Spline->GetActorLabel(), PointCount);
}
// ─── Cover Point Placement ──────────────────────────────────────────────────
APS_AI_Behavior_CoverPoint* FPS_AI_Behavior_SplineEdMode::PlaceCoverPoint(
const FVector& WorldLocation, const FRotator& Facing)
{
UWorld* World = GetWorld();
if (!World) return nullptr;
GEditor->BeginTransaction(FText::FromString(TEXT("Place Cover Point")));
FActorSpawnParameters SpawnParams;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
APS_AI_Behavior_CoverPoint* NewPoint = World->SpawnActor<APS_AI_Behavior_CoverPoint>(
APS_AI_Behavior_CoverPoint::StaticClass(),
FTransform(Facing, WorldLocation),
SpawnParams);
if (NewPoint)
{
NewPoint->PointType = CurrentCoverType;
NewPoint->AllowedNPCType = CoverAllowedNPCType;
NewPoint->Modify();
const FString TypeName = CurrentCoverType == EPS_AI_Behavior_CoverPointType::Cover
? TEXT("Cover") : TEXT("HidingSpot");
NewPoint->SetActorLabel(FString::Printf(TEXT("%s_%d"), *TypeName,
FMath::RandRange(1000, 9999)));
UE_LOG(LogPS_AI_Behavior, Log, TEXT("EdMode: Placed %s at (%.0f, %.0f, %.0f)"),
*TypeName, WorldLocation.X, WorldLocation.Y, WorldLocation.Z);
}
GEditor->EndTransaction();
return NewPoint;
}
bool FPS_AI_Behavior_SplineEdMode::SnapToGround(FVector& InOutLocation) const
{
UWorld* World = GetWorld();
if (!World) return false;
// Trace downward from above the point
const FVector TraceStart = InOutLocation + FVector(0, 0, 500.0f);
const FVector TraceEnd = InOutLocation - FVector(0, 0, 5000.0f);
FHitResult Hit;
FCollisionQueryParams Params(SCENE_QUERY_STAT(SplineSnapToGround), true);
if (World->LineTraceSingleByChannel(Hit, TraceStart, TraceEnd, ECC_WorldStatic, Params))
{
InOutLocation = Hit.ImpactPoint + FVector(0, 0, 5.0f); // Slight offset above ground
return true;
}
return false;
}
void FPS_AI_Behavior_SplineEdMode::RebuildNetworkPreview()
{
UWorld* World = GetWorld();
if (!World) return;
UPS_AI_Behavior_SplineNetwork* Network = World->GetSubsystem<UPS_AI_Behavior_SplineNetwork>();
if (Network)
{
Network->RebuildNetwork();
}
}

View File

@ -0,0 +1,329 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior_SplineEdModeToolkit.h"
#include "PS_AI_Behavior_SplineEdMode.h"
#include "EditorModeManager.h"
#include "Widgets/Input/SButton.h"
#include "Widgets/Input/SCheckBox.h"
#include "Widgets/Layout/SBorder.h"
#include "Widgets/Layout/SBox.h"
#include "Widgets/Text/STextBlock.h"
#define LOCTEXT_NAMESPACE "PS_AI_BehaviorSplineToolkit"
void FPS_AI_Behavior_SplineEdModeToolkit::Init(const TSharedPtr<IToolkitHost>& InitToolkitHost)
{
ToolkitWidget = BuildToolkitWidget();
FModeToolkit::Init(InitToolkitHost);
}
FEdMode* FPS_AI_Behavior_SplineEdModeToolkit::GetEditorMode() const
{
return GLevelEditorModeTools().GetActiveMode(FPS_AI_Behavior_SplineEdMode::EM_SplineEdModeId);
}
FPS_AI_Behavior_SplineEdMode* FPS_AI_Behavior_SplineEdModeToolkit::GetSplineEdMode() const
{
return static_cast<FPS_AI_Behavior_SplineEdMode*>(GetEditorMode());
}
TSharedRef<SWidget> FPS_AI_Behavior_SplineEdModeToolkit::BuildToolkitWidget()
{
return SNew(SBorder)
.Padding(8.0f)
[
SNew(SVerticalBox)
// ─── Title ──────────────────────────────────────────────
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0, 0, 0, 8)
[
SNew(STextBlock)
.Text(LOCTEXT("Title", "PS AI Level Design"))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 14))
]
// ─── Tool Selection ─────────────────────────────────────
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0, 0, 0, 4)
[
SNew(STextBlock)
.Text(LOCTEXT("ToolLabel", "Active Tool:"))
]
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0, 0, 0, 8)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(0, 0, 4, 0)
[
SNew(SButton)
.Text(LOCTEXT("ToolSpline", "Spline"))
.ButtonColorAndOpacity_Lambda([this]()
{
auto* Mode = GetSplineEdMode();
const bool bActive = Mode && Mode->ActiveTool == EPS_AI_Behavior_EdModeTool::Spline;
return FSlateColor(bActive ? FLinearColor::White : FLinearColor(0.3f, 0.3f, 0.3f));
})
.OnClicked_Lambda([this]()
{
if (auto* Mode = GetSplineEdMode()) Mode->ActiveTool = EPS_AI_Behavior_EdModeTool::Spline;
return FReply::Handled();
})
]
+ SHorizontalBox::Slot()
.AutoWidth()
[
SNew(SButton)
.Text(LOCTEXT("ToolCover", "Cover Point"))
.ButtonColorAndOpacity_Lambda([this]()
{
auto* Mode = GetSplineEdMode();
const bool bActive = Mode && Mode->ActiveTool == EPS_AI_Behavior_EdModeTool::CoverPoint;
return FSlateColor(bActive ? FLinearColor::White : FLinearColor(0.3f, 0.3f, 0.3f));
})
.OnClicked_Lambda([this]()
{
if (auto* Mode = GetSplineEdMode()) Mode->ActiveTool = EPS_AI_Behavior_EdModeTool::CoverPoint;
return FReply::Handled();
})
]
]
// ─── Spline Type Selection ──────────────────────────────
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0, 0, 0, 4)
[
SNew(STextBlock)
.Text(LOCTEXT("TypeLabel", "Spline Type:"))
]
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0, 0, 0, 8)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(0, 0, 4, 0)
[
SNew(SButton)
.Text(LOCTEXT("Civilian", "Civilian"))
.ButtonColorAndOpacity(this, &FPS_AI_Behavior_SplineEdModeToolkit::GetTypeColor, EPS_AI_Behavior_NPCType::Civilian)
.OnClicked(this, &FPS_AI_Behavior_SplineEdModeToolkit::OnTypeButtonClicked, EPS_AI_Behavior_NPCType::Civilian)
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(0, 0, 4, 0)
[
SNew(SButton)
.Text(LOCTEXT("Enemy", "Enemy"))
.ButtonColorAndOpacity(this, &FPS_AI_Behavior_SplineEdModeToolkit::GetTypeColor, EPS_AI_Behavior_NPCType::Enemy)
.OnClicked(this, &FPS_AI_Behavior_SplineEdModeToolkit::OnTypeButtonClicked, EPS_AI_Behavior_NPCType::Enemy)
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(0, 0, 4, 0)
[
SNew(SButton)
.Text(LOCTEXT("Protector", "Protector"))
.ButtonColorAndOpacity(this, &FPS_AI_Behavior_SplineEdModeToolkit::GetTypeColor, EPS_AI_Behavior_NPCType::Protector)
.OnClicked(this, &FPS_AI_Behavior_SplineEdModeToolkit::OnTypeButtonClicked, EPS_AI_Behavior_NPCType::Protector)
]
+ SHorizontalBox::Slot()
.AutoWidth()
[
SNew(SButton)
.Text(LOCTEXT("Any", "Any"))
.ButtonColorAndOpacity(this, &FPS_AI_Behavior_SplineEdModeToolkit::GetTypeColor, EPS_AI_Behavior_NPCType::Any)
.OnClicked(this, &FPS_AI_Behavior_SplineEdModeToolkit::OnTypeButtonClicked, EPS_AI_Behavior_NPCType::Any)
]
]
// ─── Options ────────────────────────────────────────────
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0, 0, 0, 4)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.AutoWidth()
.VAlign(VAlign_Center)
.Padding(0, 0, 8, 0)
[
SNew(SCheckBox)
.IsChecked_Lambda([this]()
{
auto* Mode = GetSplineEdMode();
return Mode && Mode->bSnapToGround ? ECheckBoxState::Checked : ECheckBoxState::Unchecked;
})
.OnCheckStateChanged_Lambda([this](ECheckBoxState NewState)
{
if (auto* Mode = GetSplineEdMode())
{
Mode->bSnapToGround = (NewState == ECheckBoxState::Checked);
}
})
]
+ SHorizontalBox::Slot()
.AutoWidth()
.VAlign(VAlign_Center)
[
SNew(STextBlock)
.Text(LOCTEXT("SnapToGround", "Snap to Ground"))
]
]
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0, 0, 0, 8)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.AutoWidth()
.VAlign(VAlign_Center)
.Padding(0, 0, 8, 0)
[
SNew(SCheckBox)
.IsChecked_Lambda([this]()
{
auto* Mode = GetSplineEdMode();
return Mode && Mode->bShowJunctionPreview ? ECheckBoxState::Checked : ECheckBoxState::Unchecked;
})
.OnCheckStateChanged_Lambda([this](ECheckBoxState NewState)
{
if (auto* Mode = GetSplineEdMode())
{
Mode->bShowJunctionPreview = (NewState == ECheckBoxState::Checked);
}
})
]
+ SHorizontalBox::Slot()
.AutoWidth()
.VAlign(VAlign_Center)
[
SNew(STextBlock)
.Text(LOCTEXT("ShowJunctions", "Show Junction Preview"))
]
]
// ─── Cover Point Type ───────────────────────────────────
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0, 0, 0, 4)
[
SNew(STextBlock)
.Text(LOCTEXT("CoverTypeLabel", "Cover Point Type:"))
]
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0, 0, 0, 8)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(0, 0, 4, 0)
[
SNew(SButton)
.Text(LOCTEXT("CoverType", "Cover"))
.ButtonColorAndOpacity_Lambda([this]()
{
auto* Mode = GetSplineEdMode();
const bool bActive = Mode && Mode->CurrentCoverType == EPS_AI_Behavior_CoverPointType::Cover;
return FSlateColor(bActive ? FLinearColor(0.2f, 0.5f, 1.0f) : FLinearColor(0.15f, 0.25f, 0.5f));
})
.OnClicked_Lambda([this]()
{
if (auto* Mode = GetSplineEdMode()) Mode->CurrentCoverType = EPS_AI_Behavior_CoverPointType::Cover;
return FReply::Handled();
})
]
+ SHorizontalBox::Slot()
.AutoWidth()
[
SNew(SButton)
.Text(LOCTEXT("HidingType", "Hiding Spot"))
.ButtonColorAndOpacity_Lambda([this]()
{
auto* Mode = GetSplineEdMode();
const bool bActive = Mode && Mode->CurrentCoverType == EPS_AI_Behavior_CoverPointType::HidingSpot;
return FSlateColor(bActive ? FLinearColor(1.0f, 0.85f, 0.0f) : FLinearColor(0.5f, 0.42f, 0.0f));
})
.OnClicked_Lambda([this]()
{
if (auto* Mode = GetSplineEdMode()) Mode->CurrentCoverType = EPS_AI_Behavior_CoverPointType::HidingSpot;
return FReply::Handled();
})
]
]
// ─── Instructions ───────────────────────────────────────
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0, 8, 0, 0)
[
SNew(STextBlock)
.Text(LOCTEXT("Instructions",
"SPLINE TOOL:\n"
" LMB: Add point\n"
" Ctrl+LMB: Select to extend\n"
" Enter/Space: Finalize\n"
" Escape: Cancel\n\n"
"COVER POINT TOOL:\n"
" LMB: Place cover point\n"
" Arrow = NPC facing direction"))
.ColorAndOpacity(FSlateColor(FLinearColor(0.5f, 0.5f, 0.5f)))
]
];
}
FReply FPS_AI_Behavior_SplineEdModeToolkit::OnTypeButtonClicked(EPS_AI_Behavior_NPCType Type)
{
if (auto* Mode = GetSplineEdMode())
{
Mode->CurrentSplineType = Type;
}
return FReply::Handled();
}
bool FPS_AI_Behavior_SplineEdModeToolkit::IsTypeSelected(EPS_AI_Behavior_NPCType Type) const
{
auto* Mode = GetSplineEdMode();
return Mode && Mode->CurrentSplineType == Type;
}
FSlateColor FPS_AI_Behavior_SplineEdModeToolkit::GetTypeColor(EPS_AI_Behavior_NPCType Type) const
{
const bool bSelected = IsTypeSelected(Type);
const float Alpha = bSelected ? 1.0f : 0.4f;
switch (Type)
{
case EPS_AI_Behavior_NPCType::Civilian: return FSlateColor(FLinearColor(0.2f, 0.8f, 0.2f, Alpha));
case EPS_AI_Behavior_NPCType::Enemy: return FSlateColor(FLinearColor(0.9f, 0.2f, 0.2f, Alpha));
case EPS_AI_Behavior_NPCType::Protector: return FSlateColor(FLinearColor(0.2f, 0.4f, 1.0f, Alpha));
case EPS_AI_Behavior_NPCType::Any: return FSlateColor(FLinearColor(1.0f, 0.7f, 0.0f, Alpha));
default: return FSlateColor(FLinearColor(0.5f, 0.5f, 0.5f, Alpha));
}
}
#undef LOCTEXT_NAMESPACE

View File

@ -0,0 +1,83 @@
// Copyright Asterion. All Rights Reserved.
#include "PS_AI_Behavior_SplineVisualizer.h"
#include "PS_AI_Behavior_SplinePath.h"
#include "Components/SplineComponent.h"
void FPS_AI_Behavior_SplineVisualizer::DrawVisualization(
const UActorComponent* Component, const FSceneView* View, FPrimitiveDrawInterface* PDI)
{
const USplineComponent* SplineComp = Cast<USplineComponent>(Component);
if (!SplineComp) return;
const APS_AI_Behavior_SplinePath* SplinePath = Cast<APS_AI_Behavior_SplinePath>(SplineComp->GetOwner());
if (!SplinePath) return;
const float SplineLength = SplineComp->GetSplineLength();
if (SplineLength <= 0.0f) return;
// ─── Draw direction arrows every 500cm ──────────────────────────────
const float ArrowSpacing = 500.0f;
for (float Dist = ArrowSpacing * 0.5f; Dist < SplineLength; Dist += ArrowSpacing)
{
const FVector Pos = SplineComp->GetLocationAtDistanceAlongSpline(Dist, ESplineCoordinateSpace::World);
const FVector Dir = SplineComp->GetDirectionAtDistanceAlongSpline(Dist, ESplineCoordinateSpace::World);
const FVector ArrowEnd = Pos + Dir * 50.0f;
const FVector Right = FVector::CrossProduct(Dir, FVector::UpVector).GetSafeNormal();
// Arrow shaft
PDI->DrawLine(Pos - Dir * 20.0f, ArrowEnd, FLinearColor::White, SDPG_World, 1.5f);
// Arrow head
PDI->DrawLine(ArrowEnd, ArrowEnd - Dir * 20.0f + Right * 12.0f,
FLinearColor::White, SDPG_World, 1.5f);
PDI->DrawLine(ArrowEnd, ArrowEnd - Dir * 20.0f - Right * 12.0f,
FLinearColor::White, SDPG_World, 1.5f);
// Bidirectional? Draw reverse arrow too
if (SplinePath->bBidirectional)
{
const FVector RevEnd = Pos - Dir * 50.0f;
PDI->DrawLine(Pos + Dir * 20.0f, RevEnd, FLinearColor(0.5f, 0.5f, 0.5f), SDPG_World, 1.0f);
PDI->DrawLine(RevEnd, RevEnd + Dir * 20.0f + Right * 10.0f,
FLinearColor(0.5f, 0.5f, 0.5f), SDPG_World, 1.0f);
PDI->DrawLine(RevEnd, RevEnd + Dir * 20.0f - Right * 10.0f,
FLinearColor(0.5f, 0.5f, 0.5f), SDPG_World, 1.0f);
}
}
// ─── Draw distance markers every 1000cm (10m) ──────────────────────
const float MarkerSpacing = 1000.0f;
for (float Dist = MarkerSpacing; Dist < SplineLength; Dist += MarkerSpacing)
{
const FVector Pos = SplineComp->GetLocationAtDistanceAlongSpline(Dist, ESplineCoordinateSpace::World);
// Small cross marker
PDI->DrawLine(Pos + FVector(15, 0, 0), Pos - FVector(15, 0, 0),
FLinearColor(0.7f, 0.7f, 0.7f), SDPG_World, 1.0f);
PDI->DrawLine(Pos + FVector(0, 15, 0), Pos - FVector(0, 15, 0),
FLinearColor(0.7f, 0.7f, 0.7f), SDPG_World, 1.0f);
}
// ─── Draw junctions as yellow/orange spheres ────────────────────────
for (const FPS_AI_Behavior_SplineJunction& Junction : SplinePath->Junctions)
{
const FVector JuncPos = Junction.WorldLocation;
// Draw a star/cross shape at junction
const float Size = 20.0f;
const FLinearColor JuncColor = FLinearColor(1.0f, 0.9f, 0.0f); // Yellow
PDI->DrawLine(JuncPos + FVector(Size, 0, 0), JuncPos - FVector(Size, 0, 0), JuncColor, SDPG_Foreground, 3.0f);
PDI->DrawLine(JuncPos + FVector(0, Size, 0), JuncPos - FVector(0, Size, 0), JuncColor, SDPG_Foreground, 3.0f);
PDI->DrawLine(JuncPos + FVector(0, 0, Size), JuncPos - FVector(0, 0, Size), JuncColor, SDPG_Foreground, 3.0f);
// Line to the other spline's junction point
if (Junction.OtherSpline.IsValid())
{
PDI->DrawLine(JuncPos, JuncPos + FVector(0, 0, 40.0f),
FLinearColor(1.0f, 0.5f, 0.0f), SDPG_Foreground, 2.0f);
}
}
}

View File

@ -0,0 +1,405 @@
// Copyright Asterion. All Rights Reserved.
#include "SPS_AI_Behavior_SplinePanel.h"
#include "PS_AI_Behavior_SplinePath.h"
#include "PS_AI_Behavior_SplineNetwork.h"
#include "Components/SplineComponent.h"
#include "Editor.h"
#include "EngineUtils.h"
#include "Engine/World.h"
#include "Widgets/Input/SButton.h"
#include "Widgets/Layout/SBorder.h"
#include "Widgets/Layout/SScrollBox.h"
#include "Widgets/Text/STextBlock.h"
#include "Selection.h"
#include "CollisionQueryParams.h"
#define LOCTEXT_NAMESPACE "PS_AI_BehaviorSplinePanel"
const FName SPS_AI_Behavior_SplinePanel::TabId = FName("PS_AI_BehaviorSplinePanel");
void SPS_AI_Behavior_SplinePanel::Construct(const FArguments& InArgs)
{
ChildSlot
[
SNew(SVerticalBox)
// ─── Title ──────────────────────────────────────────────────
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(8)
[
SNew(STextBlock)
.Text(LOCTEXT("PanelTitle", "PS AI Spline Network"))
.Font(FCoreStyle::GetDefaultFontStyle("Bold", 16))
]
// ─── Creation Buttons ───────────────────────────────────────
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(8, 4)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot().AutoWidth().Padding(0, 0, 4, 0)
[
SNew(SButton)
.Text(LOCTEXT("NewCivilian", "+ Civilian"))
.ButtonColorAndOpacity(FLinearColor(0.2f, 0.8f, 0.2f))
.OnClicked(this, &SPS_AI_Behavior_SplinePanel::OnCreateSplineClicked, EPS_AI_Behavior_NPCType::Civilian)
]
+ SHorizontalBox::Slot().AutoWidth().Padding(0, 0, 4, 0)
[
SNew(SButton)
.Text(LOCTEXT("NewEnemy", "+ Enemy"))
.ButtonColorAndOpacity(FLinearColor(0.9f, 0.2f, 0.2f))
.OnClicked(this, &SPS_AI_Behavior_SplinePanel::OnCreateSplineClicked, EPS_AI_Behavior_NPCType::Enemy)
]
+ SHorizontalBox::Slot().AutoWidth().Padding(0, 0, 4, 0)
[
SNew(SButton)
.Text(LOCTEXT("NewProtector", "+ Protector"))
.ButtonColorAndOpacity(FLinearColor(0.2f, 0.4f, 1.0f))
.OnClicked(this, &SPS_AI_Behavior_SplinePanel::OnCreateSplineClicked, EPS_AI_Behavior_NPCType::Protector)
]
+ SHorizontalBox::Slot().AutoWidth()
[
SNew(SButton)
.Text(LOCTEXT("NewAny", "+ Any"))
.ButtonColorAndOpacity(FLinearColor(1.0f, 0.7f, 0.0f))
.OnClicked(this, &SPS_AI_Behavior_SplinePanel::OnCreateSplineClicked, EPS_AI_Behavior_NPCType::Any)
]
]
// ─── Action Buttons ─────────────────────────────────────────
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(8, 4)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot().AutoWidth().Padding(0, 0, 4, 0)
[
SNew(SButton)
.Text(LOCTEXT("Refresh", "Refresh List"))
.OnClicked_Lambda([this]() { RefreshSplineList(); return FReply::Handled(); })
]
+ SHorizontalBox::Slot().AutoWidth().Padding(0, 0, 4, 0)
[
SNew(SButton)
.Text(LOCTEXT("RebuildNetwork", "Rebuild Junctions"))
.OnClicked(this, &SPS_AI_Behavior_SplinePanel::OnRebuildNetworkClicked)
]
+ SHorizontalBox::Slot().AutoWidth().Padding(0, 0, 4, 0)
[
SNew(SButton)
.Text(LOCTEXT("Validate", "Validate Network"))
.OnClicked(this, &SPS_AI_Behavior_SplinePanel::OnValidateNetworkClicked)
]
+ SHorizontalBox::Slot().AutoWidth()
[
SNew(SButton)
.Text(LOCTEXT("SnapGround", "Snap Selected to Ground"))
.OnClicked(this, &SPS_AI_Behavior_SplinePanel::OnSnapSelectedToGroundClicked)
]
]
// ─── Spline List ────────────────────────────────────────────
+ SVerticalBox::Slot()
.FillHeight(1.0f)
.Padding(8)
[
SAssignNew(SplineListView, SListView<TSharedPtr<FSplineListEntry>>)
.ListItemsSource(&SplineEntries)
.OnGenerateRow(this, &SPS_AI_Behavior_SplinePanel::GenerateSplineRow)
.OnSelectionChanged(this, &SPS_AI_Behavior_SplinePanel::OnSplineSelected)
.HeaderRow(
SNew(SHeaderRow)
+ SHeaderRow::Column("Name").DefaultLabel(LOCTEXT("ColName", "Name")).FillWidth(0.3f)
+ SHeaderRow::Column("Type").DefaultLabel(LOCTEXT("ColType", "Type")).FillWidth(0.15f)
+ SHeaderRow::Column("Length").DefaultLabel(LOCTEXT("ColLength", "Length")).FillWidth(0.15f)
+ SHeaderRow::Column("Points").DefaultLabel(LOCTEXT("ColPoints", "Pts")).FillWidth(0.1f)
+ SHeaderRow::Column("Junctions").DefaultLabel(LOCTEXT("ColJunctions", "Junctions")).FillWidth(0.1f)
)
]
];
RefreshSplineList();
}
void SPS_AI_Behavior_SplinePanel::RefreshSplineList()
{
SplineEntries.Empty();
UWorld* World = GetEditorWorld();
if (!World) return;
for (TActorIterator<APS_AI_Behavior_SplinePath> It(World); It; ++It)
{
APS_AI_Behavior_SplinePath* Spline = *It;
if (!Spline) continue;
TSharedPtr<FSplineListEntry> Entry = MakeShared<FSplineListEntry>();
Entry->Spline = Spline;
Entry->Name = Spline->GetActorLabel().IsEmpty() ? Spline->GetName() : Spline->GetActorLabel();
Entry->Type = Spline->SplineCategory;
Entry->Length = Spline->GetSplineLength();
Entry->JunctionCount = Spline->Junctions.Num();
Entry->PointCount = Spline->SplineComp ? Spline->SplineComp->GetNumberOfSplinePoints() : 0;
SplineEntries.Add(Entry);
}
if (SplineListView.IsValid())
{
SplineListView->RequestListRefresh();
}
}
TSharedRef<ITableRow> SPS_AI_Behavior_SplinePanel::GenerateSplineRow(
TSharedPtr<FSplineListEntry> Entry,
const TSharedRef<STableViewBase>& OwnerTable)
{
const FLinearColor TypeColor = GetColorForType(Entry->Type);
return SNew(STableRow<TSharedPtr<FSplineListEntry>>, OwnerTable)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot().FillWidth(0.3f).Padding(4, 2)
[
SNew(STextBlock).Text(FText::FromString(Entry->Name))
]
+ SHorizontalBox::Slot().FillWidth(0.15f).Padding(4, 2)
[
SNew(STextBlock)
.Text(FText::FromString(UEnum::GetDisplayValueAsText(Entry->Type).ToString()))
.ColorAndOpacity(FSlateColor(TypeColor))
]
+ SHorizontalBox::Slot().FillWidth(0.15f).Padding(4, 2)
[
SNew(STextBlock)
.Text(FText::FromString(FString::Printf(TEXT("%.0f cm"), Entry->Length)))
]
+ SHorizontalBox::Slot().FillWidth(0.1f).Padding(4, 2)
[
SNew(STextBlock)
.Text(FText::FromString(FString::FromInt(Entry->PointCount)))
]
+ SHorizontalBox::Slot().FillWidth(0.1f).Padding(4, 2)
[
SNew(STextBlock)
.Text(FText::FromString(FString::FromInt(Entry->JunctionCount)))
]
];
}
void SPS_AI_Behavior_SplinePanel::OnSplineSelected(
TSharedPtr<FSplineListEntry> Entry, ESelectInfo::Type SelectInfo)
{
if (!Entry.IsValid() || !Entry->Spline.IsValid()) return;
// Select in editor and focus
GEditor->SelectNone(true, true);
GEditor->SelectActor(Entry->Spline.Get(), true, true);
GEditor->MoveViewportCamerasToActor(*Entry->Spline.Get(), false);
}
FReply SPS_AI_Behavior_SplinePanel::OnCreateSplineClicked(EPS_AI_Behavior_NPCType Type)
{
UWorld* World = GetEditorWorld();
if (!World) return FReply::Handled();
// Get viewport camera location as spawn point
FVector SpawnLoc = FVector::ZeroVector;
if (GEditor && GEditor->GetActiveViewport())
{
FEditorViewportClient* ViewportClient = static_cast<FEditorViewportClient*>(
GEditor->GetActiveViewport()->GetClient());
if (ViewportClient)
{
SpawnLoc = ViewportClient->GetViewLocation() + ViewportClient->GetViewRotation().Vector() * 500.0f;
}
}
GEditor->BeginTransaction(FText::FromString(TEXT("Create Spline Path")));
FActorSpawnParameters SpawnParams;
SpawnParams.SpawnCollisionHandlingOverride = ESpawnActorCollisionHandlingMethod::AlwaysSpawn;
APS_AI_Behavior_SplinePath* NewSpline = World->SpawnActor<APS_AI_Behavior_SplinePath>(
APS_AI_Behavior_SplinePath::StaticClass(), FTransform(SpawnLoc), SpawnParams);
if (NewSpline)
{
NewSpline->SplineCategory = Type;
const FString TypeName = UEnum::GetDisplayValueAsText(Type).ToString();
NewSpline->SetActorLabel(FString::Printf(TEXT("SplinePath_%s"), *TypeName));
NewSpline->Modify();
GEditor->SelectNone(true, true);
GEditor->SelectActor(NewSpline, true, true);
}
GEditor->EndTransaction();
RefreshSplineList();
return FReply::Handled();
}
FReply SPS_AI_Behavior_SplinePanel::OnRebuildNetworkClicked()
{
UWorld* World = GetEditorWorld();
if (!World) return FReply::Handled();
UPS_AI_Behavior_SplineNetwork* Network = World->GetSubsystem<UPS_AI_Behavior_SplineNetwork>();
if (Network)
{
Network->RebuildNetwork();
}
RefreshSplineList();
return FReply::Handled();
}
FReply SPS_AI_Behavior_SplinePanel::OnValidateNetworkClicked()
{
UWorld* World = GetEditorWorld();
if (!World) return FReply::Handled();
int32 OrphanCount = 0;
int32 TooShortCount = 0;
int32 SinglePointCount = 0;
for (TActorIterator<APS_AI_Behavior_SplinePath> It(World); It; ++It)
{
APS_AI_Behavior_SplinePath* Spline = *It;
if (!Spline) continue;
if (Spline->Junctions.Num() == 0)
{
++OrphanCount;
UE_LOG(LogPS_AI_Behavior, Warning,
TEXT("Validation: Orphan spline '%s' has no junctions."),
*Spline->GetActorLabel());
}
if (Spline->GetSplineLength() < 100.0f)
{
++TooShortCount;
UE_LOG(LogPS_AI_Behavior, Warning,
TEXT("Validation: Spline '%s' is too short (%.0f cm)."),
*Spline->GetActorLabel(), Spline->GetSplineLength());
}
if (Spline->SplineComp && Spline->SplineComp->GetNumberOfSplinePoints() < 2)
{
++SinglePointCount;
UE_LOG(LogPS_AI_Behavior, Warning,
TEXT("Validation: Spline '%s' has only %d point(s)."),
*Spline->GetActorLabel(), Spline->SplineComp->GetNumberOfSplinePoints());
}
}
if (OrphanCount == 0 && TooShortCount == 0 && SinglePointCount == 0)
{
UE_LOG(LogPS_AI_Behavior, Log, TEXT("Validation: Network OK — no issues found."));
}
else
{
UE_LOG(LogPS_AI_Behavior, Warning,
TEXT("Validation: %d orphan(s), %d too short, %d single-point."),
OrphanCount, TooShortCount, SinglePointCount);
}
return FReply::Handled();
}
FReply SPS_AI_Behavior_SplinePanel::OnSnapSelectedToGroundClicked()
{
UWorld* World = GetEditorWorld();
if (!World) return FReply::Handled();
// Get selected actors
TArray<APS_AI_Behavior_SplinePath*> SelectedSplines;
for (FSelectionIterator It(GEditor->GetSelectedActorIterator()); It; ++It)
{
if (APS_AI_Behavior_SplinePath* Spline = Cast<APS_AI_Behavior_SplinePath>(*It))
{
SelectedSplines.Add(Spline);
}
}
if (SelectedSplines.Num() == 0)
{
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("Snap to Ground: No SplinePath actors selected."));
return FReply::Handled();
}
GEditor->BeginTransaction(FText::FromString(TEXT("Snap Spline Points to Ground")));
for (APS_AI_Behavior_SplinePath* Spline : SelectedSplines)
{
if (!Spline->SplineComp) continue;
Spline->Modify();
const int32 NumPoints = Spline->SplineComp->GetNumberOfSplinePoints();
for (int32 i = 0; i < NumPoints; ++i)
{
FVector PointLoc = Spline->SplineComp->GetLocationAtSplinePoint(i, ESplineCoordinateSpace::World);
// Trace down
FHitResult Hit;
FCollisionQueryParams Params(SCENE_QUERY_STAT(SnapSplineToGround), true);
Params.AddIgnoredActor(Spline);
const FVector TraceStart = PointLoc + FVector(0, 0, 500.0f);
const FVector TraceEnd = PointLoc - FVector(0, 0, 5000.0f);
if (World->LineTraceSingleByChannel(Hit, TraceStart, TraceEnd, ECC_WorldStatic, Params))
{
const FVector NewLoc = Hit.ImpactPoint + FVector(0, 0, 5.0f);
Spline->SplineComp->SetLocationAtSplinePoint(i, NewLoc, ESplineCoordinateSpace::World, true);
}
}
Spline->SplineComp->UpdateSpline();
UE_LOG(LogPS_AI_Behavior, Log, TEXT("Snapped %d points of '%s' to ground."),
NumPoints, *Spline->GetActorLabel());
}
GEditor->EndTransaction();
RefreshSplineList();
return FReply::Handled();
}
UWorld* SPS_AI_Behavior_SplinePanel::GetEditorWorld() const
{
return GEditor ? GEditor->GetEditorWorldContext().World() : nullptr;
}
FLinearColor SPS_AI_Behavior_SplinePanel::GetColorForType(EPS_AI_Behavior_NPCType Type) const
{
switch (Type)
{
case EPS_AI_Behavior_NPCType::Civilian: return FLinearColor(0.2f, 0.8f, 0.2f);
case EPS_AI_Behavior_NPCType::Enemy: return FLinearColor(0.9f, 0.2f, 0.2f);
case EPS_AI_Behavior_NPCType::Protector: return FLinearColor(0.2f, 0.4f, 1.0f);
case EPS_AI_Behavior_NPCType::Any: return FLinearColor(1.0f, 0.7f, 0.0f);
default: return FLinearColor::White;
}
}
#undef LOCTEXT_NAMESPACE

View File

@ -0,0 +1,25 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "Modules/ModuleManager.h"
class FPS_AI_BehaviorEditorModule : public IModuleInterface
{
public:
virtual void StartupModule() override;
virtual void ShutdownModule() override;
private:
void RegisterEdMode();
void UnregisterEdMode();
void RegisterVisualizer();
void UnregisterVisualizer();
void RegisterDetailCustomizations();
void UnregisterDetailCustomizations();
void RegisterSplinePanel();
void UnregisterSplinePanel();
};

View File

@ -0,0 +1,102 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "EdMode.h"
#include "PS_AI_Behavior_Definitions.h"
class APS_AI_Behavior_SplinePath;
class APS_AI_Behavior_CoverPoint;
class USplineComponent;
/** Active tool within the EdMode. */
enum class EPS_AI_Behavior_EdModeTool : uint8
{
Spline, // Place spline points
CoverPoint, // Place cover points / hiding spots
};
/**
* Editor Mode for interactive placement of splines and cover points.
* Activated from the toolbar. Supports two tools:
* - Spline: click to add points, Enter to finalize
* - CoverPoint: click to place, arrow shows facing direction
*/
class FPS_AI_Behavior_SplineEdMode : public FEdMode
{
public:
static const FEditorModeID EM_SplineEdModeId;
FPS_AI_Behavior_SplineEdMode();
virtual ~FPS_AI_Behavior_SplineEdMode();
// ─── FEdMode Interface ──────────────────────────────────────────────
virtual void Enter() override;
virtual void Exit() override;
virtual bool HandleClick(FEditorViewportClient* InViewportClient,
HHitProxy* HitProxy, const FViewportClick& Click) override;
virtual bool InputKey(FEditorViewportClient* ViewportClient,
FViewport* Viewport, FKey Key, EInputEvent Event) override;
virtual void Render(const FSceneView* View, FViewport* Viewport, FPrimitiveDrawInterface* PDI) override;
virtual bool UsesToolkits() const override { return true; }
virtual bool IsCompatibleWith(FEditorModeID OtherModeID) const override;
// ─── Active Tool ────────────────────────────────────────────────────
/** Which tool is currently active. */
EPS_AI_Behavior_EdModeTool ActiveTool = EPS_AI_Behavior_EdModeTool::Spline;
// ─── Spline Placement ───────────────────────────────────────────────
/** The type of spline currently being placed. */
EPS_AI_Behavior_NPCType CurrentSplineType = EPS_AI_Behavior_NPCType::Civilian;
/** Whether to snap placed points to the ground. */
bool bSnapToGround = true;
/** Whether to show junction preview in the viewport. */
bool bShowJunctionPreview = true;
/** Finalize the current spline and start a new one. */
void FinalizeCurrentSpline();
/** Get the spline currently being built (can be null). */
APS_AI_Behavior_SplinePath* GetActiveSpline() const { return ActiveSpline; }
/** Select an existing spline for extension. */
void SelectSplineForExtension(APS_AI_Behavior_SplinePath* Spline);
// ─── Cover Point Placement ──────────────────────────────────────────
/** Type of cover point to place. */
EPS_AI_Behavior_CoverPointType CurrentCoverType = EPS_AI_Behavior_CoverPointType::Cover;
/** NPC type restriction for newly placed cover points. */
EPS_AI_Behavior_NPCType CoverAllowedNPCType = EPS_AI_Behavior_NPCType::Any;
private:
/** The spline actor being built. */
APS_AI_Behavior_SplinePath* ActiveSpline = nullptr;
/** Number of points added to the active spline. */
int32 PointCount = 0;
/** Spawn a new SplinePath actor of the current type. */
APS_AI_Behavior_SplinePath* SpawnNewSpline(const FVector& FirstPoint);
/** Add a point to the active spline. */
void AddPointToSpline(const FVector& WorldLocation);
/** Place a cover point at the given location facing the camera. */
APS_AI_Behavior_CoverPoint* PlaceCoverPoint(const FVector& WorldLocation, const FRotator& Facing);
/** Snap a world location to the ground via line trace. */
bool SnapToGround(FVector& InOutLocation) const;
/** Rebuild the spline network preview. */
void RebuildNetworkPreview();
};

View File

@ -0,0 +1,43 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Toolkits/BaseToolkit.h"
#include "PS_AI_Behavior_Definitions.h"
class FPS_AI_Behavior_SplineEdMode;
/**
* Toolkit (toolbar widget) for the Spline EdMode.
* Shows buttons for spline type selection, snap toggle, and preview toggle.
*/
class FPS_AI_Behavior_SplineEdModeToolkit : public FModeToolkit
{
public:
virtual void Init(const TSharedPtr<IToolkitHost>& InitToolkitHost) override;
virtual FName GetToolkitFName() const override { return FName("PS_AI_BehaviorSplineEdModeToolkit"); }
virtual FText GetBaseToolkitName() const override { return FText::FromString("PS AI Spline"); }
virtual class FEdMode* GetEditorMode() const override;
virtual TSharedPtr<SWidget> GetInlineContent() const override { return ToolkitWidget; }
private:
TSharedPtr<SWidget> ToolkitWidget;
FPS_AI_Behavior_SplineEdMode* GetSplineEdMode() const;
/** Build the toolbar widget. */
TSharedRef<SWidget> BuildToolkitWidget();
/** Callbacks for type buttons. */
FReply OnTypeButtonClicked(EPS_AI_Behavior_NPCType Type);
/** Is this type currently selected? */
bool IsTypeSelected(EPS_AI_Behavior_NPCType Type) const;
/** Get color for a type. */
FSlateColor GetTypeColor(EPS_AI_Behavior_NPCType Type) const;
};

View File

@ -0,0 +1,19 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "ComponentVisualizer.h"
class USplineComponent;
/**
* Component Visualizer for SplinePath's SplineComponent.
* Draws junctions, direction arrows, and distance markers in the editor viewport.
*/
class FPS_AI_Behavior_SplineVisualizer : public FComponentVisualizer
{
public:
virtual void DrawVisualization(const UActorComponent* Component,
const FSceneView* View, FPrimitiveDrawInterface* PDI) override;
};

View File

@ -0,0 +1,65 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "Widgets/SCompoundWidget.h"
#include "Widgets/Views/SListView.h"
#include "PS_AI_Behavior_Definitions.h"
class APS_AI_Behavior_SplinePath;
/** Row data for the spline list. */
struct FSplineListEntry
{
TWeakObjectPtr<APS_AI_Behavior_SplinePath> Spline;
FString Name;
EPS_AI_Behavior_NPCType Type;
float Length;
int32 JunctionCount;
int32 PointCount;
};
/**
* Dockable panel for managing spline paths.
* Shows a list of all splines, creation buttons, validation, and network rebuild.
*/
class SPS_AI_Behavior_SplinePanel : public SCompoundWidget
{
public:
SLATE_BEGIN_ARGS(SPS_AI_Behavior_SplinePanel) {}
SLATE_END_ARGS()
void Construct(const FArguments& InArgs);
static const FName TabId;
private:
// ─── List ───────────────────────────────────────────────────────────
TArray<TSharedPtr<FSplineListEntry>> SplineEntries;
TSharedPtr<SListView<TSharedPtr<FSplineListEntry>>> SplineListView;
/** Refresh the spline list from the world. */
void RefreshSplineList();
/** Generate a row widget for the list. */
TSharedRef<ITableRow> GenerateSplineRow(
TSharedPtr<FSplineListEntry> Entry,
const TSharedRef<STableViewBase>& OwnerTable);
/** Handle selection — focus viewport on the spline. */
void OnSplineSelected(TSharedPtr<FSplineListEntry> Entry, ESelectInfo::Type SelectInfo);
// ─── Actions ────────────────────────────────────────────────────────
FReply OnCreateSplineClicked(EPS_AI_Behavior_NPCType Type);
FReply OnRebuildNetworkClicked();
FReply OnValidateNetworkClicked();
FReply OnSnapSelectedToGroundClicked();
// ─── Helpers ────────────────────────────────────────────────────────
UWorld* GetEditorWorld() const;
FLinearColor GetColorForType(EPS_AI_Behavior_NPCType Type) const;
};