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

View File

@ -80,12 +80,24 @@ void APS_AI_Behavior_AIController::OnPossess(APawn* InPawn)
}
SetupBlackboard();
if (bAutoStartBehavior)
{
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();
TryBindGazeComponent();
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Possessed Pawn '%s' — BT started, TeamId=%d."),
*GetName(), *InPawn->GetName(), TeamId);
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Possessed Pawn '%s' — TeamId=%d, AutoStart=%d."),
*GetName(), *InPawn->GetName(), TeamId, (int32)bAutoStartBehavior);
}
void APS_AI_Behavior_AIController::OnUnPossess()
@ -227,24 +239,84 @@ void APS_AI_Behavior_AIController::SetupBlackboard()
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;
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] BehaviorTreeAsset=%s"),
*GetName(), BTToRun ? *BTToRun->GetName() : TEXT("null"));
// Fallback: get from personality profile
if (!BTToRun && PersonalityComp && PersonalityComp->Profile)
{
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)
{
RunBehaviorTree(BTToRun);
}
else
if (!BTToRun)
{
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());
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)
@ -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 ─────────────────────────
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;
}
if (CurrentState == EPS_AI_Behavior_State::Scripted)
{
return EPS_AI_Behavior_State::Scripted;
}
const float Courage = GetTrait(EPS_AI_Behavior_TraitAxis::Courage);
const float Aggressivity = GetTrait(EPS_AI_Behavior_TraitAxis::Aggressivity);
const float Caution = GetTrait(EPS_AI_Behavior_TraitAxis::Caution);

View File

@ -59,6 +59,10 @@ public:
// ─── 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. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Behavior")
TObjectPtr<UBehaviorTree> BehaviorTreeAsset;
@ -107,6 +111,14 @@ public:
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior|Blackboard")
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:
virtual void OnPossess(APawn* InPawn) override;
virtual void OnUnPossess() override;
@ -126,9 +138,6 @@ private:
/** Initialize Blackboard with required keys. */
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.
* 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"),
TakingCover UMETA(DisplayName = "Taking Cover"),
Dead UMETA(DisplayName = "Dead"),
Scripted UMETA(DisplayName = "Scripted"),
};
/**