Fix perception to separate Pawn (team checks) from ThreatTarget (aiming)

Split ResolveToPawn into FindOwningPawn + GetThreatTarget so non-Pawn
actors (PS_AimTargetActor) are properly resolved for team/attitude checks
while still being used as BB target. Add attack range hysteresis (10%
buffer), target persistence (80% threshold), melee no-cooldown chase,
ranged midpoint approach. New files: CoverShootCycle task, CheckCombatType
decorator, MinAttackRange/MaxAttackRange in PersonalityProfile.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
j.foucher 2026-03-27 20:20:42 +01:00
parent e7c3598dce
commit 2588883a1c
18 changed files with 1593 additions and 180 deletions

View File

@ -0,0 +1,29 @@
// Copyright Asterion. All Rights Reserved.
#include "BT/PS_AI_Behavior_BTDecorator_CheckCombatType.h"
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_Interface.h"
UPS_AI_Behavior_BTDecorator_CheckCombatType::UPS_AI_Behavior_BTDecorator_CheckCombatType()
{
NodeName = TEXT("Check Combat Type");
}
bool UPS_AI_Behavior_BTDecorator_CheckCombatType::CalculateRawConditionValue(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC) return false;
APawn* Pawn = AIC->GetPawn();
if (!Pawn || !Pawn->Implements<UPS_AI_Behavior_Interface>()) return false;
return IPS_AI_Behavior_Interface::Execute_GetBehaviorCombatType(Pawn) == RequiredType;
}
FString UPS_AI_Behavior_BTDecorator_CheckCombatType::GetStaticDescription() const
{
const UEnum* TypeEnum = StaticEnum<EPS_AI_Behavior_CombatType>();
return FString::Printf(TEXT("Combat Type == %s"),
*TypeEnum->GetDisplayNameTextByValue(static_cast<int64>(RequiredType)).ToString());
}

View File

@ -2,10 +2,13 @@
#include "BT/PS_AI_Behavior_BTService_EvaluateReaction.h"
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_PerceptionComponent.h"
#include "PS_AI_Behavior_Interface.h"
#include "PS_AI_Behavior_PersonalityComponent.h"
#include "PS_AI_Behavior_PersonalityProfile.h"
#include "PS_AI_Behavior_Definitions.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "Perception/AISense_Hearing.h"
UPS_AI_Behavior_BTService_EvaluateReaction::UPS_AI_Behavior_BTService_EvaluateReaction()
{
@ -35,25 +38,124 @@ void UPS_AI_Behavior_BTService_EvaluateReaction::TickNode(
const bool bHostile = IPS_AI_Behavior_Interface::Execute_IsBehaviorHostile(Pawn);
const EPS_AI_Behavior_NPCType NPCType = IPS_AI_Behavior_Interface::Execute_GetBehaviorNPCType(Pawn);
// An infiltrated Enemy (hostile=false) has TeamId=1 (civilian disguise).
// When hostile flips to true, switch to TeamId=2 (enemy).
// Get faction from profile
const uint8 Faction = (Personality && Personality->Profile)
? Personality->Profile->Faction : 0;
// Infiltrated Enemy (hostile=false) → disguised as Civilian.
// When hostile flips to true → reveal true Enemy TeamId.
uint8 ExpectedTeamId;
switch (NPCType)
if (NPCType == EPS_AI_Behavior_NPCType::Enemy && !bHostile)
{
case EPS_AI_Behavior_NPCType::Civilian: ExpectedTeamId = 1; break;
case EPS_AI_Behavior_NPCType::Enemy: ExpectedTeamId = bHostile ? 2 : 1; break;
case EPS_AI_Behavior_NPCType::Protector: ExpectedTeamId = 3; break;
default: ExpectedTeamId = FGenericTeamId::NoTeam; break;
ExpectedTeamId = PS_AI_Behavior_Team::DisguisedTeamId;
}
else
{
ExpectedTeamId = PS_AI_Behavior_Team::MakeTeamId(NPCType, Faction);
}
if (AIC->GetGenericTeamId().GetId() != ExpectedTeamId)
{
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Hostility changed: TeamId %d -> %d (hostile=%d)"),
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Hostility changed: TeamId 0x%02X -> 0x%02X (hostile=%d)"),
*AIC->GetName(), AIC->GetGenericTeamId().GetId(), ExpectedTeamId, (int32)bHostile);
AIC->SetTeamId(ExpectedTeamId);
}
}
// ─── Gunshot reaction: flip non-hostile enemies to hostile ──────────
if (Pawn && Pawn->Implements<UPS_AI_Behavior_Interface>())
{
const EPS_AI_Behavior_NPCType NPCType = IPS_AI_Behavior_Interface::Execute_GetBehaviorNPCType(Pawn);
const bool bHostile = IPS_AI_Behavior_Interface::Execute_IsBehaviorHostile(Pawn);
if (NPCType == EPS_AI_Behavior_NPCType::Enemy && !bHostile)
{
// Check if this enemy perceives a gunshot stimulus → become hostile toward shooter
if (UPS_AI_Behavior_PerceptionComponent* Perception = AIC->GetBehaviorPerception())
{
AActor* GunShotInstigator = nullptr;
TArray<AActor*> PerceivedActors;
Perception->GetCurrentlyPerceivedActors(nullptr, PerceivedActors);
for (AActor* RawActor : PerceivedActors)
{
if (!RawActor || GunShotInstigator) continue;
// Resolve weapon/item to owning Pawn (walk Owner/Instigator chain)
AActor* ResolvedActor = RawActor;
if (!Cast<APawn>(RawActor))
{
AActor* Cur = RawActor;
for (int32 D = 0; D < 4; ++D)
{
if (APawn* IP = Cur->GetInstigator()) { ResolvedActor = IP; break; }
AActor* OA = Cur->GetOwner();
if (!OA || OA == Cur) break;
if (APawn* OP = Cast<APawn>(OA)) { ResolvedActor = OP; break; }
Cur = OA;
}
}
// Skip same exact team (same NPCType + same Faction)
// Allied teams still allow gunfire through (e.g. disguised enemy hears Protector fire)
const uint8 MyTeam = AIC->GetGenericTeamId().GetId();
const uint8 TheirTeam = UPS_AI_Behavior_PerceptionComponent::GetActorTeamId(ResolvedActor);
if (MyTeam == TheirTeam)
{
continue;
}
FActorPerceptionBlueprintInfo Info;
if (Perception->GetActorsPerception(RawActor, Info))
{
for (const FAIStimulus& S : Info.LastSensedStimuli)
{
if (S.IsValid() &&
S.Type == UAISense::GetSenseID<UAISense_Hearing>() &&
PS_AI_Behavior_Tags_Internal::IsGunfire(S.Tag))
{
// For VR: check if Pawn has a custom threat actor
if (ResolvedActor->Implements<UPS_AI_Behavior_Interface>())
{
AActor* ThreatActor = IPS_AI_Behavior_Interface::Execute_GetBehaviorThreatActor(ResolvedActor);
if (ThreatActor) ResolvedActor = ThreatActor;
}
GunShotInstigator = ResolvedActor;
break;
}
}
}
}
if (GunShotInstigator)
{
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] Gunshot heard from '%s' — becoming hostile!"),
*AIC->GetName(), *GunShotInstigator->GetName());
// 1. Flip hostile → TeamId will update on next block above
IPS_AI_Behavior_Interface::Execute_SetBehaviorHostile(Pawn, true);
// 2. Immediately update TeamId so perception sees the shooter as hostile NOW
const uint8 EnemyFaction = (Personality && Personality->Profile)
? Personality->Profile->Faction : 0;
AIC->SetTeamId(PS_AI_Behavior_Team::MakeTeamId(EPS_AI_Behavior_NPCType::Enemy, EnemyFaction));
// 3. Write the shooter as ThreatActor in BB so combat targets them directly
BB->SetValueAsObject(PS_AI_Behavior_BB::ThreatActor, GunShotInstigator);
BB->SetValueAsVector(PS_AI_Behavior_BB::ThreatLocation, GunShotInstigator->GetActorLocation());
// 4. Set a meaningful threat level so EvaluateReaction enters Combat
if (Personality)
{
Personality->PerceivedThreatLevel = FMath::Max(Personality->PerceivedThreatLevel, 0.6f);
BB->SetValueAsFloat(PS_AI_Behavior_BB::ThreatLevel, Personality->PerceivedThreatLevel);
}
}
}
}
}
// ─── Evaluate and apply the reaction ────────────────────────────────
EPS_AI_Behavior_State NewState = Personality->ApplyReaction();

View File

@ -4,8 +4,11 @@
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_Interface.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_PersonalityComponent.h"
#include "PS_AI_Behavior_PersonalityProfile.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "Navigation/PathFollowingComponent.h"
#include "NavigationSystem.h"
UPS_AI_Behavior_BTTask_Attack::UPS_AI_Behavior_BTTask_Attack()
{
@ -42,28 +45,68 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_Attack::ExecuteTask(
return EBTNodeResult::Failed;
}
}
// ─── Query combat type and range ─────────────────────────────────
FAttackMemory* Memory = reinterpret_cast<FAttackMemory*>(NodeMemory);
Memory->bMovingToTarget = false;
Memory->bAttacking = false;
Memory->bInRange = false;
Memory->RepositionTimer = 0.0f;
Memory->CombatType = EPS_AI_Behavior_CombatType::Melee;
Memory->MinRange = 100.0f;
Memory->MaxRange = AttackMoveRadius;
// Tell the Pawn to start attacking via interface
// CombatType from interface (depends on weapon/pawn)
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
Memory->CombatType = IPS_AI_Behavior_Interface::Execute_GetBehaviorCombatType(Pawn);
}
// Min/Max attack range from PersonalityProfile
if (UPS_AI_Behavior_PersonalityComponent* Personality = AIC->GetPersonalityComponent())
{
if (Personality->Profile)
{
Memory->MinRange = Personality->Profile->MinAttackRange;
Memory->MaxRange = Personality->Profile->MaxAttackRange;
}
}
// Melee: approach to half MinRange (get close). Ranged: approach to midpoint of band.
const float ApproachRange = (Memory->CombatType == EPS_AI_Behavior_CombatType::Melee)
? Memory->MinRange * 0.5f : (Memory->MinRange + Memory->MaxRange) * 0.5f;
// Check if already in range
const float DistToTarget = FVector::Dist(Pawn->GetActorLocation(), Target->GetActorLocation());
if (DistToTarget <= Memory->MaxRange)
{
Memory->bInRange = true;
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_BehaviorStartAttack(Pawn, Target);
Memory->bAttacking = true;
}
}
// Move toward target
const EPathFollowingRequestResult::Type Result = AIC->MoveToActor(
Target, AttackMoveRadius, /*bUsePathfinding=*/true, /*bAllowStrafe=*/true);
// Initial move toward target if not in range
if (DistToTarget > Memory->MaxRange)
{
const EPathFollowingRequestResult::Type Result = AIC->MoveToLocation(
Target->GetActorLocation(), ApproachRange, /*bStopOnOverlap=*/true,
/*bUsePathfinding=*/true, /*bProjectGoal=*/true, /*bCanStrafe=*/false);
if (Result != EPathFollowingRequestResult::AlreadyAtGoal)
{
Memory->bMovingToTarget = true;
}
}
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Attack: started on '%s'"),
*AIC->GetName(), *Target->GetName());
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: started on '%s' (%s, range=[%.0f-%.0f], dist=%.0f, inRange=%d, attacking=%d, hasInterface=%d)"),
*AIC->GetName(), *Target->GetName(),
Memory->CombatType == EPS_AI_Behavior_CombatType::Ranged ? TEXT("Ranged") : TEXT("Melee"),
Memory->MinRange, Memory->MaxRange, DistToTarget,
Memory->bInRange ? 1 : 0, Memory->bAttacking ? 1 : 0,
Pawn->Implements<UPS_AI_Behavior_Interface>() ? 1 : 0);
// Stay InProgress — the Decorator Observer Aborts will pull us out
return EBTNodeResult::InProgress;
@ -105,15 +148,108 @@ void UPS_AI_Behavior_BTTask_Attack::TickTask(
FAttackMemory* Memory = reinterpret_cast<FAttackMemory*>(NodeMemory);
// Keep moving toward target if out of range
if (Memory->bMovingToTarget && AIC->GetMoveStatus() == EPathFollowingStatus::Idle)
// Tick reposition cooldown
if (Memory->RepositionTimer > 0.0f)
{
// Re-issue move if target moved
AIC->MoveToActor(Target, AttackMoveRadius, /*bUsePathfinding=*/true, /*bAllowStrafe=*/true);
Memory->RepositionTimer -= DeltaSeconds;
}
// The Pawn handles the actual shooting/melee via the interface
// We just keep the NPC moving toward the target
const float DistToTarget = FVector::Dist(Pawn->GetActorLocation(), Target->GetActorLocation());
const bool bCanReposition = (Memory->RepositionTimer <= 0.0f);
if (Memory->CombatType == EPS_AI_Behavior_CombatType::Melee)
{
// ─── Melee: continuously chase target (no cooldown — always pursue) ──
if (AIC->GetMoveStatus() == EPathFollowingStatus::Idle && DistToTarget > Memory->MinRange)
{
AIC->MoveToLocation(
Target->GetActorLocation(), Memory->MinRange * 0.5f, /*bStopOnOverlap=*/true,
/*bUsePathfinding=*/true, /*bProjectGoal=*/true, /*bCanStrafe=*/false);
}
}
else
{
// ─── Ranged: maintain distance between MinRange and MaxRange ─
const float MidRange = (Memory->MinRange + Memory->MaxRange) * 0.5f;
if (AIC->GetMoveStatus() == EPathFollowingStatus::Idle)
{
if (bCanReposition && DistToTarget < Memory->MinRange)
{
// Too close — back away to midpoint of band
const FVector AwayDir = (Pawn->GetActorLocation() - Target->GetActorLocation()).GetSafeNormal2D();
const float RetreatDist = MidRange - DistToTarget + 50.0f;
const FVector RetreatPoint = Pawn->GetActorLocation() + AwayDir * RetreatDist;
// Project to navmesh
UNavigationSystemV1* NavSys = FNavigationSystem::GetCurrent<UNavigationSystemV1>(GetWorld());
if (NavSys)
{
FNavLocation NavLoc;
if (NavSys->ProjectPointToNavigation(RetreatPoint, NavLoc, FVector(300.0f, 300.0f, 200.0f)))
{
AIC->MoveToLocation(
NavLoc.Location, 50.0f, /*bStopOnOverlap=*/true,
/*bUsePathfinding=*/true, /*bProjectGoal=*/false, /*bCanStrafe=*/false);
// Longer cooldown after retreat to prevent repeated backing
Memory->RepositionTimer = RepositionCooldown * 3.0f;
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Attack: backing away %.0fcm (dist=%.0f < min=%.0f)"),
*AIC->GetName(), RetreatDist, DistToTarget, Memory->MinRange);
}
}
}
else if (DistToTarget > Memory->MaxRange)
{
// Too far — advance toward target to midpoint of band (no cooldown — chase aggressively)
AIC->MoveToLocation(
Target->GetActorLocation(), MidRange, /*bStopOnOverlap=*/true,
/*bUsePathfinding=*/true, /*bProjectGoal=*/true, /*bCanStrafe=*/false);
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Attack: advancing to midRange=%.0f (dist=%.0f > max=%.0f)"),
*AIC->GetName(), MidRange, DistToTarget, Memory->MaxRange);
}
// else: between MinRange and MaxRange — hold position, Pawn handles shooting
}
}
// ─── Toggle attack based on range (with hysteresis to prevent flickering) ──
// Enter range at MaxRange, leave range at MaxRange + buffer
const float EnterRange = Memory->MaxRange;
const float LeaveRange = Memory->MaxRange * 1.1f; // 10% hysteresis
const bool bNowInRange = Memory->bInRange
? (DistToTarget <= LeaveRange) // already in range → need to go PAST LeaveRange to exit
: (DistToTarget <= EnterRange); // not in range → need to get WITHIN EnterRange to enter
if (bNowInRange && !Memory->bInRange)
{
// Entered range → start attacking
Memory->bInRange = true;
if (Pawn->Implements<UPS_AI_Behavior_Interface>() && !Memory->bAttacking)
{
IPS_AI_Behavior_Interface::Execute_BehaviorStartAttack(Pawn, Target);
Memory->bAttacking = true;
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: in range (%.0f <= %.0f) — StartAttack on '%s'"),
*AIC->GetName(), DistToTarget, AttackRange, *Target->GetName());
}
else if (!Pawn->Implements<UPS_AI_Behavior_Interface>())
{
UE_LOG(LogPS_AI_Behavior, Error, TEXT("[%s] Attack: in range but Pawn does NOT implement IPS_AI_Behavior_Interface — StartAttack cannot be called!"),
*AIC->GetName());
}
}
else if (!bNowInRange && Memory->bInRange)
{
// Left range → stop attacking
Memory->bInRange = false;
if (Pawn->Implements<UPS_AI_Behavior_Interface>() && Memory->bAttacking)
{
IPS_AI_Behavior_Interface::Execute_BehaviorStopAttack(Pawn);
Memory->bAttacking = false;
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: out of range (%.0f > %.0f) — StopAttack"),
*AIC->GetName(), DistToTarget, AttackRange);
}
}
}
EBTNodeResult::Type UPS_AI_Behavior_BTTask_Attack::AbortTask(
@ -147,5 +283,6 @@ void UPS_AI_Behavior_BTTask_Attack::OnTaskFinished(
FString UPS_AI_Behavior_BTTask_Attack::GetStaticDescription() const
{
return FString::Printf(TEXT("Move to threat (radius %.0fcm) and attack via interface."), AttackMoveRadius);
return FString::Printf(TEXT("Range-aware attack.\nFallback radius: %.0fcm\nReposition cooldown: %.1fs"),
AttackMoveRadius, RepositionCooldown);
}

View File

@ -0,0 +1,432 @@
// Copyright Asterion. All Rights Reserved.
#include "BT/PS_AI_Behavior_BTTask_CoverShootCycle.h"
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_Interface.h"
#include "PS_AI_Behavior_CoverPoint.h"
#include "PS_AI_Behavior_PersonalityComponent.h"
#include "PS_AI_Behavior_PersonalityProfile.h"
#include "PS_AI_Behavior_Definitions.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "Navigation/PathFollowingComponent.h"
#include "EngineUtils.h"
UPS_AI_Behavior_BTTask_CoverShootCycle::UPS_AI_Behavior_BTTask_CoverShootCycle()
{
NodeName = TEXT("Cover Shoot Cycle");
bNotifyTick = true;
bNotifyTaskFinished = true;
}
EBTNodeResult::Type UPS_AI_Behavior_BTTask_CoverShootCycle::ExecuteTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC || !AIC->GetPawn()) return EBTNodeResult::Failed;
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())
{
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] CoverShootCycle: no CoverLocation in BB."), *AIC->GetName());
return EBTNodeResult::Failed;
}
// We need a threat
AActor* Target = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor));
if (!Target)
{
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] CoverShootCycle: no ThreatActor in BB."), *AIC->GetName());
return EBTNodeResult::Failed;
}
// ─── Initialize memory with personality modulation ───────────────
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;
// Base values
Memory->EffPeekMin = PeekDurationMin;
Memory->EffPeekMax = PeekDurationMax;
Memory->EffCoverMin = CoverDurationMin;
Memory->EffCoverMax = CoverDurationMax;
Memory->EffMaxCycles = MaxCyclesBeforeAdvance;
Memory->bCanAdvance = true;
// Modulate by personality traits
if (UPS_AI_Behavior_PersonalityComponent* Personality = AIC->GetPersonalityComponent())
{
const float Aggressivity = Personality->GetTrait(EPS_AI_Behavior_TraitAxis::Aggressivity);
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
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
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,
TEXT("[%s] CoverShootCycle: peek=[%.1f-%.1f]s, cover=[%.1f-%.1f]s, maxCycles=%d, canAdvance=%d"),
*AIC->GetName(),
Memory->EffPeekMin, Memory->EffPeekMax,
Memory->EffCoverMin, Memory->EffCoverMax,
Memory->EffMaxCycles, (int32)Memory->bCanAdvance);
}
// ─── Move to cover position ──────────────────────────────────────
const EPathFollowingRequestResult::Type Result = AIC->MoveToLocation(
CoverLoc, 80.0f, /*bStopOnOverlap=*/true,
/*bUsePathfinding=*/true, /*bProjectGoal=*/true, /*bCanStrafe=*/false);
if (Result == EPathFollowingRequestResult::Failed)
{
return EBTNodeResult::Failed;
}
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));
}
else
{
Memory->bMoveRequested = true;
BB->SetValueAsEnum(PS_AI_Behavior_BB::CombatSubState,
static_cast<uint8>(EPS_AI_Behavior_CombatSubState::Engaging));
}
return EBTNodeResult::InProgress;
}
void UPS_AI_Behavior_BTTask_CoverShootCycle::TickTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC || !AIC->GetPawn())
{
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return;
}
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (!BB) { FinishLatentTask(OwnerComp, EBTNodeResult::Failed); return; }
AActor* Target = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor));
if (!Target)
{
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return;
}
// Validate target
APawn* Pawn = AIC->GetPawn();
if (Pawn && Pawn->Implements<UPS_AI_Behavior_Interface>())
{
if (!IPS_AI_Behavior_Interface::Execute_IsTargetActorValid(Pawn, Target))
{
AIC->StopMovement();
BB->ClearValue(PS_AI_Behavior_BB::ThreatActor);
BB->SetValueAsFloat(PS_AI_Behavior_BB::ThreatLevel, 0.0f);
FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
return;
}
}
FCoverShootMemory* Memory = reinterpret_cast<FCoverShootMemory*>(NodeMemory);
switch (Memory->SubState)
{
// ─── ENGAGING: Moving to cover ──────────────────────────────────
case EPS_AI_Behavior_CombatSubState::Engaging:
{
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));
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] CoverShootCycle: at cover, ducking for %.1fs"),
*AIC->GetName(), Memory->PhaseDuration);
}
break;
}
// ─── AT COVER: Ducked, waiting ──────────────────────────────────
case EPS_AI_Behavior_CombatSubState::AtCover:
{
Memory->Timer -= DeltaSeconds;
if (Memory->Timer <= 0.0f)
{
// Timer expired → peek and shoot
Memory->SubState = EPS_AI_Behavior_CombatSubState::Peeking;
Memory->PhaseDuration = FMath::RandRange(Memory->EffPeekMin, Memory->EffPeekMax);
Memory->Timer = Memory->PhaseDuration;
BB->SetValueAsEnum(PS_AI_Behavior_BB::CombatSubState,
static_cast<uint8>(EPS_AI_Behavior_CombatSubState::Peeking));
// Start attacking
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_BehaviorStartAttack(Pawn, Target);
}
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] CoverShootCycle: peeking, shooting for %.1fs"),
*AIC->GetName(), Memory->PhaseDuration);
}
break;
}
// ─── PEEKING: Shooting at target ────────────────────────────────
case EPS_AI_Behavior_CombatSubState::Peeking:
{
Memory->Timer -= DeltaSeconds;
if (Memory->Timer <= 0.0f)
{
// Stop attacking
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_BehaviorStopAttack(Pawn);
}
Memory->CycleCount++;
// Should we advance to closer cover?
if (Memory->bCanAdvance && Memory->CycleCount >= Memory->EffMaxCycles)
{
// ─── 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));
// 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 closer cover
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;
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] CoverShootCycle: advancing to '%s' (score %.2f)"),
*AIC->GetName(), *NewPoint->GetName(), NewScore);
}
else
{
// No better cover found — stay at current position, reset cycle
UE_LOG(LogPS_AI_Behavior, Verbose,
TEXT("[%s] CoverShootCycle: no advancing cover found, resetting cycle"),
*AIC->GetName());
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));
}
}
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));
UE_LOG(LogPS_AI_Behavior, Verbose,
TEXT("[%s] CoverShootCycle: ducking back (cycle %d/%d)"),
*AIC->GetName(), Memory->CycleCount, Memory->EffMaxCycles);
}
}
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));
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] CoverShootCycle: arrived at new cover, ducking"),
*AIC->GetName());
}
break;
}
}
}
EBTNodeResult::Type UPS_AI_Behavior_BTTask_CoverShootCycle::AbortTask(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory)
{
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (AIC)
{
AIC->StopMovement();
// Stop attacking if we were peeking
APawn* Pawn = AIC->GetPawn();
if (Pawn && Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_BehaviorStopAttack(Pawn);
}
}
return EBTNodeResult::Aborted;
}
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
APawn* Pawn = AIC->GetPawn();
if (Pawn && Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_BehaviorStopAttack(Pawn);
}
// Release cover point
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (BB)
{
APS_AI_Behavior_CoverPoint* Point =
Cast<APS_AI_Behavior_CoverPoint>(BB->GetValueAsObject(PS_AI_Behavior_BB::CoverPoint));
if (Point && Pawn)
{
Point->Release(Pawn);
}
}
}
Super::OnTaskFinished(OwnerComp, NodeMemory, TaskResult);
}
// ─── Advancing Cover Search ─────────────────────────────────────────────────
APS_AI_Behavior_CoverPoint* UPS_AI_Behavior_BTTask_CoverShootCycle::FindAdvancingCover(
const UWorld* World, const FVector& NpcLoc, const FVector& ThreatLoc,
EPS_AI_Behavior_NPCType NPCType, float& OutScore) const
{
APS_AI_Behavior_CoverPoint* BestPoint = nullptr;
OutScore = -1.0f;
const float NpcDistToThreat = FVector::Dist(NpcLoc, ThreatLoc);
for (TActorIterator<APS_AI_Behavior_CoverPoint> It(const_cast<UWorld*>(World)); It; ++It)
{
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;
}
if (Score > OutScore)
{
OutScore = Score;
BestPoint = Point;
}
}
return BestPoint;
}
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)"),
PeekDurationMin, PeekDurationMax,
CoverDurationMin, CoverDurationMax,
MaxCyclesBeforeAdvance, AdvanceSearchRadius);
}

View File

@ -215,6 +215,18 @@ APS_AI_Behavior_CoverPoint* UPS_AI_Behavior_BTTask_FindCover::FindBestManualCove
Score += FMath::GetMappedRangeValueClamped(
FVector2D(0.0f, SearchRadius), FVector2D(0.15f, 0.0f), Dist);
// Advancement bias — prefer covers closer to threat than NPC is
if (AdvancementBias > 0.0f)
{
const float NpcDistToThreat = FVector::Dist(NpcLoc, ThreatLoc);
const float CoverDistToThreat = FVector::Dist(Point->GetActorLocation(), ThreatLoc);
if (NpcDistToThreat > 0.0f && CoverDistToThreat < NpcDistToThreat)
{
const float AdvanceRatio = (NpcDistToThreat - CoverDistToThreat) / NpcDistToThreat;
Score += AdvancementBias * AdvanceRatio * 0.3f;
}
}
if (Score > OutScore)
{
OutScore = Score;
@ -258,6 +270,17 @@ float UPS_AI_Behavior_BTTask_FindCover::EvaluateCoverQuality(
Score += FMath::GetMappedRangeValueClamped(
FVector2D(0.0f, SearchRadius), FVector2D(0.15f, 0.0f), DistFromNpc);
// Advancement bias — prefer candidates closer to threat than NPC is
if (AdvancementBias > 0.0f)
{
const float NpcDistToThreat = FVector::Dist(NpcLoc, ThreatLoc);
if (NpcDistToThreat > 0.0f && DistFromThreat < NpcDistToThreat)
{
const float AdvanceRatio = (NpcDistToThreat - DistFromThreat) / NpcDistToThreat;
Score += AdvancementBias * AdvanceRatio * 0.3f;
}
}
return Score;
}

View File

@ -4,9 +4,11 @@
#include "PS_AI_Behavior_Interface.h"
#include "PS_AI_Behavior_PerceptionComponent.h"
#include "PS_AI_Behavior_PersonalityComponent.h"
#include "PS_AI_Behavior_SplineFollowerComponent.h"
#include "PS_AI_Behavior_TeamComponent.h"
#include "PS_AI_Behavior_PersonalityProfile.h"
#include "BehaviorTree/BehaviorTree.h"
#include "BehaviorTree/BehaviorTreeComponent.h"
#include "BehaviorTree/BlackboardComponent.h"
#include "BehaviorTree/BlackboardData.h"
#include "BehaviorTree/Blackboard/BlackboardKeyType_Enum.h"
@ -40,52 +42,37 @@ void APS_AI_Behavior_AIController::OnPossess(APawn* InPawn)
*GetName(), *InPawn->GetName());
}
// Auto-assign Team ID from IPS_AI_Behavior interface (preferred) or PersonalityComponent
// Always recalculate — BP child CDOs may reset TeamId to 0
// Assign Team ID from NPCType + Faction
{
EPS_AI_Behavior_NPCType NPCType = EPS_AI_Behavior_NPCType::Any;
if (InPawn->Implements<UPS_AI_Behavior_Interface>())
{
// Use the interface — the host project controls the storage
NPCType = IPS_AI_Behavior_Interface::Execute_GetBehaviorNPCType(InPawn);
}
else if (PersonalityComp)
{
// Fallback: get from PersonalityProfile
NPCType = PersonalityComp->GetNPCType();
}
// Derive TeamId from NPCType
{
switch (NPCType)
{
case EPS_AI_Behavior_NPCType::Civilian:
TeamId = 1;
break;
case EPS_AI_Behavior_NPCType::Enemy:
// Check if infiltrated (hostile=false → disguised as civilian)
if (InPawn->Implements<UPS_AI_Behavior_Interface>() &&
// Get faction from profile (0 = default)
const uint8 Faction = (PersonalityComp && PersonalityComp->Profile)
? PersonalityComp->Profile->Faction : 0;
// Infiltrated enemy: disguised as Civilian until hostile
if (NPCType == EPS_AI_Behavior_NPCType::Enemy &&
InPawn->Implements<UPS_AI_Behavior_Interface>() &&
!IPS_AI_Behavior_Interface::Execute_IsBehaviorHostile(InPawn))
{
TeamId = 1; // Disguised as Civilian
TeamId = PS_AI_Behavior_Team::DisguisedTeamId;
}
else
{
TeamId = 2;
}
break;
case EPS_AI_Behavior_NPCType::Protector:
TeamId = 3;
break;
default:
TeamId = FGenericTeamId::NoTeam; // 255 → Neutral to everyone
break;
}
TeamId = PS_AI_Behavior_Team::MakeTeamId(NPCType, Faction);
}
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Auto-assigned TeamId=%d from NPCType=%s"),
*GetName(), TeamId, *UEnum::GetValueAsString(NPCType));
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] TeamId=0x%02X (%s, faction %d)"),
*GetName(), TeamId, *UEnum::GetValueAsString(NPCType), Faction);
}
SetupBlackboard();
@ -178,6 +165,12 @@ void APS_AI_Behavior_AIController::SetupBlackboard()
SplineProgressEntry.EntryName = PS_AI_Behavior_BB::SplineProgress;
SplineProgressEntry.KeyType = NewObject<UBlackboardKeyType_Float>(BlackboardAsset);
BlackboardAsset->Keys.Add(SplineProgressEntry);
// CombatSubState (stored as uint8 enum)
FBlackboardEntry CombatSubStateEntry;
CombatSubStateEntry.EntryName = PS_AI_Behavior_BB::CombatSubState;
CombatSubStateEntry.KeyType = NewObject<UBlackboardKeyType_Enum>(BlackboardAsset);
BlackboardAsset->Keys.Add(CombatSubStateEntry);
}
UBlackboardComponent* RawBBComp = nullptr;
@ -230,6 +223,56 @@ void APS_AI_Behavior_AIController::SetBehaviorState(EPS_AI_Behavior_State NewSta
*UEnum::GetValueAsString(NewState));
}
Blackboard->SetValueAsEnum(PS_AI_Behavior_BB::State, NewVal);
// ─── Dead: shut down all AI systems ─────────────────────────
if (NewState == EPS_AI_Behavior_State::Dead)
{
HandleDeath();
}
}
}
void APS_AI_Behavior_AIController::HandleDeath()
{
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] HandleDeath — shutting down AI systems."), *GetName());
// 1. Stop the Behavior Tree (no more services, tasks, or decorators)
if (UBrainComponent* Brain = GetBrainComponent())
{
Brain->StopLogic(TEXT("Dead"));
}
// 2. Stop any active movement
StopMovement();
// 3. Stop spline following
if (APawn* MyPawn = GetPawn())
{
if (UPS_AI_Behavior_SplineFollowerComponent* Spline =
MyPawn->FindComponentByClass<UPS_AI_Behavior_SplineFollowerComponent>())
{
Spline->StopFollowing();
}
// 4. Stop attack if in combat
if (MyPawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_BehaviorStopAttack(MyPawn);
}
}
// 5. Disable perception (stop detecting / being source of stimuli updates)
if (BehaviorPerception)
{
BehaviorPerception->Deactivate();
}
// 6. Clear Blackboard threat data
if (Blackboard)
{
Blackboard->ClearValue(PS_AI_Behavior_BB::ThreatActor);
Blackboard->ClearValue(PS_AI_Behavior_BB::ThreatLocation);
Blackboard->SetValueAsFloat(PS_AI_Behavior_BB::ThreatLevel, 0.0f);
}
}
@ -301,21 +344,25 @@ ETeamAttitude::Type APS_AI_Behavior_AIController::GetTeamAttitudeTowards(const A
return ETeamAttitude::Neutral;
}
// Same team → Friendly
// Same TeamId = same NPCType + same Faction → always Friendly
if (TeamId == OtherTeam)
{
return ETeamAttitude::Friendly;
}
// ─── Custom cross-team attitudes ────────────────────────────────────
// ─── NPCType-based attitude ─────────────────────────────────────────
const EPS_AI_Behavior_NPCType MyType = PS_AI_Behavior_Team::GetNPCType(TeamId);
const EPS_AI_Behavior_NPCType TheirType = PS_AI_Behavior_Team::GetNPCType(OtherTeam);
// Civilian (1) ↔ Protector (3) → Friendly
if ((TeamId == 1 && OtherTeam == 3) || (TeamId == 3 && OtherTeam == 1))
// Civilian ↔ Protector → always Friendly (regardless of faction)
if ((MyType == EPS_AI_Behavior_NPCType::Civilian && TheirType == EPS_AI_Behavior_NPCType::Protector) ||
(MyType == EPS_AI_Behavior_NPCType::Protector && TheirType == EPS_AI_Behavior_NPCType::Civilian))
{
return ETeamAttitude::Friendly;
}
// Everything else → Hostile
// Same NPCType but different faction (e.g. rival enemy gangs) → Hostile
// Different NPCType (e.g. Enemy vs Civilian) → Hostile
return ETeamAttitude::Hostile;
}

View File

@ -4,7 +4,9 @@
#include "Components/ArrowComponent.h"
#include "Components/BillboardComponent.h"
#include "Engine/World.h"
#include "Engine/Texture2D.h"
#include "CollisionQueryParams.h"
#include "UObject/ConstructorHelpers.h"
APS_AI_Behavior_CoverPoint::APS_AI_Behavior_CoverPoint()
{
@ -27,6 +29,13 @@ APS_AI_Behavior_CoverPoint::APS_AI_Behavior_CoverPoint()
SpriteComp->SetRelativeLocation(FVector(0, 0, 80.0f));
SpriteComp->bIsScreenSizeScaled = true;
SpriteComp->ScreenSize = 0.0025f;
// Load editor sprites — Cover: default (no change), HidingSpot: fog icon
static ConstructorHelpers::FObjectFinder<UTexture2D> HidingSpotSpriteFinder(
TEXT("/Engine/EditorResources/S_AtmosphericHeightFog"));
CoverSpriteTexture = nullptr; // Keep default billboard sprite for Cover
HidingSpotSpriteTexture = HidingSpotSpriteFinder.Succeeded() ? HidingSpotSpriteFinder.Object : nullptr;
#endif
}
@ -164,13 +173,17 @@ void APS_AI_Behavior_CoverPoint::UpdateVisualization()
if (!ArrowComp) return;
FLinearColor Color = FLinearColor::White;
UTexture2D* SpriteTexture = nullptr;
switch (PointType)
{
case EPS_AI_Behavior_CoverPointType::Cover:
Color = FLinearColor(0.2f, 0.5f, 1.0f); // Blue
SpriteTexture = CoverSpriteTexture;
break;
case EPS_AI_Behavior_CoverPointType::HidingSpot:
Color = FLinearColor(1.0f, 0.85f, 0.0f); // Yellow
SpriteTexture = HidingSpotSpriteTexture;
break;
default:
break;
@ -182,6 +195,11 @@ void APS_AI_Behavior_CoverPoint::UpdateVisualization()
}
ArrowComp->SetArrowColor(Color);
if (SpriteComp && SpriteTexture)
{
SpriteComp->SetSprite(SpriteTexture);
}
#endif
}

View File

@ -4,6 +4,7 @@
#include "PS_AI_Behavior_Interface.h"
#include "PS_AI_Behavior_PersonalityComponent.h"
#include "PS_AI_Behavior_PersonalityProfile.h"
#include "PS_AI_Behavior_TeamComponent.h"
#include "PS_AI_Behavior_Settings.h"
#include "Perception/AISenseConfig_Sight.h"
#include "Perception/AISenseConfig_Hearing.h"
@ -53,7 +54,7 @@ void UPS_AI_Behavior_PerceptionComponent::ConfigureSenses()
// ─── Hearing ────────────────────────────────────────────────────────
UAISenseConfig_Hearing* HearingConfig = NewObject<UAISenseConfig_Hearing>(this);
HearingConfig->HearingRange = Settings->DefaultHearingRange;
HearingConfig->SetMaxAge(Settings->PerceptionMaxAge);
HearingConfig->SetMaxAge(Settings->HearingMaxAge);
HearingConfig->DetectionByAffiliation.bDetectEnemies = true;
HearingConfig->DetectionByAffiliation.bDetectNeutrals = true;
HearingConfig->DetectionByAffiliation.bDetectFriendlies = true;
@ -74,20 +75,113 @@ void UPS_AI_Behavior_PerceptionComponent::HandlePerceptionUpdated(const TArray<A
// This callback can be used for immediate alert reactions.
}
// ─── Helpers ────────────────────────────────────────────────────────────────
/**
* Find the owning Pawn for a perceived actor.
* Walks up the Owner/Instigator chain until a Pawn is found.
* Returns the Pawn itself used for team/attitude checks.
* Returns nullptr if no Pawn can be found.
*/
static APawn* FindOwningPawn(AActor* Actor)
{
if (!Actor) return nullptr;
// If already a Pawn, return it
if (APawn* ActorAsPawn = Cast<APawn>(Actor))
{
return ActorAsPawn;
}
// Not a Pawn — walk up Owner/Instigator chain to find the owning Pawn
AActor* Current = Actor;
for (int32 Depth = 0; Depth < 4; ++Depth) // Safety limit
{
// Try Instigator first (most direct for weapons)
if (APawn* InstigatorPawn = Current->GetInstigator())
{
return InstigatorPawn;
}
// Try Owner
AActor* OwnerActor = Current->GetOwner();
if (!OwnerActor || OwnerActor == Current) break;
if (APawn* OwnerPawn = Cast<APawn>(OwnerActor))
{
return OwnerPawn;
}
Current = OwnerActor; // Continue up the chain
}
// Fallback: could not resolve to a Pawn.
// Log once per actor class to avoid spam.
static TSet<FName> WarnedClasses;
const FName ClassName = Actor->GetClass()->GetFName();
if (!WarnedClasses.Contains(ClassName))
{
WarnedClasses.Add(ClassName);
UE_LOG(LogPS_AI_Behavior, Warning,
TEXT("FindOwningPawn: '%s' (class=%s) could not be resolved to a Pawn. Set Owner or Instigator. Instigator=%s, Owner=%s"),
*Actor->GetName(), *Actor->GetClass()->GetName(),
Actor->GetInstigator() ? *Actor->GetInstigator()->GetName() : TEXT("null"),
Actor->GetOwner() ? *Actor->GetOwner()->GetName() : TEXT("null"));
}
return nullptr;
}
/**
* Get the threat target actor for a Pawn.
* Calls GetBehaviorThreatActor() if the Pawn implements the interface,
* otherwise returns the Pawn itself.
* This is what goes into the Blackboard (e.g. PS_AimTargetActor for aiming).
*/
static AActor* GetThreatTarget(APawn* Pawn)
{
if (!Pawn) return nullptr;
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
AActor* Resolved = IPS_AI_Behavior_Interface::Execute_GetBehaviorThreatActor(Pawn);
return Resolved ? Resolved : Pawn;
}
return Pawn;
}
/** Extract an actor's TeamId (checking controller, then TeamComponent). */
uint8 UPS_AI_Behavior_PerceptionComponent::GetActorTeamId(const AActor* Actor)
{
const APawn* ActorPawn = Cast<APawn>(Actor);
if (!ActorPawn) return FGenericTeamId::NoTeam;
if (const AController* C = ActorPawn->GetController())
{
if (const IGenericTeamAgentInterface* T = Cast<IGenericTeamAgentInterface>(C))
{
return T->GetGenericTeamId().GetId();
}
}
if (const UPS_AI_Behavior_TeamComponent* TC = ActorPawn->FindComponentByClass<UPS_AI_Behavior_TeamComponent>())
{
return TC->GetGenericTeamId().GetId();
}
return FGenericTeamId::NoTeam;
}
// ─── Actor Classification ───────────────────────────────────────────────────
EPS_AI_Behavior_TargetType UPS_AI_Behavior_PerceptionComponent::ClassifyActor(const AActor* Actor)
{
if (!Actor) return EPS_AI_Behavior_TargetType::Civilian; // Safe default
// Check if player-controlled
const APawn* Pawn = Cast<APawn>(Actor);
if (Pawn && Pawn->IsPlayerControlled())
{
return EPS_AI_Behavior_TargetType::Player;
}
// Check via IPS_AI_Behavior interface
// 1) Check via IPS_AI_Behavior interface (NPCs and any Pawn implementing it)
if (Actor->Implements<UPS_AI_Behavior_Interface>())
{
const EPS_AI_Behavior_NPCType NPCType =
@ -102,9 +196,21 @@ EPS_AI_Behavior_TargetType UPS_AI_Behavior_PerceptionComponent::ClassifyActor(co
}
}
// Fallback: check PersonalityComponent
// 2) Check TeamComponent (player characters, non-AI pawns)
if (Pawn)
{
if (const UPS_AI_Behavior_TeamComponent* TeamComp = Pawn->FindComponentByClass<UPS_AI_Behavior_TeamComponent>())
{
switch (TeamComp->Role)
{
case EPS_AI_Behavior_NPCType::Civilian: return EPS_AI_Behavior_TargetType::Civilian;
case EPS_AI_Behavior_NPCType::Enemy: return EPS_AI_Behavior_TargetType::Enemy;
case EPS_AI_Behavior_NPCType::Protector: return EPS_AI_Behavior_TargetType::Protector;
default: break;
}
}
// 3) Fallback: PersonalityComponent
if (const auto* PersonalityComp = Pawn->FindComponentByClass<UPS_AI_Behavior_PersonalityComponent>())
{
switch (PersonalityComp->GetNPCType())
@ -115,6 +221,12 @@ EPS_AI_Behavior_TargetType UPS_AI_Behavior_PerceptionComponent::ClassifyActor(co
default: break;
}
}
// 4) Player-controlled but no role defined → Player type
if (Pawn->IsPlayerControlled())
{
return EPS_AI_Behavior_TargetType::Player;
}
}
return EPS_AI_Behavior_TargetType::Civilian;
@ -184,56 +296,117 @@ AActor* UPS_AI_Behavior_PerceptionComponent::GetHighestThreatActor(
const AAIController* AIC = Cast<AAIController>(Owner);
APawn* MyPawn = AIC ? AIC->GetPawn() : Cast<APawn>(const_cast<AActor*>(Owner));
for (AActor* Actor : PerceivedActors)
// ─── Score accumulation per resolved actor ───────────────────────
// Multiple RawActors can resolve to the same Pawn (e.g. Pawn seen + weapon heard).
// Accumulate their sense scores so a single actor gets all stimuli credit.
struct FActorScore
{
if (!Actor || Actor == Owner) continue;
AActor* Actor = nullptr;
float Score = 0.0f;
EPS_AI_Behavior_TargetType ActorType = EPS_AI_Behavior_TargetType::Civilian;
bool bIsHostile = false;
};
TMap<AActor*, FActorScore> ScoreMap;
// Skip self (when owner is AIController, also skip own pawn)
if (AIC && Actor == AIC->GetPawn()) continue;
for (AActor* RawActor : PerceivedActors)
{
// Find the owning Pawn (for team/attitude checks)
APawn* OwningPawn = FindOwningPawn(RawActor);
if (!OwningPawn) continue; // Can't resolve → skip
// Skip self
if (OwningPawn == Owner) continue;
if (AIC && OwningPawn == AIC->GetPawn()) continue;
// Get the threat target (PS_AimTargetActor or Pawn itself — for BB targeting)
AActor* ThreatTarget = GetThreatTarget(OwningPawn);
if (!ThreatTarget) ThreatTarget = OwningPawn;
// Skip non-hostile actors UNLESS they have a gunfire hearing stimulus
bool bActorIsHostile = false;
bool bActorHasGunshot = false;
// Skip non-hostile actors (only Hostile actors are valid threats)
if (AIC)
{
const ETeamAttitude::Type Attitude = AIC->GetTeamAttitudeTowards(*Actor);
if (Attitude != ETeamAttitude::Hostile)
// Attitude check against the PAWN (has TeamId), not the ThreatTarget
const ETeamAttitude::Type Attitude = AIC->GetTeamAttitudeTowards(*OwningPawn);
bActorIsHostile = (Attitude == ETeamAttitude::Hostile);
if (!bActorIsHostile)
{
// Same exact team (same NPCType + same Faction) → always skip
// Allied teams (Civilian ↔ Protector) → allow gunfire through
if (AIC->GetGenericTeamId().GetId() == GetActorTeamId(OwningPawn))
{
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Target '%s': SKIPPED (same team 0x%02X)"),
*Owner->GetName(), *OwningPawn->GetName(), GetActorTeamId(OwningPawn));
continue;
}
// Allied or Neutral: check for gunfire tag — gunshot source is a valid threat
FActorPerceptionBlueprintInfo GunInfo;
if (GetActorsPerception(RawActor, GunInfo))
{
for (const FAIStimulus& S : GunInfo.LastSensedStimuli)
{
if (S.IsValid() &&
S.Type == UAISense::GetSenseID<UAISense_Hearing>() &&
PS_AI_Behavior_Tags_Internal::IsGunfire(S.Tag))
{
bActorHasGunshot = true;
break;
}
}
}
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)"),
*Owner->GetName(), *OwningPawn->GetName(), static_cast<int32>(Attitude),
GetActorTeamId(OwningPawn), AIC->GetGenericTeamId().GetId());
continue; // Not hostile, no gunshot — skip
}
}
}
// Skip invalid targets (dead, despawning, etc.) via interface
if (MyPawn && MyPawn->Implements<UPS_AI_Behavior_Interface>())
{
if (!IPS_AI_Behavior_Interface::Execute_IsTargetActorValid(MyPawn, Actor))
if (!IPS_AI_Behavior_Interface::Execute_IsTargetActorValid(MyPawn, ThreatTarget))
{
continue;
}
}
// ─── Classify this actor ────────────────────────────────────────
const EPS_AI_Behavior_TargetType ActorType = ClassifyActor(Actor);
// ─── Classify & score per owning Pawn, but store ThreatTarget for BB ───
FActorScore& Entry = ScoreMap.FindOrAdd(OwningPawn);
if (!Entry.Actor)
{
// First time seeing this Pawn — initialize with its ThreatTarget
Entry.Actor = ThreatTarget;
Entry.ActorType = ClassifyActor(OwningPawn);
Entry.bIsHostile = bActorIsHostile;
// ─── Score calculation ──────────────────────────────────────────
float Score = 0.0f;
// Priority rank bonus: actors in the priority list score higher
// This is used for COMBAT targeting (who to attack first)
// But ALL hostile actors are valid threats (for fleeing, alerting, etc.)
const int32 PriorityIndex = ActivePriority.IndexOfByKey(ActorType);
// Priority rank bonus (applied once per actor)
const int32 PriorityIndex = ActivePriority.IndexOfByKey(Entry.ActorType);
if (PriorityIndex != INDEX_NONE)
{
Score += (ActivePriority.Num() - PriorityIndex) * 100.0f;
Entry.Score += (ActivePriority.Num() - PriorityIndex) * 100.0f;
}
else
{
// Not in priority list but still Hostile — valid threat, lower score
Score += 10.0f;
Entry.Score += 10.0f;
}
// Damage sense override: actor that hit us gets a massive bonus
// (bypasses priority — self-defense)
// Distance bonus (applied once per actor)
const float Dist = FVector::Dist(OwnerLoc, ThreatTarget->GetActorLocation());
Entry.Score += FMath::GetMappedRangeValueClamped(
FVector2D(0.0f, 6000.0f), FVector2D(20.0f, 0.0f), Dist);
}
// Accumulate sense scores from this RawActor (weapon heard + pawn seen → both count)
FActorPerceptionBlueprintInfo Info;
if (GetActorsPerception(Actor, Info))
if (GetActorsPerception(RawActor, Info))
{
for (const FAIStimulus& Stimulus : Info.LastSensedStimuli)
{
@ -241,30 +414,77 @@ AActor* UPS_AI_Behavior_PerceptionComponent::GetHighestThreatActor(
if (Stimulus.Type == UAISense::GetSenseID<UAISense_Damage>())
{
Score += 500.0f; // Self-defense: always prioritize attacker
Entry.Score += 500.0f;
}
else if (Stimulus.Type == UAISense::GetSenseID<UAISense_Sight>())
else if (Stimulus.Type == UAISense::GetSenseID<UAISense_Hearing>())
{
Score += 10.0f;
if (PS_AI_Behavior_Tags_Internal::IsGunfire(Stimulus.Tag))
{
Entry.Score += 400.0f;
}
else
{
Score += 5.0f; // Hearing
Entry.Score += 5.0f;
}
}
}
// Distance: closer targets score higher (0-20 range)
const float Dist = FVector::Dist(OwnerLoc, Actor->GetActorLocation());
Score += FMath::GetMappedRangeValueClamped(
FVector2D(0.0f, 6000.0f), FVector2D(20.0f, 0.0f), Dist);
if (Score > BestScore)
else if (Stimulus.Type == UAISense::GetSenseID<UAISense_Sight>())
{
BestScore = Score;
BestThreat = Actor;
Entry.Score += 10.0f;
}
}
}
}
// ─── Pick best actor from accumulated scores ─────────────────────
// Target persistence: get current target from BB to avoid flickering
AActor* CurrentTarget = nullptr;
float CurrentTargetScore = -1.0f;
if (AIC)
{
if (UBlackboardComponent* BB = AIC->GetBlackboardComponent())
{
CurrentTarget = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor));
}
}
for (const auto& Pair : ScoreMap)
{
const FActorScore& Entry = Pair.Value;
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Target '%s': type=%d, hostile=%d, score=%.0f"),
*Owner->GetName(), *Entry.Actor->GetName(),
static_cast<int32>(Entry.ActorType), Entry.bIsHostile ? 1 : 0, Entry.Score);
if (Entry.Score > BestScore)
{
BestScore = Entry.Score;
BestThreat = Entry.Actor;
}
// Track current target's score for persistence check
if (Entry.Actor == CurrentTarget)
{
CurrentTargetScore = Entry.Score;
}
}
// Target persistence: keep current target if its score is within 20% of the best
// This prevents flickering between targets with nearly identical scores
if (CurrentTarget && CurrentTargetScore > 0.0f && BestThreat != CurrentTarget)
{
if (CurrentTargetScore >= BestScore * 0.8f)
{
BestThreat = CurrentTarget;
BestScore = CurrentTargetScore;
}
}
if (BestThreat)
{
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] → Best target: '%s' (score=%.0f%s)"),
*Owner->GetName(), *BestThreat->GetName(), BestScore,
(BestThreat == CurrentTarget) ? TEXT(" [kept]") : TEXT(""));
}
return BestThreat;
}
@ -284,39 +504,81 @@ float UPS_AI_Behavior_PerceptionComponent::CalculateThreatLevel()
const AAIController* AIC = Cast<AAIController>(Owner);
APawn* MyPawn = AIC ? AIC->GetPawn() : Cast<APawn>(const_cast<AActor*>(Owner));
for (AActor* Actor : PerceivedActors)
for (AActor* RawActor : PerceivedActors)
{
if (!Actor) continue;
if (!RawActor) continue;
// Find the owning Pawn (for team/attitude checks)
APawn* OwningPawn = FindOwningPawn(RawActor);
if (!OwningPawn) continue;
// Get the threat target for position-based calculations
AActor* ThreatTarget = GetThreatTarget(OwningPawn);
if (!ThreatTarget) ThreatTarget = OwningPawn;
// Determine hostility and check for gunshot stimuli
bool bIsHostile = false;
bool bHasGunshot = false;
// Only count Hostile actors as threats (skip Friendly and Neutral)
if (AIC)
{
const ETeamAttitude::Type Attitude = AIC->GetTeamAttitudeTowards(*Actor);
if (Attitude != ETeamAttitude::Hostile)
// Attitude check against the PAWN (has TeamId)
const ETeamAttitude::Type Attitude = AIC->GetTeamAttitudeTowards(*OwningPawn);
bIsHostile = (Attitude == ETeamAttitude::Hostile);
// Same exact team (same NPCType + same Faction) → always skip
// Allied teams (Civilian ↔ Protector) → allow gunfire through
if (AIC->GetGenericTeamId().GetId() == GetActorTeamId(OwningPawn))
{
continue; // Only Hostile actors generate threat
continue;
}
}
// For non-hostile actors, check if they have a gunfire hearing stimulus
if (!bIsHostile)
{
FActorPerceptionBlueprintInfo GunInfo;
if (GetActorsPerception(RawActor, GunInfo))
{
for (const FAIStimulus& S : GunInfo.LastSensedStimuli)
{
if (S.IsValid() &&
S.Type == UAISense::GetSenseID<UAISense_Hearing>() &&
PS_AI_Behavior_Tags_Internal::IsGunfire(S.Tag))
{
bHasGunshot = true;
break;
}
}
}
}
// Skip actors that are neither hostile nor gunshot sources
if (!bIsHostile && !bHasGunshot)
{
continue;
}
// Skip invalid targets (dead, despawning, etc.) via interface
if (MyPawn && MyPawn->Implements<UPS_AI_Behavior_Interface>())
{
if (!IPS_AI_Behavior_Interface::Execute_IsTargetActorValid(MyPawn, Actor))
if (!IPS_AI_Behavior_Interface::Execute_IsTargetActorValid(MyPawn, ThreatTarget))
{
continue;
}
}
float ActorThreat = 0.0f;
const float Dist = FVector::Dist(OwnerLoc, Actor->GetActorLocation());
const float Dist = FVector::Dist(OwnerLoc, ThreatTarget->GetActorLocation());
// Closer = more threatening
if (bIsHostile)
{
// ─── Normal hostile threat calculation ──────────────────────
ActorThreat += FMath::GetMappedRangeValueClamped(
FVector2D(0.0f, 5000.0f), FVector2D(0.5f, 0.05f), Dist);
// Sense-based multiplier
FActorPerceptionBlueprintInfo Info;
if (GetActorsPerception(Actor, Info))
if (GetActorsPerception(RawActor, Info))
{
for (const FAIStimulus& Stimulus : Info.LastSensedStimuli)
{
@ -324,7 +586,7 @@ float UPS_AI_Behavior_PerceptionComponent::CalculateThreatLevel()
if (Stimulus.Type == UAISense::GetSenseID<UAISense_Damage>())
{
ActorThreat += 0.6f; // Being hit = big threat spike
ActorThreat += 0.6f;
}
else if (Stimulus.Type == UAISense::GetSenseID<UAISense_Sight>())
{
@ -336,6 +598,15 @@ float UPS_AI_Behavior_PerceptionComponent::CalculateThreatLevel()
}
}
}
}
else if (bHasGunshot)
{
// ─── Gunshot from non-hostile actor ────────────────────────
// Generates significant threat regardless of team affiliation
ActorThreat += FMath::GetMappedRangeValueClamped(
FVector2D(0.0f, 5000.0f), FVector2D(0.4f, 0.05f), Dist);
ActorThreat += 0.3f; // Gunshot hearing boost
}
TotalThreat += ActorThreat;
}
@ -375,3 +646,31 @@ bool UPS_AI_Behavior_PerceptionComponent::GetThreatLocation(FVector& OutLocation
return false;
}
bool UPS_AI_Behavior_PerceptionComponent::GetGunShotStimulusLocation(FVector& OutLocation)
{
TArray<AActor*> PerceivedActors;
GetCurrentlyPerceivedActors(nullptr, PerceivedActors);
for (AActor* Actor : PerceivedActors)
{
if (!Actor) continue;
FActorPerceptionBlueprintInfo Info;
if (GetActorsPerception(Actor, Info))
{
for (const FAIStimulus& Stimulus : Info.LastSensedStimuli)
{
if (Stimulus.IsValid() &&
Stimulus.Type == UAISense::GetSenseID<UAISense_Hearing>() &&
PS_AI_Behavior_Tags_Internal::IsGunfire(Stimulus.Tag))
{
OutLocation = Stimulus.StimulusLocation;
return true;
}
}
}
}
return false;
}

View File

@ -0,0 +1,29 @@
// 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_CheckCombatType.generated.h"
/**
* BT Decorator: Checks the Pawn's combat type via IPS_AI_Behavior_Interface.
* Use to route: ranged NPCs cover-shoot cycle, melee NPCs rush attack.
*/
UCLASS(meta = (DisplayName = "PS AI: Check Combat Type"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTDecorator_CheckCombatType : public UBTDecorator
{
GENERATED_BODY()
public:
UPS_AI_Behavior_BTDecorator_CheckCombatType();
/** The combat type this decorator requires to pass. */
UPROPERTY(EditAnywhere, Category = "Combat Type")
EPS_AI_Behavior_CombatType RequiredType = EPS_AI_Behavior_CombatType::Ranged;
protected:
virtual bool CalculateRawConditionValue(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) const override;
virtual FString GetStaticDescription() const override;
};

View File

@ -4,11 +4,16 @@
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_BTTask_Attack.generated.h"
/**
* BT Task: Move toward the threat actor and delegate combat to the Pawn.
*
* Queries IPS_AI_Behavior_Interface for CombatType and OptimalAttackRange:
* - Melee: rush toward target, stop at optimal range.
* - Ranged: maintain optimal distance back away if too close, advance if too far.
*
* Calls IPS_AI_Behavior_Interface::BehaviorStartAttack() on enter and
* BehaviorStopAttack() on abort. The Pawn handles the actual combat
* (aim, fire, melee, etc.) via its own systems.
@ -24,10 +29,14 @@ class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTTask_Attack : public UBTTaskNode
public:
UPS_AI_Behavior_BTTask_Attack();
/** How close the NPC tries to get to the target (cm). */
/** Fallback move radius if the Pawn doesn't implement GetBehaviorOptimalAttackRange(). */
UPROPERTY(EditAnywhere, Category = "Attack", meta = (ClampMin = "50.0"))
float AttackMoveRadius = 300.0f;
/** Cooldown between reposition attempts (seconds). Prevents constant re-pathing. */
UPROPERTY(EditAnywhere, Category = "Attack", meta = (ClampMin = "0.5", ClampMax = "5.0"))
float RepositionCooldown = 1.5f;
protected:
virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
virtual void TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
@ -39,7 +48,12 @@ private:
struct FAttackMemory
{
bool bMovingToTarget = false;
bool bAttacking = false;
bool bAttacking = false; // true when BehaviorStartAttack is active
bool bInRange = false; // true when within MaxRange of target
EPS_AI_Behavior_CombatType CombatType = EPS_AI_Behavior_CombatType::Melee;
float MinRange = 100.0f; // backs away if closer
float MaxRange = 300.0f; // advances if farther
float RepositionTimer = 0.0f;
};
virtual uint16 GetInstanceMemorySize() const override { return sizeof(FAttackMemory); }

View File

@ -0,0 +1,106 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTTaskNode.h"
#include "PS_AI_Behavior_Definitions.h"
#include "PS_AI_Behavior_BTTask_CoverShootCycle.generated.h"
class APS_AI_Behavior_CoverPoint;
/**
* BT Task: Cover-shoot cycle for ranged combat.
*
* State machine: Engaging AtCover Peeking 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.
*
* Personality traits modulate timing:
* - Aggressivity shorter cover duration, advances sooner
* - Caution longer cover duration, shorter peek
* - Courage < 0.3 never advances (stays in cover)
*
* Writes CombatSubState to BB for animation sync.
* Stays InProgress Decorator Observer Aborts pulls out on state change.
*
* Prerequisites: BTTask_FindCover must run first to write CoverLocation/CoverPoint to BB.
*/
UCLASS(meta = (DisplayName = "PS AI: Cover Shoot Cycle"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTTask_CoverShootCycle : public UBTTaskNode
{
GENERATED_BODY()
public:
UPS_AI_Behavior_BTTask_CoverShootCycle();
// ─── Timing ─────────────────────────────────────────────────────────
/** Minimum time (seconds) spent peeking/shooting. */
UPROPERTY(EditAnywhere, Category = "Cover Shoot|Timing", meta = (ClampMin = "0.5"))
float PeekDurationMin = 2.0f;
/** Maximum time (seconds) spent peeking/shooting. */
UPROPERTY(EditAnywhere, Category = "Cover Shoot|Timing", meta = (ClampMin = "0.5"))
float PeekDurationMax = 5.0f;
/** Minimum time (seconds) spent ducked behind cover. */
UPROPERTY(EditAnywhere, Category = "Cover Shoot|Timing", meta = (ClampMin = "0.5"))
float CoverDurationMin = 1.0f;
/** Maximum time (seconds) spent ducked behind cover. */
UPROPERTY(EditAnywhere, Category = "Cover Shoot|Timing", meta = (ClampMin = "0.5"))
float CoverDurationMax = 3.0f;
// ─── Advancement ────────────────────────────────────────────────────
/** Number of peek/duck cycles before advancing to a closer cover. */
UPROPERTY(EditAnywhere, Category = "Cover Shoot|Advancement", meta = (ClampMin = "1", ClampMax = "10"))
int32 MaxCyclesBeforeAdvance = 3;
/** Search radius for the next cover point when advancing (cm). */
UPROPERTY(EditAnywhere, Category = "Cover Shoot|Advancement", meta = (ClampMin = "200.0"))
float AdvanceSearchRadius = 1200.0f;
/** Advancement bias when searching for the next cover (0=neutral, 1=strongly toward threat). */
UPROPERTY(EditAnywhere, Category = "Cover Shoot|Advancement", meta = (ClampMin = "0.0", ClampMax = "1.0"))
float AdvancementBias = 0.7f;
/** Cover point type to search when advancing. */
UPROPERTY(EditAnywhere, Category = "Cover Shoot|Advancement")
EPS_AI_Behavior_CoverPointType CoverPointType = EPS_AI_Behavior_CoverPointType::Cover;
protected:
virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
virtual void TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
virtual EBTNodeResult::Type AbortTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
virtual void OnTaskFinished(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, EBTNodeResult::Type TaskResult) override;
virtual FString GetStaticDescription() const override;
private:
struct FCoverShootMemory
{
EPS_AI_Behavior_CombatSubState SubState = EPS_AI_Behavior_CombatSubState::Engaging;
float Timer = 0.0f;
float PhaseDuration = 0.0f;
int32 CycleCount = 0;
bool bMoveRequested = false;
// Effective durations (modulated by personality)
float EffPeekMin = 2.0f;
float EffPeekMax = 5.0f;
float EffCoverMin = 1.0f;
float EffCoverMax = 3.0f;
int32 EffMaxCycles = 3;
bool bCanAdvance = true;
};
virtual uint16 GetInstanceMemorySize() const override { return sizeof(FCoverShootMemory); }
/** Find an advancing cover point closer to the threat. */
APS_AI_Behavior_CoverPoint* FindAdvancingCover(
const UWorld* World, const FVector& NpcLoc, const FVector& ThreatLoc,
EPS_AI_Behavior_NPCType NPCType, float& OutScore) const;
};

View File

@ -62,6 +62,14 @@ public:
UPROPERTY(EditAnywhere, Category = "Cover|Manual Points")
bool bUseManualPointsOnly = false;
/**
* Bias toward covers that advance toward the threat (0 = none, 1 = strong).
* Used for cover-to-cover progression during combat.
* Covers closer to the threat than the NPC's current position score higher.
*/
UPROPERTY(EditAnywhere, Category = "Cover|Advancement", meta = (ClampMin = "0.0", ClampMax = "1.0"))
float AdvancementBias = 0.0f;
protected:
virtual EBTNodeResult::Type ExecuteTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory) override;
virtual void TickTask(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;

View File

@ -9,6 +9,7 @@
class UArrowComponent;
class UBillboardComponent;
class UTexture2D;
/**
* A manually placed strategic point in the level.
@ -128,4 +129,13 @@ protected:
private:
void UpdateVisualization();
#if WITH_EDITORONLY_DATA
/** Cached editor sprite textures (loaded once in constructor). */
UPROPERTY(Transient)
TObjectPtr<UTexture2D> CoverSpriteTexture;
UPROPERTY(Transient)
TObjectPtr<UTexture2D> HidingSpotSpriteTexture;
#endif
};

View File

@ -70,6 +70,24 @@ enum class EPS_AI_Behavior_CoverPointType : uint8
HidingSpot UMETA(DisplayName = "Hiding Spot", ToolTip = "Civilian hiding place (under desks, in closets, behind cars)"),
};
/** Combat style — determines engagement behavior (cover cycle vs rush). */
UENUM(BlueprintType)
enum class EPS_AI_Behavior_CombatType : uint8
{
Melee UMETA(DisplayName = "Melee", ToolTip = "Close-range: rush the target"),
Ranged UMETA(DisplayName = "Ranged", ToolTip = "Long-range: use cover, maintain distance"),
};
/** Sub-state within Combat for the cover-shoot cycle. Written to BB for animation sync. */
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"),
};
/** Personality trait axes — each scored 0.0 (low) to 1.0 (high). */
UENUM(BlueprintType)
enum class EPS_AI_Behavior_TraitAxis : uint8
@ -81,6 +99,77 @@ enum class EPS_AI_Behavior_TraitAxis : uint8
Discipline UMETA(DisplayName = "Discipline", ToolTip = "0 = undisciplined, 1 = disciplined"),
};
// ─── TeamId Encoding ────────────────────────────────────────────────────────
//
// TeamId encodes NPCType + Faction in a single uint8:
// High nibble = NPCType (0=Civilian, 1=Enemy, 2=Protector)
// Low nibble = Faction (0-15)
//
// Examples:
// Civilian → 0x00 (TeamId 0)
// Enemy faction 0 → 0x10 (TeamId 16)
// Enemy faction 1 → 0x11 (TeamId 17)
// Protector → 0x20 (TeamId 32)
//
namespace PS_AI_Behavior_Team
{
/** Build a TeamId from NPCType + Faction. */
inline uint8 MakeTeamId(EPS_AI_Behavior_NPCType Type, uint8 Faction = 0)
{
uint8 Base;
switch (Type)
{
case EPS_AI_Behavior_NPCType::Civilian: Base = 0x00; break;
case EPS_AI_Behavior_NPCType::Enemy: Base = 0x10; break;
case EPS_AI_Behavior_NPCType::Protector: Base = 0x20; break;
default: return 255; // NoTeam
}
return Base | (Faction & 0x0F);
}
/** Extract the NPCType from a TeamId. */
inline EPS_AI_Behavior_NPCType GetNPCType(uint8 InTeamId)
{
switch (InTeamId & 0xF0)
{
case 0x00: return EPS_AI_Behavior_NPCType::Civilian;
case 0x10: return EPS_AI_Behavior_NPCType::Enemy;
case 0x20: return EPS_AI_Behavior_NPCType::Protector;
default: return EPS_AI_Behavior_NPCType::Any;
}
}
/** Extract the Faction from a TeamId. */
inline uint8 GetFaction(uint8 InTeamId)
{
return InTeamId & 0x0F;
}
/** TeamId used for disguised enemies (same as Civilian faction 0). */
inline constexpr uint8 DisguisedTeamId = 0x00;
}
// ─── Stimulus Tags ──────────────────────────────────────────────────────────
namespace PS_AI_Behavior_Tags
{
/** Tag for enemy gunfire noise events. */
inline const FName EnemyFire = TEXT("EnemyFire");
/** Tag for player/protector gunfire noise events. */
inline const FName PlayerFire = TEXT("PlayerFire");
}
namespace PS_AI_Behavior_Tags_Internal
{
/** Returns true if the tag is any gunfire tag (EnemyFire or PlayerFire). */
inline bool IsGunfire(const FName& Tag)
{
return Tag == PS_AI_Behavior_Tags::EnemyFire || Tag == PS_AI_Behavior_Tags::PlayerFire;
}
}
// ─── Blackboard Key Names ───────────────────────────────────────────────────
namespace PS_AI_Behavior_BB
@ -95,4 +184,5 @@ namespace PS_AI_Behavior_BB
inline const FName HomeLocation = TEXT("HomeLocation");
inline const FName CurrentSpline = TEXT("CurrentSpline");
inline const FName SplineProgress = TEXT("SplineProgress");
inline const FName CombatSubState = TEXT("CombatSubState");
}

View File

@ -141,4 +141,30 @@ public:
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS AI Behavior")
bool CanBehaviorAttack(AActor* Target) const;
// ─── Combat Style ───────────────────────────────────────────────────
/**
* Get this NPC's combat type (Melee or Ranged).
* Depends on the Pawn's current weapon implement on your Character.
* Melee NPCs rush the target; Ranged NPCs use cover and maintain distance.
* Default: Melee.
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS AI Behavior")
EPS_AI_Behavior_CombatType GetBehaviorCombatType() const;
// ─── Actor Resolution ──────────────────────────────────────────────
/**
* Get the actor that represents the threat source for this Pawn.
* Called by the perception system to resolve weapons, VR tracked actors, etc.
* to the actual actor that NPCs should target and flee from.
*
* Default: returns Self (the Pawn itself).
* Override in VR to return the tracked body actor instead of the static Pawn root.
*
* @return The actor to use as threat source / target.
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS AI Behavior")
AActor* GetBehaviorThreatActor() const;
};

View File

@ -53,6 +53,17 @@ public:
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Perception")
bool GetThreatLocation(FVector& OutLocation);
/**
* Get the location of a perceived gunshot stimulus (any team affiliation).
* Used to set ThreatLocation when a gunshot is heard but the shooter isn't hostile.
* @param OutLocation Filled with the gunshot location if found.
* @return True if a gunshot stimulus was found.
*/
bool GetGunShotStimulusLocation(FVector& OutLocation);
/** Extract an actor's TeamId (checking controller, then TeamComponent). Returns NoTeam if unresolvable. */
static uint8 GetActorTeamId(const AActor* Actor);
protected:
virtual void BeginPlay() override;

View File

@ -32,6 +32,21 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality")
EPS_AI_Behavior_NPCType NPCType = EPS_AI_Behavior_NPCType::Civilian;
/**
* Faction index within the same NPCType.
* Used to create rival groups of the same type (e.g. two enemy gangs).
*
* Same NPCType + same Faction Friendly (allies)
* Same NPCType + different Faction Hostile (rivals)
* Civilian Protector always Friendly (regardless of faction)
* Everything else Hostile
*
* Example: "Gang A" Enemy profile Faction 0, "Gang B" Enemy profile Faction 1.
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality",
meta = (ClampMin = "0", ClampMax = "15"))
uint8 Faction = 0;
// ─── Trait Scores ───────────────────────────────────────────────────
/** Personality trait scores. Each axis ranges from 0.0 to 1.0. */
@ -80,6 +95,21 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality|Combat")
TArray<EPS_AI_Behavior_TargetType> TargetPriority;
/**
* Minimum attack range (cm). NPC backs away if target is closer than this.
* Melee: ~100cm. Ranged: ~600cm.
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality|Combat", meta = (ClampMin = "0.0"))
float MinAttackRange = 100.0f;
/**
* Maximum attack range (cm). NPC advances if target is farther than this.
* Between Min and Max, the NPC holds position and attacks.
* Melee: ~300cm. Ranged: ~1500cm.
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality|Combat", meta = (ClampMin = "50.0"))
float MaxAttackRange = 300.0f;
// ─── Movement Speed per State ──────────────────────────────────────
/**

View File

@ -94,12 +94,23 @@ bool FPS_AI_Behavior_SplineEdMode::HandleClick(
return false; // No ground hit
}
FVector ClickLocation = Hit.ImpactPoint;
// Use the ray trace impact point directly — no re-snap needed, this IS the ground
FVector ClickLocation = Hit.ImpactPoint + FVector(0, 0, 5.0f); // Slight offset above ground
// Ctrl+Click on existing spline → select for extension
if (Click.IsControlDown())
// ─── Without Ctrl: pass click through to editor (select/move actors) ─
if (!Click.IsControlDown())
{
return false; // Let the editor handle selection/dragging
}
// ─── Ctrl+Click: place or extend ─────────────────────────────────────
switch (ActiveTool)
{
case EPS_AI_Behavior_EdModeTool::Spline:
{
// Ctrl+Click near an existing spline → select for extension
if (!ActiveSpline)
{
// Check if we hit a SplinePath
AActor* HitActor = Hit.GetActor();
APS_AI_Behavior_SplinePath* HitSpline = Cast<APS_AI_Behavior_SplinePath>(HitActor);
@ -123,25 +134,16 @@ bool FPS_AI_Behavior_SplineEdMode::HandleClick(
SelectSplineForExtension(HitSpline);
return true;
}
return false;
}
// Snap to ground
if (bSnapToGround)
{
SnapToGround(ClickLocation);
}
// ─── Route to active tool ───────────────────────────────────────────
switch (ActiveTool)
{
case EPS_AI_Behavior_EdModeTool::Spline:
// Ctrl+Click elsewhere → add spline point
AddPointToSpline(ClickLocation);
}
break;
case EPS_AI_Behavior_EdModeTool::CoverPoint:
{
// Ctrl+Click → place cover point
// Cover point faces toward the camera (typical workflow)
const FVector CamLoc = InViewportClient->GetViewLocation();
const FVector DirToCamera = (CamLoc - ClickLocation).GetSafeNormal2D();