Add personality-driven Combat/Cover cycle with BB PreferCover key

- Add CombatCoverCycleDuration to PersonalityProfile (configurable base duration)
- PersonalityComponent cycles bPreferCover based on Aggressivity/Caution ratio
  - Combat duration = CycleDuration × Aggressivity/(Aggressivity+Caution)
  - Cover duration = CycleDuration × Caution/(Aggressivity+Caution)
  - Min 2s per phase, ±20% jitter
- Write PreferCover bool to Blackboard for BT decorator observer aborts
- IsCoverNeeded decorator checks both target type AND ShouldPreferCover()
- Remove TakingCover state from EvaluateReaction — cover is now a sub-mode of Combat
- BT uses Blackboard Condition on PreferCover with Observer Aborts=Both

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
j.foucher 2026-03-31 19:26:37 +02:00
parent b60086d107
commit 78149fffcd
22 changed files with 160 additions and 7 deletions

View File

@ -3,6 +3,7 @@
#include "BT/PS_AI_Behavior_BTDecorator_IsCoverNeeded.h"
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_PerceptionComponent.h"
#include "PS_AI_Behavior_PersonalityComponent.h"
#include "PS_AI_Behavior_Interface.h"
#include "BehaviorTree/BlackboardComponent.h"
@ -34,7 +35,22 @@ bool UPS_AI_Behavior_BTDecorator_IsCoverNeeded::CalculateRawConditionValue(
const EPS_AI_Behavior_NPCType TargetType =
IPS_AI_Behavior_Interface::Execute_GetBehaviorNPCType(ThreatPawn);
return DangerousTargetTypes.Contains(TargetType);
if (!DangerousTargetTypes.Contains(TargetType))
{
return false; // Target is not dangerous → no cover needed
}
// Check personality-driven combat/cover cycle timer
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (AIC)
{
if (const UPS_AI_Behavior_PersonalityComponent* Personality = AIC->GetPersonalityComponent())
{
return Personality->ShouldPreferCover();
}
}
return true; // No personality → default to cover
}
FString UPS_AI_Behavior_BTDecorator_IsCoverNeeded::GetStaticDescription() const

View File

@ -17,6 +17,7 @@
#include "BehaviorTree/Blackboard/BlackboardKeyType_Object.h"
#include "BehaviorTree/Blackboard/BlackboardKeyType_Vector.h"
#include "BehaviorTree/Blackboard/BlackboardKeyType_String.h"
#include "BehaviorTree/Blackboard/BlackboardKeyType_Bool.h"
APS_AI_Behavior_AIController::APS_AI_Behavior_AIController()
{
@ -184,6 +185,12 @@ void APS_AI_Behavior_AIController::SetupBlackboard()
ThreatPawnNameEntry.EntryName = PS_AI_Behavior_BB::ThreatPawnName;
ThreatPawnNameEntry.KeyType = NewObject<UBlackboardKeyType_String>(BlackboardAsset);
BlackboardAsset->Keys.Add(ThreatPawnNameEntry);
// PreferCover (bool: personality-driven combat/cover cycle)
FBlackboardEntry PreferCoverEntry;
PreferCoverEntry.EntryName = PS_AI_Behavior_BB::PreferCover;
PreferCoverEntry.KeyType = NewObject<UBlackboardKeyType_Bool>(BlackboardAsset);
BlackboardAsset->Keys.Add(PreferCoverEntry);
}
UBlackboardComponent* RawBBComp = nullptr;

View File

@ -102,11 +102,6 @@ EPS_AI_Behavior_State UPS_AI_Behavior_PersonalityComponent::EvaluateReaction() c
if (PerceivedThreatLevel >= EffectiveAttackThresh && Aggressivity > 0.3f)
{
// Cautious NPCs prefer cover over direct combat
if (Caution > 0.6f)
{
return EPS_AI_Behavior_State::TakingCover;
}
return EPS_AI_Behavior_State::Combat;
}
@ -138,7 +133,81 @@ EPS_AI_Behavior_State UPS_AI_Behavior_PersonalityComponent::ApplyReaction()
return CurrentState;
}
const EPS_AI_Behavior_State NewState = EvaluateReaction();
EPS_AI_Behavior_State NewState = EvaluateReaction();
// ─── Combat/Cover cycle timer ──────────────────────────────────
// While in Combat, alternate bPreferCover flag based on personality ratio.
// Writes PreferCover bool to Blackboard so BT decorators can react via observer aborts.
if (NewState == EPS_AI_Behavior_State::Combat)
{
if (!bCombatCoverCycleActive)
{
// Start the cycle — begin with cover if cautious, attack if aggressive
bCombatCoverCycleActive = true;
const float Aggressivity = GetTrait(EPS_AI_Behavior_TraitAxis::Aggressivity);
const float Caution = GetTrait(EPS_AI_Behavior_TraitAxis::Caution);
const float CombatRatio = (Aggressivity + Caution > 0.0f)
? Aggressivity / (Aggressivity + Caution) : 0.5f;
bPreferCover = (CombatRatio <= 0.5f);
CombatCoverTimer = CalculatePhaseDuration(
bPreferCover ? EPS_AI_Behavior_State::TakingCover : EPS_AI_Behavior_State::Combat);
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Combat/Cover cycle started: %s for %.1fs"),
*GetOwner()->GetName(), bPreferCover ? TEXT("Cover") : TEXT("Attack"), CombatCoverTimer);
}
else
{
// Tick down the timer (ApplyReaction is called every ~0.5s by the BT service)
CombatCoverTimer -= 0.5f;
if (CombatCoverTimer <= 0.0f)
{
// Switch phase
bPreferCover = !bPreferCover;
CombatCoverTimer = CalculatePhaseDuration(
bPreferCover ? EPS_AI_Behavior_State::TakingCover : EPS_AI_Behavior_State::Combat);
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Combat/Cover cycle switch: → %s for %.1fs"),
*GetOwner()->GetName(), bPreferCover ? TEXT("Cover") : TEXT("Attack"), CombatCoverTimer);
}
}
// Write to BB so Blackboard-based decorators can observe the change
if (AActor* Owner = GetOwner())
{
if (AAIController* AIC = Cast<AAIController>(Cast<APawn>(Owner) ?
Cast<APawn>(Owner)->GetController() : nullptr))
{
if (UBlackboardComponent* BB = AIC->GetBlackboardComponent())
{
BB->SetValueAsBool(PS_AI_Behavior_BB::PreferCover, bPreferCover);
}
}
}
}
else
{
// Left combat → reset cycle
if (bCombatCoverCycleActive)
{
bCombatCoverCycleActive = false;
bPreferCover = false;
CombatCoverTimer = 0.0f;
if (AActor* Owner = GetOwner())
{
if (AAIController* AIC = Cast<AAIController>(Cast<APawn>(Owner) ?
Cast<APawn>(Owner)->GetController() : nullptr))
{
if (UBlackboardComponent* BB = AIC->GetBlackboardComponent())
{
BB->SetValueAsBool(PS_AI_Behavior_BB::PreferCover, false);
}
}
}
}
}
if (NewState != CurrentState)
{
const EPS_AI_Behavior_State OldState = CurrentState;
@ -155,6 +224,32 @@ EPS_AI_Behavior_State UPS_AI_Behavior_PersonalityComponent::ApplyReaction()
return CurrentState;
}
float UPS_AI_Behavior_PersonalityComponent::CalculatePhaseDuration(EPS_AI_Behavior_State Phase) const
{
const float Aggressivity = GetTrait(EPS_AI_Behavior_TraitAxis::Aggressivity);
const float Caution = GetTrait(EPS_AI_Behavior_TraitAxis::Caution);
const float CycleDuration = Profile ? Profile->CombatCoverCycleDuration : 15.0f;
const float Sum = Aggressivity + Caution;
const float CombatRatio = (Sum > 0.0f) ? Aggressivity / Sum : 0.5f;
float Duration;
if (Phase == EPS_AI_Behavior_State::Combat)
{
Duration = CycleDuration * CombatRatio;
}
else
{
Duration = CycleDuration * (1.0f - CombatRatio);
}
// Clamp minimum 2s, add ±20% jitter
Duration = FMath::Max(Duration, 2.0f);
Duration *= FMath::RandRange(0.8f, 1.2f);
return Duration;
}
void UPS_AI_Behavior_PersonalityComponent::ForceState(EPS_AI_Behavior_State NewState)
{
// Only server can change replicated state

View File

@ -189,4 +189,5 @@ namespace PS_AI_Behavior_BB
inline const FName CombatSubState = TEXT("CombatSubState");
inline const FName LastKnownTargetPosition = TEXT("LastKnownTargetPosition");
inline const FName ThreatPawnName = TEXT("ThreatPawnName"); // Debug: name of the owning Pawn behind ThreatActor
inline const FName PreferCover = TEXT("PreferCover"); // Bool: personality-driven cover preference cycle
}

View File

@ -109,6 +109,14 @@ public:
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Personality")
EPS_AI_Behavior_NPCType GetNPCType() const;
/**
* Whether the NPC currently prefers cover over direct attack.
* Driven by the Combat/Cover cycle timer based on Aggressivity vs Caution.
* Used by the IsCoverNeeded BT decorator.
*/
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Personality")
bool ShouldPreferCover() const { return bPreferCover; }
// ─── Replication ────────────────────────────────────────────────────
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override;
@ -130,6 +138,20 @@ private:
*/
void HandleStateChanged(EPS_AI_Behavior_State OldState, EPS_AI_Behavior_State NewState);
// ─── Combat/Cover Cycle Timer ──────────────────────────────────────
/** Countdown timer for the current Combat or TakingCover phase. */
float CombatCoverTimer = 0.0f;
/** Whether the combat/cover cycle timer is active. */
bool bCombatCoverCycleActive = false;
/** Current cover preference — toggled by the cycle timer. */
bool bPreferCover = false;
/** Calculate the duration for a Combat or TakingCover phase based on personality. */
float CalculatePhaseDuration(EPS_AI_Behavior_State Phase) const;
/** Draw floating debug text above the NPC's head. */
void DrawDebugInfo() const;
};

View File

@ -110,6 +110,18 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality|Combat", meta = (ClampMin = "50.0"))
float MaxAttackRange = 300.0f;
// ─── Combat/Cover Cycle ────────────────────────────────────────────
/**
* Base duration (seconds) of the CombatTakingCover cycle.
* The actual time in each state is proportional to Aggressivity vs Caution:
* Combat duration = CombatCoverCycleDuration × Aggressivity / (Aggressivity + Caution)
* TakingCover duration = CombatCoverCycleDuration × Caution / (Aggressivity + Caution)
*/
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality|Combat",
meta = (ClampMin = "4.0", ClampMax = "60.0"))
float CombatCoverCycleDuration = 15.0f;
// ─── Movement Speed per State ──────────────────────────────────────
/**