Multiple fixes: threat decay per profile, state hysteresis, spline pathfinding, attack readiness

- Move ThreatDecayRate from global Settings to PersonalityProfile (per-archetype)
- Add state hysteresis in EvaluateReaction to prevent Fleeing/Combat flickering
- FindCover: verify distance on arrival, retry movement if too far
- FindAndFollowSpline: sample multiple spline points when closest is blocked
- BTTask_Attack: call BehaviorStartAttack immediately (draw weapon before LOS/range)
- CoverShootCycle: call BehaviorStartAttack on entry (weapon ready during cover approach)
- FindOwningPawn: walk AttachParentActor chain for ChildActor weapons without Owner
- Initialize PreferCover BB key to false at setup

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
j.foucher 2026-04-01 17:42:05 +02:00
parent f5897b46cc
commit af93194f48
24 changed files with 168 additions and 48 deletions

View File

@ -4,6 +4,7 @@
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_PerceptionComponent.h"
#include "PS_AI_Behavior_PersonalityComponent.h"
#include "PS_AI_Behavior_PersonalityProfile.h"
#include "PS_AI_Behavior_Statics.h"
#include "PS_AI_Behavior_Settings.h"
#include "PS_AI_Behavior_Definitions.h"
@ -34,6 +35,8 @@ void UPS_AI_Behavior_BTService_UpdateThreat::TickNode(
return;
}
UPS_AI_Behavior_PersonalityComponent* Personality = AIC->GetPersonalityComponent();
// Debug: check what perception sees
TArray<AActor*> PerceivedActors;
Perception->GetCurrentlyPerceivedActors(nullptr, PerceivedActors);
@ -60,8 +63,13 @@ void UPS_AI_Behavior_BTService_UpdateThreat::TickNode(
const float StoredThreat = BB->GetValueAsFloat(PS_AI_Behavior_BB::ThreatLevel);
// Apply decay when no threat, or take the max of new vs decayed
const UPS_AI_Behavior_Settings* Settings = GetDefault<UPS_AI_Behavior_Settings>();
const float DecayedThreat = FMath::Max(0.0f, StoredThreat - Settings->ThreatDecayRate * DeltaSeconds);
// Decay rate comes from PersonalityProfile (per-archetype), fallback to global Settings
float DecayRate = GetDefault<UPS_AI_Behavior_Settings>()->ThreatDecayRate;
if (Personality && Personality->Profile)
{
DecayRate = Personality->Profile->ThreatDecayRate;
}
const float DecayedThreat = FMath::Max(0.0f, StoredThreat - DecayRate * DeltaSeconds);
const float FinalThreat = FMath::Max(RawThreat, DecayedThreat);
BB->SetValueAsFloat(PS_AI_Behavior_BB::ThreatLevel, FinalThreat);
@ -261,7 +269,6 @@ void UPS_AI_Behavior_BTService_UpdateThreat::TickNode(
}
// Sync to PersonalityComponent
UPS_AI_Behavior_PersonalityComponent* Personality = AIC->GetPersonalityComponent();
if (Personality)
{
Personality->PerceivedThreatLevel = FinalThreat;

View File

@ -103,17 +103,19 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_Attack::ExecuteTask(
const float ApproachRange = (Memory->CombatType == EPS_AI_Behavior_CombatType::Melee)
? Memory->MinRange * 0.5f : (Memory->MinRange + Memory->MaxRange) * 0.5f;
// Start attack immediately (draw weapon, enter combat stance)
// regardless of range or LOS — the NPC is committed to combat
if (Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_BehaviorStartAttack(Pawn, Target);
Memory->bAttacking = true;
}
// Check if already in range
const float DistToTarget = FVector::Dist(Pawn->GetActorLocation(), Target->GetActorLocation());
if (DistToTarget <= Memory->MaxRange)
{
Memory->bInRange = true;
// Only start attacking if we have LOS (ranged) or always (melee)
if (Memory->bHasLOS && Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_BehaviorStartAttack(Pawn, Target);
Memory->bAttacking = true;
}
}
// Initial move toward target if not in range

View File

@ -132,6 +132,13 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_CoverShootCycle::ExecuteTask(
// Face the threat while in cover
AIC->SetFocus(Target);
// Start attack immediately (draw weapon, enter combat stance)
APawn* Pawn = AIC->GetPawn();
if (Pawn && Pawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_BehaviorStartAttack(Pawn, Target);
}
if (Result == EPathFollowingRequestResult::AlreadyAtGoal)
{
Memory->SubState = EPS_AI_Behavior_CombatSubState::AtCover;

View File

@ -148,34 +148,64 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_FindAndFollowSpline::ExecuteTask(
if (bWalkToSpline && GapToSpline > AcceptanceRadius)
{
// Walk to spline first via NavMesh
const EPathFollowingRequestResult::Type Result = AIC->MoveToLocation(
SplinePoint, AcceptanceRadius, /*bStopOnOverlap=*/true,
/*bUsePathfinding=*/true, /*bProjectDestinationToNavigation=*/true,
/*bCanStrafe=*/false);
// Try to reach the closest spline point. If blocked, sample other points along the spline.
const float SplineLength = ClosestSpline->GetSplineLength();
const int32 NumSamples = 8;
const float SampleStep = SplineLength / NumSamples;
if (Result == EPathFollowingRequestResult::Failed)
// Start with the closest point, then try evenly spaced samples
TArray<float> DistancesToTry;
DistancesToTry.Add(DistAlongSpline);
for (int32 i = 1; i <= NumSamples; ++i)
{
// Can't reach via NavMesh — try starting anyway (snap)
const FVector Fwd = AIC->GetPawn()->GetActorForwardVector();
const FVector SpDir = ClosestSpline->GetWorldDirectionAtDistance(DistAlongSpline);
Follower->StartFollowingAtDistance(ClosestSpline, DistAlongSpline,
FVector::DotProduct(Fwd, SpDir) >= 0.0f);
return EBTNodeResult::Succeeded;
const float D = FMath::Fmod(DistAlongSpline + i * SampleStep, SplineLength);
DistancesToTry.Add(D);
}
if (Result == EPathFollowingRequestResult::AlreadyAtGoal)
float ReachableDist = -1.0f;
FVector ReachablePoint = FVector::ZeroVector;
for (float TestDist : DistancesToTry)
{
const FVector Fwd = AIC->GetPawn()->GetActorForwardVector();
const FVector SpDir = ClosestSpline->GetWorldDirectionAtDistance(DistAlongSpline);
Follower->StartFollowingAtDistance(ClosestSpline, DistAlongSpline,
FVector::DotProduct(Fwd, SpDir) >= 0.0f);
return EBTNodeResult::Succeeded;
const FVector TestPoint = ClosestSpline->GetWorldLocationAtDistance(TestDist);
const EPathFollowingRequestResult::Type TestResult = AIC->MoveToLocation(
TestPoint, AcceptanceRadius, true, true, true, false);
if (TestResult == EPathFollowingRequestResult::AlreadyAtGoal)
{
const FVector Fwd = AIC->GetPawn()->GetActorForwardVector();
const FVector SpDir = ClosestSpline->GetWorldDirectionAtDistance(TestDist);
Follower->StartFollowingAtDistance(ClosestSpline, TestDist,
FVector::DotProduct(Fwd, SpDir) >= 0.0f);
return EBTNodeResult::Succeeded;
}
if (TestResult != EPathFollowingRequestResult::Failed)
{
ReachableDist = TestDist;
ReachablePoint = TestPoint;
break; // Found a reachable point, use it
}
// This point is blocked, try next
AIC->StopMovement();
}
// Store the spline to connect to after reaching it
if (ReachableDist < 0.0f)
{
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] FindAndFollowSpline: no reachable point on spline '%s' (%d samples tested)"),
*AIC->GetName(), *ClosestSpline->GetName(), DistancesToTry.Num());
return EBTNodeResult::Failed;
}
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] FindAndFollowSpline: walking to reachable spline point at dist=%.0f (gap=%.0fcm)"),
*AIC->GetName(), ReachableDist,
FVector::Dist(AIC->GetPawn()->GetActorLocation(), ReachablePoint));
Follower->CurrentSpline = ClosestSpline;
Follower->CurrentDistance = DistAlongSpline;
Follower->CurrentDistance = ReachableDist;
FFindSplineMemory* Memory = reinterpret_cast<FFindSplineMemory*>(NodeMemory);
Memory->bMovingToSpline = true;

View File

@ -207,21 +207,51 @@ void UPS_AI_Behavior_BTTask_FindCover::TickTask(
if (AIC->GetMoveStatus() == EPathFollowingStatus::Idle)
{
// Verify we actually reached the cover — pathfinding can stop early (blocked by other NPCs, etc.)
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
APawn* Pawn = AIC->GetPawn();
if (BB && Pawn)
{
const FVector CoverLoc = BB->GetValueAsVector(PS_AI_Behavior_BB::CoverLocation);
const float DistToCover = FVector::Dist2D(Pawn->GetActorLocation(), CoverLoc);
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] FindCover: MoveStatus=Idle, dist2D=%.0fcm, acceptance=%.0fcm, coverLoc=%s, pawnLoc=%s"),
*AIC->GetName(), DistToCover, AcceptanceRadius,
*CoverLoc.ToString(), *Pawn->GetActorLocation().ToString());
if (DistToCover > AcceptanceRadius * 2.0f)
{
// Still too far — retry movement
const EPathFollowingRequestResult::Type Retry = AIC->MoveToLocation(
CoverLoc, AcceptanceRadius, true, true, true, false);
if (Retry == EPathFollowingRequestResult::Failed)
{
// Can't reach at all — give up
Memory->bMoveRequested = false;
FinishLatentTask(OwnerComp, EBTNodeResult::Failed);
return;
}
if (Retry != EPathFollowingRequestResult::AlreadyAtGoal)
{
return; // Keep waiting for retry movement
}
// AlreadyAtGoal — fall through to success
}
}
Memory->bMoveRequested = false;
// Crouch at cover if the point requires it
APawn* Pawn = AIC->GetPawn();
if (Pawn && Pawn->Implements<UPS_AI_Behavior_Interface>())
if (Pawn && Pawn->Implements<UPS_AI_Behavior_Interface>() && BB)
{
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (BB)
const APS_AI_Behavior_CoverPoint* CoverPt =
Cast<APS_AI_Behavior_CoverPoint>(BB->GetValueAsObject(PS_AI_Behavior_BB::CoverPoint));
if (CoverPt && CoverPt->bCrouch)
{
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);
}
IPS_AI_Behavior_Interface::Execute_SetBehaviorCrouch(Pawn, true);
}
}

View File

@ -204,6 +204,7 @@ void APS_AI_Behavior_AIController::SetupBlackboard()
Blackboard->SetValueAsInt(PS_AI_Behavior_BB::PatrolIndex, 0);
Blackboard->SetValueAsEnum(PS_AI_Behavior_BB::State,
static_cast<uint8>(EPS_AI_Behavior_State::Idle));
Blackboard->SetValueAsBool(PS_AI_Behavior_BB::PreferCover, false);
}
}

View File

@ -96,7 +96,7 @@ APawn* UPS_AI_Behavior_PerceptionComponent::FindOwningPawn(AActor* Actor)
return ActorAsPawn;
}
// Not a Pawn — walk up Owner/Instigator chain to find the owning Pawn
// Not a Pawn — walk up Owner/Instigator/Attachment chain to find the owning Pawn
AActor* Current = Actor;
for (int32 Depth = 0; Depth < 4; ++Depth) // Safety limit
{
@ -108,14 +108,29 @@ APawn* UPS_AI_Behavior_PerceptionComponent::FindOwningPawn(AActor* Actor)
// Try Owner
AActor* OwnerActor = Current->GetOwner();
if (!OwnerActor || OwnerActor == Current) break;
if (APawn* OwnerPawn = Cast<APawn>(OwnerActor))
if (OwnerActor && OwnerActor != Current)
{
return OwnerPawn;
if (APawn* OwnerPawn = Cast<APawn>(OwnerActor))
{
return OwnerPawn;
}
Current = OwnerActor;
continue;
}
Current = OwnerActor; // Continue up the chain
// Try attachment parent (ChildActorComponent → parent Character)
AActor* ParentActor = Current->GetAttachParentActor();
if (ParentActor && ParentActor != Current)
{
if (APawn* ParentPawn = Cast<APawn>(ParentActor))
{
return ParentPawn;
}
Current = ParentActor;
continue;
}
break; // No more chain to walk
}
// Fallback: could not resolve to a Pawn.

View File

@ -93,13 +93,32 @@ EPS_AI_Behavior_State UPS_AI_Behavior_PersonalityComponent::EvaluateReaction() c
PerceivedThreatLevel, Aggressivity, Courage, Caution,
EffectiveAttackThresh, EffectiveFleeThresh, AlertThresh);
// Decision cascade
if (PerceivedThreatLevel >= EffectiveFleeThresh && Courage < 0.7f)
// Decision cascade — with hysteresis to prevent state flickering.
// Once in a state, require a larger threshold drop to leave it.
const float Hysteresis = 0.15f;
if (CurrentState == EPS_AI_Behavior_State::Fleeing)
{
// Stay fleeing until threat drops well below the threshold
if (PerceivedThreatLevel >= (EffectiveFleeThresh - Hysteresis))
{
return EPS_AI_Behavior_State::Fleeing;
}
}
else if (PerceivedThreatLevel >= EffectiveFleeThresh && Courage < 0.7f)
{
return EPS_AI_Behavior_State::Fleeing;
}
if (PerceivedThreatLevel >= EffectiveAttackThresh && Aggressivity > 0.0f)
if (CurrentState == EPS_AI_Behavior_State::Combat)
{
// Stay in combat until threat drops well below the threshold
if (PerceivedThreatLevel >= (EffectiveAttackThresh - Hysteresis))
{
return EPS_AI_Behavior_State::Combat;
}
}
else if (PerceivedThreatLevel >= EffectiveAttackThresh && Aggressivity > 0.0f)
{
return EPS_AI_Behavior_State::Combat;
}

View File

@ -79,6 +79,15 @@ public:
meta = (ClampMin = "0.0", ClampMax = "1.0"))
float AlertThreshold = 0.15f;
/**
* How fast the perceived threat level decays per second when no active threat is perceived.
* Low values = NPC stays scared/alert longer (civilians).
* High values = NPC calms down quickly (trained soldiers).
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality|Reaction",
meta = (ClampMin = "0.001", ClampMax = "1.0"))
float ThreatDecayRate = 0.05f;
// ─── Target Priority (Combat) ───────────────────────────────────────
/**