diff --git a/Unreal/PS_AI_Agent/PS_AI_Agent.uproject b/Unreal/PS_AI_Agent/PS_AI_Agent.uproject index a5aae52..217cdc3 100644 --- a/Unreal/PS_AI_Agent/PS_AI_Agent.uproject +++ b/Unreal/PS_AI_Agent/PS_AI_Agent.uproject @@ -40,6 +40,10 @@ { "Name": "AudioCapture", "Enabled": true + }, + { + "Name": "PS_AI_Behavior", + "Enabled": true } ] } \ No newline at end of file diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/PS_AI_Behavior.uplugin b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/PS_AI_Behavior.uplugin new file mode 100644 index 0000000..7c6942b --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/PS_AI_Behavior.uplugin @@ -0,0 +1,39 @@ +{ + "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": [ + { + "Name": "NavigationSystem", + "Enabled": true + } + ] +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/PS_AI_Behavior.Build.cs b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/PS_AI_Behavior.Build.cs new file mode 100644 index 0000000..e59e858 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/PS_AI_Behavior.Build.cs @@ -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", + }); + } +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTDecorator_CheckTrait.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTDecorator_CheckTrait.cpp new file mode 100644 index 0000000..effb442 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTDecorator_CheckTrait.cpp @@ -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(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(); + const UEnum* OpEnum = StaticEnum(); + + return FString::Printf(TEXT("Trait: %s %s %.2f"), + *AxisEnum->GetDisplayNameTextByValue(static_cast(TraitAxis)).ToString(), + *OpEnum->GetDisplayNameTextByValue(static_cast(Comparison)).ToString(), + Threshold); +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTService_EvaluateReaction.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTService_EvaluateReaction.cpp new file mode 100644 index 0000000..72af36e --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTService_EvaluateReaction.cpp @@ -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(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."); +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTService_UpdateThreat.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTService_UpdateThreat.cpp new file mode 100644 index 0000000..1ad9cdf --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTService_UpdateThreat.cpp @@ -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(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(); + 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."); +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_Attack.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_Attack.cpp new file mode 100644 index 0000000..764dc32 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_Attack.cpp @@ -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(OwnerComp.GetAIOwner()); + if (!AIC || !AIC->GetPawn()) return EBTNodeResult::Failed; + + UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent(); + if (!BB) return EBTNodeResult::Failed; + + // Get threat actor + AActor* Target = Cast(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor)); + if (!Target) + { + return EBTNodeResult::Failed; + } + + // Get combat component + UPS_AI_Behavior_CombatComponent* Combat = + AIC->GetPawn()->FindComponentByClass(); + 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(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(OwnerComp.GetAIOwner()); + if (!AIC || !AIC->GetPawn()) + { + FinishLatentTask(OwnerComp, EBTNodeResult::Failed); + return; + } + + UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent(); + AActor* Target = BB ? Cast(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(); + if (!Combat) + { + FinishLatentTask(OwnerComp, EBTNodeResult::Failed); + return; + } + + // Check if we can attack now + if (Combat->IsInAttackRange(Target)) + { + FAttackMemory* Memory = reinterpret_cast(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(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(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."); +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_FindAndFollowSpline.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_FindAndFollowSpline.cpp new file mode 100644 index 0000000..974cb93 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_FindAndFollowSpline.cpp @@ -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(OwnerComp.GetAIOwner()); + if (!AIC || !AIC->GetPawn()) return EBTNodeResult::Failed; + + UPS_AI_Behavior_SplineFollowerComponent* Follower = + AIC->GetPawn()->FindComponentByClass(); + 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(); + 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(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(NodeMemory); + if (!Memory->bMovingToSpline) return; + + APS_AI_Behavior_AIController* AIC = Cast(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(); + 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("")); +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_FindCover.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_FindCover.cpp new file mode 100644 index 0000000..2cce071 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_FindCover.cpp @@ -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(OwnerComp.GetAIOwner()); + if (!AIC || !AIC->GetPawn()) return EBTNodeResult::Failed; + + UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent(); + if (!BB) return EBTNodeResult::Failed; + + const 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(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(NodeMemory); + Memory->bMoveRequested = true; + return EBTNodeResult::InProgress; +} + +void UPS_AI_Behavior_BTTask_FindCover::TickTask( + UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) +{ + FCoverMemory* Memory = reinterpret_cast(NodeMemory); + if (!Memory->bMoveRequested) return; + + APS_AI_Behavior_AIController* AIC = Cast(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(OwnerComp.GetAIOwner()); + if (AIC) + { + AIC->StopMovement(); + + // Release any claimed cover point + UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent(); + if (BB) + { + APS_AI_Behavior_CoverPoint* Point = + Cast(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 It(const_cast(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); +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_FleeFrom.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_FleeFrom.cpp new file mode 100644 index 0000000..13b7164 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_FleeFrom.cpp @@ -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(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(NodeMemory); + Memory->bMoveRequested = true; + return EBTNodeResult::InProgress; +} + +void UPS_AI_Behavior_BTTask_FleeFrom::TickTask( + UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) +{ + FFleeMemory* Memory = reinterpret_cast(NodeMemory); + if (!Memory->bMoveRequested) return; + + APS_AI_Behavior_AIController* AIC = Cast(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(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(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); +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_FollowSpline.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_FollowSpline.cpp new file mode 100644 index 0000000..0d306a3 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_FollowSpline.cpp @@ -0,0 +1,135 @@ +// 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(); + 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(); + } + + // Listen for end-of-spline + FFollowMemory* Memory = reinterpret_cast(NodeMemory); + Memory->Elapsed = 0.0f; + Memory->bEndReached = false; + + // Bind to end delegate + Follower->OnSplineEndReached.AddWeakLambda(this, + [Memory](APS_AI_Behavior_SplinePath* /*Spline*/) + { + Memory->bEndReached = true; + }); + + return EBTNodeResult::InProgress; +} + +void UPS_AI_Behavior_BTTask_FollowSpline::TickTask( + UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) +{ + FFollowMemory* Memory = reinterpret_cast(NodeMemory); + + // Check if spline end was reached + if (Memory->bEndReached) + { + 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(); + if (Follower) + { + Follower->PauseFollowing(); + } + } + FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded); + return; + } + } + + // Verify follower is still active + AAIController* AIC = OwnerComp.GetAIOwner(); + if (!AIC || !AIC->GetPawn()) + { + FinishLatentTask(OwnerComp, EBTNodeResult::Failed); + return; + } + + UPS_AI_Behavior_SplineFollowerComponent* Follower = + AIC->GetPawn()->FindComponentByClass(); + if (!Follower || !Follower->bIsFollowing) + { + FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded); + } +} + +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(); + 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("")); +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_Patrol.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_Patrol.cpp new file mode 100644 index 0000000..aa7eea1 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_Patrol.cpp @@ -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(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(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(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(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(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); +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/EQS/PS_AI_Behavior_EQSContext_Threat.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/EQS/PS_AI_Behavior_EQSContext_Threat.cpp new file mode 100644 index 0000000..c040af8 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/EQS/PS_AI_Behavior_EQSContext_Threat.cpp @@ -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(QueryInstance.Owner.Get()); + if (!QuerierActor) return; + + const APawn* QuerierPawn = Cast(QuerierActor); + if (!QuerierPawn) return; + + APS_AI_Behavior_AIController* AIC = Cast(QuerierPawn->GetController()); + if (!AIC) return; + + UBlackboardComponent* BB = AIC->GetBlackboardComponent(); + if (!BB) return; + + // Try to provide the threat actor first + AActor* ThreatActor = Cast(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); + } +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/EQS/PS_AI_Behavior_EQSGenerator_CoverPoints.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/EQS/PS_AI_Behavior_EQSGenerator_CoverPoints.cpp new file mode 100644 index 0000000..7cc8142 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/EQS/PS_AI_Behavior_EQSGenerator_CoverPoints.cpp @@ -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(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(QuerierActor); + if (QuerierPawn) + { + if (QuerierPawn->Implements()) + { + NPCType = IPS_AI_Behavior::Execute_GetBehaviorNPCType(const_cast(QuerierPawn)); + } + else if (const auto* PC = QuerierPawn->FindComponentByClass()) + { + NPCType = PC->GetNPCType(); + } + } + + // Collect matching cover points + TArray FoundPoints; + + for (TActorIterator It(const_cast(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(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"))); +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/EQS/PS_AI_Behavior_EQSTest_CoverQuality.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/EQS/PS_AI_Behavior_EQSTest_CoverQuality.cpp new file mode 100644 index 0000000..b7aea3c --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/EQS/PS_AI_Behavior_EQSTest_CoverQuality.cpp @@ -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 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 TraceHeights; + if (NumTraceHeights == 1) + { + TraceHeights.Add((MinTraceHeight + MaxTraceHeight) * 0.5f); + } + else + { + for (int32 i = 0; i < NumTraceHeights; ++i) + { + const float Alpha = static_cast(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 / TraceHeights.Num(); + It.SetScore(TestPurpose, FilterType, Score); + } +} + +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)); +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior.cpp new file mode 100644 index 0000000..a117d75 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior.cpp @@ -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 diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_AIController.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_AIController.cpp new file mode 100644 index 0000000..54a7782 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_AIController.cpp @@ -0,0 +1,355 @@ +// 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(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(); + 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()) + { + // Use the interface — the host project controls the storage + NPCType = IPS_AI_Behavior::Execute_GetBehaviorNPCType(InPawn); + + // Also check if the interface provides a specific TeamId + const uint8 InterfaceTeamId = IPS_AI_Behavior::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() && + !IPS_AI_Behavior::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(this, TEXT("RuntimeBlackboardData")); + + // State (stored as uint8 enum) + FBlackboardEntry StateEntry; + StateEntry.EntryName = PS_AI_Behavior_BB::State; + StateEntry.KeyType = NewObject(BlackboardAsset); + BlackboardAsset->Keys.Add(StateEntry); + + // ThreatActor + FBlackboardEntry ThreatActorEntry; + ThreatActorEntry.EntryName = PS_AI_Behavior_BB::ThreatActor; + ThreatActorEntry.KeyType = NewObject(BlackboardAsset); + Cast(ThreatActorEntry.KeyType)->BaseClass = AActor::StaticClass(); + BlackboardAsset->Keys.Add(ThreatActorEntry); + + // ThreatLocation + FBlackboardEntry ThreatLocEntry; + ThreatLocEntry.EntryName = PS_AI_Behavior_BB::ThreatLocation; + ThreatLocEntry.KeyType = NewObject(BlackboardAsset); + BlackboardAsset->Keys.Add(ThreatLocEntry); + + // ThreatLevel + FBlackboardEntry ThreatLevelEntry; + ThreatLevelEntry.EntryName = PS_AI_Behavior_BB::ThreatLevel; + ThreatLevelEntry.KeyType = NewObject(BlackboardAsset); + BlackboardAsset->Keys.Add(ThreatLevelEntry); + + // CoverLocation + FBlackboardEntry CoverEntry; + CoverEntry.EntryName = PS_AI_Behavior_BB::CoverLocation; + CoverEntry.KeyType = NewObject(BlackboardAsset); + BlackboardAsset->Keys.Add(CoverEntry); + + // CoverPoint (Object — APS_AI_Behavior_CoverPoint) + FBlackboardEntry CoverPointEntry; + CoverPointEntry.EntryName = PS_AI_Behavior_BB::CoverPoint; + CoverPointEntry.KeyType = NewObject(BlackboardAsset); + Cast(CoverPointEntry.KeyType)->BaseClass = AActor::StaticClass(); + BlackboardAsset->Keys.Add(CoverPointEntry); + + // PatrolIndex + FBlackboardEntry PatrolEntry; + PatrolEntry.EntryName = PS_AI_Behavior_BB::PatrolIndex; + PatrolEntry.KeyType = NewObject(BlackboardAsset); + BlackboardAsset->Keys.Add(PatrolEntry); + + // HomeLocation + FBlackboardEntry HomeEntry; + HomeEntry.EntryName = PS_AI_Behavior_BB::HomeLocation; + HomeEntry.KeyType = NewObject(BlackboardAsset); + BlackboardAsset->Keys.Add(HomeEntry); + + // CurrentSpline (Object — APS_AI_Behavior_SplinePath) + FBlackboardEntry SplineEntry; + SplineEntry.EntryName = PS_AI_Behavior_BB::CurrentSpline; + SplineEntry.KeyType = NewObject(BlackboardAsset); + Cast(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(BlackboardAsset); + BlackboardAsset->Keys.Add(SplineProgressEntry); + } + + UseBlackboard(BlackboardAsset, Blackboard); + + // 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(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(NewState)); + } +} + +EPS_AI_Behavior_State APS_AI_Behavior_AIController::GetBehaviorState() const +{ + if (Blackboard) + { + return static_cast( + 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(&Other); + if (!OtherPawn) + { + OtherPawn = Cast(Other.GetInstigator()); + } + + uint8 OtherTeam = FGenericTeamId::NoTeam; + + if (OtherPawn) + { + // Check via AIController first + if (const AAIController* OtherAIC = Cast(OtherPawn->GetController())) + { + OtherTeam = OtherAIC->GetGenericTeamId().GetId(); + } + // Check via IPS_AI_Behavior interface + else if (OtherPawn->Implements()) + { + OtherTeam = IPS_AI_Behavior::Execute_GetBehaviorTeamId(const_cast(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(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( + 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()); + } +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_CombatComponent.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_CombatComponent.cpp new file mode 100644 index 0000000..e4f281a --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_CombatComponent.cpp @@ -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& 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); +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_CoverPoint.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_CoverPoint.cpp new file mode 100644 index 0000000..6ed3c6f --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_CoverPoint.cpp @@ -0,0 +1,192 @@ +// 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(TEXT("Root")); + RootComponent = Root; + +#if WITH_EDITORONLY_DATA + ArrowComp = CreateDefaultSubobject(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(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& 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& 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& Occ : CurrentOccupants) + { + if (Occ.Get() == Occupant) return true; + } + + // Cleanup stale + CurrentOccupants.RemoveAll([](const TWeakObjectPtr& 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& 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(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& 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; + 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; + } + + 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 diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_Definitions.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_Definitions.cpp new file mode 100644 index 0000000..b7dfdfc --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_Definitions.cpp @@ -0,0 +1,5 @@ +// Copyright Asterion. All Rights Reserved. + +#include "PS_AI_Behavior_Definitions.h" + +DEFINE_LOG_CATEGORY(LogPS_AI_Behavior); diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_Interface.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_Interface.cpp new file mode 100644 index 0000000..3b1667f --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_Interface.cpp @@ -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". diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_PerceptionComponent.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_PerceptionComponent.cpp new file mode 100644 index 0000000..818ef11 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_PerceptionComponent.cpp @@ -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(); + + // ─── Sight ────────────────────────────────────────────────────────── + UAISenseConfig_Sight* SightConfig = NewObject(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(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(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& 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(Actor); + if (Pawn && Pawn->IsPlayerControlled()) + { + return EPS_AI_Behavior_TargetType::Player; + } + + // Check via IPS_AI_Behavior interface + if (Actor->Implements()) + { + const EPS_AI_Behavior_NPCType NPCType = + IPS_AI_Behavior::Execute_GetBehaviorNPCType(const_cast(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()) + { + 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() const +{ + // Get priority from PersonalityProfile if available + TArray Priority; + + const AActor* Owner = GetOwner(); + if (Owner) + { + // Owner is the AIController, get the Pawn + const AAIController* AIC = Cast(Owner); + const APawn* MyPawn = AIC ? AIC->GetPawn() : Cast(const_cast(Owner)); + + if (MyPawn) + { + if (const auto* PersonalityComp = MyPawn->FindComponentByClass()) + { + if (PersonalityComp->Profile) + { + Priority = PersonalityComp->Profile->TargetPriority; + } + } + } + } + + return GetHighestThreatActor(Priority); +} + +AActor* UPS_AI_Behavior_PerceptionComponent::GetHighestThreatActor( + const TArray& TargetPriority) const +{ + // Gather all perceived actors from all senses + TArray 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 DefaultPriority = { + EPS_AI_Behavior_TargetType::Protector, + EPS_AI_Behavior_TargetType::Player, + EPS_AI_Behavior_TargetType::Civilian, + }; + const TArray& 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(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()) + { + Score += 500.0f; // Self-defense: always prioritize attacker + } + else if (Stimulus.Type == UAISense::GetSenseID()) + { + 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 +{ + const AActor* Owner = GetOwner(); + if (!Owner) return 0.0f; + + const FVector OwnerLoc = Owner->GetActorLocation(); + float TotalThreat = 0.0f; + + TArray PerceivedActors; + GetCurrentlyPerceivedActors(nullptr, PerceivedActors); // All senses + + for (const 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()) + { + ActorThreat += 0.6f; // Being hit = big threat spike + } + else if (Stimulus.Type == UAISense::GetSenseID()) + { + ActorThreat += 0.2f; + } + else if (Stimulus.Type == UAISense::GetSenseID()) + { + 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) const +{ + AActor* Threat = GetHighestThreatActor(); + if (Threat) + { + OutLocation = Threat->GetActorLocation(); + return true; + } + + // Fallback: check last known stimulus location + TArray 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; +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_PersonalityComponent.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_PersonalityComponent.cpp new file mode 100644 index 0000000..37090ba --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_PersonalityComponent.cpp @@ -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& 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(EPS_AI_Behavior_TraitAxis::Discipline); ++i) + { + RuntimeTraits.Add(static_cast(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()) + { + IPS_AI_Behavior::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()) + { + float NewSpeed = Profile ? Profile->GetSpeedForState(NewState) : 150.0f; + IPS_AI_Behavior::Execute_SetBehaviorMovementSpeed(Owner, NewSpeed); + + // 3. Notify the Pawn of the state change + IPS_AI_Behavior::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()) + { + return IPS_AI_Behavior::Execute_GetBehaviorNPCType(Owner); + } + + // Fallback: read from PersonalityProfile + return Profile ? Profile->NPCType : EPS_AI_Behavior_NPCType::Civilian; +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_PersonalityProfile.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_PersonalityProfile.cpp new file mode 100644 index 0000000..cf96fc7 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_PersonalityProfile.cpp @@ -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()); +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_Settings.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_Settings.cpp new file mode 100644 index 0000000..2ce27f5 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_Settings.cpp @@ -0,0 +1,7 @@ +// Copyright Asterion. All Rights Reserved. + +#include "PS_AI_Behavior_Settings.h" + +UPS_AI_Behavior_Settings::UPS_AI_Behavior_Settings() +{ +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_SplineFollowerComponent.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_SplineFollowerComponent.cpp new file mode 100644 index 0000000..aaa6cb9 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_SplineFollowerComponent.cpp @@ -0,0 +1,310 @@ +// 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 "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& 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(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& 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(); + 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(); + 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 + } + } +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_SplineNetwork.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_SplineNetwork.cpp new file mode 100644 index 0000000..beb9c60 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_SplineNetwork.cpp @@ -0,0 +1,268 @@ +// 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); + + UWorld* World = GetWorld(); + if (World) + { + BeginPlayHandle = World->OnWorldBeginPlay.AddUObject(this, &UPS_AI_Behavior_SplineNetwork::OnWorldBeginPlay); + } +} + +void UPS_AI_Behavior_SplineNetwork::Deinitialize() +{ + UWorld* World = GetWorld(); + if (World && BeginPlayHandle.IsValid()) + { + World->OnWorldBeginPlay.Remove(BeginPlayHandle); + } + + AllSplines.Empty(); + TotalJunctions = 0; + Super::Deinitialize(); +} + +void UPS_AI_Behavior_SplineNetwork::OnWorldBeginPlay(UWorld& 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 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 UPS_AI_Behavior_SplineNetwork::GetSplinesForCategory( + EPS_AI_Behavior_NPCType Category) const +{ + TArray 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; +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_SplinePath.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_SplinePath.cpp new file mode 100644 index 0000000..51cfe80 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_SplinePath.cpp @@ -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(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 APS_AI_Behavior_SplinePath::GetUpcomingJunctions( + float CurrentDistance, float LookAheadDist, bool bForward) const +{ + TArray 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 diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTDecorator_CheckTrait.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTDecorator_CheckTrait.h new file mode 100644 index 0000000..ce2f9b6 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTDecorator_CheckTrait.h @@ -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; +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTService_EvaluateReaction.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTService_EvaluateReaction.h new file mode 100644 index 0000000..7bca7ad --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTService_EvaluateReaction.h @@ -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; +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTService_UpdateThreat.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTService_UpdateThreat.h new file mode 100644 index 0000000..74b2f50 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTService_UpdateThreat.h @@ -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; +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_Attack.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_Attack.h new file mode 100644 index 0000000..74e645e --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_Attack.h @@ -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); } +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_FindAndFollowSpline.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_FindAndFollowSpline.h new file mode 100644 index 0000000..ca9a479 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_FindAndFollowSpline.h @@ -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); } +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_FindCover.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_FindCover.h new file mode 100644 index 0000000..bcf9e45 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_FindCover.h @@ -0,0 +1,92 @@ +// Copyright Asterion. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "BehaviorTree/BTTaskNode.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; +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_FleeFrom.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_FleeFrom.h new file mode 100644 index 0000000..e41003d --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_FleeFrom.h @@ -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; +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_FollowSpline.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_FollowSpline.h new file mode 100644 index 0000000..6382f04 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_FollowSpline.h @@ -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); } +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_Patrol.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_Patrol.h new file mode 100644 index 0000000..85a470a --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTTask_Patrol.h @@ -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); } +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/EQS/PS_AI_Behavior_EQSContext_Threat.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/EQS/PS_AI_Behavior_EQSContext_Threat.h new file mode 100644 index 0000000..8ea2a54 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/EQS/PS_AI_Behavior_EQSContext_Threat.h @@ -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; +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/EQS/PS_AI_Behavior_EQSGenerator_CoverPoints.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/EQS/PS_AI_Behavior_EQSGenerator_CoverPoints.h new file mode 100644 index 0000000..c231e0b --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/EQS/PS_AI_Behavior_EQSGenerator_CoverPoints.h @@ -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; +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/EQS/PS_AI_Behavior_EQSTest_CoverQuality.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/EQS/PS_AI_Behavior_EQSTest_CoverQuality.h new file mode 100644 index 0000000..65e1442 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/EQS/PS_AI_Behavior_EQSTest_CoverQuality.h @@ -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; +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior.h new file mode 100644 index 0000000..f2a3ad2 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior.h @@ -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; +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_AIController.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_AIController.h new file mode 100644 index 0000000..4d0559a --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_AIController.h @@ -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 BehaviorTreeAsset; + + /** Blackboard Data asset. If null, a default one is created at runtime. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Behavior") + TObjectPtr BlackboardAsset; + + /** Patrol waypoints — set by level designer, spawner, or Blueprint. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Patrol") + TArray 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 BehaviorPerception; + + /** Cached ref to the Pawn's PersonalityComponent. */ + UPROPERTY(Transient) + TObjectPtr 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(); +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_CombatComponent.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_CombatComponent.h new file mode 100644 index 0000000..ed56c4c --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_CombatComponent.h @@ -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 DamageTypeClass; + + // ─── Runtime State ────────────────────────────────────────────────── + + /** Current attack target — replicated so clients can show targeting visuals. */ + UPROPERTY(VisibleAnywhere, BlueprintReadOnly, Replicated, Category = "Combat|Runtime") + TObjectPtr 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& 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; +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_CoverPoint.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_CoverPoint.h new file mode 100644 index 0000000..c428f24 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_CoverPoint.h @@ -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 SpriteComp; + + UPROPERTY(VisibleAnywhere, Category = "Components") + TObjectPtr 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, BlueprintReadOnly, Category = "Cover Point|Runtime") + TArray> 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(); +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_Definitions.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_Definitions.h new file mode 100644 index 0000000..25ed348 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_Definitions.h @@ -0,0 +1,98 @@ +// Copyright Asterion. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "PS_AI_Behavior_Definitions.generated.h" + +// ─── Log Category ─────────────────────────────────────────────────────────── + +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"); +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_Interface.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_Interface.h new file mode 100644 index 0000000..85c482f --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_Interface.h @@ -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 +{ + 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); +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_PerceptionComponent.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_PerceptionComponent.h new file mode 100644 index 0000000..191e9e3 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_PerceptionComponent.h @@ -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& TargetPriority) const; + + /** Convenience overload — reads priority from the Pawn's PersonalityProfile. */ + AActor* GetHighestThreatActor() const; + + /** + * 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() const; + + /** + * 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) const; + +protected: + virtual void BeginPlay() override; + + UFUNCTION() + void HandlePerceptionUpdated(const TArray& 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(); +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_PersonalityComponent.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_PersonalityComponent.h new file mode 100644 index 0000000..4ea8872 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_PersonalityComponent.h @@ -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 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 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& 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); +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_PersonalityProfile.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_PersonalityProfile.h new file mode 100644 index 0000000..35badf1 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_PersonalityProfile.h @@ -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 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 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 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 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; +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_Settings.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_Settings.h new file mode 100644 index 0000000..c108134 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_Settings.h @@ -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"); } +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_SplineFollowerComponent.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_SplineFollowerComponent.h new file mode 100644 index 0000000..ed155eb --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_SplineFollowerComponent.h @@ -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& 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 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; +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_SplineNetwork.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_SplineNetwork.h new file mode 100644 index 0000000..7c65520 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_SplineNetwork.h @@ -0,0 +1,110 @@ +// 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 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> 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); + + /** World init callback. */ + void OnWorldBeginPlay(UWorld& InWorld); + + FDelegateHandle BeginPlayHandle; +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_SplinePath.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_SplinePath.h new file mode 100644 index 0000000..b41bd76 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_SplinePath.h @@ -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 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 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 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 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(); +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/PS_AI_BehaviorEditor.Build.cs b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/PS_AI_BehaviorEditor.Build.cs new file mode 100644 index 0000000..21f2e09 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/PS_AI_BehaviorEditor.Build.cs @@ -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", + }); + } +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Private/PS_AI_BehaviorEditor.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Private/PS_AI_BehaviorEditor.cpp new file mode 100644 index 0000000..623f869 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Private/PS_AI_BehaviorEditor.cpp @@ -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::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 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 + { + 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("LevelEditor"); + TSharedPtr 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 diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Private/PS_AI_Behavior_SplineEdMode.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Private/PS_AI_Behavior_SplineEdMode.cpp new file mode 100644 index 0000000..c685e1b --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Private/PS_AI_Behavior_SplineEdMode.cpp @@ -0,0 +1,436 @@ +// 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 FInputClick& Click) +{ + if (Click.Key != 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); + + const FVector WorldOrigin = View->ViewMatrices.GetViewOrigin(); + FVector WorldDirection; + + // 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.bControlDown) + { + // Check if we hit a SplinePath + AActor* HitActor = Hit.GetActor(); + APS_AI_Behavior_SplinePath* HitSpline = Cast(HitActor); + + if (!HitSpline) + { + // Check nearby splines + for (TActorIterator 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 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 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::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::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(); + if (Network) + { + Network->RebuildNetwork(); + } +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Private/PS_AI_Behavior_SplineEdModeToolkit.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Private/PS_AI_Behavior_SplineEdModeToolkit.cpp new file mode 100644 index 0000000..8539c28 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Private/PS_AI_Behavior_SplineEdModeToolkit.cpp @@ -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& 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(GetEditorMode()); +} + +TSharedRef 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 diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Private/PS_AI_Behavior_SplineVisualizer.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Private/PS_AI_Behavior_SplineVisualizer.cpp new file mode 100644 index 0000000..895434f --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Private/PS_AI_Behavior_SplineVisualizer.cpp @@ -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(Component); + if (!SplineComp) return; + + const APS_AI_Behavior_SplinePath* SplinePath = Cast(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); + } + } +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Private/SPS_AI_Behavior_SplinePanel.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Private/SPS_AI_Behavior_SplinePanel.cpp new file mode 100644 index 0000000..a7e9337 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Private/SPS_AI_Behavior_SplinePanel.cpp @@ -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>) + .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 It(World); It; ++It) + { + APS_AI_Behavior_SplinePath* Spline = *It; + if (!Spline) continue; + + TSharedPtr Entry = MakeShared(); + 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 SPS_AI_Behavior_SplinePanel::GenerateSplineRow( + TSharedPtr Entry, + const TSharedRef& OwnerTable) +{ + const FLinearColor TypeColor = GetColorForType(Entry->Type); + + return SNew(STableRow>, 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 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( + 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::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(); + 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 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 SelectedSplines; + for (FSelectionIterator It(GEditor->GetSelectedActorIterator()); It; ++It) + { + if (APS_AI_Behavior_SplinePath* Spline = Cast(*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 diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Public/PS_AI_BehaviorEditor.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Public/PS_AI_BehaviorEditor.h new file mode 100644 index 0000000..ac4b101 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Public/PS_AI_BehaviorEditor.h @@ -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(); +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Public/PS_AI_Behavior_SplineEdMode.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Public/PS_AI_Behavior_SplineEdMode.h new file mode 100644 index 0000000..0910827 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Public/PS_AI_Behavior_SplineEdMode.h @@ -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 FInputClick& 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(); +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Public/PS_AI_Behavior_SplineEdModeToolkit.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Public/PS_AI_Behavior_SplineEdModeToolkit.h new file mode 100644 index 0000000..06449da --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Public/PS_AI_Behavior_SplineEdModeToolkit.h @@ -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& 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 GetInlineContent() const override { return ToolkitWidget; } + +private: + TSharedPtr ToolkitWidget; + + FPS_AI_Behavior_SplineEdMode* GetSplineEdMode() const; + + /** Build the toolbar widget. */ + TSharedRef 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; +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Public/PS_AI_Behavior_SplineVisualizer.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Public/PS_AI_Behavior_SplineVisualizer.h new file mode 100644 index 0000000..8de20b1 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Public/PS_AI_Behavior_SplineVisualizer.h @@ -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; +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Public/SPS_AI_Behavior_SplinePanel.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Public/SPS_AI_Behavior_SplinePanel.h new file mode 100644 index 0000000..719330d --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Public/SPS_AI_Behavior_SplinePanel.h @@ -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 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> SplineEntries; + TSharedPtr>> SplineListView; + + /** Refresh the spline list from the world. */ + void RefreshSplineList(); + + /** Generate a row widget for the list. */ + TSharedRef GenerateSplineRow( + TSharedPtr Entry, + const TSharedRef& OwnerTable); + + /** Handle selection — focus viewport on the spline. */ + void OnSplineSelected(TSharedPtr 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; +};