diff --git a/Build_Plugin.bat b/Build_Plugin.bat new file mode 100644 index 0000000..7c3f800 --- /dev/null +++ b/Build_Plugin.bat @@ -0,0 +1,65 @@ +@echo off +setlocal + +:: ─── Configuration ────────────────────────────────────────────────────────── +set UE_ROOT=C:\Program Files\Epic Games\UE_5.5 +set UPROJECT=E:\ASTERION\GIT\PS_AI_Agent\Unreal\PS_AI_Agent\PS_AI_Agent.uproject +set PLATFORM=Win64 +set CONFIG=Development +set TARGET=PS_AI_AgentEditor + +:: ─── Colors ───────────────────────────────────────────────────────────────── +set GREEN=[92m +set RED=[91m +set YELLOW=[93m +set RESET=[0m + +:: ─── Banner ───────────────────────────────────────────────────────────────── +echo. +echo %GREEN%============================================%RESET% +echo PS_AI_Agent - Plugin Build +echo Target: %TARGET% %PLATFORM% %CONFIG% +echo %GREEN%============================================%RESET% +echo. + +:: ─── Verify paths ─────────────────────────────────────────────────────────── +if not exist "%UE_ROOT%\Engine\Binaries\ThirdParty\DotNet\8.0.300\win-x64\dotnet.exe" ( + echo %RED%ERROR: UE5.5 dotnet not found at %UE_ROOT%%RESET% + echo Check UE_ROOT path in this script. + pause + exit /b 1 +) + +if not exist "%UPROJECT%" ( + echo %RED%ERROR: .uproject not found: %UPROJECT%%RESET% + pause + exit /b 1 +) + +:: ─── Build ────────────────────────────────────────────────────────────────── +echo %YELLOW%Building %TARGET% (%CONFIG%)...%RESET% +echo. + +"%UE_ROOT%\Engine\Binaries\ThirdParty\DotNet\8.0.300\win-x64\dotnet.exe" ^ + "%UE_ROOT%\Engine\Binaries\DotNET\UnrealBuildTool\UnrealBuildTool.dll" ^ + %TARGET% %PLATFORM% %CONFIG% ^ + -Project="%UPROJECT%" ^ + -WaitMutex ^ + -FromMsBuild + +set BUILD_EXIT=%ERRORLEVEL% + +echo. +if %BUILD_EXIT% EQU 0 ( + echo %GREEN%============================================%RESET% + echo BUILD SUCCEEDED + echo %GREEN%============================================%RESET% +) else ( + echo %RED%============================================%RESET% + echo BUILD FAILED (exit code: %BUILD_EXIT%) + echo %RED%============================================%RESET% +) + +echo. +pause +exit /b %BUILD_EXIT% diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Content/E_BehaviorState.uasset b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Content/E_BehaviorState.uasset new file mode 100644 index 0000000..a1564af Binary files /dev/null and b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Content/E_BehaviorState.uasset differ 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 index 1ad9cdf..c614486 100644 --- 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 @@ -27,7 +27,30 @@ void UPS_AI_Behavior_BTService_UpdateThreat::TickNode( if (!BB) return; UPS_AI_Behavior_PerceptionComponent* Perception = AIC->GetBehaviorPerception(); - if (!Perception) return; + if (!Perception) + { + UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] UpdateThreat: No PerceptionComponent!"), *AIC->GetName()); + return; + } + + // Debug: check what perception sees + TArray PerceivedActors; + Perception->GetCurrentlyPerceivedActors(nullptr, PerceivedActors); + UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] UpdateThreat: Perceived %d actors"), + *AIC->GetName(), PerceivedActors.Num()); + for (AActor* A : PerceivedActors) + { + UE_LOG(LogPS_AI_Behavior, Verbose, TEXT(" - %s"), *A->GetName()); + } + + // Also check known actors (perceived in the past but maybe lost sight) + TArray KnownActors; + Perception->GetKnownPerceivedActors(nullptr, KnownActors); + if (KnownActors.Num() != PerceivedActors.Num()) + { + UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] UpdateThreat: Known (past) %d actors"), + *AIC->GetName(), KnownActors.Num()); + } // Calculate current threat const float RawThreat = Perception->CalculateThreatLevel(); 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 index 974cb93..efeac2c 100644 --- 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 @@ -31,6 +31,13 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_FindAndFollowSpline::ExecuteTask( return EBTNodeResult::Failed; } + // If already following a spline, don't re-search — just succeed immediately + // The Follow Spline task will continue the movement + if (Follower->bIsFollowing && Follower->CurrentSpline) + { + return EBTNodeResult::Succeeded; + } + // Determine NPC type EPS_AI_Behavior_NPCType NPCType = EPS_AI_Behavior_NPCType::Civilian; UPS_AI_Behavior_PersonalityComponent* Personality = AIC->GetPersonalityComponent(); @@ -66,6 +73,11 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_FindAndFollowSpline::ExecuteTask( const FVector SplinePoint = ClosestSpline->GetWorldLocationAtDistance(DistAlongSpline); const float GapToSpline = FVector::Dist(AIC->GetPawn()->GetActorLocation(), SplinePoint); + UE_LOG(LogPS_AI_Behavior, Verbose, + TEXT("[%s] FindAndFollowSpline: found spline '%s' at dist=%.0f/%.0f, gap=%.0fcm"), + *AIC->GetName(), *ClosestSpline->GetName(), DistAlongSpline, + ClosestSpline->GetSplineLength(), GapToSpline); + if (bWalkToSpline && GapToSpline > AcceptanceRadius) { // Walk to spline first via NavMesh @@ -77,13 +89,19 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_FindAndFollowSpline::ExecuteTask( if (Result == EPathFollowingRequestResult::Failed) { // Can't reach via NavMesh — try starting anyway (snap) - Follower->StartFollowingAtDistance(ClosestSpline, DistAlongSpline); + const FVector Fwd = AIC->GetPawn()->GetActorForwardVector(); + const FVector SpDir = ClosestSpline->GetWorldDirectionAtDistance(DistAlongSpline); + Follower->StartFollowingAtDistance(ClosestSpline, DistAlongSpline, + FVector::DotProduct(Fwd, SpDir) >= 0.0f); return EBTNodeResult::Succeeded; } if (Result == EPathFollowingRequestResult::AlreadyAtGoal) { - Follower->StartFollowingAtDistance(ClosestSpline, DistAlongSpline); + const FVector Fwd = AIC->GetPawn()->GetActorForwardVector(); + const FVector SpDir = ClosestSpline->GetWorldDirectionAtDistance(DistAlongSpline); + Follower->StartFollowingAtDistance(ClosestSpline, DistAlongSpline, + FVector::DotProduct(Fwd, SpDir) >= 0.0f); return EBTNodeResult::Succeeded; } @@ -97,7 +115,26 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_FindAndFollowSpline::ExecuteTask( } // Close enough — start immediately - Follower->StartFollowingAtDistance(ClosestSpline, DistAlongSpline); + // Choose direction based on NPC's current movement direction vs spline tangent + const FVector NpcForward = AIC->GetPawn()->GetActorForwardVector(); + const FVector SplineDirForward = ClosestSpline->GetWorldDirectionAtDistance(DistAlongSpline); + const float DotForward = FVector::DotProduct(NpcForward, SplineDirForward); + const bool bForward = (DotForward >= 0.0f); + + // If NPC is very close to an end, ensure we don't start going into a wall + const float SplineLen = ClosestSpline->GetSplineLength(); + const float MinEndMargin = 50.0f; + bool bFinalForward = bForward; + if (bFinalForward && DistAlongSpline > SplineLen - MinEndMargin) + { + bFinalForward = false; // Too close to the end, go backward + } + else if (!bFinalForward && DistAlongSpline < MinEndMargin) + { + bFinalForward = true; // Too close to the start, go forward + } + + Follower->StartFollowingAtDistance(ClosestSpline, DistAlongSpline, bFinalForward); return EBTNodeResult::Succeeded; } 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 index aa7eea1..5d2a610 100644 --- 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 @@ -4,6 +4,7 @@ #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_Patrol::UPS_AI_Behavior_BTTask_Patrol() @@ -17,27 +18,39 @@ 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; + if (!AIC || !AIC->GetPawn()) 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]; + FVector Destination; + + if (AIC->PatrolPoints.Num() > 0) + { + // ─── Mode manuel : waypoints définis ──────────────────────────── + const int32 PatrolIdx = BB->GetValueAsInt(PS_AI_Behavior_BB::PatrolIndex); + const int32 SafeIdx = PatrolIdx % AIC->PatrolPoints.Num(); + Destination = AIC->PatrolPoints[SafeIdx]; + } + else + { + // ─── Mode auto : point aléatoire sur NavMesh ──────────────────── + const FVector HomeLoc = BB->GetValueAsVector(PS_AI_Behavior_BB::HomeLocation); + const FVector CurrentLoc = AIC->GetPawn()->GetActorLocation(); + + if (!FindRandomPatrolPoint(GetWorld(), HomeLoc, CurrentLoc, Destination)) + { + UE_LOG(LogPS_AI_Behavior, Verbose, + TEXT("[%s] Patrol: no valid NavMesh point found within %.0fcm."), + *AIC->GetName(), PatrolRadius); + return EBTNodeResult::Failed; + } + } // Issue move request const EPathFollowingRequestResult::Type Result = AIC->MoveToLocation( @@ -47,19 +60,22 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_Patrol::ExecuteTask( if (Result == EPathFollowingRequestResult::Failed) { - UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Patrol: MoveTo failed for point %d."), - *AIC->GetName(), SafeIdx); + UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Patrol: MoveTo failed."), *AIC->GetName()); 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()); + // Advance patrol index (for manual mode) + if (AIC->PatrolPoints.Num() > 0) + { + const int32 PatrolIdx = BB->GetValueAsInt(PS_AI_Behavior_BB::PatrolIndex); + BB->SetValueAsInt(PS_AI_Behavior_BB::PatrolIndex, + (PatrolIdx + 1) % AIC->PatrolPoints.Num()); + } return EBTNodeResult::InProgress; } @@ -88,24 +104,22 @@ void UPS_AI_Behavior_BTTask_Patrol::TickTask( 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) + if (AIC->GetMoveStatus() == EPathFollowingStatus::Idle) { - // Move completed — start wait at waypoint + // Move completed — start wait Memory->bMoveRequested = false; Memory->bIsWaiting = true; Memory->WaitRemaining = FMath::RandRange(MinWaitTime, MaxWaitTime); - // Advance patrol index - UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent(); - if (BB) + // Advance patrol index (for manual mode) + if (AIC->PatrolPoints.Num() > 0) { - const int32 PatrolIdx = BB->GetValueAsInt(PS_AI_Behavior_BB::PatrolIndex); - const int32 NumPoints = AIC->PatrolPoints.Num(); - if (NumPoints > 0) + UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent(); + if (BB) { - BB->SetValueAsInt(PS_AI_Behavior_BB::PatrolIndex, (PatrolIdx + 1) % NumPoints); + const int32 PatrolIdx = BB->GetValueAsInt(PS_AI_Behavior_BB::PatrolIndex); + BB->SetValueAsInt(PS_AI_Behavior_BB::PatrolIndex, + (PatrolIdx + 1) % AIC->PatrolPoints.Num()); } } } @@ -123,8 +137,58 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_Patrol::AbortTask( return EBTNodeResult::Aborted; } +bool UPS_AI_Behavior_BTTask_Patrol::FindRandomPatrolPoint( + const UWorld* World, const FVector& HomeLoc, + const FVector& CurrentLoc, FVector& OutPoint) const +{ + UNavigationSystemV1* NavSys = FNavigationSystem::GetCurrent( + const_cast(World)); + if (!NavSys) return false; + + // Project HomeLoc onto NavMesh first (spawn position might not be exactly on it) + FNavLocation ProjectedHome; + const FVector ProjectionExtent(500.0f, 500.0f, 500.0f); + FVector SearchOrigin = HomeLoc; + + if (NavSys->ProjectPointToNavigation(HomeLoc, ProjectedHome, ProjectionExtent)) + { + SearchOrigin = ProjectedHome.Location; + } + else + { + // HomeLoc not on NavMesh — try from current position instead + FNavLocation ProjectedCurrent; + if (NavSys->ProjectPointToNavigation(CurrentLoc, ProjectedCurrent, ProjectionExtent)) + { + SearchOrigin = ProjectedCurrent.Location; + } + else + { + return false; // Neither home nor current pos are on NavMesh + } + } + + // Try multiple times to find a valid point far enough from current position + for (int32 Attempt = 0; Attempt < 10; ++Attempt) + { + FNavLocation NavLoc; + if (NavSys->GetRandomReachablePointInRadius(SearchOrigin, PatrolRadius, NavLoc)) + { + const float DistFromCurrent = FVector::Dist2D(NavLoc.Location, CurrentLoc); + if (DistFromCurrent >= MinPatrolDistance) + { + OutPoint = NavLoc.Location; + return true; + } + } + } + + return false; +} + FString UPS_AI_Behavior_BTTask_Patrol::GetStaticDescription() const { - return FString::Printf(TEXT("Patrol (wait %.1f-%.1fs, radius %.0fcm)"), - MinWaitTime, MaxWaitTime, AcceptanceRadius); + return FString::Printf( + TEXT("Patrol (wait %.1f-%.1fs)\nManual waypoints OR auto NavMesh (radius %.0fcm, min dist %.0fcm)"), + MinWaitTime, MaxWaitTime, PatrolRadius, MinPatrolDistance); } 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 index 4d8952d..fd5c81e 100644 --- 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 @@ -16,7 +16,8 @@ UPS_AI_Behavior_PerceptionComponent::UPS_AI_Behavior_PerceptionComponent() { - // Senses are configured in BeginPlay after settings are available + // Senses must be configured after construction (NewObject not allowed in CDO constructors). + // We configure in BeginPlay and force a perception system update. } void UPS_AI_Behavior_PerceptionComponent::BeginPlay() @@ -24,6 +25,9 @@ void UPS_AI_Behavior_PerceptionComponent::BeginPlay() ConfigureSenses(); Super::BeginPlay(); + // Force the perception system to re-register our senses now that they're configured + RequestStimuliListenerUpdate(); + OnPerceptionUpdated.AddDynamic(this, &UPS_AI_Behavior_PerceptionComponent::HandlePerceptionUpdated); } @@ -184,23 +188,35 @@ AActor* UPS_AI_Behavior_PerceptionComponent::GetHighestThreatActor( const AAIController* AIC = Cast(Owner); if (AIC && Actor == AIC->GetPawn()) continue; + // Skip non-hostile actors (only Hostile actors are valid threats) + if (AIC) + { + const ETeamAttitude::Type Attitude = AIC->GetTeamAttitudeTowards(*Actor); + if (Attitude != ETeamAttitude::Hostile) + { + 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; + // Priority rank bonus: actors in the priority list score higher + // This is used for COMBAT targeting (who to attack first) + // But ALL hostile actors are valid threats (for fleeing, alerting, etc.) + const int32 PriorityIndex = ActivePriority.IndexOfByKey(ActorType); + if (PriorityIndex != INDEX_NONE) + { + Score += (ActivePriority.Num() - PriorityIndex) * 100.0f; + } + else + { + // Not in priority list but still Hostile — valid threat, lower score + Score += 10.0f; + } // Damage sense override: actor that hit us gets a massive bonus // (bypasses priority — self-defense) @@ -252,10 +268,23 @@ float UPS_AI_Behavior_PerceptionComponent::CalculateThreatLevel() TArray PerceivedActors; GetCurrentlyPerceivedActors(nullptr, PerceivedActors); // All senses + // Get our AIController for attitude checks + const AAIController* AIC = Cast(Owner); + for (AActor* Actor : PerceivedActors) { if (!Actor) continue; + // Only count Hostile actors as threats (skip Friendly and Neutral) + if (AIC) + { + const ETeamAttitude::Type Attitude = AIC->GetTeamAttitudeTowards(*Actor); + if (Attitude != ETeamAttitude::Hostile) + { + continue; // Only Hostile actors generate threat + } + } + float ActorThreat = 0.0f; const float Dist = FVector::Dist(OwnerLoc, Actor->GetActorLocation()); 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 index 440c4d3..96b6248 100644 --- 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 @@ -8,6 +8,7 @@ #include "GameFramework/Character.h" #include "GameFramework/CharacterMovementComponent.h" #include "Net/UnrealNetwork.h" +#include "DrawDebugHelpers.h" UPS_AI_Behavior_SplineFollowerComponent::UPS_AI_Behavior_SplineFollowerComponent() { @@ -59,6 +60,13 @@ bool UPS_AI_Behavior_SplineFollowerComponent::StartFollowingAtDistance( bIsFollowing = true; LastHandledJunctionIndex = -1; + UE_LOG(LogPS_AI_Behavior, Warning, + TEXT("[%s] StartFollowingAtDistance: spline='%s' dist=%.0f/%.0f dir=%s (was spline='%s')"), + GetOwner() ? *GetOwner()->GetName() : TEXT("?"), + *Spline->GetName(), CurrentDistance, Spline->GetSplineLength(), + bForward ? TEXT("FWD") : TEXT("BWD"), + OldSpline ? *OldSpline->GetName() : TEXT("none")); + if (OldSpline && OldSpline != Spline) { OnSplineChanged.Broadcast(OldSpline, Spline); @@ -147,8 +155,17 @@ void UPS_AI_Behavior_SplineFollowerComponent::TickComponent( } // ─── Advance along spline ─────────────────────────────────────────── - const float Speed = GetEffectiveSpeed(); + // Use the NPC's actual movement speed (from CMC) instead of a fixed speed. + // This keeps the target point in sync with how fast the character really moves. + float Speed = GetEffectiveSpeed(); + ACharacter* OwnerCharacter = Cast(GetOwner()); + if (OwnerCharacter && OwnerCharacter->GetCharacterMovement()) + { + Speed = OwnerCharacter->GetCharacterMovement()->GetMaxSpeed(); + } const float Delta = Speed * DeltaTime; + const float PrevDistance = CurrentDistance; + const bool bPrevForward = bMovingForward; if (bMovingForward) { @@ -159,6 +176,30 @@ void UPS_AI_Behavior_SplineFollowerComponent::TickComponent( CurrentDistance -= Delta; } + // Log direction changes and significant distance jumps + if (bDrawDebug) + { + if (bMovingForward != bPrevForward) + { + UE_LOG(LogPS_AI_Behavior, Warning, + TEXT("[%s] DIRECTION CHANGED: %s -> %s at dist=%.0f/%.0f"), + GetOwner() ? *GetOwner()->GetName() : TEXT("?"), + bPrevForward ? TEXT("FWD") : TEXT("BWD"), + bMovingForward ? TEXT("FWD") : TEXT("BWD"), + CurrentDistance, SplineLen); + } + + const float DistJump = FMath::Abs(CurrentDistance - PrevDistance); + if (DistJump > Delta * 2.0f && DistJump > 10.0f) + { + UE_LOG(LogPS_AI_Behavior, Warning, + TEXT("[%s] DIST JUMP: %.0f -> %.0f (delta=%.1f, expected=%.1f) fwd=%d"), + GetOwner() ? *GetOwner()->GetName() : TEXT("?"), + PrevDistance, CurrentDistance, DistJump, Delta, + (int32)bMovingForward); + } + } + // ─── End of spline handling ───────────────────────────────────────── if (CurrentDistance >= SplineLen) { @@ -168,8 +209,17 @@ void UPS_AI_Behavior_SplineFollowerComponent::TickComponent( } else if (bReverseAtEnd) { - CurrentDistance = SplineLen - (CurrentDistance - SplineLen); - bMovingForward = false; + // Clamp to the end — don't reverse the target point yet. + // Wait until the NPC is close enough, then reverse. + CurrentDistance = SplineLen; + const FVector EndPoint = CurrentSpline->GetWorldLocationAtDistance(SplineLen); + const float GapToEnd = FVector::Dist2D(GetOwner()->GetActorLocation(), EndPoint); + if (GapToEnd < 80.0f) + { + // NPC has caught up — now reverse + bMovingForward = false; + } + // Otherwise, target stays at the end and NPC walks toward it } else { @@ -187,8 +237,15 @@ void UPS_AI_Behavior_SplineFollowerComponent::TickComponent( } else if (bReverseAtEnd) { - CurrentDistance = -CurrentDistance; - bMovingForward = true; + // Clamp to the start — wait for NPC to catch up + CurrentDistance = 0.0f; + const FVector StartPoint = CurrentSpline->GetWorldLocationAtDistance(0.0f); + const float GapToStart = FVector::Dist2D(GetOwner()->GetActorLocation(), StartPoint); + if (GapToStart < 80.0f) + { + // NPC has caught up — now reverse + bMovingForward = true; + } } else { @@ -211,14 +268,24 @@ void UPS_AI_Behavior_SplineFollowerComponent::TickComponent( ACharacter* Character = Cast(Owner); if (Character && Character->GetCharacterMovement()) { - // Compute velocity to reach the spline point - FVector DesiredVelocity = (TargetLocation - CurrentLocation) / FMath::Max(DeltaTime, 0.001f); + // Steer toward the spline point directly. + // The spline advances smoothly each frame so the target point moves along the curve. + // This gives natural cornering without drifting. + const FVector ToTarget = TargetLocation - CurrentLocation; + const float GapToSpline = ToTarget.Size2D(); - // Clamp to avoid teleporting on large frame spikes - const float MaxVel = Speed * 3.0f; - if (DesiredVelocity.SizeSquared() > MaxVel * MaxVel) + FVector DesiredVelocity; + if (GapToSpline < 1.0f) { - DesiredVelocity = DesiredVelocity.GetSafeNormal() * MaxVel; + // Exactly on the spline — use tangent to avoid zero velocity + const FVector SplineTangent = CurrentSpline->GetWorldDirectionAtDistance(CurrentDistance) + * (bMovingForward ? 1.0f : -1.0f); + DesiredVelocity = SplineTangent * Speed; + } + else + { + // Steer toward the target point on the spline, clamped to Speed + DesiredVelocity = ToTarget.GetSafeNormal() * Speed; } Character->GetCharacterMovement()->RequestDirectMove(DesiredVelocity, /*bForceMaxSpeed=*/false); @@ -229,17 +296,55 @@ void UPS_AI_Behavior_SplineFollowerComponent::TickComponent( Owner->SetActorLocation(TargetLocation); } - // Smooth rotation — flip if going backward - FRotator FinalTargetRot = TargetRotation; - if (!bMovingForward) + // Smooth rotation — face toward the target point (not the spline tangent) + // This prevents the NPC from looking too far into corners + const FVector ToTargetDir = (TargetLocation - CurrentLocation).GetSafeNormal2D(); + FRotator FinalTargetRot; + if (!ToTargetDir.IsNearlyZero()) { - FinalTargetRot.Yaw += 180.0f; + FinalTargetRot = ToTargetDir.Rotation(); + } + else + { + // Fallback to spline tangent if on top of the target + 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 + // ─── Debug drawing ───────────────────────────────────────────────── + if (bDrawDebug) + { + const UWorld* World = GetWorld(); + if (World) + { + // Target point on spline (green sphere) + DrawDebugSphere(World, TargetLocation, 15.0f, 8, FColor::Green, false, -1.0f, 0, 2.0f); + + // Line from NPC to target point (yellow = gap) + DrawDebugLine(World, CurrentLocation, TargetLocation, FColor::Yellow, false, -1.0f, 0, 1.5f); + + // Spline tangent direction (cyan arrow) + const FVector Tangent = CurrentSpline->GetWorldDirectionAtDistance(CurrentDistance) + * (bMovingForward ? 1.0f : -1.0f); + DrawDebugDirectionalArrow(World, TargetLocation, TargetLocation + Tangent * 150.0f, + 20.0f, FColor::Cyan, false, -1.0f, 0, 2.0f); + + // Gap distance text + const float DebugGap = FVector::Dist(CurrentLocation, TargetLocation); + DrawDebugString(World, CurrentLocation + FVector(0, 0, 100.0f), + FString::Printf(TEXT("Gap: %.0fcm Dist: %.0f/%.0f"), + DebugGap, CurrentDistance, CurrentSpline->GetSplineLength()), + nullptr, FColor::White, 0.0f, true); + } + } + // ─── Junction handling ────────────────────────────────────────────── HandleJunctions(); } @@ -277,8 +382,31 @@ void UPS_AI_Behavior_SplineFollowerComponent::HandleJunctions() LastHandledJunctionIndex = i; OnApproachingJunction.Broadcast(CurrentSpline, i, DistToJunction); + if (bDrawDebug) + { + UE_LOG(LogPS_AI_Behavior, Log, + TEXT("[%s] Junction detected: idx=%d, dist along=%.0f, dist to=%.0fcm, worldPos=(%.0f,%.0f,%.0f)"), + GetOwner() ? *GetOwner()->GetName() : TEXT("?"), + i, J.DistanceOnThisSpline, DistToJunction, + J.WorldLocation.X, J.WorldLocation.Y, J.WorldLocation.Z); + } + if (bAutoChooseAtJunction) { + // Only switch with a probability — don't always change at every junction + const float SwitchChance = 0.7f; // 70% chance to switch + const float Roll = FMath::FRand(); + if (Roll > SwitchChance) + { + if (bDrawDebug) + { + UE_LOG(LogPS_AI_Behavior, Log, + TEXT("[%s] Junction %d: skipped (roll=%.2f > chance=%.2f)"), + GetOwner() ? *GetOwner()->GetName() : TEXT("?"), i, Roll, SwitchChance); + } + break; // Stay on current spline + } + // Use SplineNetwork subsystem to choose UPS_AI_Behavior_SplineNetwork* Network = GetWorld()->GetSubsystem(); @@ -301,7 +429,67 @@ void UPS_AI_Behavior_SplineFollowerComponent::HandleJunctions() if (ChosenSpline && ChosenSpline != CurrentSpline) { - SwitchToSpline(ChosenSpline, J.DistanceOnOtherSpline, bMovingForward); + // Only switch if the junction points are actually close in world space + const FVector CurrentPos = GetOwner()->GetActorLocation(); + const FVector JunctionPos = J.WorldLocation; + const float WorldGap = FVector::Dist(CurrentPos, JunctionPos); + if (WorldGap > JunctionDetectionDistance * 2.0f) + { + if (bDrawDebug) + { + UE_LOG(LogPS_AI_Behavior, Log, + TEXT("[%s] Junction %d: rejected (worldGap=%.0f > detect=%.0f)"), + GetOwner() ? *GetOwner()->GetName() : TEXT("?"), + i, WorldGap, JunctionDetectionDistance); + } + break; // Too far in world space + } + + // Check direction continuity: avoid U-turns + // Compare our current movement direction with the new spline's direction at the junction + const FVector CurrentDir = CurrentSpline->GetWorldDirectionAtDistance(CurrentDistance) + * (bMovingForward ? 1.0f : -1.0f); + + const FVector NewDirForward = ChosenSpline->GetWorldDirectionAtDistance(J.DistanceOnOtherSpline); + const FVector NewDirBackward = -NewDirForward; + + const float DotForward = FVector::DotProduct(CurrentDir, NewDirForward); + const float DotBackward = FVector::DotProduct(CurrentDir, NewDirBackward); + + // Choose the direction on the new spline that best continues our current heading + bool bNewForward; + float BestDot; + if (DotForward >= DotBackward) + { + bNewForward = true; + BestDot = DotForward; + } + else + { + bNewForward = false; + BestDot = DotBackward; + } + + // Only switch if the direction change is less than ~135° (dot > -0.7) + // This prevents full U-turns but allows wide turns at crossings + if (BestDot < -0.7f) + { + if (bDrawDebug) + { + UE_LOG(LogPS_AI_Behavior, Log, + TEXT("[%s] Junction %d: rejected U-turn (dot=%.2f)"), + GetOwner() ? *GetOwner()->GetName() : TEXT("?"), i, BestDot); + } + break; // Would cause a near-180° U-turn, skip + } + + UE_LOG(LogPS_AI_Behavior, Verbose, + TEXT("[%s] Junction switch: %s -> %s (dot=%.2f, dir=%s, worldGap=%.0fcm)"), + GetOwner() ? *GetOwner()->GetName() : TEXT("?"), + *CurrentSpline->GetName(), *ChosenSpline->GetName(), + BestDot, bNewForward ? TEXT("fwd") : TEXT("bwd"), + FVector::Dist(CurrentPos, JunctionPos)); + SwitchToSpline(ChosenSpline, J.DistanceOnOtherSpline, bNewForward); } } 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 index 51cfe80..f8d9a9b 100644 --- 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 @@ -102,6 +102,12 @@ FRotator APS_AI_Behavior_SplinePath::GetWorldRotationAtDistance(float Distance) return SplineComp->GetRotationAtDistanceAlongSpline(Distance, ESplineCoordinateSpace::World); } +FVector APS_AI_Behavior_SplinePath::GetWorldDirectionAtDistance(float Distance) const +{ + if (!SplineComp) return FVector::ForwardVector; + return SplineComp->GetDirectionAtDistanceAlongSpline(Distance, ESplineCoordinateSpace::World); +} + void APS_AI_Behavior_SplinePath::UpdateSplineVisualization() { if (!SplineComp) return; 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 index 85a470a..f530b4b 100644 --- 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 @@ -7,11 +7,14 @@ #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). + * BT Task: Patrol around the NPC's home location. * - * Optional random wait at each waypoint. + * Two modes (automatic): + * - If AIController has PatrolPoints → cycles through them. + * - If no PatrolPoints → picks a random NavMesh point within PatrolRadius + * around the HomeLocation (from Blackboard). + * + * Waits a random time at each waypoint before moving to the next. */ UCLASS(meta = (DisplayName = "PS AI: Patrol")) class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTTask_Patrol : public UBTTaskNode @@ -33,6 +36,20 @@ public: UPROPERTY(EditAnywhere, Category = "Patrol", meta = (ClampMin = "0.0")) float MaxWaitTime = 4.0f; + /** + * Radius around HomeLocation to pick random patrol points (cm). + * Only used when no manual PatrolPoints are defined. + */ + UPROPERTY(EditAnywhere, Category = "Patrol|Auto", meta = (ClampMin = "200.0")) + float PatrolRadius = 1500.0f; + + /** + * Minimum distance from current position for a random patrol point (cm). + * Prevents picking a point right next to the NPC. + */ + UPROPERTY(EditAnywhere, Category = "Patrol|Auto", meta = (ClampMin = "100.0")) + float MinPatrolDistance = 300.0f; + protected: virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override; virtual void TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override; @@ -40,7 +57,6 @@ protected: virtual FString GetStaticDescription() const override; private: - /** Per-instance memory. */ struct FPatrolMemory { float WaitRemaining = 0.0f; @@ -49,4 +65,11 @@ private: }; virtual uint16 GetInstanceMemorySize() const override { return sizeof(FPatrolMemory); } + + /** + * Find a random navigable point within PatrolRadius of HomeLocation. + * Ensures the point is at least MinPatrolDistance from the NPC. + */ + bool FindRandomPatrolPoint(const UWorld* World, const FVector& HomeLoc, + const FVector& CurrentLoc, FVector& OutPoint) const; }; 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 index c24af98..2b65e6c 100644 --- 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 @@ -24,7 +24,7 @@ class UBillboardComponent; * - 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")) +UCLASS(BlueprintType, Blueprintable, Placeable, meta = (DisplayName = "PS AI Cover Point")) class PS_AI_BEHAVIOR_API APS_AI_Behavior_CoverPoint : public AActor { GENERATED_BODY() 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 index ed155eb..30b587a 100644 --- 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 @@ -71,7 +71,11 @@ public: * If false, stop and fire OnSplineEndReached. */ UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spline Follower") - bool bReverseAtEnd = false; + bool bReverseAtEnd = true; + + /** Draw debug info: target point on spline, direction, gap distance. */ + UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spline Follower|Debug") + bool bDrawDebug = false; // ─── Runtime State ────────────────────────────────────────────────── 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 index b41bd76..23f55b4 100644 --- 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 @@ -44,7 +44,7 @@ struct FPS_AI_Behavior_SplineJunction * and lets NPCs switch between paths at those points. * - Supports bidirectional travel by default. */ -UCLASS(BlueprintType, Blueprintable, meta = (DisplayName = "PS AI Spline Path")) +UCLASS(BlueprintType, Blueprintable, Placeable, meta = (DisplayName = "PS AI Spline Path")) class PS_AI_BEHAVIOR_API APS_AI_Behavior_SplinePath : public AActor { GENERATED_BODY() @@ -131,6 +131,10 @@ public: UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Spline") FRotator GetWorldRotationAtDistance(float Distance) const; + /** Get world-space tangent direction at a distance along the spline. */ + UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Spline") + FVector GetWorldDirectionAtDistance(float Distance) const; + #if WITH_EDITOR virtual void PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) override; #endif diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Private/PS_AI_Behavior_PersonalityProfileFactory.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Private/PS_AI_Behavior_PersonalityProfileFactory.cpp new file mode 100644 index 0000000..ff64132 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Private/PS_AI_Behavior_PersonalityProfileFactory.cpp @@ -0,0 +1,29 @@ +// Copyright Asterion. All Rights Reserved. + +#include "PS_AI_Behavior_PersonalityProfileFactory.h" +#include "PS_AI_Behavior_PersonalityProfile.h" + +UPS_AI_Behavior_PersonalityProfileFactory::UPS_AI_Behavior_PersonalityProfileFactory() +{ + bCreateNew = true; + bEditAfterNew = true; + SupportedClass = UPS_AI_Behavior_PersonalityProfile::StaticClass(); +} + +UObject* UPS_AI_Behavior_PersonalityProfileFactory::FactoryCreateNew( + UClass* InClass, UObject* InParent, FName InName, EObjectFlags Flags, + UObject* Context, FFeedbackContext* Warn) +{ + return NewObject(InParent, InClass, InName, Flags); +} + +FText UPS_AI_Behavior_PersonalityProfileFactory::GetDisplayName() const +{ + return FText::FromString(TEXT("Personality Profile")); +} + +uint32 UPS_AI_Behavior_PersonalityProfileFactory::GetMenuCategories() const +{ + // EAssetTypeCategories::Misc = 1 + return 1; +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Private/PS_AI_Behavior_PersonalityProfileFactory.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Private/PS_AI_Behavior_PersonalityProfileFactory.h new file mode 100644 index 0000000..1a3aaa4 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_BehaviorEditor/Private/PS_AI_Behavior_PersonalityProfileFactory.h @@ -0,0 +1,27 @@ +// Copyright Asterion. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "Factories/Factory.h" +#include "PS_AI_Behavior_PersonalityProfileFactory.generated.h" + +/** + * Factory to create PersonalityProfile Data Assets from the Content Browser. + * Right-click → PS AI Behavior → Personality Profile + */ +UCLASS() +class UPS_AI_Behavior_PersonalityProfileFactory : public UFactory +{ + GENERATED_BODY() + +public: + UPS_AI_Behavior_PersonalityProfileFactory(); + + virtual UObject* FactoryCreateNew(UClass* InClass, UObject* InParent, + FName InName, EObjectFlags Flags, UObject* Context, + FFeedbackContext* Warn) override; + + virtual FText GetDisplayName() const override; + virtual uint32 GetMenuCategories() const override; +}; 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 index ac4b101..27f41d6 100644 --- 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 @@ -22,4 +22,7 @@ private: void RegisterSplinePanel(); void UnregisterSplinePanel(); + + /** Create or update the BB_Behavior Blackboard asset with proper enum keys. */ + void EnsureBlackboardAsset(); };