Add Scripted state and bAutoStartBehavior for external NPC control

Allow NPCs to start without a Behavior Tree and be controlled externally
via Blueprint, Level Sequences, or triggers. Adds Scripted enum state that
prevents BT services from overriding state, plus StartBehavior/StopBehavior
BlueprintCallable functions to toggle BT execution at runtime.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
j.foucher 2026-04-03 19:26:26 +02:00
parent a31ac1d782
commit c1afddc8b7
12 changed files with 111 additions and 12 deletions

View File

@ -25,6 +25,9 @@ void UPS_AI_Behavior_BTService_EvaluateReaction::TickNode(
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) { UE_LOG(LogPS_AI_Behavior, Warning, TEXT("EvaluateReaction: no AIC!")); return; } if (!AIC) { UE_LOG(LogPS_AI_Behavior, Warning, TEXT("EvaluateReaction: no AIC!")); return; }
// Scripted state: external control — don't touch the state
if (AIC->GetBehaviorState() == EPS_AI_Behavior_State::Scripted) { return; }
UPS_AI_Behavior_PersonalityComponent* Personality = AIC->GetPersonalityComponent(); UPS_AI_Behavior_PersonalityComponent* Personality = AIC->GetPersonalityComponent();
if (!Personality) { UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] EvaluateReaction: no PersonalityComponent!"), *AIC->GetName()); return; } if (!Personality) { UE_LOG(LogPS_AI_Behavior, Warning, TEXT("[%s] EvaluateReaction: no PersonalityComponent!"), *AIC->GetName()); return; }

View File

@ -25,6 +25,9 @@ void UPS_AI_Behavior_BTService_UpdateThreat::TickNode(
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) return; if (!AIC) return;
// Scripted state: external control — don't accumulate threat
if (AIC->GetBehaviorState() == EPS_AI_Behavior_State::Scripted) { return; }
UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent(); UBlackboardComponent* BB = OwnerComp.GetBlackboardComponent();
if (!BB) return; if (!BB) return;

View File

@ -80,12 +80,24 @@ void APS_AI_Behavior_AIController::OnPossess(APawn* InPawn)
} }
SetupBlackboard(); SetupBlackboard();
if (bAutoStartBehavior)
{
StartBehavior(); StartBehavior();
}
else
{
// Stay in Scripted state — no BT, no services, controlled externally
SetBehaviorState(EPS_AI_Behavior_State::Scripted);
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] bAutoStartBehavior=false — entering Scripted state."),
*GetName());
}
TryBindConversationAgent(); TryBindConversationAgent();
TryBindGazeComponent(); TryBindGazeComponent();
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Possessed Pawn '%s' — BT started, TeamId=%d."), UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Possessed Pawn '%s' — TeamId=%d, AutoStart=%d."),
*GetName(), *InPawn->GetName(), TeamId); *GetName(), *InPawn->GetName(), TeamId, (int32)bAutoStartBehavior);
} }
void APS_AI_Behavior_AIController::OnUnPossess() void APS_AI_Behavior_AIController::OnUnPossess()
@ -227,24 +239,84 @@ void APS_AI_Behavior_AIController::SetupBlackboard()
void APS_AI_Behavior_AIController::StartBehavior() void APS_AI_Behavior_AIController::StartBehavior()
{ {
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] StartBehavior called. Pawn=%s, HasBB=%d"),
*GetName(),
GetPawn() ? *GetPawn()->GetName() : TEXT("null"),
Blackboard != nullptr);
// Check if a BT was previously loaded and stopped (StopBehavior case)
UBehaviorTreeComponent* BTComp = Cast<UBehaviorTreeComponent>(GetBrainComponent());
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] BrainComponent=%s, BTComp=%s, CurrentTree=%s, IsRunning=%d"),
*GetName(),
GetBrainComponent() ? *GetBrainComponent()->GetName() : TEXT("null"),
BTComp ? *BTComp->GetName() : TEXT("null"),
(BTComp && BTComp->GetCurrentTree()) ? *BTComp->GetCurrentTree()->GetName() : TEXT("null"),
BTComp ? (int32)BTComp->IsRunning() : -1);
if (BTComp && BTComp->GetCurrentTree() && !BTComp->IsRunning())
{
BTComp->RestartLogic();
if (GetBehaviorState() == EPS_AI_Behavior_State::Scripted)
{
SetBehaviorState(EPS_AI_Behavior_State::Idle);
}
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] StartBehavior — BT restarted (RestartLogic). IsRunning=%d"),
*GetName(), (int32)BTComp->IsRunning());
return;
}
// First-time start: load and run the BT
UBehaviorTree* BTToRun = BehaviorTreeAsset; UBehaviorTree* BTToRun = BehaviorTreeAsset;
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] BehaviorTreeAsset=%s"),
*GetName(), BTToRun ? *BTToRun->GetName() : TEXT("null"));
// Fallback: get from personality profile // Fallback: get from personality profile
if (!BTToRun && PersonalityComp && PersonalityComp->Profile) if (!BTToRun && PersonalityComp && PersonalityComp->Profile)
{ {
BTToRun = PersonalityComp->Profile->DefaultBehaviorTree.LoadSynchronous(); BTToRun = PersonalityComp->Profile->DefaultBehaviorTree.LoadSynchronous();
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Loaded from Profile: %s"),
*GetName(), BTToRun ? *BTToRun->GetName() : TEXT("null"));
} }
if (BTToRun) if (!BTToRun)
{
RunBehaviorTree(BTToRun);
}
else
{ {
UE_LOG(LogPS_AI_Behavior, Warning, UE_LOG(LogPS_AI_Behavior, Warning,
TEXT("[%s] No BehaviorTree assigned and none in PersonalityProfile — NPC will be inert."), TEXT("[%s] StartBehavior FAILED — No BehaviorTree assigned and none in PersonalityProfile."),
*GetName()); *GetName());
return;
} }
const bool bSuccess = RunBehaviorTree(BTToRun);
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] RunBehaviorTree('%s') returned %d"),
*GetName(), *BTToRun->GetName(), (int32)bSuccess);
// Check post-run state
BTComp = Cast<UBehaviorTreeComponent>(GetBrainComponent());
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Post-run: BTComp=%s, IsRunning=%d, BB=%s"),
*GetName(),
BTComp ? *BTComp->GetName() : TEXT("null"),
BTComp ? (int32)BTComp->IsRunning() : -1,
Blackboard ? TEXT("valid") : TEXT("null"));
if (bSuccess && GetBehaviorState() == EPS_AI_Behavior_State::Scripted)
{
SetBehaviorState(EPS_AI_Behavior_State::Idle);
}
}
void APS_AI_Behavior_AIController::StopBehavior()
{
if (UBrainComponent* Brain = GetBrainComponent())
{
Brain->StopLogic(TEXT("Scripted"));
}
StopMovement();
SetBehaviorState(EPS_AI_Behavior_State::Scripted);
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] StopBehavior — BT stopped, entering Scripted state."), *GetName());
} }
void APS_AI_Behavior_AIController::SetBehaviorState(EPS_AI_Behavior_State NewState) void APS_AI_Behavior_AIController::SetBehaviorState(EPS_AI_Behavior_State NewState)
@ -274,6 +346,12 @@ void APS_AI_Behavior_AIController::SetBehaviorState(EPS_AI_Behavior_State NewSta
} }
} }
// ─── Scripted: stop movement, NPC stays alive ──────────────
if (NewState == EPS_AI_Behavior_State::Scripted)
{
StopMovement();
}
// ─── 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

@ -72,6 +72,11 @@ EPS_AI_Behavior_State UPS_AI_Behavior_PersonalityComponent::EvaluateReaction() c
return EPS_AI_Behavior_State::Dead; return EPS_AI_Behavior_State::Dead;
} }
if (CurrentState == EPS_AI_Behavior_State::Scripted)
{
return EPS_AI_Behavior_State::Scripted;
}
const float Courage = GetTrait(EPS_AI_Behavior_TraitAxis::Courage); const float Courage = GetTrait(EPS_AI_Behavior_TraitAxis::Courage);
const float Aggressivity = GetTrait(EPS_AI_Behavior_TraitAxis::Aggressivity); const float Aggressivity = GetTrait(EPS_AI_Behavior_TraitAxis::Aggressivity);
const float Caution = GetTrait(EPS_AI_Behavior_TraitAxis::Caution); const float Caution = GetTrait(EPS_AI_Behavior_TraitAxis::Caution);

View File

@ -59,6 +59,10 @@ public:
// ─── Configuration ────────────────────────────────────────────────── // ─── Configuration ──────────────────────────────────────────────────
/** If false, the Behavior Tree does NOT start on possess. Call StartBehavior() manually. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Behavior")
bool bAutoStartBehavior = true;
/** Behavior Tree to run. If null, uses the Profile's DefaultBehaviorTree. */ /** Behavior Tree to run. If null, uses the Profile's DefaultBehaviorTree. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Behavior") UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Behavior")
TObjectPtr<UBehaviorTree> BehaviorTreeAsset; TObjectPtr<UBehaviorTree> BehaviorTreeAsset;
@ -107,6 +111,14 @@ public:
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Blackboard") UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Blackboard")
EPS_AI_Behavior_State GetBehaviorState() const; EPS_AI_Behavior_State GetBehaviorState() const;
/** Start (or restart) the Behavior Tree. If state is Scripted, transitions to Idle. */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior")
void StartBehavior();
/** Stop the Behavior Tree and enter Scripted state. NPC stays alive and perceptible. */
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior")
void StopBehavior();
protected: protected:
virtual void OnPossess(APawn* InPawn) override; virtual void OnPossess(APawn* InPawn) override;
virtual void OnUnPossess() override; virtual void OnUnPossess() override;
@ -126,9 +138,6 @@ private:
/** Initialize Blackboard with required keys. */ /** Initialize Blackboard with required keys. */
void SetupBlackboard(); void SetupBlackboard();
/** Start the Behavior Tree (from asset or profile). */
void StartBehavior();
/** /**
* Attempt to bind to PS_AI_ConvAgent_ElevenLabsComponent if present on the Pawn. * Attempt to bind to PS_AI_ConvAgent_ElevenLabsComponent if present on the Pawn.
* Uses UObject reflection no compile-time dependency on PS_AI_ConvAgent. * Uses UObject reflection no compile-time dependency on PS_AI_ConvAgent.

View File

@ -43,6 +43,7 @@ enum class EPS_AI_Behavior_State : uint8
Fleeing UMETA(DisplayName = "Fleeing"), Fleeing UMETA(DisplayName = "Fleeing"),
TakingCover UMETA(DisplayName = "Taking Cover"), TakingCover UMETA(DisplayName = "Taking Cover"),
Dead UMETA(DisplayName = "Dead"), Dead UMETA(DisplayName = "Dead"),
Scripted UMETA(DisplayName = "Scripted"),
}; };
/** /**