Fix BT state transitions, attack persistence, and hostile team switching

BT Attack task:
- Stay InProgress permanently instead of returning Succeeded after each attack
- Stay InProgress on MoveToActor failure instead of Failed (retry next tick)
- Add verbose logging for attack state (target, range, distance)

BT EvaluateReaction service:
- Auto-detect hostility changes via IPS_AI_Behavior interface
- Dynamically update TeamId when IsBehaviorHostile() changes (infiltrator reveal)

AIController:
- Remove GetBehaviorTeamId from interface (TeamId is now 100% automatic)
- TeamId derived from NPCType + hostile state, no user implementation needed
- Add BB State change logging for debug
- Use SetValueAsEnum consistently for BehaviorState key

Interface:
- Remove GetBehaviorTeamId — TeamId is computed by the plugin automatically

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
j.foucher 2026-03-26 18:02:48 +01:00
parent 2e04cb0334
commit 9d054cc46f
4 changed files with 53 additions and 32 deletions

View File

@ -2,6 +2,7 @@
#include "BT/PS_AI_Behavior_BTService_EvaluateReaction.h"
#include "PS_AI_Behavior_AIController.h"
#include "PS_AI_Behavior_Interface.h"
#include "PS_AI_Behavior_PersonalityComponent.h"
#include "PS_AI_Behavior_Definitions.h"
#include "BehaviorTree/BlackboardComponent.h"
@ -27,7 +28,33 @@ void UPS_AI_Behavior_BTService_EvaluateReaction::TickNode(
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (!BB) return;
// Evaluate and apply the reaction
// ─── Check for hostility change → update TeamId dynamically ────────
APawn* Pawn = AIC->GetPawn();
if (Pawn && Pawn->Implements<UPS_AI_Behavior_Interface>())
{
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).
uint8 ExpectedTeamId;
switch (NPCType)
{
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;
}
if (AIC->GetGenericTeamId().GetId() != ExpectedTeamId)
{
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Hostility changed: TeamId %d -> %d (hostile=%d)"),
*AIC->GetName(), AIC->GetGenericTeamId().GetId(), ExpectedTeamId, (int32)bHostile);
AIC->SetTeamId(ExpectedTeamId);
}
}
// ─── Evaluate and apply the reaction ────────────────────────────────
const EPS_AI_Behavior_State NewState = Personality->ApplyReaction();
// Write to Blackboard

View File

@ -27,6 +27,7 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_Attack::ExecuteTask(
AActor* Target = Cast<AActor>(BB->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor));
if (!Target)
{
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: no ThreatActor in BB."), *AIC->GetName());
return EBTNodeResult::Failed;
}
@ -36,19 +37,23 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_Attack::ExecuteTask(
if (!Combat)
{
UE_LOG(LogPS_AI_Behavior, Warning,
TEXT("[%s] Attack task: no CombatComponent on Pawn."), *AIC->GetName());
TEXT("[%s] Attack: no CombatComponent on Pawn."), *AIC->GetName());
return EBTNodeResult::Failed;
}
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Attack: target='%s', range=%.0f, dist=%.0f, inRange=%d, canAttack=%d"),
*AIC->GetName(), *Target->GetName(), Combat->AttackRange,
FVector::Dist(AIC->GetPawn()->GetActorLocation(), Target->GetActorLocation()),
(int32)Combat->IsInAttackRange(Target), (int32)Combat->CanAttack());
// Try to attack immediately if in range
if (Combat->IsInAttackRange(Target))
{
if (Combat->CanAttack())
{
Combat->ExecuteAttack(Target);
return EBTNodeResult::Succeeded;
}
// In range but on cooldown — wait
// Stay InProgress — keep attacking while in combat state
return EBTNodeResult::InProgress;
}
@ -59,7 +64,9 @@ EBTNodeResult::Type UPS_AI_Behavior_BTTask_Attack::ExecuteTask(
if (Result == EPathFollowingRequestResult::Failed)
{
return EBTNodeResult::Failed;
UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] Attack: MoveToActor failed — no path to target."), *AIC->GetName());
// Stay InProgress anyway — will retry next tick instead of giving up
return EBTNodeResult::InProgress;
}
FAttackMemory* Memory = reinterpret_cast<FAttackMemory*>(NodeMemory);
@ -107,9 +114,9 @@ void UPS_AI_Behavior_BTTask_Attack::TickTask(
if (Combat->CanAttack())
{
Combat->ExecuteAttack(Target);
FinishLatentTask(OwnerComp, EBTNodeResult::Succeeded);
}
// Else: wait for cooldown (stay InProgress)
// Stay InProgress — keep attacking (cooldown handles the rate)
// Observer Aborts on the Decorator will pull us out when state changes
}
else
{

View File

@ -48,13 +48,6 @@ void APS_AI_Behavior_AIController::OnPossess(APawn* InPawn)
{
// Use the interface — the host project controls the storage
NPCType = IPS_AI_Behavior_Interface::Execute_GetBehaviorNPCType(InPawn);
// Also check if the interface provides a specific TeamId
const uint8 InterfaceTeamId = IPS_AI_Behavior_Interface::Execute_GetBehaviorTeamId(InPawn);
if (InterfaceTeamId != FGenericTeamId::NoTeam)
{
TeamId = InterfaceTeamId;
}
}
else if (PersonalityComp)
{
@ -227,7 +220,16 @@ void APS_AI_Behavior_AIController::SetBehaviorState(EPS_AI_Behavior_State NewSta
{
if (Blackboard)
{
Blackboard->SetValueAsEnum(PS_AI_Behavior_BB::State, static_cast<uint8>(NewState));
const uint8 OldVal = Blackboard->GetValueAsEnum(PS_AI_Behavior_BB::State);
const uint8 NewVal = static_cast<uint8>(NewState);
if (OldVal != NewVal)
{
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] BB State: %s -> %s"),
*GetName(),
*UEnum::GetValueAsString(static_cast<EPS_AI_Behavior_State>(OldVal)),
*UEnum::GetValueAsString(NewState));
}
Blackboard->SetValueAsEnum(PS_AI_Behavior_BB::State, NewVal);
}
}
@ -274,16 +276,12 @@ ETeamAttitude::Type APS_AI_Behavior_AIController::GetTeamAttitudeTowards(const A
if (OtherPawn)
{
// Check via AIController first
// Check via AIController (NPC with our behavior system)
if (const AAIController* OtherAIC = Cast<AAIController>(OtherPawn->GetController()))
{
OtherTeam = OtherAIC->GetGenericTeamId().GetId();
}
// Check via IPS_AI_Behavior interface
else if (OtherPawn->Implements<UPS_AI_Behavior_Interface>())
{
OtherTeam = IPS_AI_Behavior_Interface::Execute_GetBehaviorTeamId(const_cast<APawn*>(OtherPawn));
}
// Players or other pawns without AIController → NoTeam (Neutral)
}
// NoTeam (255) → Neutral

View File

@ -27,7 +27,6 @@
* virtual void SetBehaviorNPCType_Implementation(EPS_AI_Behavior_NPCType T) override { MyType = T; }
* virtual bool IsBehaviorHostile_Implementation() const override { return bHostile; }
* virtual void SetBehaviorHostile_Implementation(bool b) override { bHostile = b; }
* virtual uint8 GetBehaviorTeamId_Implementation() const override { return bHostile ? 2 : 1; }
* };
*/
UINTERFACE(BlueprintType, Blueprintable, meta = (DisplayName = "PS AI Behavior Interface"))
@ -69,16 +68,6 @@ public:
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS AI Behavior")
void SetBehaviorHostile(bool bNewHostile);
// ─── Team ───────────────────────────────────────────────────────────
/**
* Get the Team ID for perception affiliation.
* Convention: Civilian=1, Enemy=2, Protector=3, NoTeam=255.
* Infiltrated enemies return 1 (Civilian) until SetHostile(true).
*/
UFUNCTION(BlueprintNativeEvent, BlueprintCallable, Category = "PS AI Behavior")
uint8 GetBehaviorTeamId() const;
// ─── Movement ───────────────────────────────────────────────────────
/**