WIP: Cover system firing position, IsCoverNeeded decorator, EQS contexts

- CoverShootCycle: add FiringPositionQuery EQS for peek/shoot positions
  (NPC moves between cover position and firing position with LOS)
- Add BTDecorator_IsCoverNeeded: skip cover when target is Civilian
- Add EQSContext_CoverLocation: provides BB CoverLocation to EQS generators
- FindCover: add debug draw toggle and EQS refinement debug spheres
- Definitions: add ECoverShootSubState and CoverPointType::HidingSpot

NOTE: Has compilation errors to fix (signature mismatches in
CoverShootCycle StartPeeking/ReturnToCover, missing forward-declare)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
j.foucher 2026-03-30 17:53:52 +02:00
parent 98f0dbdce5
commit 9aaf16c655
15 changed files with 684 additions and 196 deletions

View File

@ -0,0 +1,50 @@
// Copyright Asterion. All Rights Reserved.
#include "BT/PS_AI_Behavior_BTDecorator_IsCoverNeeded.h"
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_Interface.h"
#include "BehaviorTree/BlackboardComponent.h"
UPS_AI_Behavior_BTDecorator_IsCoverNeeded::UPS_AI_Behavior_BTDecorator_IsCoverNeeded()
{
NodeName = TEXT("Is Cover Needed");
// Default: cover needed against Protector and Enemy, not Civilian
DangerousTargetTypes.Add(EPS_AI_Behavior_NPCType::Protector);
DangerousTargetTypes.Add(EPS_AI_Behavior_NPCType::Enemy);
}
bool UPS_AI_Behavior_BTDecorator_IsCoverNeeded::CalculateRawConditionValue(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const
{
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (!BB) return false;
AActor* ThreatActor = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor));
if (!ThreatActor) return false;
// If the target doesn't implement the interface, assume dangerous (safe default)
APawn* ThreatPawn = Cast<APawn>(ThreatActor);
if (!ThreatPawn || !ThreatPawn->Implements<UPS_AI_Behavior_Interface>())
{
return true;
}
const EPS_AI_Behavior_NPCType TargetType =
IPS_AI_Behavior_Interface::Execute_GetBehaviorNPCType(ThreatPawn);
return DangerousTargetTypes.Contains(TargetType);
}
FString UPS_AI_Behavior_BTDecorator_IsCoverNeeded::GetStaticDescription() const
{
const UEnum* TypeEnum = StaticEnum<EPS_AI_Behavior_NPCType>();
FString TypeList;
for (const EPS_AI_Behavior_NPCType& Type : DangerousTargetTypes)
{
if (!TypeList.IsEmpty()) TypeList += TEXT(", ");
TypeList += TypeEnum->GetDisplayNameTextByValue(static_cast<int64>(Type)).ToString();
}
return FString::Printf(TEXT("Cover needed vs: %s"),
TypeList.IsEmpty() ? TEXT("None") : *TypeList);
}

View File

@ -12,6 +12,31 @@
#include "Navigation/PathFollowingComponent.h"
#include "CollisionQueryParams.h"
#include "EngineUtils.h"
#include "EnvironmentQuery/EnvQuery.h"
#include "EnvironmentQuery/EnvQueryManager.h"
#include "DrawDebugHelpers.h"
// ─── Helper: Crouch at cover if required ────────────────────────────────────
namespace
{
void CrouchAtCoverIfNeeded(APawn* Pawn, UBlackboardComponent* BB)
{
if (!Pawn || !Pawn->Implements<UPS_AI_Behavior_Interface>() || !BB) return;
const APS_AI_Behavior_CoverPoint* CoverPt =
Cast<APS_AI_Behavior_CoverPoint>(BB->GetValueAsObject(PS_AI_Behavior_BB::CoverPoint));
if (CoverPt && CoverPt->bCrouch)
{
IPS_AI_Behavior_Interface::Execute_SetBehaviorCrouch(Pawn, true);
}
}
void SetSubState(UBlackboardComponent* BB, EPS_AI_Behavior_CombatSubState State)
{
BB->SetValueAsEnum(PS_AI_Behavior_BB::CombatSubState, static_cast<uint8>(State));
}
}
// ─── Constructor ────────────────────────────────────────────────────────────
UPS_AI_Behavior_BTTask_CoverShootCycle::UPS_AI_Behavior_BTTask_CoverShootCycle()
{
@ -20,6 +45,8 @@ UPS_AI_Behavior_BTTask_CoverShootCycle::UPS_AI_Behavior_BTTask_CoverShootCycle()
bNotifyTaskFinished = true;
}
// ─── ExecuteTask ────────────────────────────────────────────────────────────
EBTNodeResult::Type UPS_AI_Behavior_BTTask_CoverShootCycle::ExecuteTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
@ -29,7 +56,6 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_CoverShootCycle::ExecuteTask(
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (!BB) return EBTNodeResult::Failed;
// We need a cover location (written by BTTask_FindCover)
const FVector CoverLoc = BB->GetValueAsVector(PS_AI_Behavior_BB::CoverLocation);
if (CoverLoc.IsZero())
{
@ -37,7 +63,6 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_CoverShootCycle::ExecuteTask(
return EBTNodeResult::Failed;
}
// We need a threat
AActor* Target = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor));
if (!Target)
{
@ -45,13 +70,16 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_CoverShootCycle::ExecuteTask(
return EBTNodeResult::Failed;
}
// ─── Initialize memory with personality modulation ───────────────
// ─── Initialize memory ──────────────────────────────────────────
FCoverShootMemory* Memory = reinterpret_cast<FCoverShootMemory*>(NodeMemory);
Memory->SubState = EPS_AI_Behavior_CombatSubState::Engaging;
Memory->Timer = 0.0f;
Memory->PhaseDuration = 0.0f;
Memory->CycleCount = 0;
Memory->bMoveRequested = false;
Memory->FiringPosition = FVector::ZeroVector;
Memory->bHasFiringPosition = false;
Memory->bEQSRunning = false;
// Base values
Memory->EffPeekMin = PeekDurationMin;
@ -68,22 +96,19 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_CoverShootCycle::ExecuteTask(
const float Caution = Personality->GetTrait(EPS_AI_Behavior_TraitAxis::Caution);
const float Courage = Personality->GetTrait(EPS_AI_Behavior_TraitAxis::Courage);
// Aggressive → peek longer, cover shorter, advance sooner
const float AggrFactor = 0.7f + Aggressivity * 0.6f; // 0.7 1.3
const float AggrFactor = 0.7f + Aggressivity * 0.6f;
Memory->EffPeekMin *= AggrFactor;
Memory->EffPeekMax *= AggrFactor;
Memory->EffCoverMin /= AggrFactor;
Memory->EffCoverMax /= AggrFactor;
Memory->EffMaxCycles = FMath::Max(1, FMath::RoundToInt(MaxCyclesBeforeAdvance * (1.5f - Aggressivity * 0.5f)));
// Cautious → cover longer, peek shorter
const float CautionFactor = 0.5f + Caution * 1.0f; // 0.5 1.5
const float CautionFactor = 0.5f + Caution * 1.0f;
Memory->EffCoverMin *= CautionFactor;
Memory->EffCoverMax *= CautionFactor;
Memory->EffPeekMin /= CautionFactor;
Memory->EffPeekMax /= CautionFactor;
// Low courage → never advance
Memory->bCanAdvance = (Courage >= 0.3f);
UE_LOG(LogPS_AI_Behavior, Verbose,
@ -94,10 +119,9 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_CoverShootCycle::ExecuteTask(
Memory->EffMaxCycles, (int32)Memory->bCanAdvance);
}
// ─── Move to cover position ─────────────────────────────────────
// ─── Move to cover position ─────────────────────────────────────
const EPathFollowingRequestResult::Type Result = AIC->MoveToLocation(
CoverLoc, 80.0f, /*bStopOnOverlap=*/true,
/*bUsePathfinding=*/true, /*bProjectGoal=*/true, /*bCanStrafe=*/false);
CoverLoc, 80.0f, true, true, true, false);
if (Result == EPathFollowingRequestResult::Failed)
{
@ -106,23 +130,22 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_CoverShootCycle::ExecuteTask(
if (Result == EPathFollowingRequestResult::AlreadyAtGoal)
{
// Already at cover — start the cycle
Memory->SubState = EPS_AI_Behavior_CombatSubState::AtCover;
Memory->PhaseDuration = FMath::RandRange(Memory->EffCoverMin, Memory->EffCoverMax);
Memory->Timer = Memory->PhaseDuration;
BB->SetValueAsEnum(PS_AI_Behavior_BB::CombatSubState,
static_cast<uint8>(EPS_AI_Behavior_CombatSubState::AtCover));
SetSubState(BB, EPS_AI_Behavior_CombatSubState::AtCover);
}
else
{
Memory->bMoveRequested = true;
BB->SetValueAsEnum(PS_AI_Behavior_BB::CombatSubState,
static_cast<uint8>(EPS_AI_Behavior_CombatSubState::Engaging));
SetSubState(BB, EPS_AI_Behavior_CombatSubState::Engaging);
}
return EBTNodeResult::InProgress;
}
// ─── TickTask ───────────────────────────────────────────────────────────────
void UPS_AI_Behavior_BTTask_CoverShootCycle::TickTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
@ -139,13 +162,11 @@ void UPS_AI_Behavior_BTTask_CoverShootCycle::TickTask(
AActor* Target = Cast<AActor>(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;
}
// Validate target
APawn* Pawn = AIC->GetPawn();
if (Pawn && Pawn->Implements<UPS_AI_Behavior_Interface>())
{
@ -153,7 +174,6 @@ void UPS_AI_Behavior_BTTask_CoverShootCycle::TickTask(
{
AIC->StopMovement();
BB->ClearValue(PS_AI_Behavior_BB::ThreatActor);
// De-escalate so decorator can re-trigger when threat returns
AIC->SetBehaviorState(EPS_AI_Behavior_State::Alerted);
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return;
@ -162,6 +182,9 @@ void UPS_AI_Behavior_BTTask_CoverShootCycle::TickTask(
FCoverShootMemory* Memory = reinterpret_cast<FCoverShootMemory*>(NodeMemory);
// Wait for EQS to complete
if (Memory->bEQSRunning) return;
switch (Memory->SubState)
{
// ─── ENGAGING: Moving to cover ──────────────────────────────────
@ -169,25 +192,12 @@ void UPS_AI_Behavior_BTTask_CoverShootCycle::TickTask(
{
if (!Memory->bMoveRequested || AIC->GetMoveStatus() == EPathFollowingStatus::Idle)
{
// Arrived at cover → start duck phase
Memory->bMoveRequested = false;
Memory->SubState = EPS_AI_Behavior_CombatSubState::AtCover;
Memory->PhaseDuration = FMath::RandRange(Memory->EffCoverMin, Memory->EffCoverMax);
Memory->Timer = Memory->PhaseDuration;
BB->SetValueAsEnum(PS_AI_Behavior_BB::CombatSubState,
static_cast<uint8>(EPS_AI_Behavior_CombatSubState::AtCover));
// Crouch if the cover point requires it
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
const APS_AI_Behavior_CoverPoint* CoverPt =
Cast<APS_AI_Behavior_CoverPoint>(BB->GetValueAsObject(PS_AI_Behavior_BB::CoverPoint));
if (CoverPt && CoverPt->bCrouch)
{
IPS_AI_Behavior_Interface::Execute_SetBehaviorCrouch(Pawn, true);
}
}
SetSubState(BB, EPS_AI_Behavior_CombatSubState::AtCover);
CrouchAtCoverIfNeeded(Pawn, BB);
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] CoverShootCycle: at cover, ducking for %.1fs"),
*AIC->GetName(), Memory->PhaseDuration);
@ -201,83 +211,32 @@ void UPS_AI_Behavior_BTTask_CoverShootCycle::TickTask(
Memory->Timer -= DeltaSeconds;
if (Memory->Timer <= 0.0f)
{
// LOS check before peeking — no point shooting into a wall
const bool bHasLOS = UPS_AI_Behavior_Statics::HasLineOfSight(
Pawn->GetWorld(), Pawn, Target, 150.0f);
// Try to find a firing position via EQS, or peek in place
StartPeeking(OwnerComp, NodeMemory, AIC, Pawn, Target);
}
break;
}
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 found and no LOS → abandon cover, fall back to Attack task
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] CoverShootCycle: no LOS and no advancing cover → abandoning cover for Attack fallback"),
*AIC->GetName());
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return;
}
break;
}
// Has LOS → peek and shoot normally
// ─── MOVING TO FIRE: Going to firing position ───────────────────
case EPS_AI_Behavior_CombatSubState::MovingToFire:
{
if (!Memory->bMoveRequested || AIC->GetMoveStatus() == EPathFollowingStatus::Idle)
{
// Arrived at firing position → start shooting
Memory->bMoveRequested = false;
Memory->SubState = EPS_AI_Behavior_CombatSubState::Peeking;
Memory->PhaseDuration = FMath::RandRange(Memory->EffPeekMin, Memory->EffPeekMax);
Memory->Timer = Memory->PhaseDuration;
Memory->LOSCheckTimer = 0.3f;
SetSubState(BB, EPS_AI_Behavior_CombatSubState::Peeking);
BB->SetValueAsEnum(PS_AI_Behavior_BB::CombatSubState,
static_cast<uint8>(EPS_AI_Behavior_CombatSubState::Peeking));
// Stand up to shoot
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_SetBehaviorCrouch(Pawn, false);
IPS_AI_Behavior_Interface::Execute_BehaviorStartAttack(Pawn, Target);
}
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] CoverShootCycle: peeking, shooting for %.1fs"),
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] CoverShootCycle: at firing position, shooting for %.1fs"),
*AIC->GetName(), Memory->PhaseDuration);
}
break;
@ -286,36 +245,24 @@ void UPS_AI_Behavior_BTTask_CoverShootCycle::TickTask(
// ─── PEEKING: Shooting at target ────────────────────────────────
case EPS_AI_Behavior_CombatSubState::Peeking:
{
// Continuous LOS check while peeking — stop shooting if target hides
// Continuous LOS check
Memory->LOSCheckTimer -= DeltaSeconds;
if (Memory->LOSCheckTimer <= 0.0f)
{
Memory->LOSCheckTimer = 0.3f; // check every 0.3s
Memory->LOSCheckTimer = 0.3f;
const bool bStillHasLOS = UPS_AI_Behavior_Statics::HasLineOfSight(
Pawn->GetWorld(), Pawn, Target, 150.0f);
if (!bStillHasLOS)
{
// Target hid — stop shooting, crouch back to cover
// Lost LOS → stop shooting, return to cover
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_BehaviorStopAttack(Pawn);
const APS_AI_Behavior_CoverPoint* CoverPt =
Cast<APS_AI_Behavior_CoverPoint>(BB->GetValueAsObject(PS_AI_Behavior_BB::CoverPoint));
if (CoverPt && CoverPt->bCrouch)
{
IPS_AI_Behavior_Interface::Execute_SetBehaviorCrouch(Pawn, true);
}
}
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] CoverShootCycle: lost LOS during peek → back to cover"),
TEXT("[%s] CoverShootCycle: lost LOS during peek → returning to cover"),
*AIC->GetName());
Memory->SubState = EPS_AI_Behavior_CombatSubState::AtCover;
Memory->PhaseDuration = FMath::RandRange(Memory->EffCoverMin, Memory->EffCoverMax);
Memory->Timer = Memory->PhaseDuration;
BB->SetValueAsEnum(PS_AI_Behavior_BB::CombatSubState,
static_cast<uint8>(EPS_AI_Behavior_CombatSubState::AtCover));
ReturnToCover(OwnerComp, NodeMemory, AIC, Pawn);
break;
}
}
@ -323,12 +270,11 @@ void UPS_AI_Behavior_BTTask_CoverShootCycle::TickTask(
Memory->Timer -= DeltaSeconds;
if (Memory->Timer <= 0.0f)
{
// Stop attacking
// Peek timer expired → stop shooting
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_BehaviorStopAttack(Pawn);
}
Memory->CycleCount++;
// Should we advance to closer cover?
@ -336,18 +282,12 @@ void UPS_AI_Behavior_BTTask_CoverShootCycle::TickTask(
{
// ─── Advance to next cover ──────────────────────
Memory->SubState = EPS_AI_Behavior_CombatSubState::Advancing;
BB->SetValueAsEnum(PS_AI_Behavior_BB::CombatSubState,
static_cast<uint8>(EPS_AI_Behavior_CombatSubState::Advancing));
SetSubState(BB, 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);
}
if (OldPoint) OldPoint->Release(Pawn);
// Find a closer cover
const FVector NpcLoc = Pawn->GetActorLocation();
const FVector ThreatLoc = Target->GetActorLocation();
EPS_AI_Behavior_NPCType NPCType = EPS_AI_Behavior_NPCType::Any;
@ -365,10 +305,10 @@ void UPS_AI_Behavior_BTTask_CoverShootCycle::TickTask(
NewPoint->Claim(Pawn);
BB->SetValueAsObject(PS_AI_Behavior_BB::CoverPoint, NewPoint);
BB->SetValueAsVector(PS_AI_Behavior_BB::CoverLocation, NewPoint->GetActorLocation());
Memory->bHasFiringPosition = false; // Reset firing position for new cover
AIC->MoveToLocation(
NewPoint->GetActorLocation(), 80.0f, /*bStopOnOverlap=*/true,
/*bUsePathfinding=*/true, /*bProjectGoal=*/true, /*bCanStrafe=*/false);
NewPoint->GetActorLocation(), 80.0f, true, true, true, false);
Memory->bMoveRequested = true;
Memory->CycleCount = 0;
@ -377,71 +317,56 @@ void UPS_AI_Behavior_BTTask_CoverShootCycle::TickTask(
}
else
{
// No better cover found — stay at current position, reset cycle
// No better cover → return to current cover, reset cycle
UE_LOG(LogPS_AI_Behavior, Verbose,
TEXT("[%s] CoverShootCycle: no advancing cover found, resetting cycle"),
TEXT("[%s] CoverShootCycle: no advancing cover found, returning to cover"),
*AIC->GetName());
Memory->SubState = EPS_AI_Behavior_CombatSubState::AtCover;
Memory->PhaseDuration = FMath::RandRange(Memory->EffCoverMin, Memory->EffCoverMax);
Memory->Timer = Memory->PhaseDuration;
ReturnToCover(OwnerComp, NodeMemory, AIC, Pawn);
Memory->CycleCount = 0;
BB->SetValueAsEnum(PS_AI_Behavior_BB::CombatSubState,
static_cast<uint8>(EPS_AI_Behavior_CombatSubState::AtCover));
}
}
else
{
// ─── Duck back behind cover ─────────────────────
Memory->SubState = EPS_AI_Behavior_CombatSubState::AtCover;
Memory->PhaseDuration = FMath::RandRange(Memory->EffCoverMin, Memory->EffCoverMax);
Memory->Timer = Memory->PhaseDuration;
BB->SetValueAsEnum(PS_AI_Behavior_BB::CombatSubState,
static_cast<uint8>(EPS_AI_Behavior_CombatSubState::AtCover));
// Re-crouch if cover requires it
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
const APS_AI_Behavior_CoverPoint* CoverPt =
Cast<APS_AI_Behavior_CoverPoint>(BB->GetValueAsObject(PS_AI_Behavior_BB::CoverPoint));
if (CoverPt && CoverPt->bCrouch)
{
IPS_AI_Behavior_Interface::Execute_SetBehaviorCrouch(Pawn, true);
}
}
// ─── Return to cover ────────────────────────────
ReturnToCover(OwnerComp, NodeMemory, AIC, Pawn);
UE_LOG(LogPS_AI_Behavior, Verbose,
TEXT("[%s] CoverShootCycle: ducking back (cycle %d/%d)"),
TEXT("[%s] CoverShootCycle: returning to cover (cycle %d/%d)"),
*AIC->GetName(), Memory->CycleCount, Memory->EffMaxCycles);
}
}
break;
}
// ─── RETURNING TO COVER: Moving back after shooting ─────────────
case EPS_AI_Behavior_CombatSubState::ReturningToCover:
{
if (!Memory->bMoveRequested || AIC->GetMoveStatus() == EPathFollowingStatus::Idle)
{
Memory->bMoveRequested = false;
Memory->SubState = EPS_AI_Behavior_CombatSubState::AtCover;
Memory->PhaseDuration = FMath::RandRange(Memory->EffCoverMin, Memory->EffCoverMax);
Memory->Timer = Memory->PhaseDuration;
SetSubState(BB, EPS_AI_Behavior_CombatSubState::AtCover);
CrouchAtCoverIfNeeded(Pawn, BB);
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] CoverShootCycle: back at cover, ducking for %.1fs"),
*AIC->GetName(), Memory->PhaseDuration);
}
break;
}
// ─── ADVANCING: Moving to next cover ────────────────────────────
case EPS_AI_Behavior_CombatSubState::Advancing:
{
if (!Memory->bMoveRequested || AIC->GetMoveStatus() == EPathFollowingStatus::Idle)
{
// Arrived at new cover → duck
Memory->bMoveRequested = false;
Memory->SubState = EPS_AI_Behavior_CombatSubState::AtCover;
Memory->PhaseDuration = FMath::RandRange(Memory->EffCoverMin, Memory->EffCoverMax);
Memory->Timer = Memory->PhaseDuration;
BB->SetValueAsEnum(PS_AI_Behavior_BB::CombatSubState,
static_cast<uint8>(EPS_AI_Behavior_CombatSubState::AtCover));
// Crouch if the new cover point requires it
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
const APS_AI_Behavior_CoverPoint* CoverPt =
Cast<APS_AI_Behavior_CoverPoint>(BB->GetValueAsObject(PS_AI_Behavior_BB::CoverPoint));
if (CoverPt && CoverPt->bCrouch)
{
IPS_AI_Behavior_Interface::Execute_SetBehaviorCrouch(Pawn, true);
}
}
SetSubState(BB, EPS_AI_Behavior_CombatSubState::AtCover);
CrouchAtCoverIfNeeded(Pawn, BB);
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] CoverShootCycle: arrived at new cover, ducking"),
*AIC->GetName());
@ -449,8 +374,303 @@ void UPS_AI_Behavior_BTTask_CoverShootCycle::TickTask(
break;
}
}
// ─── Debug visualization ────────────────────────────────────────
#if ENABLE_DRAW_DEBUG
if (bDebugDraw && Pawn && Target)
{
UWorld* World = Pawn->GetWorld();
const FVector HeadLoc = Pawn->GetActorLocation() + FVector(0, 0, 120.0f);
const FVector TargetLoc = Target->GetActorLocation();
const FVector CoverLoc = BB->GetValueAsVector(PS_AI_Behavior_BB::CoverLocation);
// Sub-state label
const TCHAR* SubStateStr = TEXT("?");
FColor SubStateColor = FColor::White;
switch (Memory->SubState)
{
case EPS_AI_Behavior_CombatSubState::Engaging: SubStateStr = TEXT("ENGAGING"); SubStateColor = FColor::Yellow; break;
case EPS_AI_Behavior_CombatSubState::AtCover: SubStateStr = TEXT("AT COVER"); SubStateColor = FColor::Cyan; break;
case EPS_AI_Behavior_CombatSubState::MovingToFire: SubStateStr = TEXT("→ FIRE POS"); SubStateColor = FColor::Magenta; break;
case EPS_AI_Behavior_CombatSubState::Peeking: SubStateStr = TEXT("PEEKING"); SubStateColor = FColor::Red; break;
case EPS_AI_Behavior_CombatSubState::ReturningToCover: SubStateStr = TEXT("→ COVER"); SubStateColor = FColor::Blue; break;
case EPS_AI_Behavior_CombatSubState::Advancing: SubStateStr = TEXT("ADVANCING"); SubStateColor = FColor::Orange; break;
}
DrawDebugString(World, HeadLoc + FVector(0, 0, 30.0f),
FString::Printf(TEXT("%s [%.1fs] C:%d/%d"), SubStateStr, Memory->Timer, Memory->CycleCount, Memory->EffMaxCycles),
nullptr, SubStateColor, 0.0f, true);
// LOS line to target
const bool bLOS = UPS_AI_Behavior_Statics::HasLineOfSight(World, Pawn, Target, 150.0f);
DrawDebugLine(World, HeadLoc, TargetLoc + FVector(0, 0, 100.0f),
bLOS ? FColor::Green : FColor::Red, false, 0.0f, 0, 1.0f);
// Firing position marker
if (Memory->bHasFiringPosition)
{
// Firing position: red solid point
DrawDebugSphere(World, Memory->FiringPosition + FVector(0, 0, 30.0f),
20.0f, 8, FColor::Red, false, 0.0f);
// Line cover → firing position (white)
DrawDebugLine(World, CoverLoc + FVector(0, 0, 20.0f),
Memory->FiringPosition + FVector(0, 0, 20.0f),
FColor::White, false, 0.0f, 0, 1.0f);
// Line firing position → threat (green)
DrawDebugLine(World, Memory->FiringPosition + FVector(0, 0, 100.0f),
TargetLoc + FVector(0, 0, 100.0f),
FColor(0, 200, 0), false, 0.0f, 0, 1.0f);
DrawDebugString(World, Memory->FiringPosition + FVector(0, 0, 50.0f),
TEXT("FIRE"), nullptr, FColor::Red, 0.0f, true);
}
}
#endif
}
// ─── StartPeeking ───────────────────────────────────────────────────────────
void UPS_AI_Behavior_BTTask_CoverShootCycle::StartPeeking(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory,
APS_AI_Behavior_AIController* AIC, APawn* Pawn, AActor* Target)
{
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
FCoverShootMemory* Memory = reinterpret_cast<FCoverShootMemory*>(NodeMemory);
// If we have a FiringPositionQuery → find a firing position via EQS
if (FiringPositionQuery)
{
RunFiringPositionQuery(OwnerComp, NodeMemory, Pawn);
return; // EQS callback will handle the transition
}
// No EQS query → legacy behavior: check LOS from cover and shoot in place
const bool bHasLOS = UPS_AI_Behavior_Statics::HasLineOfSight(
Pawn->GetWorld(), Pawn, Target, 150.0f);
if (!bHasLOS)
{
// No LOS and no firing position query → try to advance
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] CoverShootCycle: no LOS from cover and no FiringPositionQuery → advancing"),
*AIC->GetName());
Memory->SubState = EPS_AI_Behavior_CombatSubState::Advancing;
SetSubState(BB, EPS_AI_Behavior_CombatSubState::Advancing);
APS_AI_Behavior_CoverPoint* OldPoint =
Cast<APS_AI_Behavior_CoverPoint>(BB->GetValueAsObject(PS_AI_Behavior_BB::CoverPoint));
if (OldPoint) OldPoint->Release(Pawn);
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, true, true, true, false);
Memory->bMoveRequested = true;
Memory->CycleCount = 0;
}
else
{
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] CoverShootCycle: no LOS and no advancing cover → abandoning cover"),
*AIC->GetName());
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
}
return;
}
// Has LOS → shoot in place (legacy)
Memory->SubState = EPS_AI_Behavior_CombatSubState::Peeking;
Memory->PhaseDuration = FMath::RandRange(Memory->EffPeekMin, Memory->EffPeekMax);
Memory->Timer = Memory->PhaseDuration;
Memory->LOSCheckTimer = 0.3f;
Memory->bHasFiringPosition = false;
SetSubState(BB, EPS_AI_Behavior_CombatSubState::Peeking);
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_SetBehaviorCrouch(Pawn, false);
IPS_AI_Behavior_Interface::Execute_BehaviorStartAttack(Pawn, Target);
}
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] CoverShootCycle: peeking in place for %.1fs"),
*AIC->GetName(), Memory->PhaseDuration);
}
// ─── ReturnToCover ──────────────────────────────────────────────────────────
void UPS_AI_Behavior_BTTask_CoverShootCycle::ReturnToCover(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory,
APS_AI_Behavior_AIController* AIC, APawn* Pawn)
{
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
FCoverShootMemory* Memory = reinterpret_cast<FCoverShootMemory*>(NodeMemory);
// If we have a firing position (we moved away from cover), move back
if (Memory->bHasFiringPosition)
{
const FVector CoverLoc = BB->GetValueAsVector(PS_AI_Behavior_BB::CoverLocation);
Memory->SubState = EPS_AI_Behavior_CombatSubState::ReturningToCover;
SetSubState(BB, EPS_AI_Behavior_CombatSubState::ReturningToCover);
AIC->MoveToLocation(CoverLoc, 80.0f, true, true, true, false);
Memory->bMoveRequested = true;
}
else
{
// No firing position was used → already at cover, duck directly
Memory->SubState = EPS_AI_Behavior_CombatSubState::AtCover;
Memory->PhaseDuration = FMath::RandRange(Memory->EffCoverMin, Memory->EffCoverMax);
Memory->Timer = Memory->PhaseDuration;
SetSubState(BB, EPS_AI_Behavior_CombatSubState::AtCover);
CrouchAtCoverIfNeeded(Pawn, BB);
}
}
// ─── EQS Firing Position ────────────────────────────────────────────────────
void UPS_AI_Behavior_BTTask_CoverShootCycle::RunFiringPositionQuery(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, APawn* Pawn)
{
FCoverShootMemory* Memory = reinterpret_cast<FCoverShootMemory*>(NodeMemory);
UWorld* World = Pawn->GetWorld();
UEnvQueryManager* EQSManager = UEnvQueryManager::GetCurrent(World);
if (!EQSManager)
{
// Fallback: peek in place
Memory->bHasFiringPosition = false;
return;
}
Memory->bEQSRunning = true;
FEnvQueryRequest Request(FiringPositionQuery, Pawn);
Request.Execute(EEnvQueryRunMode::SingleResult,
FQueryFinishedSignature::CreateUObject(this,
&UPS_AI_Behavior_BTTask_CoverShootCycle::OnFiringPositionQueryFinished,
&OwnerComp, NodeMemory));
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] CoverShootCycle: firing position EQS launched"),
*Pawn->GetName());
}
void UPS_AI_Behavior_BTTask_CoverShootCycle::OnFiringPositionQueryFinished(
TSharedPtr<FEnvQueryResult> Result,
UBehaviorTreeComponent* OwnerComp, uint8* NodeMemory)
{
if (!OwnerComp || !NodeMemory) return;
FCoverShootMemory* Memory = reinterpret_cast<FCoverShootMemory*>(NodeMemory);
Memory->bEQSRunning = false;
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp->GetAIOwner());
if (!AIC || !AIC->GetPawn()) return;
APawn* Pawn = AIC->GetPawn();
UBlackboardComponent* BB = OwnerComp->GetBlackboardComponent();
if (!BB) return;
AActor* Target = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor));
if (Result.IsValid() && Result->IsSuccessful())
{
// Found a firing position → move to it
Memory->FiringPosition = Result->GetItemAsLocation(0);
Memory->bHasFiringPosition = true;
Memory->SubState = EPS_AI_Behavior_CombatSubState::MovingToFire;
SetSubState(BB, EPS_AI_Behavior_CombatSubState::MovingToFire);
// Stand up to move to firing position
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_SetBehaviorCrouch(Pawn, false);
}
AIC->MoveToLocation(Memory->FiringPosition, 50.0f, true, true, true, false);
Memory->bMoveRequested = true;
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] CoverShootCycle: moving to firing position %s"),
*AIC->GetName(), *Memory->FiringPosition.ToString());
#if ENABLE_DRAW_DEBUG
if (bDebugDraw)
{
UWorld* World = Pawn->GetWorld();
// Draw all EQS firing candidates as solid boxes
const int32 NumItems = Result->Items.Num();
for (int32 i = 0; i < NumItems; ++i)
{
if (!Result->Items[i].IsValid()) continue;
const FVector ItemLoc = Result->GetItemAsLocation(i);
const float ItemScore = Result->GetItemScore(i);
const uint8 G = static_cast<uint8>(FMath::Lerp(50.0f, 255.0f, FMath::Clamp(ItemScore, 0.0f, 1.0f)));
DrawDebugBox(World, ItemLoc + FVector(0, 0, 20.0f),
FVector(8.0f), FColor(255, G, 0), false, 5.0f);
}
// Chosen firing position: large red point
DrawDebugSphere(World, Memory->FiringPosition + FVector(0, 0, 30.0f),
25.0f, 8, FColor::Red, false, 5.0f);
DrawDebugString(World, Memory->FiringPosition + FVector(0, 0, 55.0f),
FString::Printf(TEXT("FIRE POS (%d)"), NumItems),
nullptr, FColor::Red, 5.0f, true);
}
#endif
}
else
{
// 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());
if (Target)
{
const bool bHasLOS = UPS_AI_Behavior_Statics::HasLineOfSight(
Pawn->GetWorld(), Pawn, Target, 150.0f);
if (bHasLOS)
{
Memory->SubState = EPS_AI_Behavior_CombatSubState::Peeking;
Memory->PhaseDuration = FMath::RandRange(Memory->EffPeekMin, Memory->EffPeekMax);
Memory->Timer = Memory->PhaseDuration;
Memory->LOSCheckTimer = 0.3f;
SetSubState(BB, EPS_AI_Behavior_CombatSubState::Peeking);
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_SetBehaviorCrouch(Pawn, false);
IPS_AI_Behavior_Interface::Execute_BehaviorStartAttack(Pawn, Target);
}
}
else
{
// No LOS, no firing position → stay in cover, wait
Memory->SubState = EPS_AI_Behavior_CombatSubState::AtCover;
Memory->PhaseDuration = FMath::RandRange(Memory->EffCoverMin, Memory->EffCoverMax);
Memory->Timer = Memory->PhaseDuration;
SetSubState(BB, EPS_AI_Behavior_CombatSubState::AtCover);
}
}
}
}
// ─── AbortTask ──────────────────────────────────────────────────────────────
EBTNodeResult::Type UPS_AI_Behavior_BTTask_CoverShootCycle::AbortTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
@ -459,7 +679,6 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_CoverShootCycle::AbortTask(
{
AIC->StopMovement();
// Stop attacking and stand up if we were peeking/crouching
APawn* Pawn = AIC->GetPawn();
if (Pawn && Pawn->Implements<UPS_AI_Behavior_Interface>())
{
@ -471,13 +690,14 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_CoverShootCycle::AbortTask(
return EBTNodeResult::Aborted;
}
// ─── OnTaskFinished ─────────────────────────────────────────────────────────
void UPS_AI_Behavior_BTTask_CoverShootCycle::OnTaskFinished(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, EBTNodeResult::Type TaskResult)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (AIC)
{
// Stop attacking and stand up
APawn* Pawn = AIC->GetPawn();
if (Pawn && Pawn->Implements<UPS_AI_Behavior_Interface>())
{
@ -485,7 +705,6 @@ void UPS_AI_Behavior_BTTask_CoverShootCycle::OnTaskFinished(
IPS_AI_Behavior_Interface::Execute_SetBehaviorCrouch(Pawn, false);
}
// Release cover point
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (BB)
{
@ -516,39 +735,28 @@ APS_AI_Behavior_CoverPoint* UPS_AI_Behavior_BTTask_CoverShootCycle::FindAdvancin
{
APS_AI_Behavior_CoverPoint* Point = *It;
if (!Point || !Point->bEnabled) continue;
// Type filter
if (Point->PointType != CoverPointType) continue;
// NPC type accessibility
if (!Point->IsAccessibleTo(NPCType)) continue;
// Availability
if (!Point->HasRoom()) continue;
// Distance check from NPC
const float DistFromNpc = FVector::Dist(NpcLoc, Point->GetActorLocation());
if (DistFromNpc > AdvanceSearchRadius) continue;
// Must be closer to threat than NPC currently is
const float CoverDistToThreat = FVector::Dist(Point->GetActorLocation(), ThreatLoc);
if (CoverDistToThreat >= NpcDistToThreat) continue;
// Evaluate quality against threat
float Score = Point->EvaluateAgainstThreat(ThreatLoc);
// Distance bonus — closer to NPC is better (less travel time)
Score += FMath::GetMappedRangeValueClamped(
FVector2D(0.0f, AdvanceSearchRadius), FVector2D(0.15f, 0.0f), DistFromNpc);
// Advancement bonus — how much closer to threat this cover gets us
if (NpcDistToThreat > 0.0f)
{
const float AdvanceRatio = (NpcDistToThreat - CoverDistToThreat) / NpcDistToThreat;
Score += AdvancementBias * AdvanceRatio * 0.3f;
}
// LOS bonus — strongly favor covers with clear line of sight to the target
// LOS bonus — favor covers near positions with LOS to threat
{
const FVector TraceStart = Point->GetActorLocation() + FVector(0, 0, 150.0f);
FHitResult Hit;
@ -556,7 +764,7 @@ APS_AI_Behavior_CoverPoint* UPS_AI_Behavior_BTTask_CoverShootCycle::FindAdvancin
if (!const_cast<UWorld*>(World)->LineTraceSingleByChannel(
Hit, TraceStart, ThreatLoc, ECC_Visibility, Params))
{
Score += 0.3f; // Clear LOS from this cover
Score += 0.3f;
}
}
@ -570,11 +778,14 @@ APS_AI_Behavior_CoverPoint* UPS_AI_Behavior_BTTask_CoverShootCycle::FindAdvancin
return BestPoint;
}
// ─── GetStaticDescription ───────────────────────────────────────────────────
FString UPS_AI_Behavior_BTTask_CoverShootCycle::GetStaticDescription() const
{
return FString::Printf(
TEXT("Cover-shoot cycle.\nPeek: %.1f%.1fs | Cover: %.1f%.1fs\nAdvance after %d cycles (radius %.0fcm)"),
TEXT("Cover-shoot cycle.\nPeek: %.1f%.1fs | Cover: %.1f%.1fs\nAdvance after %d cycles (radius %.0fcm)\nFiring EQS: %s"),
PeekDurationMin, PeekDurationMax,
CoverDurationMin, CoverDurationMax,
MaxCyclesBeforeAdvance, AdvanceSearchRadius);
MaxCyclesBeforeAdvance, AdvanceSearchRadius,
FiringPositionQuery ? *FiringPositionQuery->GetName() : TEXT("None (in-place)"));
}

View File

@ -12,7 +12,21 @@
#include "CollisionQueryParams.h"
#include "Engine/World.h"
#include "EngineUtils.h"
#include "EnvironmentQuery/EnvQuery.h"
#include "EnvironmentQuery/EnvQueryManager.h"
#include "DrawDebugHelpers.h"
namespace
{
FColor ScoreToColor(float Score)
{
// 0 = red, 0.5 = yellow, 1 = green
const float Clamped = FMath::Clamp(Score, 0.0f, 1.0f);
const uint8 R = static_cast<uint8>(FMath::Lerp(255.0f, 0.0f, Clamped));
const uint8 G = static_cast<uint8>(FMath::Lerp(0.0f, 255.0f, Clamped));
return FColor(R, G, 0);
}
}
UPS_AI_Behavior_BTTask_FindCover::UPS_AI_Behavior_BTTask_FindCover()
{
@ -85,6 +99,17 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_FindCover::ExecuteTask(
}
const float Score = EvaluateCoverQuality(World, NavLoc.Location, ThreatLoc, NpcLoc);
#if ENABLE_DRAW_DEBUG
if (bDebugDraw)
{
DrawDebugSphere(World, NavLoc.Location + FVector(0, 0, 30.0f),
15.0f, 6, ScoreToColor(Score), false, 5.0f);
DrawDebugString(World, NavLoc.Location + FVector(0, 0, 55.0f),
FString::Printf(TEXT("%.2f"), Score), nullptr, FColor::White, 5.0f, true);
}
#endif
if (Score > BestScore)
{
BestScore = Score;
@ -122,6 +147,25 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_FindCover::ExecuteTask(
BB->SetValueAsVector(PS_AI_Behavior_BB::CoverLocation, BestCoverPos);
#if ENABLE_DRAW_DEBUG
if (bDebugDraw)
{
// Search radius circle
DrawDebugCircle(World, NpcLoc, SearchRadius, 32, FColor(80, 80, 80),
false, 5.0f, 0, 1.0f, FVector::RightVector, FVector::ForwardVector);
// Chosen cover: large sphere + line from NPC
DrawDebugSphere(World, BestCoverPos + FVector(0, 0, 40.0f),
30.0f, 10, FColor::Cyan, false, 5.0f);
DrawDebugLine(World, NpcLoc, BestCoverPos, FColor::Cyan, false, 5.0f, 0, 1.5f);
DrawDebugString(World, BestCoverPos + FVector(0, 0, 65.0f),
FString::Printf(TEXT("COVER %.2f %s"), BestScore,
ChosenPoint ? TEXT("(Manual)") : TEXT("(Procedural)")),
nullptr, FColor::Cyan, 5.0f, true);
// Line to threat
DrawDebugLine(World, BestCoverPos, ThreatLoc, FColor(255, 100, 0), false, 5.0f, 0, 1.0f);
}
#endif
FCoverMemory* Memory = reinterpret_cast<FCoverMemory*>(NodeMemory);
// If we have a refinement EQS query and a manual CoverPoint, refine the position
@ -262,6 +306,18 @@ APS_AI_Behavior_CoverPoint* UPS_AI_Behavior_BTTask_FindCover::FindBestManualCove
}
}
// Debug: sphere colored by score (cover candidates)
#if ENABLE_DRAW_DEBUG
if (bDebugDraw)
{
const FVector Loc = Point->GetActorLocation();
DrawDebugSphere(const_cast<UWorld*>(World), Loc + FVector(0, 0, 30.0f),
15.0f, 6, ScoreToColor(Score), false, 5.0f);
DrawDebugString(const_cast<UWorld*>(World), Loc + FVector(0, 0, 55.0f),
FString::Printf(TEXT("%.2f"), Score), nullptr, FColor::White, 5.0f, true);
}
#endif
if (Score > OutScore)
{
OutScore = Score;
@ -383,6 +439,38 @@ void UPS_AI_Behavior_BTTask_FindCover::OnRefinementQueryFinished(
FinalPos = Result->GetItemAsLocation(0);
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] FindCover: EQS refined position %s (was %s)"),
*AIC->GetName(), *FinalPos.ToString(), *OriginalCoverPos.ToString());
#if ENABLE_DRAW_DEBUG
if (bDebugDraw)
{
UWorld* World = AIC->GetWorld();
if (World)
{
// Draw EQS refinement items as BOXES (cover = spheres, refinement = boxes)
const int32 NumItems = Result->Items.Num();
for (int32 i = 0; i < NumItems; ++i)
{
if (!Result->Items[i].IsValid()) continue;
const FVector ItemLoc = Result->GetItemAsLocation(i);
const float ItemScore = Result->GetItemScore(i);
DrawDebugBox(World, ItemLoc + FVector(0, 0, 20.0f),
FVector(8.0f), ScoreToColor(ItemScore), false, 5.0f);
}
// Original position (yellow box)
DrawDebugBox(World, OriginalCoverPos + FVector(0, 0, 50.0f),
FVector(15.0f), FColor::Yellow, false, 5.0f, 0, 2.0f);
// Refined position (green box, bigger)
DrawDebugBox(World, FinalPos + FVector(0, 0, 50.0f),
FVector(20.0f), FColor::Green, false, 5.0f, 0, 2.5f);
// Arrow from original to refined
DrawDebugDirectionalArrow(World, OriginalCoverPos + FVector(0, 0, 50.0f),
FinalPos + FVector(0, 0, 50.0f), 10.0f, FColor::Green, false, 5.0f, 0, 1.5f);
DrawDebugString(World, FinalPos + FVector(0, 0, 75.0f),
FString::Printf(TEXT("REFINED (%d)"), NumItems),
nullptr, FColor::Green, 5.0f, true);
}
}
#endif
}
else
{

View File

@ -0,0 +1,30 @@
// Copyright Asterion. All Rights Reserved.
#include "EQS/PS_AI_Behavior_EQSContext_CoverLocation.h"
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_Definitions.h"
#include "EnvironmentQuery/EnvQueryTypes.h"
#include "EnvironmentQuery/Items/EnvQueryItemType_Point.h"
#include "BehaviorTree/BlackboardComponent.h"
void UPS_AI_Behavior_EQSContext_CoverLocation::ProvideContext(
FEnvQueryInstance& QueryInstance, FEnvQueryContextData& ContextData) const
{
const AActor* QuerierActor = Cast<AActor>(QueryInstance.Owner.Get());
if (!QuerierActor) return;
const APawn* QuerierPawn = Cast<APawn>(QuerierActor);
if (!QuerierPawn) return;
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(QuerierPawn->GetController());
if (!AIC) return;
UBlackboardComponent* BB = AIC->GetBlackboardComponent();
if (!BB) return;
const FVector CoverLoc = BB->GetValueAsVector(PS_AI_Behavior_BB::CoverLocation);
if (!CoverLoc.IsZero())
{
UEnvQueryItemType_Point::SetContextHelper(ContextData, CoverLoc);
}
}

View File

@ -0,0 +1,37 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTDecorator.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_BTDecorator_IsCoverNeeded.generated.h"
/**
* BT Decorator: Checks whether the current threat target warrants taking cover.
*
* Reads the ThreatActor from Blackboard and inspects its NPCType.
* Cover is considered necessary against armed/dangerous targets (Protector, Enemy)
* but not against unarmed targets (Civilian).
*
* Place on the cover-shoot sequence so that enemies skip cover when chasing civilians.
*/
UCLASS(meta = (DisplayName = "PS AI: Is Cover Needed"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTDecorator_IsCoverNeeded : public UBTDecorator
{
GENERATED_BODY()
public:
UPS_AI_Behavior_BTDecorator_IsCoverNeeded();
/**
* NPC types that are considered dangerous enough to require cover.
* If the threat's NPCType is NOT in this set, the decorator fails skip cover.
*/
UPROPERTY(EditAnywhere, Category = "Cover", meta = (Bitmask, BitmaskEnum = "/Script/PS_AI_Behavior.EPS_AI_Behavior_NPCType"))
TArray<EPS_AI_Behavior_NPCType> DangerousTargetTypes;
protected:
virtual bool CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const override;
virtual FString GetStaticDescription() const override;
};

View File

@ -4,19 +4,24 @@
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "EnvironmentQuery/EnvQueryTypes.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_BTTask_CoverShootCycle.generated.h"
class APS_AI_Behavior_AIController;
class APS_AI_Behavior_CoverPoint;
class UEnvQuery;
/**
* BT Task: Cover-shoot cycle for ranged combat.
*
* State machine: Engaging AtCover Peeking AtCover ... Advancing AtCover ...
* State machine:
* Engaging AtCover (crouch) [EQS] MovingToFire Peeking (shoot)
* ReturningToCover AtCover ... Advancing AtCover ...
*
* The NPC moves to the cover position from Blackboard, then alternates between
* ducking (AtCover) and shooting (Peeking). After MaxCyclesBeforeAdvance peek/duck
* cycles, advances to a closer cover point toward the threat.
* If FiringPositionQuery is set, the NPC physically moves between the cover
* position (protected) and a nearby firing position (with LOS to threat).
* If null, the NPC shoots from cover (legacy behavior: stand up and fire in place).
*
* Personality traits modulate timing:
* - Aggressivity shorter cover duration, advances sooner
@ -54,6 +59,17 @@ public:
UPROPERTY(EditAnywhere, Category = "Cover Shoot|Timing", meta = (ClampMin = "0.5"))
float CoverDurationMax = 3.0f;
// ─── Firing Position ────────────────────────────────────────────────
/**
* Optional EQS query to find a firing position near the cover.
* Should return positions with LOS to threat, close to CoverLocation.
* Use OnCircle generator around PS AI: Cover Location + LineOfSight filter + Distance score.
* If null, the NPC shoots from cover (stand up in place legacy fallback).
*/
UPROPERTY(EditAnywhere, Category = "Cover Shoot|Firing Position")
TObjectPtr<UEnvQuery> FiringPositionQuery;
// ─── Advancement ────────────────────────────────────────────────────
/** Number of peek/duck cycles before advancing to a closer cover. */
@ -72,6 +88,12 @@ public:
UPROPERTY(EditAnywhere, Category = "Cover Shoot|Advancement")
EPS_AI_Behavior_CoverPointType CoverPointType = EPS_AI_Behavior_CoverPointType::Cover;
// ─── Debug ─────────────────────────────────────────────────────────
/** Draw debug info: sub-state label, LOS line, cycle counter, firing position. */
UPROPERTY(EditAnywhere, Category = "Cover Shoot|Debug")
bool bDebugDraw = false;
protected:
virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
virtual void TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
@ -87,7 +109,12 @@ private:
float PhaseDuration = 0.0f;
int32 CycleCount = 0;
bool bMoveRequested = false;
float LOSCheckTimer = 0.0f; // cooldown for LOS checks during Peeking
float LOSCheckTimer = 0.0f;
// Firing position (found via EQS)
FVector FiringPosition = FVector::ZeroVector;
bool bHasFiringPosition = false;
bool bEQSRunning = false;
// Effective durations (modulated by personality)
float EffPeekMin = 2.0f;
@ -104,4 +131,19 @@ private:
APS_AI_Behavior_CoverPoint* FindAdvancingCover(
const UWorld* World, const FVector& NpcLoc, const FVector& ThreatLoc,
EPS_AI_Behavior_NPCType NPCType, float& OutScore) const;
/** Run EQS to find the best firing position near the cover. */
void RunFiringPositionQuery(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, APawn* Pawn);
/** Callback when the firing position EQS query completes. */
void OnFiringPositionQueryFinished(TSharedPtr<FEnvQueryResult> Result,
UBehaviorTreeComponent* OwnerComp, uint8* NodeMemory);
/** Transition to peeking: move to firing position or shoot in place. */
void StartPeeking(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory,
APS_AI_Behavior_AIController* AIC, APawn* Pawn, AActor* Target);
/** Transition back to cover after shooting. */
void ReturnToCover(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory,
APS_AI_Behavior_AIController* AIC, APawn* Pawn);
};

View File

@ -81,6 +81,12 @@ public:
UPROPERTY(EditAnywhere, Category = "Cover|EQS Refinement")
TObjectPtr<UEnvQuery> RefinementQuery;
// ─── Debug ─────────────────────────────────────────────────────────
/** Draw debug spheres showing cover candidates and their scores at runtime. */
UPROPERTY(EditAnywhere, Category = "Cover|Debug")
bool bDebugDraw = false;
/** Radius around the CoverPoint for EQS refinement search (cm). */
UPROPERTY(EditAnywhere, Category = "Cover|EQS Refinement", meta = (ClampMin = "100.0", ClampMax = "500.0"))
float RefinementRadius = 200.0f;

View File

@ -0,0 +1,22 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "EnvironmentQuery/EnvQueryContext.h"
#include "PS_AI_Behavior_EQSContext_CoverLocation.generated.h"
/**
* EQS Context: Returns the current CoverLocation from the Blackboard.
* Use as the "Generate Around" context in EQS queries (e.g., OnCircle generator)
* to generate refinement candidates around the chosen CoverPoint instead of around the Querier.
*/
UCLASS(meta = (DisplayName = "PS AI: Cover Location"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_EQSContext_CoverLocation : public UEnvQueryContext
{
GENERATED_BODY()
public:
virtual void ProvideContext(FEnvQueryInstance& QueryInstance,
FEnvQueryContextData& ContextData) const override;
};

View File

@ -82,10 +82,12 @@ enum class EPS_AI_Behavior_CombatType : uint8
UENUM(BlueprintType)
enum class EPS_AI_Behavior_CombatSubState : uint8
{
Engaging UMETA(DisplayName = "Engaging", ToolTip = "Moving to combat position"),
AtCover UMETA(DisplayName = "At Cover", ToolTip = "Ducked behind cover"),
Peeking UMETA(DisplayName = "Peeking", ToolTip = "Leaning out, shooting"),
Advancing UMETA(DisplayName = "Advancing", ToolTip = "Moving to next cover"),
Engaging UMETA(DisplayName = "Engaging", ToolTip = "Moving to combat position"),
AtCover UMETA(DisplayName = "At Cover", ToolTip = "Ducked behind cover"),
MovingToFire UMETA(DisplayName = "Moving To Fire", ToolTip = "Moving to firing position"),
Peeking UMETA(DisplayName = "Peeking", ToolTip = "At firing position, shooting"),
ReturningToCover UMETA(DisplayName = "Returning To Cover", ToolTip = "Moving back to cover after shooting"),
Advancing UMETA(DisplayName = "Advancing", ToolTip = "Moving to next cover"),
};
/** Personality trait axes — each scored 0.0 (low) to 1.0 (high). */