Cover system Phase 8: EQS filter fix, LOS lateral spread, omniscient threat awareness, debug improvements

- Fix EQS SetScore filter bug: pass FloatValueMin/Max instead of hardcoded 0/1 (filters were ignored)
- Add LateralSpread to LineOfSight EQS test: multiple traces for wider LOS check
- Add bDrawDebug to CoverQuality and LineOfSight EQS tests
- Ignore ThreatActor collision in EQS traces (AimTargetActor was blocking its own LOS)
- Add navmesh projection in EQSContext_CoverLocation for cover points inside geometry
- Add omniscient awareness: enemies detect top-priority targets (Protectors) within sight radius without perception cone
- Suppress target switching during TakingCover state to prevent cover invalidation
- Fix flanking check: trace at chest height instead of feet
- Add debug visualization for EQS fallback paths (NO REFINE, NO FIRE POS, IN PLACE)
- Clean up diagnostic logs: verbose for per-trace EQS details, log level for summaries

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
j.foucher 2026-03-31 16:30:40 +02:00
parent 86d3ae118d
commit 44f7e860aa
26 changed files with 288 additions and 50 deletions

View File

@ -76,17 +76,31 @@ void UPS_AI_Behavior_BTService_UpdateThreat::TickNode(
if (ThreatActor)
{
// Target switched by perception (higher score) → reset LOS tracking
// Target switched by perception (higher score)
if (ThreatActor != CurrentBBTarget)
{
Memory->TimeSinceLOS = 0.0f;
Memory->bHadLOS = true;
Memory->bInvestigating = false;
Memory->LastVisiblePosition = ThreatActor->GetActorLocation();
BB->ClearValue(PS_AI_Behavior_BB::LastKnownTargetPosition);
const EPS_AI_Behavior_State CurrentState = AIC->GetBehaviorState();
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] UpdateThreat: target switched to '%s', LOS tracking reset"),
*AIC->GetName(), *ThreatActor->GetName());
// During TakingCover: don't switch target — cover is positioned against current threat.
// The flanking check will handle cover invalidation if needed.
if (CurrentBBTarget && CurrentState == EPS_AI_Behavior_State::TakingCover)
{
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] UpdateThreat: suppressed target switch to '%s' — in TakingCover (keeping '%s')"),
*AIC->GetName(), *ThreatActor->GetName(), *CurrentBBTarget->GetName());
ThreatActor = CurrentBBTarget; // Keep current target
}
else
{
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);

View File

@ -220,8 +220,15 @@ void UPS_AI_Behavior_BTTask_CoverShootCycle::TickTask(
if (Memory->LOSCheckTimer <= 0.0f)
{
Memory->LOSCheckTimer = 0.5f;
const bool bThreatHasLOS = UPS_AI_Behavior_Statics::HasLineOfSight(
Pawn->GetWorld(), Target, Pawn, 60.0f);
// Check if threat can see NPC at chest height (not feet)
const FVector ThreatEye = Target->GetActorLocation() + FVector(0, 0, 60.0f);
const FVector NpcChest = Pawn->GetActorLocation() + FVector(0, 0, 60.0f);
FHitResult FlankHit;
FCollisionQueryParams FlankParams(SCENE_QUERY_STAT(FlankLOS), true);
FlankParams.AddIgnoredActor(Target);
FlankParams.AddIgnoredActor(Pawn);
const bool bThreatHasLOS = !Pawn->GetWorld()->LineTraceSingleByChannel(
FlankHit, ThreatEye, NpcChest, ECC_Visibility, FlankParams);
if (bThreatHasLOS)
{
UE_LOG(LogPS_AI_Behavior, Log,
@ -469,6 +476,14 @@ void UPS_AI_Behavior_BTTask_CoverShootCycle::TickTask(
DrawDebugString(World, Memory->FiringPosition + FVector(0, 0, 50.0f),
TEXT("FIRE"), nullptr, FColor::Red, 0.0f, true);
}
else if (Memory->SubState == EPS_AI_Behavior_CombatSubState::Peeking)
{
// No firing position — shooting in place
DrawDebugSphere(World, Pawn->GetActorLocation() + FVector(0, 0, 30.0f),
15.0f, 6, FColor::Orange, false, 0.0f);
DrawDebugString(World, Pawn->GetActorLocation() + FVector(0, 0, 50.0f),
TEXT("IN PLACE"), nullptr, FColor::Orange, 0.0f, true);
}
}
#endif
}
@ -687,8 +702,23 @@ void UPS_AI_Behavior_BTTask_CoverShootCycle::OnFiringPositionQueryFinished(
// EQS failed → fallback: peek in place if LOS exists
Memory->bHasFiringPosition = false;
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] CoverShootCycle: firing position EQS failed, peeking in place"),
*AIC->GetName());
const int32 NumItems = Result.IsValid() ? Result->Items.Num() : 0;
const FVector CoverLoc = BB->GetValueAsVector(PS_AI_Behavior_BB::CoverLocation);
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] CoverShootCycle: firing position EQS FAILED at cover %s — items=%d"),
*AIC->GetName(), *CoverLoc.ToString(), NumItems);
#if ENABLE_DRAW_DEBUG
if (bDebugDraw)
{
UWorld* World = Pawn->GetWorld();
const FVector PawnLoc = Pawn->GetActorLocation();
DrawDebugSphere(World, PawnLoc + FVector(0, 0, 30.0f),
20.0f, 8, FColor::Orange, false, 5.0f);
DrawDebugString(World, PawnLoc + FVector(0, 0, 55.0f),
TEXT("NO FIRE POS"), nullptr, FColor::Orange, 5.0f, true);
}
#endif
if (Target)
{

View File

@ -440,18 +440,8 @@ void UPS_AI_Behavior_BTTask_FindCover::OnRefinementQueryFinished(
// Log all items with their scores for debugging
const int32 NumSurvived = Result->Items.Num();
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] FindCover: EQS refinement — %d items survived filters:"),
*AIC->GetName(), NumSurvived);
for (int32 i = 0; i < NumSurvived; ++i)
{
if (!Result->Items[i].IsValid()) continue;
const FVector Loc = Result->GetItemAsLocation(i);
const float ItemScore = Result->GetItemScore(i);
UE_LOG(LogPS_AI_Behavior, Warning, TEXT(" [%d] %s score=%.3f %s"),
i, *Loc.ToString(), ItemScore, (i == 0) ? TEXT("← CHOSEN") : TEXT(""));
}
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] FindCover: refined %s → %s"),
*AIC->GetName(), *OriginalCoverPos.ToString(), *FinalPos.ToString());
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] FindCover: EQS refinement — %d items survived, refined %s → %s"),
*AIC->GetName(), NumSurvived, *OriginalCoverPos.ToString(), *FinalPos.ToString());
#if ENABLE_DRAW_DEBUG
if (bDebugDraw)
@ -487,8 +477,26 @@ void UPS_AI_Behavior_BTTask_FindCover::OnRefinementQueryFinished(
}
else
{
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] FindCover: EQS refinement failed, using original cover position"),
*AIC->GetName());
const int32 NumItems = Result.IsValid() ? Result->Items.Num() : 0;
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] FindCover: EQS refinement FAILED — items=%d, valid=%s"),
*AIC->GetName(), NumItems,
Result.IsValid() ? TEXT("yes") : TEXT("no"));
#if ENABLE_DRAW_DEBUG
if (bDebugDraw)
{
UWorld* World = AIC->GetWorld();
if (World)
{
// Show that refinement failed — orange box at original position
DrawDebugBox(World, OriginalCoverPos + FVector(0, 0, 50.0f),
FVector(20.0f), FColor::Orange, false, 5.0f, 0, 2.5f);
DrawDebugString(World, OriginalCoverPos + FVector(0, 0, 75.0f),
TEXT("NO REFINE"), nullptr, FColor::Orange, 5.0f, true);
}
}
#endif
}
// Update BB with refined position

View File

@ -6,6 +6,7 @@
#include "EnvironmentQuery/EnvQueryTypes.h"
#include "EnvironmentQuery/Items/EnvQueryItemType_Point.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "NavigationSystem.h"
void UPS_AI_Behavior_EQSContext_CoverLocation::ProvideContext(
FEnvQueryInstance& QueryInstance, FEnvQueryContextData& ContextData) const
@ -22,19 +23,39 @@ void UPS_AI_Behavior_EQSContext_CoverLocation::ProvideContext(
UBlackboardComponent* BB = AIC->GetBlackboardComponent();
if (!BB) return;
// Prefer the original CoverPoint actor location (center of cover geometry)
// over the refined CoverLocation (which may be offset behind cover)
const AActor* CoverPoint = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::CoverPoint));
if (CoverPoint)
// Use CoverPoint actor location (center of cover geometry) for circle generation.
// Fallback to CoverLocation vector if no actor.
FVector RawLoc = FVector::ZeroVector;
const AActor* CoverPointActor = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::CoverPoint));
if (CoverPointActor)
{
UEnvQueryItemType_Point::SetContextHelper(ContextData, CoverPoint->GetActorLocation());
return;
RawLoc = CoverPointActor->GetActorLocation();
}
else
{
RawLoc = BB->GetValueAsVector(PS_AI_Behavior_BB::CoverLocation);
}
// Fallback to CoverLocation vector (procedural cover, no actor)
const FVector CoverLoc = BB->GetValueAsVector(PS_AI_Behavior_BB::CoverLocation);
if (!CoverLoc.IsZero())
if (RawLoc.IsZero()) return;
// Project to navmesh so the EQS generator works correctly
// (cover points inside geometry or above navmesh need projection)
FVector FinalLoc = RawLoc;
UNavigationSystemV1* NavSys = FNavigationSystem::GetCurrent<UNavigationSystemV1>(QuerierPawn->GetWorld());
if (NavSys)
{
UEnvQueryItemType_Point::SetContextHelper(ContextData, CoverLoc);
FNavLocation NavLoc;
if (NavSys->ProjectPointToNavigation(RawLoc, NavLoc, FVector(200.0f, 200.0f, 200.0f)))
{
FinalLoc = NavLoc.Location;
}
}
UE_LOG(LogPS_AI_Behavior, Verbose,
TEXT("[EQSContext_CoverLocation] %s '%s' raw=%s → nav=%s"),
CoverPointActor ? TEXT("CoverPoint") : TEXT("CoverLocation"),
CoverPointActor ? *CoverPointActor->GetName() : TEXT("(vector)"),
*RawLoc.ToString(), *FinalLoc.ToString());
UEnvQueryItemType_Point::SetContextHelper(ContextData, FinalLoc);
}

View File

@ -7,6 +7,7 @@
#include "EnvironmentQuery/Items/EnvQueryItemType_VectorBase.h"
#include "CollisionQueryParams.h"
#include "Engine/World.h"
#include "DrawDebugHelpers.h"
UPS_AI_Behavior_EQSTest_CoverQuality::UPS_AI_Behavior_EQSTest_CoverQuality()
{
@ -38,6 +39,20 @@ void UPS_AI_Behavior_EQSTest_CoverQuality::RunTest(FEnvQueryInstance& QueryInsta
FCollisionQueryParams TraceParams(SCENE_QUERY_STAT(CoverQualityEQS), true);
// Ignore the threat actor itself (e.g. AimTargetActor sphere blocks its own traces)
TArray<AActor*> ThreatActors;
if (QueryInstance.PrepareContext(UPS_AI_Behavior_EQSContext_Threat::StaticClass(), ThreatActors))
{
for (AActor* A : ThreatActors)
{
TraceParams.AddIgnoredActor(A);
for (AActor* Parent = A->GetAttachParentActor(); Parent; Parent = Parent->GetAttachParentActor())
{
TraceParams.AddIgnoredActor(Parent);
}
}
}
// Compute height steps
TArray<float> TraceHeights;
if (NumTraceHeights == 1)
@ -86,7 +101,7 @@ void UPS_AI_Behavior_EQSTest_CoverQuality::RunTest(FEnvQueryInstance& QueryInsta
{
const float HitDist = FVector::Dist(CandidatePos, Hit.ImpactPoint);
UE_LOG(LogPS_AI_Behavior, Log,
UE_LOG(LogPS_AI_Behavior, Verbose,
TEXT("CoverQuality[%d] h=%.0f lat=%.0f: HIT '%s' dist=%.0fcm"),
It.GetIndex(), Height, Lateral,
Hit.GetActor() ? *Hit.GetActor()->GetName() : TEXT("null"),
@ -100,7 +115,7 @@ void UPS_AI_Behavior_EQSTest_CoverQuality::RunTest(FEnvQueryInstance& QueryInsta
}
else
{
UE_LOG(LogPS_AI_Behavior, Log,
UE_LOG(LogPS_AI_Behavior, Verbose,
TEXT("CoverQuality[%d] h=%.0f lat=%.0f: NO HIT"),
It.GetIndex(), Height, Lateral);
}
@ -109,12 +124,26 @@ void UPS_AI_Behavior_EQSTest_CoverQuality::RunTest(FEnvQueryInstance& QueryInsta
const float Score = BlockedCount / static_cast<float>(TotalTraces);
UE_LOG(LogPS_AI_Behavior, Log,
UE_LOG(LogPS_AI_Behavior, Verbose,
TEXT("CoverQuality[%d] at %s → score=%.2f (blocked=%d/%d)"),
It.GetIndex(), *CandidatePos.ToString(),
Score, (int32)BlockedCount, TotalTraces);
It.SetScore(TestPurpose, FilterType, Score, 0.0f, 1.0f);
#if ENABLE_DRAW_DEBUG
if (bDrawDebug)
{
// Color: red(0) → yellow(0.5) → green(1)
const uint8 R = static_cast<uint8>(FMath::Lerp(255.0f, 0.0f, FMath::Clamp(Score, 0.0f, 1.0f)));
const uint8 G = static_cast<uint8>(FMath::Lerp(0.0f, 255.0f, FMath::Clamp(Score, 0.0f, 1.0f)));
DrawDebugBox(const_cast<UWorld*>(World), CandidatePos + FVector(0, 0, 20.0f),
FVector(8.0f), FColor(R, G, 0), false, 5.0f);
DrawDebugString(const_cast<UWorld*>(World), CandidatePos + FVector(0, 0, 35.0f),
FString::Printf(TEXT("%.0f%%"), Score * 100.0f), nullptr,
FColor(R, G, 0), 5.0f, true);
}
#endif
It.SetScore(TestPurpose, FilterType, Score, FloatValueMin.GetValue(), FloatValueMax.GetValue());
}
}

View File

@ -2,10 +2,12 @@
#include "EQS/PS_AI_Behavior_EQSTest_LineOfSight.h"
#include "EQS/PS_AI_Behavior_EQSContext_Threat.h"
#include "PS_AI_Behavior_Definitions.h"
#include "EnvironmentQuery/EnvQueryTypes.h"
#include "EnvironmentQuery/Items/EnvQueryItemType_VectorBase.h"
#include "CollisionQueryParams.h"
#include "Engine/World.h"
#include "DrawDebugHelpers.h"
UPS_AI_Behavior_EQSTest_LineOfSight::UPS_AI_Behavior_EQSTest_LineOfSight()
{
@ -37,20 +39,91 @@ void UPS_AI_Behavior_EQSTest_LineOfSight::RunTest(FEnvQueryInstance& QueryInstan
FCollisionQueryParams TraceParams(SCENE_QUERY_STAT(LOSTestEQS), true);
const FVector TraceEnd = ThreatLoc + FVector(0, 0, TargetHeightOffset);
// Ignore the threat actor itself (e.g. AimTargetActor sphere blocks its own traces)
TArray<AActor*> ThreatActors;
if (QueryInstance.PrepareContext(UPS_AI_Behavior_EQSContext_Threat::StaticClass(), ThreatActors))
{
for (AActor* A : ThreatActors)
{
TraceParams.AddIgnoredActor(A);
// Also ignore parent chain (AimTarget → Character capsule)
for (AActor* Parent = A->GetAttachParentActor(); Parent; Parent = Parent->GetAttachParentActor())
{
TraceParams.AddIgnoredActor(Parent);
}
}
}
// Lateral offsets: center + optional left/right perpendicular to threat direction
TArray<float> LateralOffsets;
LateralOffsets.Add(0.0f);
if (LateralSpread > 0.0f)
{
LateralOffsets.Add(-LateralSpread);
LateralOffsets.Add(LateralSpread);
}
const int32 TotalTraces = LateralOffsets.Num();
for (FEnvQueryInstance::ItemIterator It(this, QueryInstance); It; ++It)
{
const FVector CandidatePos = GetItemLocation(QueryInstance, It.GetIndex());
const FVector TraceStart = CandidatePos + FVector(0, 0, TraceHeight);
FHitResult Hit;
const bool bBlocked = World->LineTraceSingleByChannel(
Hit, TraceStart, TraceEnd, ECC_Visibility, TraceParams);
// Direction from candidate to threat (2D) and its perpendicular
const FVector DirToThreat = (ThreatLoc - CandidatePos).GetSafeNormal2D();
const FVector LateralDir = FVector::CrossProduct(FVector::UpVector, DirToThreat);
// Score: 1.0 = clear LOS (not blocked), 0.0 = blocked
const float Score = bBlocked ? 0.0f : 1.0f;
It.SetScore(TestPurpose, FilterType, Score, 0.0f, 1.0f);
int32 ClearCount = 0;
for (float Lateral : LateralOffsets)
{
const FVector TraceStart = CandidatePos + FVector(0, 0, TraceHeight) + LateralDir * Lateral;
const FVector TraceEnd = ThreatLoc + FVector(0, 0, TargetHeightOffset) + LateralDir * Lateral;
FHitResult Hit;
const bool bBlocked = World->LineTraceSingleByChannel(
Hit, TraceStart, TraceEnd, ECC_Visibility, TraceParams);
if (!bBlocked)
{
ClearCount++;
}
else
{
UE_LOG(LogPS_AI_Behavior, Verbose,
TEXT("LOS[%d] lat=%.0f: BLOCKED by '%s' at %s (dist=%.0fcm)"),
It.GetIndex(), Lateral,
Hit.GetActor() ? *Hit.GetActor()->GetName() : TEXT("null"),
*Hit.ImpactPoint.ToString(),
FVector::Dist(CandidatePos, Hit.ImpactPoint));
}
#if ENABLE_DRAW_DEBUG
if (bDrawDebug)
{
const FColor Color = bBlocked ? FColor::Red : FColor::Green;
DrawDebugLine(const_cast<UWorld*>(World), TraceStart, TraceEnd,
Color, false, 5.0f, 0, 0.5f);
}
#endif
}
// Score: ratio of clear traces (1.0 = all clear, 0.0 = all blocked)
const float Score = static_cast<float>(ClearCount) / static_cast<float>(TotalTraces);
UE_LOG(LogPS_AI_Behavior, Verbose,
TEXT("LOS[%d] at %s → clear=%d/%d score=%.2f"),
It.GetIndex(), *CandidatePos.ToString(),
ClearCount, TotalTraces, Score);
#if ENABLE_DRAW_DEBUG
if (bDrawDebug)
{
const uint8 G = static_cast<uint8>(Score * 255.0f);
DrawDebugSphere(const_cast<UWorld*>(World), CandidatePos + FVector(0, 0, 20.0f),
10.0f, 6, FColor(255 - G, G, 0), false, 5.0f);
}
#endif
It.SetScore(TestPurpose, FilterType, Score, FloatValueMin.GetValue(), FloatValueMax.GetValue());
}
}

View File

@ -16,6 +16,7 @@
#include "GameFramework/SpectatorPawn.h"
#include "AIController.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "EngineUtils.h"
UPS_AI_Behavior_PerceptionComponent::UPS_AI_Behavior_PerceptionComponent()
{
@ -270,6 +271,53 @@ AActor* UPS_AI_Behavior_PerceptionComponent::GetHighestThreatActor(
TArray<AActor*> PerceivedActors;
GetCurrentlyPerceivedActors(nullptr, PerceivedActors); // All senses at once
// ─── Omniscient awareness for high-priority target types ────────
// Protectors (police, player) are always known if within sight radius,
// even outside the perception cone. This ensures enemies prioritize
// dangerous threats they're aware of (e.g. a cop standing next to them).
if (TargetPriority.Num() > 0)
{
const AAIController* OwnerAIC = Cast<AAIController>(GetOwner());
const APawn* OwnerPawn = OwnerAIC ? OwnerAIC->GetPawn() : nullptr;
if (OwnerPawn)
{
const EPS_AI_Behavior_TargetType TopPriority = TargetPriority[0];
const UPS_AI_Behavior_Settings* Settings = GetDefault<UPS_AI_Behavior_Settings>();
const float AwarenessRadius = Settings->DefaultSightRadius;
const FVector OwnerLoc = OwnerPawn->GetActorLocation();
for (TActorIterator<APawn> It(GetWorld()); It; ++It)
{
APawn* CandidatePawn = *It;
if (!CandidatePawn || CandidatePawn == OwnerPawn) continue;
// Already perceived → skip
if (PerceivedActors.Contains(CandidatePawn)) continue;
// Check distance
const float Dist = FVector::Dist(OwnerLoc, CandidatePawn->GetActorLocation());
if (Dist > AwarenessRadius) continue;
// Check if this actor matches the top priority type
const EPS_AI_Behavior_TargetType CandidateType = ClassifyActor(CandidatePawn);
const EPS_AI_Behavior_TargetType MappedType =
(CandidateType == EPS_AI_Behavior_TargetType::Player)
? EPS_AI_Behavior_TargetType::Protector
: CandidateType;
if (MappedType == TopPriority)
{
PerceivedActors.Add(CandidatePawn);
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] Omniscient awareness: added '%s' (type=%d, dist=%.0f) — top priority target"),
*OwnerPawn->GetName(), *CandidatePawn->GetName(),
static_cast<int32>(CandidateType), Dist);
}
}
}
}
if (PerceivedActors.Num() == 0)
{
return nullptr;

View File

@ -25,11 +25,11 @@ public:
UPROPERTY(EditAnywhere, Category = "Cover", meta = (ClampMin = "1", ClampMax = "5"))
int32 NumTraceHeights = 3;
/** Minimum height for the lowest trace (cm relative to item — items are at capsule center, ~90cm above ground). */
/** Minimum height for the lowest trace (cm relative to item — items are at navmesh level). */
UPROPERTY(EditAnywhere, Category = "Cover")
float MinTraceHeight = 0.0f;
/** Maximum height for the highest trace (cm relative to item — 70 ≈ top of head from capsule center). */
/** Maximum height for the highest trace (cm relative to item — 70 ≈ knee-to-waist from navmesh). */
UPROPERTY(EditAnywhere, Category = "Cover")
float MaxTraceHeight = 70.0f;
@ -48,6 +48,9 @@ public:
UPROPERTY(EditAnywhere, Category = "Cover", meta = (ClampMin = "0.0", ClampMax = "100.0"))
float LateralSpread = 30.0f;
/** Draw debug boxes for each candidate with color-coded score. */
UPROPERTY(EditAnywhere, Category = "Debug")
bool bDrawDebug = false;
protected:
virtual void RunTest(FEnvQueryInstance& QueryInstance) const override;

View File

@ -31,6 +31,18 @@ public:
UPROPERTY(EditDefaultsOnly, Category = "LOS", meta = (ClampMin = "0.0"))
float TargetHeightOffset = 100.0f;
/**
* Lateral offset (cm) for side traces perpendicular to threat direction.
* 0 = center trace only, 30 = adds traces at ±30cm.
* Requires wider clear LOS, not just a narrow slit past cover edge.
*/
UPROPERTY(EditAnywhere, Category = "LOS", meta = (ClampMin = "0.0", ClampMax = "100.0"))
float LateralSpread = 0.0f;
/** Draw debug spheres and LOS lines for each candidate. */
UPROPERTY(EditAnywhere, Category = "Debug")
bool bDrawDebug = false;
protected:
virtual void RunTest(FEnvQueryInstance& QueryInstance) const override;
virtual FText GetDescriptionTitle() const override;