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 <noreply@anthropic.com>
This commit is contained in:
j.foucher 2026-03-28 11:03:27 +01:00
parent 2588883a1c
commit 65b86e2fbd
14 changed files with 734 additions and 80 deletions

View File

@ -23,13 +23,13 @@ void UPS_AI_Behavior_BTService_EvaluateReaction::TickNode(
Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds); Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner()); APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(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(); 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(); 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 ──────── // ─── Check for hostility change → update TeamId dynamically ────────
APawn* Pawn = AIC->GetPawn(); APawn* Pawn = AIC->GetPawn();

View File

@ -4,6 +4,7 @@
#include "PS_AI_Behavior_AIController.h" #include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_PerceptionComponent.h" #include "PS_AI_Behavior_PerceptionComponent.h"
#include "PS_AI_Behavior_PersonalityComponent.h" #include "PS_AI_Behavior_PersonalityComponent.h"
#include "PS_AI_Behavior_Statics.h"
#include "PS_AI_Behavior_Settings.h" #include "PS_AI_Behavior_Settings.h"
#include "PS_AI_Behavior_Definitions.h" #include "PS_AI_Behavior_Definitions.h"
#include "BehaviorTree/BlackboardComponent.h" #include "BehaviorTree/BlackboardComponent.h"
@ -65,19 +66,157 @@ void UPS_AI_Behavior_BTService_UpdateThreat::TickNode(
BB->SetValueAsFloat(PS_AI_Behavior_BB::ThreatLevel, FinalThreat); 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<FUpdateThreatMemory*>(NodeMemory);
AActor* ThreatActor = Perception->GetHighestThreatActor(); AActor* ThreatActor = Perception->GetHighestThreatActor();
AActor* CurrentBBTarget = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor));
if (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->SetValueAsObject(PS_AI_Behavior_BB::ThreatActor, ThreatActor);
// 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->SetValueAsVector(PS_AI_Behavior_BB::ThreatLocation, ThreatActor->GetActorLocation());
BB->ClearValue(PS_AI_Behavior_BB::LastKnownTargetPosition);
} }
else else
{ {
// No valid threat — clear BB and force threat to zero // 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::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 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); BB->ClearValue(PS_AI_Behavior_BB::ThreatLocation);
BB->SetValueAsFloat(PS_AI_Behavior_BB::ThreatLevel, 0.0f); }
} }
// Sync to PersonalityComponent // Sync to PersonalityComponent
@ -90,5 +229,7 @@ void UPS_AI_Behavior_BTService_UpdateThreat::TickNode(
FString UPS_AI_Behavior_BTService_UpdateThreat::GetStaticDescription() const 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);
} }

View File

@ -6,9 +6,12 @@
#include "PS_AI_Behavior_Definitions.h" #include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_PersonalityComponent.h" #include "PS_AI_Behavior_PersonalityComponent.h"
#include "PS_AI_Behavior_PersonalityProfile.h" #include "PS_AI_Behavior_PersonalityProfile.h"
#include "PS_AI_Behavior_Statics.h"
#include "BehaviorTree/BlackboardComponent.h" #include "BehaviorTree/BlackboardComponent.h"
#include "Navigation/PathFollowingComponent.h" #include "Navigation/PathFollowingComponent.h"
#include "NavigationSystem.h" #include "NavigationSystem.h"
#include "EnvironmentQuery/EnvQuery.h"
#include "EnvironmentQuery/EnvQueryManager.h"
UPS_AI_Behavior_BTTask_Attack::UPS_AI_Behavior_BTTask_Attack() 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) UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{ {
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner()); APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(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(); 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<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor)); AActor* Target = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor));
if (!Target) 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; return EBTNodeResult::Failed;
} }
@ -40,8 +53,11 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_Attack::ExecuteTask(
{ {
if (!IPS_AI_Behavior_Interface::Execute_IsTargetActorValid(Pawn, Target)) 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->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; return EBTNodeResult::Failed;
} }
} }
@ -51,12 +67,16 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_Attack::ExecuteTask(
Memory->bMovingToTarget = false; Memory->bMovingToTarget = false;
Memory->bAttacking = false; Memory->bAttacking = false;
Memory->bInRange = false; Memory->bInRange = false;
Memory->bHasLOS = true;
Memory->bSeekingFiringPos = false;
Memory->bEQSQueryRunning = false;
Memory->RepositionTimer = 0.0f; Memory->RepositionTimer = 0.0f;
Memory->LOSCheckTimer = 0.0f;
Memory->CombatType = EPS_AI_Behavior_CombatType::Melee; Memory->CombatType = EPS_AI_Behavior_CombatType::Melee;
Memory->MinRange = 100.0f; Memory->MinRange = 100.0f;
Memory->MaxRange = AttackMoveRadius; Memory->MaxRange = AttackMoveRadius;
// CombatType from interface (depends on weapon/pawn) // CombatType from interface
if (Pawn->Implements<UPS_AI_Behavior_Interface>()) if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{ {
Memory->CombatType = IPS_AI_Behavior_Interface::Execute_GetBehaviorCombatType(Pawn); 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) const float ApproachRange = (Memory->CombatType == EPS_AI_Behavior_CombatType::Melee)
? Memory->MinRange * 0.5f : (Memory->MinRange + Memory->MaxRange) * 0.5f; ? 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) if (DistToTarget <= Memory->MaxRange)
{ {
Memory->bInRange = true; Memory->bInRange = true;
if (Pawn->Implements<UPS_AI_Behavior_Interface>()) // Only start attacking if we have LOS (ranged) or always (melee)
if (Memory->bHasLOS && Pawn->Implements<UPS_AI_Behavior_Interface>())
{ {
IPS_AI_Behavior_Interface::Execute_BehaviorStartAttack(Pawn, Target); IPS_AI_Behavior_Interface::Execute_BehaviorStartAttack(Pawn, Target);
Memory->bAttacking = true; 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(), *AIC->GetName(), *Target->GetName(),
Memory->CombatType == EPS_AI_Behavior_CombatType::Ranged ? TEXT("Ranged") : TEXT("Melee"), Memory->CombatType == EPS_AI_Behavior_CombatType::Ranged ? TEXT("Ranged") : TEXT("Melee"),
Memory->MinRange, Memory->MaxRange, DistToTarget, Memory->MinRange, Memory->MaxRange, DistToTarget,
Memory->bInRange ? 1 : 0, Memory->bAttacking ? 1 : 0, Memory->bInRange ? 1 : 0, Memory->bHasLOS ? 1 : 0, Memory->bAttacking ? 1 : 0);
Pawn->Implements<UPS_AI_Behavior_Interface>() ? 1 : 0);
// Stay InProgress — the Decorator Observer Aborts will pull us out
return EBTNodeResult::InProgress; return EBTNodeResult::InProgress;
} }
@ -126,40 +152,56 @@ void UPS_AI_Behavior_BTTask_Attack::TickTask(
AActor* Target = BB ? Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor)) : nullptr; AActor* Target = BB ? Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor)) : nullptr;
if (!Target) 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); FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return; return;
} }
// Check if target is still valid (alive, not despawning) via interface
APawn* Pawn = AIC->GetPawn(); APawn* Pawn = AIC->GetPawn();
// Validate target
if (Pawn && Pawn->Implements<UPS_AI_Behavior_Interface>()) if (Pawn && Pawn->Implements<UPS_AI_Behavior_Interface>())
{ {
if (!IPS_AI_Behavior_Interface::Execute_IsTargetActorValid(Pawn, Target)) if (!IPS_AI_Behavior_Interface::Execute_IsTargetActorValid(Pawn, Target))
{ {
// Target is dead/invalid — clear BB, StopAttack called by OnTaskFinished
AIC->StopMovement(); AIC->StopMovement();
BB->ClearValue(PS_AI_Behavior_BB::ThreatActor); 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
FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded); AIC->SetBehaviorState(EPS_AI_Behavior_State::Alerted);
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return; return;
} }
} }
FAttackMemory* Memory = reinterpret_cast<FAttackMemory*>(NodeMemory); FAttackMemory* Memory = reinterpret_cast<FAttackMemory*>(NodeMemory);
// Tick reposition cooldown // Tick cooldowns
if (Memory->RepositionTimer > 0.0f) if (Memory->RepositionTimer > 0.0f)
{ {
Memory->RepositionTimer -= DeltaSeconds; 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 float DistToTarget = FVector::Dist(Pawn->GetActorLocation(), Target->GetActorLocation());
const bool bCanReposition = (Memory->RepositionTimer <= 0.0f); const bool bCanReposition = (Memory->RepositionTimer <= 0.0f);
// ─── MOVEMENT LOGIC ─────────────────────────────────────────────
if (Memory->CombatType == EPS_AI_Behavior_CombatType::Melee) 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) if (AIC->GetMoveStatus() == EPathFollowingStatus::Idle && DistToTarget > Memory->MinRange)
{ {
AIC->MoveToLocation( AIC->MoveToLocation(
@ -169,19 +211,52 @@ void UPS_AI_Behavior_BTTask_Attack::TickTask(
} }
else else
{ {
// ─── Ranged: maintain distance between MinRange and MaxRange ─ // ─── Ranged: LOS-aware positioning ──────────────────────────
const float MidRange = (Memory->MinRange + Memory->MaxRange) * 0.5f; if (Memory->bSeekingFiringPos)
{
// Waiting for firing position move to complete
if (AIC->GetMoveStatus() == EPathFollowingStatus::Idle) 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) 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 FVector AwayDir = (Pawn->GetActorLocation() - Target->GetActorLocation()).GetSafeNormal2D();
const float RetreatDist = MidRange - DistToTarget + 50.0f; const float RetreatDist = MidRange - DistToTarget + 50.0f;
const FVector RetreatPoint = Pawn->GetActorLocation() + AwayDir * RetreatDist; const FVector RetreatPoint = Pawn->GetActorLocation() + AwayDir * RetreatDist;
// Project to navmesh
UNavigationSystemV1* NavSys = FNavigationSystem::GetCurrent<UNavigationSystemV1>(GetWorld()); UNavigationSystemV1* NavSys = FNavigationSystem::GetCurrent<UNavigationSystemV1>(GetWorld());
if (NavSys) if (NavSys)
{ {
@ -191,53 +266,64 @@ void UPS_AI_Behavior_BTTask_Attack::TickTask(
AIC->MoveToLocation( AIC->MoveToLocation(
NavLoc.Location, 50.0f, /*bStopOnOverlap=*/true, NavLoc.Location, 50.0f, /*bStopOnOverlap=*/true,
/*bUsePathfinding=*/true, /*bProjectGoal=*/false, /*bCanStrafe=*/false); /*bUsePathfinding=*/true, /*bProjectGoal=*/false, /*bCanStrafe=*/false);
// Longer cooldown after retreat to prevent repeated backing
Memory->RepositionTimer = RepositionCooldown * 3.0f; 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) else if (DistToTarget > Memory->MaxRange)
{ {
// Too far — advance toward target to midpoint of band (no cooldown — chase aggressively) // Too far — advance
AIC->MoveToLocation( AIC->MoveToLocation(
Target->GetActorLocation(), MidRange, /*bStopOnOverlap=*/true, Target->GetActorLocation(), MidRange, /*bStopOnOverlap=*/true,
/*bUsePathfinding=*/true, /*bProjectGoal=*/true, /*bCanStrafe=*/false); /*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) ── // ─── Toggle attack based on range + LOS ─────────────────────────
// Enter range at MaxRange, leave range at MaxRange + buffer
const float EnterRange = Memory->MaxRange; 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 const bool bNowInRange = Memory->bInRange
? (DistToTarget <= LeaveRange) // already in range → need to go PAST LeaveRange to exit ? (DistToTarget <= LeaveRange)
: (DistToTarget <= EnterRange); // not in range → need to get WITHIN EnterRange to enter : (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; Memory->bInRange = true;
if (Pawn->Implements<UPS_AI_Behavior_Interface>() && !Memory->bAttacking) if (Pawn->Implements<UPS_AI_Behavior_Interface>() && !Memory->bAttacking)
{ {
IPS_AI_Behavior_Interface::Execute_BehaviorStartAttack(Pawn, Target); IPS_AI_Behavior_Interface::Execute_BehaviorStartAttack(Pawn, Target);
Memory->bAttacking = true; Memory->bAttacking = true;
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: in range (%.0f <= %.0f) — StartAttack on '%s'"), UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: in range + LOS → StartAttack on '%s'"),
*AIC->GetName(), DistToTarget, AttackRange, *Target->GetName()); *AIC->GetName(), *Target->GetName());
} }
else if (!Pawn->Implements<UPS_AI_Behavior_Interface>()) }
else if (bNowInRange && !bCanAttack && Memory->bAttacking)
{ {
UE_LOG(LogPS_AI_Behavior, Error, TEXT("[%s] Attack: in range but Pawn does NOT implement IPS_AI_Behavior_Interface — StartAttack cannot be called!"), // In range but lost LOS → stop attacking (stay in range though)
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
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()); *AIC->GetName());
} }
} }
else if (bNowInRange && bCanAttack && !Memory->bAttacking)
{
// In range, regained LOS → restart attacking
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
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) else if (!bNowInRange && Memory->bInRange)
{ {
// Left range → stop attacking // Left range → stop attacking
@ -246,8 +332,8 @@ void UPS_AI_Behavior_BTTask_Attack::TickTask(
{ {
IPS_AI_Behavior_Interface::Execute_BehaviorStopAttack(Pawn); IPS_AI_Behavior_Interface::Execute_BehaviorStopAttack(Pawn);
Memory->bAttacking = false; Memory->bAttacking = false;
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: out of range (%.0f > %.0f) — StopAttack"), UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: out of range StopAttack"),
*AIC->GetName(), DistToTarget, AttackRange); *AIC->GetName());
} }
} }
} }
@ -260,14 +346,12 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_Attack::AbortTask(
{ {
AIC->StopMovement(); AIC->StopMovement();
} }
// StopAttack is called by OnTaskFinished (covers all exit paths)
return EBTNodeResult::Aborted; return EBTNodeResult::Aborted;
} }
void UPS_AI_Behavior_BTTask_Attack::OnTaskFinished( void UPS_AI_Behavior_BTTask_Attack::OnTaskFinished(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, EBTNodeResult::Type TaskResult) 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<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner()); APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (AIC) if (AIC)
{ {
@ -281,8 +365,99 @@ void UPS_AI_Behavior_BTTask_Attack::OnTaskFinished(
Super::OnTaskFinished(OwnerComp, NodeMemory, TaskResult); 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<FAttackMemory*>(NodeMemory);
if (!FiringPositionQuery)
{
// No EQS query configured → fallback: advance toward target
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(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<AActor> 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<FEnvQueryResult> Result,
UBehaviorTreeComponent* OwnerComp, uint8* NodeMemory,
TWeakObjectPtr<AActor> WeakTarget)
{
if (!OwnerComp || !NodeMemory) return;
FAttackMemory* Memory = reinterpret_cast<FAttackMemory*>(NodeMemory);
Memory->bEQSQueryRunning = false;
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(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 FString UPS_AI_Behavior_BTTask_Attack::GetStaticDescription() const
{ {
return FString::Printf(TEXT("Range-aware attack.\nFallback radius: %.0fcm\nReposition cooldown: %.1fs"), return FString::Printf(TEXT("LOS-aware attack.\nFallback radius: %.0fcm\nLOS check: every %.1fs\nEQS query: %s"),
AttackMoveRadius, RepositionCooldown); AttackMoveRadius, LOSCheckInterval,
FiringPositionQuery ? *FiringPositionQuery->GetName() : TEXT("None (fallback)"));
} }

View File

@ -7,8 +7,10 @@
#include "PS_AI_Behavior_PersonalityComponent.h" #include "PS_AI_Behavior_PersonalityComponent.h"
#include "PS_AI_Behavior_PersonalityProfile.h" #include "PS_AI_Behavior_PersonalityProfile.h"
#include "PS_AI_Behavior_Definitions.h" #include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_Statics.h"
#include "BehaviorTree/BlackboardComponent.h" #include "BehaviorTree/BlackboardComponent.h"
#include "Navigation/PathFollowingComponent.h" #include "Navigation/PathFollowingComponent.h"
#include "CollisionQueryParams.h"
#include "EngineUtils.h" #include "EngineUtils.h"
UPS_AI_Behavior_BTTask_CoverShootCycle::UPS_AI_Behavior_BTTask_CoverShootCycle() UPS_AI_Behavior_BTTask_CoverShootCycle::UPS_AI_Behavior_BTTask_CoverShootCycle()
@ -137,6 +139,8 @@ void UPS_AI_Behavior_BTTask_CoverShootCycle::TickTask(
AActor* Target = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor)); AActor* Target = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor));
if (!Target) if (!Target)
{ {
// De-escalate so decorator can re-trigger when threat returns
AIC->SetBehaviorState(EPS_AI_Behavior_State::Alerted);
FinishLatentTask(OwnerComp, EBTNodeResult::Failed); FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return; return;
} }
@ -149,8 +153,9 @@ void UPS_AI_Behavior_BTTask_CoverShootCycle::TickTask(
{ {
AIC->StopMovement(); AIC->StopMovement();
BB->ClearValue(PS_AI_Behavior_BB::ThreatActor); 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
FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded); AIC->SetBehaviorState(EPS_AI_Behavior_State::Alerted);
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return; return;
} }
} }
@ -185,7 +190,68 @@ void UPS_AI_Behavior_BTTask_CoverShootCycle::TickTask(
Memory->Timer -= DeltaSeconds; Memory->Timer -= DeltaSeconds;
if (Memory->Timer <= 0.0f) 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<uint8>(EPS_AI_Behavior_CombatSubState::Advancing));
// Release current cover point
APS_AI_Behavior_CoverPoint* OldPoint =
Cast<APS_AI_Behavior_CoverPoint>(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<uint8>(EPS_AI_Behavior_CombatSubState::AtCover));
}
break;
}
// Has LOS → peek and shoot normally
Memory->SubState = EPS_AI_Behavior_CombatSubState::Peeking; Memory->SubState = EPS_AI_Behavior_CombatSubState::Peeking;
Memory->PhaseDuration = FMath::RandRange(Memory->EffPeekMin, Memory->EffPeekMax); Memory->PhaseDuration = FMath::RandRange(Memory->EffPeekMin, Memory->EffPeekMax);
Memory->Timer = Memory->PhaseDuration; Memory->Timer = Memory->PhaseDuration;
@ -412,6 +478,18 @@ APS_AI_Behavior_CoverPoint* UPS_AI_Behavior_BTTask_CoverShootCycle::FindAdvancin
Score += AdvancementBias * AdvanceRatio * 0.3f; 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<UWorld*>(World)->LineTraceSingleByChannel(
Hit, TraceStart, ThreatLoc, ECC_Visibility, Params))
{
Score += 0.3f; // Clear LOS from this cover
}
}
if (Score > OutScore) if (Score > OutScore)
{ {
OutScore = Score; OutScore = Score;

View File

@ -16,6 +16,7 @@
#include "BehaviorTree/Blackboard/BlackboardKeyType_Int.h" #include "BehaviorTree/Blackboard/BlackboardKeyType_Int.h"
#include "BehaviorTree/Blackboard/BlackboardKeyType_Object.h" #include "BehaviorTree/Blackboard/BlackboardKeyType_Object.h"
#include "BehaviorTree/Blackboard/BlackboardKeyType_Vector.h" #include "BehaviorTree/Blackboard/BlackboardKeyType_Vector.h"
#include "BehaviorTree/Blackboard/BlackboardKeyType_String.h"
APS_AI_Behavior_AIController::APS_AI_Behavior_AIController() 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.EntryName = PS_AI_Behavior_BB::CombatSubState;
CombatSubStateEntry.KeyType = NewObject<UBlackboardKeyType_Enum>(BlackboardAsset); CombatSubStateEntry.KeyType = NewObject<UBlackboardKeyType_Enum>(BlackboardAsset);
BlackboardAsset->Keys.Add(CombatSubStateEntry); BlackboardAsset->Keys.Add(CombatSubStateEntry);
// LastKnownTargetPosition (vector — for LOS investigation)
FBlackboardEntry LastKnownEntry;
LastKnownEntry.EntryName = PS_AI_Behavior_BB::LastKnownTargetPosition;
LastKnownEntry.KeyType = NewObject<UBlackboardKeyType_Vector>(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<UBlackboardKeyType_String>(BlackboardAsset);
BlackboardAsset->Keys.Add(ThreatPawnNameEntry);
} }
UBlackboardComponent* RawBBComp = nullptr; 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 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 // Try to get the other actor's team ID
const APawn* OtherPawn = Cast<APawn>(&Other); const APawn* OtherPawn = Cast<APawn>(&Other);
if (!OtherPawn) if (!OtherPawn)
@ -336,11 +347,38 @@ ETeamAttitude::Type APS_AI_Behavior_AIController::GetTeamAttitudeTowards(const A
OtherTeam = TeamComp->GetGenericTeamId().GetId(); OtherTeam = TeamComp->GetGenericTeamId().GetId();
} }
} }
// 3) Fallback: check if Pawn implements IPS_AI_Behavior_Interface → derive TeamId from NPCType
if (OtherTeam == FGenericTeamId::NoTeam && OtherPawn->Implements<UPS_AI_Behavior_Interface>())
{
const EPS_AI_Behavior_NPCType OtherNPCType =
IPS_AI_Behavior_Interface::Execute_GetBehaviorNPCType(const_cast<APawn*>(OtherPawn));
if (OtherNPCType != EPS_AI_Behavior_NPCType::Any)
{
OtherTeam = PS_AI_Behavior_Team::MakeTeamId(OtherNPCType);
}
}
} }
// NoTeam (255) → Neutral // ─── NoTeam handling ────────────────────────────────────────────────
if (TeamId == FGenericTeamId::NoTeam || OtherTeam == FGenericTeamId::NoTeam) 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; return ETeamAttitude::Neutral;
} }

View File

@ -13,7 +13,9 @@
#include "Perception/AISense_Hearing.h" #include "Perception/AISense_Hearing.h"
#include "Perception/AISense_Damage.h" #include "Perception/AISense_Damage.h"
#include "GameFramework/Pawn.h" #include "GameFramework/Pawn.h"
#include "GameFramework/SpectatorPawn.h"
#include "AIController.h" #include "AIController.h"
#include "BehaviorTree/BlackboardComponent.h"
UPS_AI_Behavior_PerceptionComponent::UPS_AI_Behavior_PerceptionComponent() UPS_AI_Behavior_PerceptionComponent::UPS_AI_Behavior_PerceptionComponent()
{ {
@ -83,7 +85,7 @@ void UPS_AI_Behavior_PerceptionComponent::HandlePerceptionUpdated(const TArray<A
* Returns the Pawn itself used for team/attitude checks. * Returns the Pawn itself used for team/attitude checks.
* Returns nullptr if no Pawn can be found. * Returns nullptr if no Pawn can be found.
*/ */
static APawn* FindOwningPawn(AActor* Actor) APawn* UPS_AI_Behavior_PerceptionComponent::FindOwningPawn(AActor* Actor)
{ {
if (!Actor) return nullptr; if (!Actor) return nullptr;
@ -318,6 +320,9 @@ AActor* UPS_AI_Behavior_PerceptionComponent::GetHighestThreatActor(
if (OwningPawn == Owner) continue; if (OwningPawn == Owner) continue;
if (AIC && OwningPawn == AIC->GetPawn()) continue; if (AIC && OwningPawn == AIC->GetPawn()) continue;
// Skip spectator pawns (editor camera, spectating players, etc.)
if (OwningPawn->IsA<ASpectatorPawn>()) continue;
// Get the threat target (PS_AimTargetActor or Pawn itself — for BB targeting) // Get the threat target (PS_AimTargetActor or Pawn itself — for BB targeting)
AActor* ThreatTarget = GetThreatTarget(OwningPawn); AActor* ThreatTarget = GetThreatTarget(OwningPawn);
if (!ThreatTarget) ThreatTarget = OwningPawn; if (!ThreatTarget) ThreatTarget = OwningPawn;
@ -338,7 +343,7 @@ AActor* UPS_AI_Behavior_PerceptionComponent::GetHighestThreatActor(
// Allied teams (Civilian ↔ Protector) → allow gunfire through // Allied teams (Civilian ↔ Protector) → allow gunfire through
if (AIC->GetGenericTeamId().GetId() == GetActorTeamId(OwningPawn)) 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)); *Owner->GetName(), *OwningPawn->GetName(), GetActorTeamId(OwningPawn));
continue; continue;
} }
@ -361,7 +366,7 @@ AActor* UPS_AI_Behavior_PerceptionComponent::GetHighestThreatActor(
if (!bActorHasGunshot) 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<int32>(Attitude), *Owner->GetName(), *OwningPawn->GetName(), static_cast<int32>(Attitude),
GetActorTeamId(OwningPawn), AIC->GetGenericTeamId().GetId()); GetActorTeamId(OwningPawn), AIC->GetGenericTeamId().GetId());
continue; // Not hostile, no gunshot — skip continue; // Not hostile, no gunshot — skip
@ -441,24 +446,27 @@ AActor* UPS_AI_Behavior_PerceptionComponent::GetHighestThreatActor(
float CurrentTargetScore = -1.0f; float CurrentTargetScore = -1.0f;
if (AIC) if (AIC)
{ {
if (UBlackboardComponent* BB = AIC->GetBlackboardComponent()) if (const UBlackboardComponent* BB = AIC->GetBlackboardComponent())
{ {
CurrentTarget = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor)); CurrentTarget = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor));
} }
} }
APawn* BestOwningPawn = nullptr;
for (const auto& Pair : ScoreMap) for (const auto& Pair : ScoreMap)
{ {
const FActorScore& Entry = Pair.Value; const FActorScore& Entry = Pair.Value;
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Target '%s': type=%d, hostile=%d, score=%.0f"), UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Target '%s' (pawn='%s'): type=%d, hostile=%d, score=%.0f"),
*Owner->GetName(), *Entry.Actor->GetName(), *Owner->GetName(), *Entry.Actor->GetName(), *Pair.Key->GetName(),
static_cast<int32>(Entry.ActorType), Entry.bIsHostile ? 1 : 0, Entry.Score); static_cast<int32>(Entry.ActorType), Entry.bIsHostile ? 1 : 0, Entry.Score);
if (Entry.Score > BestScore) if (Entry.Score > BestScore)
{ {
BestScore = Entry.Score; BestScore = Entry.Score;
BestThreat = Entry.Actor; BestThreat = Entry.Actor;
BestOwningPawn = Cast<APawn>(Pair.Key);
} }
// Track current target's score for persistence check // Track current target's score for persistence check
@ -476,15 +484,32 @@ AActor* UPS_AI_Behavior_PerceptionComponent::GetHighestThreatActor(
{ {
BestThreat = CurrentTarget; BestThreat = CurrentTarget;
BestScore = CurrentTargetScore; BestScore = CurrentTargetScore;
// Find the owning Pawn for the kept target
for (const auto& Pair : ScoreMap)
{
if (Pair.Value.Actor == CurrentTarget) { BestOwningPawn = Cast<APawn>(Pair.Key); break; }
}
} }
} }
// Store the owning Pawn for callers (UpdateThreat reads this for debug display)
LastThreatOwningPawn = BestOwningPawn;
if (BestThreat) if (BestThreat)
{ {
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] → Best target: '%s' (score=%.0f%s)"), UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] → Best target: '%s' (pawn='%s', score=%.0f%s)"),
*Owner->GetName(), *BestThreat->GetName(), BestScore, *Owner->GetName(), *BestThreat->GetName(),
BestOwningPawn ? *BestOwningPawn->GetName() : TEXT("?"),
BestScore,
(BestThreat == CurrentTarget) ? TEXT(" [kept]") : TEXT("")); (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; return BestThreat;
} }
@ -512,6 +537,9 @@ float UPS_AI_Behavior_PerceptionComponent::CalculateThreatLevel()
APawn* OwningPawn = FindOwningPawn(RawActor); APawn* OwningPawn = FindOwningPawn(RawActor);
if (!OwningPawn) continue; if (!OwningPawn) continue;
// Skip spectator pawns
if (OwningPawn->IsA<ASpectatorPawn>()) continue;
// Get the threat target for position-based calculations // Get the threat target for position-based calculations
AActor* ThreatTarget = GetThreatTarget(OwningPawn); AActor* ThreatTarget = GetThreatTarget(OwningPawn);
if (!ThreatTarget) ThreatTarget = OwningPawn; if (!ThreatTarget) ThreatTarget = OwningPawn;

View File

@ -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 EffectiveFleeThresh = FleeThresh * (0.5f + Courage * 0.5f) * (1.5f - Caution * 0.5f);
const float EffectiveAttackThresh = AttackThresh * (1.5f - Aggressivity * 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 // Decision cascade
if (PerceivedThreatLevel >= EffectiveFleeThresh && Courage < 0.7f) if (PerceivedThreatLevel >= EffectiveFleeThresh && Courage < 0.7f)
{ {
@ -231,10 +237,15 @@ void UPS_AI_Behavior_PersonalityComponent::DrawDebugInfo() const
{ {
DebugTeamId = AIC->GetGenericTeamId().GetId(); 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 (const UBlackboardComponent* BB = AIC->GetBlackboardComponent())
{ {
if (AActor* ThreatActor = Cast<AActor>(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<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor)))
{ {
ThreatActorName = ThreatActor->GetName(); ThreatActorName = ThreatActor->GetName();
} }
@ -274,11 +285,15 @@ void UPS_AI_Behavior_PersonalityComponent::DrawDebugInfo() const
} }
// ─── Build text ───────────────────────────────────────────────────── // ─── 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( 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, *NPCName,
*UEnum::GetDisplayValueAsText(NPCType).ToString(), *UEnum::GetDisplayValueAsText(NPCType).ToString(),
DebugTeamId, *FactionStr,
bHostile ? TEXT("HOSTILE") : TEXT(""), bHostile ? TEXT("HOSTILE") : TEXT(""),
*UEnum::GetDisplayValueAsText(CurrentState).ToString(), *UEnum::GetDisplayValueAsText(CurrentState).ToString(),
ThreatLevel, ThreatLevel,

View File

@ -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);
}

View File

@ -22,7 +22,26 @@ class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTService_UpdateThreat : public UBTServ
public: public:
UPS_AI_Behavior_BTService_UpdateThreat(); 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: protected:
virtual void TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override; virtual void TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
virtual FString GetStaticDescription() const 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); }
}; };

View File

@ -4,9 +4,12 @@
#include "CoreMinimal.h" #include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h" #include "BehaviorTree/BTTaskNode.h"
#include "EnvironmentQuery/EnvQueryTypes.h"
#include "PS_AI_Behavior_Definitions.h" #include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_BTTask_Attack.generated.h" #include "PS_AI_Behavior_BTTask_Attack.generated.h"
class UEnvQuery;
/** /**
* BT Task: Move toward the threat actor and delegate combat to the Pawn. * 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")) UPROPERTY(EditAnywhere, Category = "Attack", meta = (ClampMin = "0.5", ClampMax = "5.0"))
float RepositionCooldown = 1.5f; 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<UEnvQuery> FiringPositionQuery;
protected: protected:
virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override; virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
virtual void TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override; virtual void TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
@ -50,11 +69,24 @@ private:
bool bMovingToTarget = false; bool bMovingToTarget = false;
bool bAttacking = false; // true when BehaviorStartAttack is active bool bAttacking = false; // true when BehaviorStartAttack is active
bool bInRange = false; // true when within MaxRange of target 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; EPS_AI_Behavior_CombatType CombatType = EPS_AI_Behavior_CombatType::Melee;
float MinRange = 100.0f; // backs away if closer float MinRange = 100.0f; // backs away if closer
float MaxRange = 300.0f; // advances if farther float MaxRange = 300.0f; // advances if farther
float RepositionTimer = 0.0f; 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); } 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<FEnvQueryResult> Result,
UBehaviorTreeComponent* OwnerComp, uint8* NodeMemory,
TWeakObjectPtr<AActor> WeakTarget);
}; };

View File

@ -101,6 +101,9 @@ protected:
UPROPERTY(Transient) UPROPERTY(Transient)
TObjectPtr<UPS_AI_Behavior_PersonalityComponent> PersonalityComp; TObjectPtr<UPS_AI_Behavior_PersonalityComponent> PersonalityComp;
/** Shut down all AI systems when the NPC dies. Called automatically by SetBehaviorState(Dead). */
void HandleDeath();
private: private:
/** Initialize Blackboard with required keys. */ /** Initialize Blackboard with required keys. */
void SetupBlackboard(); void SetupBlackboard();

View File

@ -185,4 +185,6 @@ namespace PS_AI_Behavior_BB
inline const FName CurrentSpline = TEXT("CurrentSpline"); inline const FName CurrentSpline = TEXT("CurrentSpline");
inline const FName SplineProgress = TEXT("SplineProgress"); 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
} }

View File

@ -64,6 +64,13 @@ public:
/** Extract an actor's TeamId (checking controller, then TeamComponent). Returns NoTeam if unresolvable. */ /** Extract an actor's TeamId (checking controller, then TeamComponent). Returns NoTeam if unresolvable. */
static uint8 GetActorTeamId(const AActor* Actor); 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<APawn> LastThreatOwningPawn;
protected: protected:
virtual void BeginPlay() override; virtual void BeginPlay() override;

View File

@ -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);
};