Compare commits

...

5 Commits

Author SHA1 Message Date
69b9844a4b bin 2026-03-31 20:33:30 +02:00
78149fffcd 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>
2026-03-31 19:26:37 +02:00
b60086d107 Add server authority checks for networking safety
- PerceptionComponent: omniscient TActorIterator only runs with HasAuthority()
- PersonalityComponent: ApplyReaction and ForceState gate replicated CurrentState writes to server only

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:24:55 +02:00
d6325b373d Fix IsCoverNeeded decorator: resolve AimTargetActor to owning Pawn
- IsCoverNeeded used Cast<APawn> on ThreatActor which failed for AimTargetActors
  → always returned true (assume dangerous) → enemies took cover against civilians
- Fix: use FindOwningPawn to walk Owner/Instigator chain to the actual Pawn
- Revert inline civilian check in EvaluateReaction (decorator handles it in BT)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 18:20:04 +02:00
011bfcf62a Add uncrouch on state change and flee: civilians stand up when leaving cover
- AIController: auto uncrouch when leaving Fleeing/TakingCover state
- FleeFrom: uncrouch at start of flee (civilian was still crouched from HidingSpot)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 17:59:22 +02:00
30 changed files with 198 additions and 11 deletions

View File

@ -2,6 +2,8 @@
#include "BT/PS_AI_Behavior_BTDecorator_IsCoverNeeded.h" #include "BT/PS_AI_Behavior_BTDecorator_IsCoverNeeded.h"
#include "PS_AI_Behavior_AIController.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 "PS_AI_Behavior_Interface.h"
#include "BehaviorTree/BlackboardComponent.h" #include "BehaviorTree/BlackboardComponent.h"
@ -23,17 +25,32 @@ bool UPS_AI_Behavior_BTDecorator_IsCoverNeeded::CalculateRawConditionValue(
AActor* ThreatActor = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor)); AActor* ThreatActor = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor));
if (!ThreatActor) return false; if (!ThreatActor) return false;
// If the target doesn't implement the interface, assume dangerous (safe default) // Resolve ThreatActor to owning Pawn (AimTargetActor → Character)
APawn* ThreatPawn = Cast<APawn>(ThreatActor); APawn* ThreatPawn = UPS_AI_Behavior_PerceptionComponent::FindOwningPawn(ThreatActor);
if (!ThreatPawn || !ThreatPawn->Implements<UPS_AI_Behavior_Interface>()) if (!ThreatPawn || !ThreatPawn->Implements<UPS_AI_Behavior_Interface>())
{ {
return true; return true; // Can't resolve → assume dangerous (safe default)
} }
const EPS_AI_Behavior_NPCType TargetType = const EPS_AI_Behavior_NPCType TargetType =
IPS_AI_Behavior_Interface::Execute_GetBehaviorNPCType(ThreatPawn); 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 FString UPS_AI_Behavior_BTDecorator_IsCoverNeeded::GetStaticDescription() const

View File

@ -2,6 +2,7 @@
#include "BT/PS_AI_Behavior_BTTask_FleeFrom.h" #include "BT/PS_AI_Behavior_BTTask_FleeFrom.h"
#include "PS_AI_Behavior_AIController.h" #include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_Interface.h"
#include "PS_AI_Behavior_Definitions.h" #include "PS_AI_Behavior_Definitions.h"
#include "BehaviorTree/BlackboardComponent.h" #include "BehaviorTree/BlackboardComponent.h"
#include "NavigationSystem.h" #include "NavigationSystem.h"
@ -20,6 +21,13 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_FleeFrom::ExecuteTask(
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner()); APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (!AIC || !AIC->GetPawn()) return EBTNodeResult::Failed; if (!AIC || !AIC->GetPawn()) return EBTNodeResult::Failed;
// Stand up if crouching (e.g. was hiding at a cover point before fleeing)
APawn* FleePawn = AIC->GetPawn();
if (FleePawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_SetBehaviorCrouch(FleePawn, false);
}
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent(); UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (!BB) return EBTNodeResult::Failed; if (!BB) return EBTNodeResult::Failed;

View File

@ -17,6 +17,7 @@
#include "BehaviorTree/Blackboard/BlackboardKeyType_Object.h" #include "BehaviorTree/Blackboard/BlackboardKeyType_Object.h"
#include "BehaviorTree/Blackboard/BlackboardKeyType_Vector.h" #include "BehaviorTree/Blackboard/BlackboardKeyType_Vector.h"
#include "BehaviorTree/Blackboard/BlackboardKeyType_String.h" #include "BehaviorTree/Blackboard/BlackboardKeyType_String.h"
#include "BehaviorTree/Blackboard/BlackboardKeyType_Bool.h"
APS_AI_Behavior_AIController::APS_AI_Behavior_AIController() 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.EntryName = PS_AI_Behavior_BB::ThreatPawnName;
ThreatPawnNameEntry.KeyType = NewObject<UBlackboardKeyType_String>(BlackboardAsset); ThreatPawnNameEntry.KeyType = NewObject<UBlackboardKeyType_String>(BlackboardAsset);
BlackboardAsset->Keys.Add(ThreatPawnNameEntry); 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; UBlackboardComponent* RawBBComp = nullptr;
@ -237,6 +244,18 @@ void APS_AI_Behavior_AIController::SetBehaviorState(EPS_AI_Behavior_State NewSta
} }
Blackboard->SetValueAsEnum(PS_AI_Behavior_BB::State, NewVal); Blackboard->SetValueAsEnum(PS_AI_Behavior_BB::State, NewVal);
// ─── Leaving cover/hiding: stand up ─────────────────────────
const EPS_AI_Behavior_State OldState = static_cast<EPS_AI_Behavior_State>(OldVal);
if ((OldState == EPS_AI_Behavior_State::Fleeing || OldState == EPS_AI_Behavior_State::TakingCover)
&& NewState != EPS_AI_Behavior_State::Fleeing && NewState != EPS_AI_Behavior_State::TakingCover)
{
APawn* MyPawn = GetPawn();
if (MyPawn && MyPawn->Implements<UPS_AI_Behavior_Interface>())
{
IPS_AI_Behavior_Interface::Execute_SetBehaviorCrouch(MyPawn, false);
}
}
// ─── Dead: shut down all AI systems ───────────────────────── // ─── Dead: shut down all AI systems ─────────────────────────
if (NewState == EPS_AI_Behavior_State::Dead) if (NewState == EPS_AI_Behavior_State::Dead)
{ {

View File

@ -280,7 +280,8 @@ AActor* UPS_AI_Behavior_PerceptionComponent::GetHighestThreatActor(
const AAIController* OwnerAIC = Cast<AAIController>(GetOwner()); const AAIController* OwnerAIC = Cast<AAIController>(GetOwner());
const APawn* OwnerPawn = OwnerAIC ? OwnerAIC->GetPawn() : nullptr; const APawn* OwnerPawn = OwnerAIC ? OwnerAIC->GetPawn() : nullptr;
if (OwnerPawn) // Only run on server — TActorIterator results differ on clients
if (OwnerPawn && OwnerPawn->HasAuthority())
{ {
const EPS_AI_Behavior_TargetType TopPriority = TargetPriority[0]; const EPS_AI_Behavior_TargetType TopPriority = TargetPriority[0];
const UPS_AI_Behavior_Settings* Settings = GetDefault<UPS_AI_Behavior_Settings>(); const UPS_AI_Behavior_Settings* Settings = GetDefault<UPS_AI_Behavior_Settings>();

View File

@ -102,11 +102,6 @@ EPS_AI_Behavior_State UPS_AI_Behavior_PersonalityComponent::EvaluateReaction() c
if (PerceivedThreatLevel >= EffectiveAttackThresh && Aggressivity > 0.3f) 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; return EPS_AI_Behavior_State::Combat;
} }
@ -132,7 +127,87 @@ EPS_AI_Behavior_State UPS_AI_Behavior_PersonalityComponent::EvaluateReaction() c
EPS_AI_Behavior_State UPS_AI_Behavior_PersonalityComponent::ApplyReaction() EPS_AI_Behavior_State UPS_AI_Behavior_PersonalityComponent::ApplyReaction()
{ {
const EPS_AI_Behavior_State NewState = EvaluateReaction(); // Only server can change replicated state
if (!GetOwner() || !GetOwner()->HasAuthority())
{
return CurrentState;
}
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) if (NewState != CurrentState)
{ {
const EPS_AI_Behavior_State OldState = CurrentState; const EPS_AI_Behavior_State OldState = CurrentState;
@ -149,8 +224,40 @@ EPS_AI_Behavior_State UPS_AI_Behavior_PersonalityComponent::ApplyReaction()
return CurrentState; 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) void UPS_AI_Behavior_PersonalityComponent::ForceState(EPS_AI_Behavior_State NewState)
{ {
// Only server can change replicated state
if (!GetOwner() || !GetOwner()->HasAuthority())
{
return;
}
if (NewState != CurrentState) if (NewState != CurrentState)
{ {
const EPS_AI_Behavior_State OldState = CurrentState; const EPS_AI_Behavior_State OldState = CurrentState;

View File

@ -189,4 +189,5 @@ namespace PS_AI_Behavior_BB
inline const FName CombatSubState = TEXT("CombatSubState"); inline const FName CombatSubState = TEXT("CombatSubState");
inline const FName LastKnownTargetPosition = TEXT("LastKnownTargetPosition"); inline const FName LastKnownTargetPosition = TEXT("LastKnownTargetPosition");
inline const FName ThreatPawnName = TEXT("ThreatPawnName"); // Debug: name of the owning Pawn behind ThreatActor 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") UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Personality")
EPS_AI_Behavior_NPCType GetNPCType() const; 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 ──────────────────────────────────────────────────── // ─── Replication ────────────────────────────────────────────────────
virtual void GetLifetimeReplicatedProps(TArray<FLifetimeProperty>& OutLifetimeProps) const override; 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); 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. */ /** Draw floating debug text above the NPC's head. */
void DrawDebugInfo() const; void DrawDebugInfo() const;
}; };

View File

@ -110,6 +110,18 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality|Combat", meta = (ClampMin = "50.0")) UPROPERTY(EditAnywhere, BlueprintReadOnly, Category = "Personality|Combat", meta = (ClampMin = "50.0"))
float MaxAttackRange = 300.0f; 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 ────────────────────────────────────── // ─── Movement Speed per State ──────────────────────────────────────
/** /**