From 65b86e2fbd715b63eb5a9c4b9cff1e6d53c1b891 Mon Sep 17 00:00:00 2001 From: "j.foucher" Date: Sat, 28 Mar 2026 11:03:27 +0100 Subject: [PATCH] Fix combat BT stuck on spline, LOS through target capsule, NoTeam targeting - Attack/CoverShootCycle: de-escalate to Alerted on failure so decorator can re-trigger when threat returns (fixes BT stuck on spline branch) - UpdateThreat: keep BB ThreatActor during brief perception gaps instead of clearing immediately (use LOS timeout for graceful degradation) - HasLineOfSight: ignore actors up the attachment chain so trace doesn't hit the target Pawn's capsule when aiming at its AimTarget child actor - NoTeam actors (spectators, editor pawns) treated as Neutral instead of Hostile, plus SpectatorPawn explicit filter in perception - BB debug key ThreatPawnName shows owning Pawn name (resolved via perception's LastThreatOwningPawn) instead of cryptic AimTarget name - FindOwningPawn promoted to public static on PerceptionComponent Co-Authored-By: Claude Opus 4.6 --- ...AI_Behavior_BTService_EvaluateReaction.cpp | 6 +- .../PS_AI_Behavior_BTService_UpdateThreat.cpp | 155 +++++++++- .../BT/PS_AI_Behavior_BTTask_Attack.cpp | 275 ++++++++++++++---- .../PS_AI_Behavior_BTTask_CoverShootCycle.cpp | 84 +++++- .../Private/PS_AI_Behavior_AIController.cpp | 46 ++- .../PS_AI_Behavior_PerceptionComponent.cpp | 44 ++- .../PS_AI_Behavior_PersonalityComponent.cpp | 23 +- .../Private/PS_AI_Behavior_Statics.cpp | 60 ++++ .../PS_AI_Behavior_BTService_UpdateThreat.h | 19 ++ .../Public/BT/PS_AI_Behavior_BTTask_Attack.h | 32 ++ .../Public/PS_AI_Behavior_AIController.h | 3 + .../Public/PS_AI_Behavior_Definitions.h | 4 +- .../PS_AI_Behavior_PerceptionComponent.h | 7 + .../Public/PS_AI_Behavior_Statics.h | 56 ++++ 14 files changed, 734 insertions(+), 80 deletions(-) create mode 100644 Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_Statics.cpp create mode 100644 Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_Statics.h 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 index 54d8be3..8ec530c 100644 --- 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 @@ -23,13 +23,13 @@ void UPS_AI_Behavior_BTService_EvaluateReaction::TickNode( Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds); APS_AI_Behavior_AIController* AIC = Cast(OwnerComp.GetAIOwner()); - if (!AIC) return; + if (!AIC) { UE_LOG(LogPS_AI_Behavior, Warning, TEXT("EvaluateReaction: no AIC!")); return; } UPS_AI_Behavior_PersonalityComponent* Personality = AIC->GetPersonalityComponent(); - if (!Personality) return; + if (!Personality) { UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] EvaluateReaction: no PersonalityComponent!"), *AIC->GetName()); return; } UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent(); - if (!BB) return; + if (!BB) { UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] EvaluateReaction: no BB!"), *AIC->GetName()); return; } // ─── Check for hostility change → update TeamId dynamically ──────── APawn* Pawn = AIC->GetPawn(); 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 288eec0..a4290d8 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 @@ -4,6 +4,7 @@ #include "PS_AI_Behavior_AIController.h" #include "PS_AI_Behavior_PerceptionComponent.h" #include "PS_AI_Behavior_PersonalityComponent.h" +#include "PS_AI_Behavior_Statics.h" #include "PS_AI_Behavior_Settings.h" #include "PS_AI_Behavior_Definitions.h" #include "BehaviorTree/BlackboardComponent.h" @@ -65,19 +66,157 @@ void UPS_AI_Behavior_BTService_UpdateThreat::TickNode( BB->SetValueAsFloat(PS_AI_Behavior_BB::ThreatLevel, FinalThreat); - // Update threat actor and location + UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] UpdateThreat: raw=%.2f, stored=%.2f, final=%.2f"), + *AIC->GetName(), RawThreat, StoredThreat, FinalThreat); + + // ─── Update threat actor and location with LOS tracking ───────── + FUpdateThreatMemory* Memory = reinterpret_cast(NodeMemory); AActor* ThreatActor = Perception->GetHighestThreatActor(); + AActor* CurrentBBTarget = Cast(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor)); + if (ThreatActor) { + // Target switched by perception (higher score) → reset LOS tracking + if (ThreatActor != CurrentBBTarget) + { + Memory->TimeSinceLOS = 0.0f; + Memory->bHadLOS = true; + Memory->bInvestigating = false; + Memory->LastVisiblePosition = ThreatActor->GetActorLocation(); + BB->ClearValue(PS_AI_Behavior_BB::LastKnownTargetPosition); + + UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] UpdateThreat: target switched to '%s', LOS tracking reset"), + *AIC->GetName(), *ThreatActor->GetName()); + } + BB->SetValueAsObject(PS_AI_Behavior_BB::ThreatActor, ThreatActor); - BB->SetValueAsVector(PS_AI_Behavior_BB::ThreatLocation, ThreatActor->GetActorLocation()); + + // Debug: write the owning Pawn's name so BB shows who we're actually targeting + if (ThreatActor != CurrentBBTarget) + { + APawn* OwningPawn = Perception->LastThreatOwningPawn.Get(); + BB->SetValueAsString(PS_AI_Behavior_BB::ThreatPawnName, + OwningPawn ? OwningPawn->GetName() : ThreatActor->GetName()); + } + + // ─── LOS check ────────────────────────────────────────────── + const APawn* Pawn = AIC->GetPawn(); + const bool bHasLOS = Pawn + ? UPS_AI_Behavior_Statics::HasLineOfSight(Pawn->GetWorld(), Pawn, ThreatActor, EyeHeightOffset) + : false; + + if (bHasLOS) + { + // Can see target → update last visible position, reset timer + Memory->LastVisiblePosition = ThreatActor->GetActorLocation(); + Memory->TimeSinceLOS = 0.0f; + Memory->bHadLOS = true; + Memory->bInvestigating = false; + + BB->SetValueAsVector(PS_AI_Behavior_BB::ThreatLocation, ThreatActor->GetActorLocation()); + BB->ClearValue(PS_AI_Behavior_BB::LastKnownTargetPosition); + } + else + { + // Cannot see target → accumulate time, write last known position + Memory->TimeSinceLOS += DeltaSeconds; + + // Write ThreatLocation as the LAST VISIBLE position (not real-time) + if (!Memory->LastVisiblePosition.IsZero()) + { + BB->SetValueAsVector(PS_AI_Behavior_BB::ThreatLocation, Memory->LastVisiblePosition); + } + else + { + BB->SetValueAsVector(PS_AI_Behavior_BB::ThreatLocation, ThreatActor->GetActorLocation()); + } + + // Phase 1: after LOSLostTimeout → write LastKnownTargetPosition for investigation + if (Memory->TimeSinceLOS > LOSLostTimeout && !Memory->bInvestigating) + { + Memory->bInvestigating = true; + BB->SetValueAsVector(PS_AI_Behavior_BB::LastKnownTargetPosition, Memory->LastVisiblePosition); + + UE_LOG(LogPS_AI_Behavior, Log, + TEXT("[%s] UpdateThreat: LOS lost for %.0fs → investigating last known position %s"), + *AIC->GetName(), Memory->TimeSinceLOS, *Memory->LastVisiblePosition.ToString()); + } + + // Phase 2: after 2x timeout → give up, clear target so NPC can re-evaluate + if (Memory->TimeSinceLOS > LOSLostTimeout * 2.0f) + { + UE_LOG(LogPS_AI_Behavior, Log, + TEXT("[%s] UpdateThreat: LOS lost for %.0fs → clearing target, de-escalating"), + *AIC->GetName(), Memory->TimeSinceLOS); + + BB->ClearValue(PS_AI_Behavior_BB::ThreatActor); + BB->ClearValue(PS_AI_Behavior_BB::LastKnownTargetPosition); + BB->ClearValue(PS_AI_Behavior_BB::ThreatPawnName); + Memory->TimeSinceLOS = 0.0f; + Memory->bInvestigating = false; + Memory->bHadLOS = true; + } + } + } + else if (CurrentBBTarget) + { + // Perception lost the actor momentarily, but we had a target in BB. + // Don't clear immediately — use the LOS tracking timeout instead. + // This prevents brief perception gaps (1-2 ticks) from dropping the target. + Memory->TimeSinceLOS += DeltaSeconds; + + // Keep ThreatLocation at last visible position + if (!Memory->LastVisiblePosition.IsZero()) + { + BB->SetValueAsVector(PS_AI_Behavior_BB::ThreatLocation, Memory->LastVisiblePosition); + } + + // Phase 1: after LOSLostTimeout → investigate last known position + if (Memory->TimeSinceLOS > LOSLostTimeout && !Memory->bInvestigating) + { + Memory->bInvestigating = true; + BB->SetValueAsVector(PS_AI_Behavior_BB::LastKnownTargetPosition, Memory->LastVisiblePosition); + + UE_LOG(LogPS_AI_Behavior, Log, + TEXT("[%s] UpdateThreat: perception lost target for %.0fs → investigating last known position %s"), + *AIC->GetName(), Memory->TimeSinceLOS, *Memory->LastVisiblePosition.ToString()); + } + + // Phase 2: after 2x timeout → give up, clear target + if (Memory->TimeSinceLOS > LOSLostTimeout * 2.0f) + { + UE_LOG(LogPS_AI_Behavior, Log, + TEXT("[%s] UpdateThreat: perception lost target for %.0fs → clearing target, de-escalating"), + *AIC->GetName(), Memory->TimeSinceLOS); + + BB->ClearValue(PS_AI_Behavior_BB::ThreatActor); + BB->ClearValue(PS_AI_Behavior_BB::LastKnownTargetPosition); + BB->ClearValue(PS_AI_Behavior_BB::ThreatPawnName); + Memory->TimeSinceLOS = 0.0f; + Memory->bInvestigating = false; + Memory->bHadLOS = true; + } } else { - // No valid threat — clear BB and force threat to zero - BB->ClearValue(PS_AI_Behavior_BB::ThreatActor); - BB->ClearValue(PS_AI_Behavior_BB::ThreatLocation); - BB->SetValueAsFloat(PS_AI_Behavior_BB::ThreatLevel, 0.0f); + // No threat actor at all (never had one) + Memory->TimeSinceLOS = 0.0f; + Memory->bInvestigating = false; + Memory->bHadLOS = true; + + if (FinalThreat > 0.0f) + { + // Threat exists but no specific actor target (e.g. gunshot from friendly). + FVector ThreatLoc; + if (Perception->GetGunShotStimulusLocation(ThreatLoc)) + { + BB->SetValueAsVector(PS_AI_Behavior_BB::ThreatLocation, ThreatLoc); + } + } + else + { + BB->ClearValue(PS_AI_Behavior_BB::ThreatLocation); + } } // Sync to PersonalityComponent @@ -90,5 +229,7 @@ void UPS_AI_Behavior_BTService_UpdateThreat::TickNode( FString UPS_AI_Behavior_BTService_UpdateThreat::GetStaticDescription() const { - return TEXT("Updates Blackboard threat data from perception.\nWrites: ThreatActor, ThreatLocation, ThreatLevel."); + return FString::Printf( + TEXT("Updates Blackboard threat data from perception.\nLOS timeout: %.0fs (clear at %.0fs)\nWrites: ThreatActor, ThreatLocation, ThreatLevel, LastKnownTargetPosition."), + LOSLostTimeout, LOSLostTimeout * 2.0f); } 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 index 8daaa84..2c84b7d 100644 --- 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 @@ -6,9 +6,12 @@ #include "PS_AI_Behavior_Definitions.h" #include "PS_AI_Behavior_PersonalityComponent.h" #include "PS_AI_Behavior_PersonalityProfile.h" +#include "PS_AI_Behavior_Statics.h" #include "BehaviorTree/BlackboardComponent.h" #include "Navigation/PathFollowingComponent.h" #include "NavigationSystem.h" +#include "EnvironmentQuery/EnvQuery.h" +#include "EnvironmentQuery/EnvQueryManager.h" UPS_AI_Behavior_BTTask_Attack::UPS_AI_Behavior_BTTask_Attack() { @@ -21,15 +24,25 @@ 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; + if (!AIC || !AIC->GetPawn()) + { + UE_LOG(LogPS_AI_Behavior, Warning, TEXT("Attack: FAILED — no AIC or Pawn")); + return EBTNodeResult::Failed; + } UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent(); - if (!BB) return EBTNodeResult::Failed; + if (!BB) + { + UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: FAILED — no BB"), *AIC->GetName()); + return EBTNodeResult::Failed; + } AActor* Target = Cast(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor)); if (!Target) { - UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: no ThreatActor in BB."), *AIC->GetName()); + UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: FAILED — no ThreatActor in BB"), *AIC->GetName()); + // De-escalate so decorator can re-trigger when threat returns + AIC->SetBehaviorState(EPS_AI_Behavior_State::Alerted); return EBTNodeResult::Failed; } @@ -40,8 +53,11 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_Attack::ExecuteTask( { if (!IPS_AI_Behavior_Interface::Execute_IsTargetActorValid(Pawn, Target)) { + UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: FAILED — IsTargetActorValid returned false for '%s'"), + *AIC->GetName(), *Target->GetName()); BB->ClearValue(PS_AI_Behavior_BB::ThreatActor); - BB->SetValueAsFloat(PS_AI_Behavior_BB::ThreatLevel, 0.0f); + // De-escalate so decorator can re-trigger when threat returns + AIC->SetBehaviorState(EPS_AI_Behavior_State::Alerted); return EBTNodeResult::Failed; } } @@ -51,12 +67,16 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_Attack::ExecuteTask( Memory->bMovingToTarget = false; Memory->bAttacking = false; Memory->bInRange = false; + Memory->bHasLOS = true; + Memory->bSeekingFiringPos = false; + Memory->bEQSQueryRunning = false; Memory->RepositionTimer = 0.0f; + Memory->LOSCheckTimer = 0.0f; Memory->CombatType = EPS_AI_Behavior_CombatType::Melee; Memory->MinRange = 100.0f; Memory->MaxRange = AttackMoveRadius; - // CombatType from interface (depends on weapon/pawn) + // CombatType from interface if (Pawn->Implements()) { Memory->CombatType = IPS_AI_Behavior_Interface::Execute_GetBehaviorCombatType(Pawn); @@ -72,7 +92,14 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_Attack::ExecuteTask( } } - // Melee: approach to half MinRange (get close). Ranged: approach to midpoint of band. + // ─── Initial LOS check (ranged only) ──────────────────────────── + if (Memory->CombatType == EPS_AI_Behavior_CombatType::Ranged) + { + Memory->bHasLOS = UPS_AI_Behavior_Statics::HasLineOfSight( + Pawn->GetWorld(), Pawn, Target, EyeHeightOffset); + } + + // Melee: approach to half MinRange. Ranged: approach to midpoint of band. const float ApproachRange = (Memory->CombatType == EPS_AI_Behavior_CombatType::Melee) ? Memory->MinRange * 0.5f : (Memory->MinRange + Memory->MaxRange) * 0.5f; @@ -81,7 +108,8 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_Attack::ExecuteTask( if (DistToTarget <= Memory->MaxRange) { Memory->bInRange = true; - if (Pawn->Implements()) + // Only start attacking if we have LOS (ranged) or always (melee) + if (Memory->bHasLOS && Pawn->Implements()) { IPS_AI_Behavior_Interface::Execute_BehaviorStartAttack(Pawn, Target); Memory->bAttacking = true; @@ -101,14 +129,12 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_Attack::ExecuteTask( } } - UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: started on '%s' (%s, range=[%.0f-%.0f], dist=%.0f, inRange=%d, attacking=%d, hasInterface=%d)"), + UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: started on '%s' (%s, range=[%.0f-%.0f], dist=%.0f, inRange=%d, LOS=%d, attacking=%d)"), *AIC->GetName(), *Target->GetName(), Memory->CombatType == EPS_AI_Behavior_CombatType::Ranged ? TEXT("Ranged") : TEXT("Melee"), Memory->MinRange, Memory->MaxRange, DistToTarget, - Memory->bInRange ? 1 : 0, Memory->bAttacking ? 1 : 0, - Pawn->Implements() ? 1 : 0); + Memory->bInRange ? 1 : 0, Memory->bHasLOS ? 1 : 0, Memory->bAttacking ? 1 : 0); - // Stay InProgress — the Decorator Observer Aborts will pull us out return EBTNodeResult::InProgress; } @@ -126,40 +152,56 @@ void UPS_AI_Behavior_BTTask_Attack::TickTask( AActor* Target = BB ? Cast(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor)) : nullptr; if (!Target) { - BB->SetValueAsFloat(PS_AI_Behavior_BB::ThreatLevel, 0.0f); + // De-escalate so decorator can re-trigger when threat returns + AIC->SetBehaviorState(EPS_AI_Behavior_State::Alerted); FinishLatentTask(OwnerComp, EBTNodeResult::Failed); return; } - // Check if target is still valid (alive, not despawning) via interface APawn* Pawn = AIC->GetPawn(); + + // Validate target if (Pawn && Pawn->Implements()) { if (!IPS_AI_Behavior_Interface::Execute_IsTargetActorValid(Pawn, Target)) { - // Target is dead/invalid — clear BB, StopAttack called by OnTaskFinished AIC->StopMovement(); BB->ClearValue(PS_AI_Behavior_BB::ThreatActor); - BB->SetValueAsFloat(PS_AI_Behavior_BB::ThreatLevel, 0.0f); - FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded); + // De-escalate so decorator can re-trigger when threat returns + AIC->SetBehaviorState(EPS_AI_Behavior_State::Alerted); + FinishLatentTask(OwnerComp, EBTNodeResult::Failed); return; } } FAttackMemory* Memory = reinterpret_cast(NodeMemory); - // Tick reposition cooldown + // Tick cooldowns if (Memory->RepositionTimer > 0.0f) { Memory->RepositionTimer -= DeltaSeconds; } + // ─── Periodic LOS check (ranged only) ─────────────────────────── + if (Memory->CombatType == EPS_AI_Behavior_CombatType::Ranged) + { + Memory->LOSCheckTimer -= DeltaSeconds; + if (Memory->LOSCheckTimer <= 0.0f) + { + Memory->LOSCheckTimer = LOSCheckInterval; + Memory->bHasLOS = UPS_AI_Behavior_Statics::HasLineOfSight( + Pawn->GetWorld(), Pawn, Target, EyeHeightOffset); + } + } + // Melee always has "LOS" (they just rush) + const float DistToTarget = FVector::Dist(Pawn->GetActorLocation(), Target->GetActorLocation()); const bool bCanReposition = (Memory->RepositionTimer <= 0.0f); + // ─── MOVEMENT LOGIC ───────────────────────────────────────────── if (Memory->CombatType == EPS_AI_Behavior_CombatType::Melee) { - // ─── Melee: continuously chase target (no cooldown — always pursue) ── + // Melee: continuously chase target (no LOS check — just rush) if (AIC->GetMoveStatus() == EPathFollowingStatus::Idle && DistToTarget > Memory->MinRange) { AIC->MoveToLocation( @@ -169,19 +211,52 @@ void UPS_AI_Behavior_BTTask_Attack::TickTask( } else { - // ─── Ranged: maintain distance between MinRange and MaxRange ─ - const float MidRange = (Memory->MinRange + Memory->MaxRange) * 0.5f; - - if (AIC->GetMoveStatus() == EPathFollowingStatus::Idle) + // ─── Ranged: LOS-aware positioning ────────────────────────── + if (Memory->bSeekingFiringPos) { + // Waiting for firing position move to complete + if (AIC->GetMoveStatus() == EPathFollowingStatus::Idle) + { + Memory->bSeekingFiringPos = false; + // Re-check LOS immediately after arriving + Memory->LOSCheckTimer = 0.0f; + } + } + else if (!Memory->bHasLOS && !Memory->bEQSQueryRunning && + AIC->GetMoveStatus() == EPathFollowingStatus::Idle) + { + // No LOS and idle → find a firing position + // Check if investigation (last known position) is active + const FVector LastKnownPos = BB->GetValueAsVector(PS_AI_Behavior_BB::LastKnownTargetPosition); + if (!LastKnownPos.IsZero()) + { + // Investigation mode: go to last known position + AIC->MoveToLocation( + LastKnownPos, 100.0f, /*bStopOnOverlap=*/true, + /*bUsePathfinding=*/true, /*bProjectGoal=*/true, /*bCanStrafe=*/false); + Memory->bSeekingFiringPos = true; + + UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Attack: investigating last known position %s"), + *AIC->GetName(), *LastKnownPos.ToString()); + } + else + { + // No investigation yet — try EQS for a flanking position + RunFiringPositionQuery(OwnerComp, NodeMemory, Pawn, Target); + } + } + else if (Memory->bHasLOS && AIC->GetMoveStatus() == EPathFollowingStatus::Idle) + { + // Has LOS — normal range maintenance + const float MidRange = (Memory->MinRange + Memory->MaxRange) * 0.5f; + if (bCanReposition && DistToTarget < Memory->MinRange) { - // Too close — back away to midpoint of band + // Too close — back away const FVector AwayDir = (Pawn->GetActorLocation() - Target->GetActorLocation()).GetSafeNormal2D(); const float RetreatDist = MidRange - DistToTarget + 50.0f; const FVector RetreatPoint = Pawn->GetActorLocation() + AwayDir * RetreatDist; - // Project to navmesh UNavigationSystemV1* NavSys = FNavigationSystem::GetCurrent(GetWorld()); if (NavSys) { @@ -191,53 +266,64 @@ void UPS_AI_Behavior_BTTask_Attack::TickTask( AIC->MoveToLocation( NavLoc.Location, 50.0f, /*bStopOnOverlap=*/true, /*bUsePathfinding=*/true, /*bProjectGoal=*/false, /*bCanStrafe=*/false); - // Longer cooldown after retreat to prevent repeated backing Memory->RepositionTimer = RepositionCooldown * 3.0f; - - UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Attack: backing away %.0fcm (dist=%.0f < min=%.0f)"), - *AIC->GetName(), RetreatDist, DistToTarget, Memory->MinRange); } } } else if (DistToTarget > Memory->MaxRange) { - // Too far — advance toward target to midpoint of band (no cooldown — chase aggressively) + // Too far — advance AIC->MoveToLocation( Target->GetActorLocation(), MidRange, /*bStopOnOverlap=*/true, /*bUsePathfinding=*/true, /*bProjectGoal=*/true, /*bCanStrafe=*/false); - - UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Attack: advancing to midRange=%.0f (dist=%.0f > max=%.0f)"), - *AIC->GetName(), MidRange, DistToTarget, Memory->MaxRange); } - // else: between MinRange and MaxRange — hold position, Pawn handles shooting } } - // ─── Toggle attack based on range (with hysteresis to prevent flickering) ── - // Enter range at MaxRange, leave range at MaxRange + buffer + // ─── Toggle attack based on range + LOS ───────────────────────── const float EnterRange = Memory->MaxRange; - const float LeaveRange = Memory->MaxRange * 1.1f; // 10% hysteresis + const float LeaveRange = Memory->MaxRange * 1.1f; const bool bNowInRange = Memory->bInRange - ? (DistToTarget <= LeaveRange) // already in range → need to go PAST LeaveRange to exit - : (DistToTarget <= EnterRange); // not in range → need to get WITHIN EnterRange to enter + ? (DistToTarget <= LeaveRange) + : (DistToTarget <= EnterRange); - if (bNowInRange && !Memory->bInRange) + // For ranged: require LOS to attack. For melee: always allow. + const bool bCanAttack = (Memory->CombatType == EPS_AI_Behavior_CombatType::Melee) || Memory->bHasLOS; + + if (bNowInRange && bCanAttack && !Memory->bInRange) { - // Entered range → start attacking + // Entered range with LOS → start attacking Memory->bInRange = true; if (Pawn->Implements() && !Memory->bAttacking) { IPS_AI_Behavior_Interface::Execute_BehaviorStartAttack(Pawn, Target); Memory->bAttacking = true; - UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: in range (%.0f <= %.0f) — StartAttack on '%s'"), - *AIC->GetName(), DistToTarget, AttackRange, *Target->GetName()); + UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: in range + LOS → StartAttack on '%s'"), + *AIC->GetName(), *Target->GetName()); } - else if (!Pawn->Implements()) + } + else if (bNowInRange && !bCanAttack && Memory->bAttacking) + { + // In range but lost LOS → stop attacking (stay in range though) + if (Pawn->Implements()) { - UE_LOG(LogPS_AI_Behavior, Error, TEXT("[%s] Attack: in range but Pawn does NOT implement IPS_AI_Behavior_Interface — StartAttack cannot be called!"), + IPS_AI_Behavior_Interface::Execute_BehaviorStopAttack(Pawn); + Memory->bAttacking = false; + UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: in range but NO LOS → StopAttack"), *AIC->GetName()); } } + else if (bNowInRange && bCanAttack && !Memory->bAttacking) + { + // In range, regained LOS → restart attacking + if (Pawn->Implements()) + { + IPS_AI_Behavior_Interface::Execute_BehaviorStartAttack(Pawn, Target); + Memory->bAttacking = true; + UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: LOS regained → StartAttack on '%s'"), + *AIC->GetName(), *Target->GetName()); + } + } else if (!bNowInRange && Memory->bInRange) { // Left range → stop attacking @@ -246,8 +332,8 @@ void UPS_AI_Behavior_BTTask_Attack::TickTask( { IPS_AI_Behavior_Interface::Execute_BehaviorStopAttack(Pawn); Memory->bAttacking = false; - UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: out of range (%.0f > %.0f) — StopAttack"), - *AIC->GetName(), DistToTarget, AttackRange); + UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: out of range → StopAttack"), + *AIC->GetName()); } } } @@ -260,14 +346,12 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_Attack::AbortTask( { AIC->StopMovement(); } - // StopAttack is called by OnTaskFinished (covers all exit paths) return EBTNodeResult::Aborted; } void UPS_AI_Behavior_BTTask_Attack::OnTaskFinished( UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, EBTNodeResult::Type TaskResult) { - // Always stop attacking when the task ends, regardless of how it ended APS_AI_Behavior_AIController* AIC = Cast(OwnerComp.GetAIOwner()); if (AIC) { @@ -281,8 +365,99 @@ void UPS_AI_Behavior_BTTask_Attack::OnTaskFinished( Super::OnTaskFinished(OwnerComp, NodeMemory, TaskResult); } +// ─── EQS Firing Position Query ────────────────────────────────────────────── + +void UPS_AI_Behavior_BTTask_Attack::RunFiringPositionQuery( + UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, + APawn* Pawn, AActor* Target) +{ + FAttackMemory* Memory = reinterpret_cast(NodeMemory); + + if (!FiringPositionQuery) + { + // No EQS query configured → fallback: advance toward target + APS_AI_Behavior_AIController* AIC = Cast(OwnerComp.GetAIOwner()); + if (AIC) + { + const float MidRange = (Memory->MinRange + Memory->MaxRange) * 0.5f; + AIC->MoveToLocation( + Target->GetActorLocation(), MidRange, /*bStopOnOverlap=*/true, + /*bUsePathfinding=*/true, /*bProjectGoal=*/true, /*bCanStrafe=*/false); + Memory->bSeekingFiringPos = true; + + UE_LOG(LogPS_AI_Behavior, Verbose, + TEXT("[%s] Attack: no EQS query, fallback advance toward target"), + *AIC->GetName()); + } + return; + } + + UWorld* World = Pawn->GetWorld(); + UEnvQueryManager* EQSManager = UEnvQueryManager::GetCurrent(World); + if (!EQSManager) + { + return; + } + + Memory->bEQSQueryRunning = true; + TWeakObjectPtr WeakTarget = Target; + + FEnvQueryRequest Request(FiringPositionQuery, Pawn); + Request.Execute(EEnvQueryRunMode::SingleResult, + FQueryFinishedSignature::CreateUObject(this, + &UPS_AI_Behavior_BTTask_Attack::OnFiringPositionQueryFinished, + &OwnerComp, NodeMemory, WeakTarget)); + + UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Attack: EQS firing position query launched"), + *Pawn->GetName()); +} + +void UPS_AI_Behavior_BTTask_Attack::OnFiringPositionQueryFinished( + TSharedPtr Result, + UBehaviorTreeComponent* OwnerComp, uint8* NodeMemory, + TWeakObjectPtr WeakTarget) +{ + if (!OwnerComp || !NodeMemory) return; + + FAttackMemory* Memory = reinterpret_cast(NodeMemory); + Memory->bEQSQueryRunning = false; + + APS_AI_Behavior_AIController* AIC = Cast(OwnerComp->GetAIOwner()); + if (!AIC || !AIC->GetPawn()) return; + + AActor* Target = WeakTarget.Get(); + + if (Result.IsValid() && Result->IsSuccessful()) + { + const FVector FiringPos = Result->GetItemAsLocation(0); + AIC->MoveToLocation( + FiringPos, 50.0f, /*bStopOnOverlap=*/true, + /*bUsePathfinding=*/true, /*bProjectGoal=*/false, /*bCanStrafe=*/false); + Memory->bSeekingFiringPos = true; + + UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Attack: EQS found firing position %s"), + *AIC->GetName(), *FiringPos.ToString()); + } + else + { + // EQS found nothing — fallback: advance toward target + if (Target) + { + const float MidRange = (Memory->MinRange + Memory->MaxRange) * 0.5f; + AIC->MoveToLocation( + Target->GetActorLocation(), MidRange, /*bStopOnOverlap=*/true, + /*bUsePathfinding=*/true, /*bProjectGoal=*/true, /*bCanStrafe=*/false); + Memory->bSeekingFiringPos = true; + } + + UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Attack: EQS no result, fallback advance"), + *AIC->GetName()); + } +} + FString UPS_AI_Behavior_BTTask_Attack::GetStaticDescription() const { - return FString::Printf(TEXT("Range-aware attack.\nFallback radius: %.0fcm\nReposition cooldown: %.1fs"), - AttackMoveRadius, RepositionCooldown); + return FString::Printf(TEXT("LOS-aware attack.\nFallback radius: %.0fcm\nLOS check: every %.1fs\nEQS query: %s"), + AttackMoveRadius, LOSCheckInterval, + FiringPositionQuery ? *FiringPositionQuery->GetName() : TEXT("None (fallback)")); } diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_CoverShootCycle.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_CoverShootCycle.cpp index e7033a4..5bfb6d9 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_CoverShootCycle.cpp +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTTask_CoverShootCycle.cpp @@ -7,8 +7,10 @@ #include "PS_AI_Behavior_PersonalityComponent.h" #include "PS_AI_Behavior_PersonalityProfile.h" #include "PS_AI_Behavior_Definitions.h" +#include "PS_AI_Behavior_Statics.h" #include "BehaviorTree/BlackboardComponent.h" #include "Navigation/PathFollowingComponent.h" +#include "CollisionQueryParams.h" #include "EngineUtils.h" UPS_AI_Behavior_BTTask_CoverShootCycle::UPS_AI_Behavior_BTTask_CoverShootCycle() @@ -137,6 +139,8 @@ void UPS_AI_Behavior_BTTask_CoverShootCycle::TickTask( AActor* Target = Cast(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor)); if (!Target) { + // De-escalate so decorator can re-trigger when threat returns + AIC->SetBehaviorState(EPS_AI_Behavior_State::Alerted); FinishLatentTask(OwnerComp, EBTNodeResult::Failed); return; } @@ -149,8 +153,9 @@ void UPS_AI_Behavior_BTTask_CoverShootCycle::TickTask( { AIC->StopMovement(); BB->ClearValue(PS_AI_Behavior_BB::ThreatActor); - BB->SetValueAsFloat(PS_AI_Behavior_BB::ThreatLevel, 0.0f); - FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded); + // De-escalate so decorator can re-trigger when threat returns + AIC->SetBehaviorState(EPS_AI_Behavior_State::Alerted); + FinishLatentTask(OwnerComp, EBTNodeResult::Failed); return; } } @@ -185,7 +190,68 @@ void UPS_AI_Behavior_BTTask_CoverShootCycle::TickTask( Memory->Timer -= DeltaSeconds; if (Memory->Timer <= 0.0f) { - // Timer expired → peek and shoot + // LOS check before peeking — no point shooting into a wall + const bool bHasLOS = UPS_AI_Behavior_Statics::HasLineOfSight( + Pawn->GetWorld(), Pawn, Target, 150.0f); + + if (!bHasLOS) + { + // No LOS → skip Peeking, force Advancing immediately to find a better position + UE_LOG(LogPS_AI_Behavior, Log, + TEXT("[%s] CoverShootCycle: no LOS to target from cover, skipping peek → advancing"), + *AIC->GetName()); + + Memory->SubState = EPS_AI_Behavior_CombatSubState::Advancing; + BB->SetValueAsEnum(PS_AI_Behavior_BB::CombatSubState, + static_cast(EPS_AI_Behavior_CombatSubState::Advancing)); + + // Release current cover point + APS_AI_Behavior_CoverPoint* OldPoint = + Cast(BB->GetValueAsObject(PS_AI_Behavior_BB::CoverPoint)); + if (OldPoint) + { + OldPoint->Release(Pawn); + } + + // Find a cover with better firing angle + const FVector NpcLoc = Pawn->GetActorLocation(); + const FVector ThreatLoc = Target->GetActorLocation(); + EPS_AI_Behavior_NPCType NPCType = EPS_AI_Behavior_NPCType::Any; + if (UPS_AI_Behavior_PersonalityComponent* Personality = AIC->GetPersonalityComponent()) + { + NPCType = Personality->GetNPCType(); + } + + float NewScore = -1.0f; + APS_AI_Behavior_CoverPoint* NewPoint = + FindAdvancingCover(GetWorld(), NpcLoc, ThreatLoc, NPCType, NewScore); + + if (NewPoint) + { + NewPoint->Claim(Pawn); + BB->SetValueAsObject(PS_AI_Behavior_BB::CoverPoint, NewPoint); + BB->SetValueAsVector(PS_AI_Behavior_BB::CoverLocation, NewPoint->GetActorLocation()); + + AIC->MoveToLocation( + NewPoint->GetActorLocation(), 80.0f, /*bStopOnOverlap=*/true, + /*bUsePathfinding=*/true, /*bProjectGoal=*/true, /*bCanStrafe=*/false); + Memory->bMoveRequested = true; + Memory->CycleCount = 0; + } + else + { + // No better cover → stay, retry next cycle + Memory->SubState = EPS_AI_Behavior_CombatSubState::AtCover; + Memory->PhaseDuration = FMath::RandRange(Memory->EffCoverMin, Memory->EffCoverMax); + Memory->Timer = Memory->PhaseDuration; + Memory->CycleCount = 0; + BB->SetValueAsEnum(PS_AI_Behavior_BB::CombatSubState, + static_cast(EPS_AI_Behavior_CombatSubState::AtCover)); + } + break; + } + + // Has LOS → peek and shoot normally Memory->SubState = EPS_AI_Behavior_CombatSubState::Peeking; Memory->PhaseDuration = FMath::RandRange(Memory->EffPeekMin, Memory->EffPeekMax); Memory->Timer = Memory->PhaseDuration; @@ -412,6 +478,18 @@ APS_AI_Behavior_CoverPoint* UPS_AI_Behavior_BTTask_CoverShootCycle::FindAdvancin Score += AdvancementBias * AdvanceRatio * 0.3f; } + // LOS bonus — strongly favor covers with clear line of sight to the target + { + const FVector TraceStart = Point->GetActorLocation() + FVector(0, 0, 150.0f); + FHitResult Hit; + FCollisionQueryParams Params(SCENE_QUERY_STAT(CoverLOS), true); + if (!const_cast(World)->LineTraceSingleByChannel( + Hit, TraceStart, ThreatLoc, ECC_Visibility, Params)) + { + Score += 0.3f; // Clear LOS from this cover + } + } + if (Score > OutScore) { OutScore = Score; 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 index e954fc5..e8441d5 100644 --- 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 @@ -16,6 +16,7 @@ #include "BehaviorTree/Blackboard/BlackboardKeyType_Int.h" #include "BehaviorTree/Blackboard/BlackboardKeyType_Object.h" #include "BehaviorTree/Blackboard/BlackboardKeyType_Vector.h" +#include "BehaviorTree/Blackboard/BlackboardKeyType_String.h" APS_AI_Behavior_AIController::APS_AI_Behavior_AIController() { @@ -171,6 +172,18 @@ void APS_AI_Behavior_AIController::SetupBlackboard() CombatSubStateEntry.EntryName = PS_AI_Behavior_BB::CombatSubState; CombatSubStateEntry.KeyType = NewObject(BlackboardAsset); BlackboardAsset->Keys.Add(CombatSubStateEntry); + + // LastKnownTargetPosition (vector — for LOS investigation) + FBlackboardEntry LastKnownEntry; + LastKnownEntry.EntryName = PS_AI_Behavior_BB::LastKnownTargetPosition; + LastKnownEntry.KeyType = NewObject(BlackboardAsset); + BlackboardAsset->Keys.Add(LastKnownEntry); + + // ThreatPawnName (debug: name of the Pawn behind ThreatActor) + FBlackboardEntry ThreatPawnNameEntry; + ThreatPawnNameEntry.EntryName = PS_AI_Behavior_BB::ThreatPawnName; + ThreatPawnNameEntry.KeyType = NewObject(BlackboardAsset); + BlackboardAsset->Keys.Add(ThreatPawnNameEntry); } UBlackboardComponent* RawBBComp = nullptr; @@ -306,8 +319,6 @@ void APS_AI_Behavior_AIController::SetGenericTeamId(const FGenericTeamId& InTeam 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) @@ -336,11 +347,38 @@ ETeamAttitude::Type APS_AI_Behavior_AIController::GetTeamAttitudeTowards(const A OtherTeam = TeamComp->GetGenericTeamId().GetId(); } } + + // 3) Fallback: check if Pawn implements IPS_AI_Behavior_Interface → derive TeamId from NPCType + if (OtherTeam == FGenericTeamId::NoTeam && OtherPawn->Implements()) + { + const EPS_AI_Behavior_NPCType OtherNPCType = + IPS_AI_Behavior_Interface::Execute_GetBehaviorNPCType(const_cast(OtherPawn)); + if (OtherNPCType != EPS_AI_Behavior_NPCType::Any) + { + OtherTeam = PS_AI_Behavior_Team::MakeTeamId(OtherNPCType); + } + } } - // NoTeam (255) → Neutral - if (TeamId == FGenericTeamId::NoTeam || OtherTeam == FGenericTeamId::NoTeam) + // ─── NoTeam handling ──────────────────────────────────────────────── + if (TeamId == FGenericTeamId::NoTeam) { + // We have no team → can't determine attitude + return ETeamAttitude::Neutral; + } + + if (OtherTeam == FGenericTeamId::NoTeam) + { + // NoTeam = not part of the behavior system (spectators, editor pawns, etc.) → always Neutral + static bool bLoggedNoTeamWarning = false; + if (!bLoggedNoTeamWarning) + { + bLoggedNoTeamWarning = true; + UE_LOG(LogPS_AI_Behavior, Warning, + TEXT("GetTeamAttitudeTowards: '%s' has NoTeam (no AIController team, no TeamComponent, no Interface). " + "Add UPS_AI_Behavior_TeamComponent to fix team detection. Treating as Neutral."), + OtherPawn ? *OtherPawn->GetName() : *Other.GetName()); + } return ETeamAttitude::Neutral; } 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 116624e..3ff1e08 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 @@ -13,7 +13,9 @@ #include "Perception/AISense_Hearing.h" #include "Perception/AISense_Damage.h" #include "GameFramework/Pawn.h" +#include "GameFramework/SpectatorPawn.h" #include "AIController.h" +#include "BehaviorTree/BlackboardComponent.h" UPS_AI_Behavior_PerceptionComponent::UPS_AI_Behavior_PerceptionComponent() { @@ -83,7 +85,7 @@ void UPS_AI_Behavior_PerceptionComponent::HandlePerceptionUpdated(const TArrayGetPawn()) continue; + // Skip spectator pawns (editor camera, spectating players, etc.) + if (OwningPawn->IsA()) continue; + // Get the threat target (PS_AimTargetActor or Pawn itself — for BB targeting) AActor* ThreatTarget = GetThreatTarget(OwningPawn); if (!ThreatTarget) ThreatTarget = OwningPawn; @@ -338,7 +343,7 @@ AActor* UPS_AI_Behavior_PerceptionComponent::GetHighestThreatActor( // Allied teams (Civilian ↔ Protector) → allow gunfire through if (AIC->GetGenericTeamId().GetId() == GetActorTeamId(OwningPawn)) { - UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Target '%s': SKIPPED (same team 0x%02X)"), + UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Target '%s': SKIPPED (same team 0x%02X)"), *Owner->GetName(), *OwningPawn->GetName(), GetActorTeamId(OwningPawn)); continue; } @@ -361,7 +366,7 @@ AActor* UPS_AI_Behavior_PerceptionComponent::GetHighestThreatActor( if (!bActorHasGunshot) { - UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Target '%s': SKIPPED (attitude=%d, not hostile, no gunshot, theirTeam=0x%02X, myTeam=0x%02X)"), + UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Target '%s': SKIPPED (attitude=%d, not hostile, no gunshot, theirTeam=0x%02X, myTeam=0x%02X)"), *Owner->GetName(), *OwningPawn->GetName(), static_cast(Attitude), GetActorTeamId(OwningPawn), AIC->GetGenericTeamId().GetId()); continue; // Not hostile, no gunshot — skip @@ -441,24 +446,27 @@ AActor* UPS_AI_Behavior_PerceptionComponent::GetHighestThreatActor( float CurrentTargetScore = -1.0f; if (AIC) { - if (UBlackboardComponent* BB = AIC->GetBlackboardComponent()) + if (const UBlackboardComponent* BB = AIC->GetBlackboardComponent()) { CurrentTarget = Cast(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor)); } } + APawn* BestOwningPawn = nullptr; + for (const auto& Pair : ScoreMap) { const FActorScore& Entry = Pair.Value; - UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Target '%s': type=%d, hostile=%d, score=%.0f"), - *Owner->GetName(), *Entry.Actor->GetName(), + UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Target '%s' (pawn='%s'): type=%d, hostile=%d, score=%.0f"), + *Owner->GetName(), *Entry.Actor->GetName(), *Pair.Key->GetName(), static_cast(Entry.ActorType), Entry.bIsHostile ? 1 : 0, Entry.Score); if (Entry.Score > BestScore) { BestScore = Entry.Score; BestThreat = Entry.Actor; + BestOwningPawn = Cast(Pair.Key); } // Track current target's score for persistence check @@ -476,15 +484,32 @@ AActor* UPS_AI_Behavior_PerceptionComponent::GetHighestThreatActor( { BestThreat = CurrentTarget; BestScore = CurrentTargetScore; + // Find the owning Pawn for the kept target + for (const auto& Pair : ScoreMap) + { + if (Pair.Value.Actor == CurrentTarget) { BestOwningPawn = Cast(Pair.Key); break; } + } } } + // Store the owning Pawn for callers (UpdateThreat reads this for debug display) + LastThreatOwningPawn = BestOwningPawn; + if (BestThreat) { - UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] → Best target: '%s' (score=%.0f%s)"), - *Owner->GetName(), *BestThreat->GetName(), BestScore, + UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] → Best target: '%s' (pawn='%s', score=%.0f%s)"), + *Owner->GetName(), *BestThreat->GetName(), + BestOwningPawn ? *BestOwningPawn->GetName() : TEXT("?"), + BestScore, (BestThreat == CurrentTarget) ? TEXT(" [kept]") : TEXT("")); } + else if (PerceivedActors.Num() > 0) + { + UE_LOG(LogPS_AI_Behavior, Warning, + TEXT("[%s] GetHighestThreatActor: perceived %d actors but ALL were filtered out (myTeam=0x%02X). Check TeamIds and attitude."), + *Owner->GetName(), PerceivedActors.Num(), + AIC ? AIC->GetGenericTeamId().GetId() : 255); + } return BestThreat; } @@ -512,6 +537,9 @@ float UPS_AI_Behavior_PerceptionComponent::CalculateThreatLevel() APawn* OwningPawn = FindOwningPawn(RawActor); if (!OwningPawn) continue; + // Skip spectator pawns + if (OwningPawn->IsA()) continue; + // Get the threat target for position-based calculations AActor* ThreatTarget = GetThreatTarget(OwningPawn); if (!ThreatTarget) ThreatTarget = OwningPawn; 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 index 23b9442..49f5e33 100644 --- 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 @@ -88,6 +88,12 @@ EPS_AI_Behavior_State UPS_AI_Behavior_PersonalityComponent::EvaluateReaction() c const float EffectiveFleeThresh = FleeThresh * (0.5f + Courage * 0.5f) * (1.5f - Caution * 0.5f); const float EffectiveAttackThresh = AttackThresh * (1.5f - Aggressivity * 0.5f); + UE_LOG(LogPS_AI_Behavior, Log, + TEXT("[%s] EvaluateReaction: threat=%.2f, aggr=%.2f, courage=%.2f, caution=%.2f | attackThresh=%.2f, fleeThresh=%.2f, alertThresh=%.2f"), + GetOwner() ? *GetOwner()->GetName() : TEXT("?"), + PerceivedThreatLevel, Aggressivity, Courage, Caution, + EffectiveAttackThresh, EffectiveFleeThresh, AlertThresh); + // Decision cascade if (PerceivedThreatLevel >= EffectiveFleeThresh && Courage < 0.7f) { @@ -231,10 +237,15 @@ void UPS_AI_Behavior_PersonalityComponent::DrawDebugInfo() const { DebugTeamId = AIC->GetGenericTeamId().GetId(); - // Get ThreatActor from Blackboard + // Get threat target name from Blackboard (prefer Pawn name for readability) if (const UBlackboardComponent* BB = AIC->GetBlackboardComponent()) { - if (AActor* ThreatActor = Cast(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor))) + const FString PawnName = BB->GetValueAsString(PS_AI_Behavior_BB::ThreatPawnName); + if (!PawnName.IsEmpty()) + { + ThreatActorName = PawnName; + } + else if (AActor* ThreatActor = Cast(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor))) { ThreatActorName = ThreatActor->GetName(); } @@ -274,11 +285,15 @@ void UPS_AI_Behavior_PersonalityComponent::DrawDebugInfo() const } // ─── Build text ───────────────────────────────────────────────────── + // Decode faction from TeamId + const uint8 DebugFaction = PS_AI_Behavior_Team::GetFaction(DebugTeamId); + const FString FactionStr = DebugFaction > 0 ? FString::Printf(TEXT(" F%d"), DebugFaction) : TEXT(""); + const FString DebugText = FString::Printf( - TEXT("%s [%s] Team:%d %s\n%s Threat:%.2f → %s%s"), + TEXT("%s [%s%s] %s\n%s Threat:%.2f → %s%s"), *NPCName, *UEnum::GetDisplayValueAsText(NPCType).ToString(), - DebugTeamId, + *FactionStr, bHostile ? TEXT("HOSTILE") : TEXT(""), *UEnum::GetDisplayValueAsText(CurrentState).ToString(), ThreatLevel, diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_Statics.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_Statics.cpp new file mode 100644 index 0000000..18a7a73 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_Statics.cpp @@ -0,0 +1,60 @@ +// Copyright Asterion. All Rights Reserved. + +#include "PS_AI_Behavior_Statics.h" +#include "Perception/AISense_Hearing.h" +#include "CollisionQueryParams.h" +#include "Engine/World.h" + +void UPS_AI_Behavior_Statics::ReportGunfire(UObject* WorldContext, FVector Location, + AActor* Shooter, bool bIsEnemyFire, float Loudness, float MaxRange) +{ + UWorld* World = GEngine ? GEngine->GetWorldFromContextObject(WorldContext, EGetWorldErrorMode::LogAndReturnNull) : nullptr; + if (!World) + { + UE_LOG(LogPS_AI_Behavior, Warning, TEXT("ReportGunfire: invalid WorldContext.")); + return; + } + + const FName Tag = bIsEnemyFire ? PS_AI_Behavior_Tags::EnemyFire : PS_AI_Behavior_Tags::PlayerFire; + + UAISense_Hearing::ReportNoiseEvent( + World, + Location, + Loudness, + Shooter, + MaxRange, + Tag); + + UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("ReportGunfire: at %s by '%s' (tag=%s, loudness=%.1f)"), + *Location.ToString(), + Shooter ? *Shooter->GetName() : TEXT("null"), + *Tag.ToString(), + Loudness); +} + +bool UPS_AI_Behavior_Statics::HasLineOfSight(const UWorld* World, const AActor* Source, + const AActor* Target, float EyeHeightOffset) +{ + if (!World || !Source || !Target) return false; + + const FVector TraceStart = Source->GetActorLocation() + FVector(0, 0, EyeHeightOffset); + const FVector TraceEnd = Target->GetActorLocation(); + + FHitResult Hit; + FCollisionQueryParams Params(SCENE_QUERY_STAT(BehaviorLOS), true); + Params.AddIgnoredActor(Source); + Params.AddIgnoredActor(Target); + + // Also ignore actors up the attachment chain (e.g. AimTarget → ChildActor → Character capsule) + // This handles cases where Owner/Instigator aren't set but the actor is physically attached. + for (const AActor* Cur = Target->GetAttachParentActor(); Cur; Cur = Cur->GetAttachParentActor()) + { + Params.AddIgnoredActor(Cur); + } + for (const AActor* Cur = Source->GetAttachParentActor(); Cur; Cur = Cur->GetAttachParentActor()) + { + Params.AddIgnoredActor(Cur); + } + + return !World->LineTraceSingleByChannel(Hit, TraceStart, TraceEnd, ECC_Visibility, Params); +} 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 index 74b2f50..e9bed96 100644 --- 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 @@ -22,7 +22,26 @@ class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTService_UpdateThreat : public UBTServ public: UPS_AI_Behavior_BTService_UpdateThreat(); + /** Seconds without LOS before writing LastKnownTargetPosition and starting investigation. */ + UPROPERTY(EditAnywhere, Category = "Threat|LOS", meta = (ClampMin = "5.0")) + float LOSLostTimeout = 20.0f; + + /** Eye height offset for LOS traces (cm above pawn origin). */ + UPROPERTY(EditAnywhere, Category = "Threat|LOS", meta = (ClampMin = "50.0", ClampMax = "200.0")) + float EyeHeightOffset = 150.0f; + protected: virtual void TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override; virtual FString GetStaticDescription() const override; + +private: + struct FUpdateThreatMemory + { + float TimeSinceLOS = 0.0f; + FVector LastVisiblePosition = FVector::ZeroVector; + bool bHadLOS = true; + bool bInvestigating = false; + }; + + virtual uint16 GetInstanceMemorySize() const override { return sizeof(FUpdateThreatMemory); } }; 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 index c252e3e..8d1d0bb 100644 --- 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 @@ -4,9 +4,12 @@ #include "CoreMinimal.h" #include "BehaviorTree/BTTaskNode.h" +#include "EnvironmentQuery/EnvQueryTypes.h" #include "PS_AI_Behavior_Definitions.h" #include "PS_AI_Behavior_BTTask_Attack.generated.h" +class UEnvQuery; + /** * BT Task: Move toward the threat actor and delegate combat to the Pawn. * @@ -37,6 +40,22 @@ public: UPROPERTY(EditAnywhere, Category = "Attack", meta = (ClampMin = "0.5", ClampMax = "5.0")) float RepositionCooldown = 1.5f; + /** Eye height offset for line-of-sight traces (cm above actor origin). */ + UPROPERTY(EditAnywhere, Category = "Attack|LOS", meta = (ClampMin = "50.0", ClampMax = "200.0")) + float EyeHeightOffset = 150.0f; + + /** How often to check LOS (seconds). Avoids per-frame traces. */ + UPROPERTY(EditAnywhere, Category = "Attack|LOS", meta = (ClampMin = "0.1", ClampMax = "1.0")) + float LOSCheckInterval = 0.2f; + + /** + * EQS query asset for finding a firing position with clear LOS. + * Compose in editor: OnCircle generator + LineOfSight filter + Distance tests. + * If null, fallback: advance directly toward the target. + */ + UPROPERTY(EditAnywhere, Category = "Attack|LOS") + TObjectPtr FiringPositionQuery; + protected: virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override; virtual void TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override; @@ -50,11 +69,24 @@ private: bool bMovingToTarget = false; bool bAttacking = false; // true when BehaviorStartAttack is active bool bInRange = false; // true when within MaxRange of target + bool bHasLOS = true; // true when line-of-sight to target is clear + bool bSeekingFiringPos = false; // true when moving to a flanking/firing position EPS_AI_Behavior_CombatType CombatType = EPS_AI_Behavior_CombatType::Melee; float MinRange = 100.0f; // backs away if closer float MaxRange = 300.0f; // advances if farther float RepositionTimer = 0.0f; + float LOSCheckTimer = 0.0f; // cooldown for LOS trace checks + bool bEQSQueryRunning = false; // true while an EQS query is in flight }; virtual uint16 GetInstanceMemorySize() const override { return sizeof(FAttackMemory); } + + /** Run the EQS firing position query. Falls back to advancing toward target if no query asset. */ + void RunFiringPositionQuery(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, + APawn* Pawn, AActor* Target); + + /** Callback when the EQS query completes. */ + void OnFiringPositionQueryFinished(TSharedPtr Result, + UBehaviorTreeComponent* OwnerComp, uint8* NodeMemory, + TWeakObjectPtr WeakTarget); }; 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 index 4d0559a..39aa4ce 100644 --- 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 @@ -101,6 +101,9 @@ protected: UPROPERTY(Transient) TObjectPtr PersonalityComp; + /** Shut down all AI systems when the NPC dies. Called automatically by SetBehaviorState(Dead). */ + void HandleDeath(); + private: /** Initialize Blackboard with required keys. */ void SetupBlackboard(); 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 index 47f7655..363ff1d 100644 --- 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 @@ -184,5 +184,7 @@ namespace PS_AI_Behavior_BB inline const FName HomeLocation = TEXT("HomeLocation"); inline const FName CurrentSpline = TEXT("CurrentSpline"); inline const FName SplineProgress = TEXT("SplineProgress"); - inline const FName CombatSubState = TEXT("CombatSubState"); + inline const FName CombatSubState = TEXT("CombatSubState"); + inline const FName LastKnownTargetPosition = TEXT("LastKnownTargetPosition"); + inline const FName ThreatPawnName = TEXT("ThreatPawnName"); // Debug: name of the owning Pawn behind ThreatActor } 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 index f3c0360..991b2dc 100644 --- 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 @@ -64,6 +64,13 @@ public: /** Extract an actor's TeamId (checking controller, then TeamComponent). Returns NoTeam if unresolvable. */ static uint8 GetActorTeamId(const AActor* Actor); + /** Walk Owner/Instigator chain to find the Pawn that owns a perceived actor (weapon, AimTarget, etc.). */ + static APawn* FindOwningPawn(AActor* Actor); + + /** The owning Pawn of the last selected ThreatActor (set by GetHighestThreatActor). */ + UPROPERTY(Transient) + TWeakObjectPtr LastThreatOwningPawn; + protected: virtual void BeginPlay() override; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_Statics.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_Statics.h new file mode 100644 index 0000000..e8641a2 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_Statics.h @@ -0,0 +1,56 @@ +// Copyright Asterion. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "Kismet/BlueprintFunctionLibrary.h" +#include "PS_AI_Behavior_Definitions.h" +#include "PS_AI_Behavior_Statics.generated.h" + +/** + * Static helpers for the PS AI Behavior plugin. + * Call from Blueprint or C++ to interact with the behavior system. + */ +UCLASS() +class PS_AI_BEHAVIOR_API UPS_AI_Behavior_Statics : public UBlueprintFunctionLibrary +{ + GENERATED_BODY() + +public: + + /** + * Report a gunfire noise event so all nearby NPCs react. + * Uses tag "EnemyFire" or "PlayerFire" depending on bIsEnemyFire. + * + * Compatible with existing ReportNoiseEvent calls using those tags. + * + * - Enemies (non-hostile) hearing this will become hostile toward the shooter. + * - Civilians hearing this will flee based on their personality traits. + * - Already-hostile enemies treat this as additional threat. + * + * @param WorldContext Any world-context object (self, weapon, etc.) + * @param Location World location of the gunshot. + * @param Shooter The actor who fired (Instigator for perception). + * @param bIsEnemyFire True = tag "EnemyFire", False = tag "PlayerFire". + * @param Loudness Noise loudness (default 1.0). Scales with HearingRange. + * @param MaxRange Override max hearing range (0 = use sense default). + */ + UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Perception", + meta = (WorldContext = "WorldContext", DefaultToSelf = "Shooter")) + static void ReportGunfire(UObject* WorldContext, FVector Location, + AActor* Shooter, bool bIsEnemyFire = false, + float Loudness = 1.0f, float MaxRange = 0.0f); + + /** + * Check if there is a clear line-of-sight between two actors. + * Traces from the source actor's eye height to the target actor's location. + * + * @param World The world to trace in. + * @param Source The actor looking (trace starts at Source + EyeHeightOffset). + * @param Target The actor being looked at (trace ends at Target location). + * @param EyeHeightOffset Height offset above Source's origin for the trace start (cm). + * @return True if line-of-sight is clear (no blocking geometry). + */ + static bool HasLineOfSight(const UWorld* World, const AActor* Source, const AActor* Target, + float EyeHeightOffset = 150.0f); +};