Add gaze bridge: behavior-driven look-at via ConvAgent GazeComponent

New BTService_UpdateGaze bridges PS_AI_Behavior to PS_AI_ConvAgent's
GazeComponent via runtime reflection (zero compile dependency).

Priority system:
- Combat/TakingCover: gaze disabled (aim animation handles it)
- Alerted: look at ThreatActor (head + eyes, no body)
- Conversation: skip (ConvAgent manages)
- Proximity: glance at nearest perceived actor within radius

Proximity gaze features:
- Lock on target until release (no jumping)
- Configurable duration, cooldown, radius on AIController
- Front-facing only (dot product filter)
- Skip spectators and hostile actors

GazeComponent fix:
- Sync SmoothedBodyYaw to actual actor rotation when body tracking
  is disabled, preventing stale head offset during spline movement

Files:
- New: BTService_UpdateGaze.h/.cpp
- Modified: AIController.h/.cpp (gaze bridge, config, bind, cleanup)
- Modified: GazeComponent.cpp (body yaw sync fix)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
j.foucher 2026-04-02 12:16:33 +02:00
parent d471714fbd
commit d9fb46bc96
5 changed files with 403 additions and 0 deletions

View File

@ -0,0 +1,28 @@
// Copyright Asterion. All Rights Reserved.
#include "BT/PS_AI_Behavior_BTService_UpdateGaze.h"
#include "PS_AI_Behavior_AIController.h"
UPS_AI_Behavior_BTService_UpdateGaze::UPS_AI_Behavior_BTService_UpdateGaze()
{
NodeName = TEXT("Update Gaze");
Interval = 0.3f;
RandomDeviation = 0.05f;
}
void UPS_AI_Behavior_BTService_UpdateGaze::TickNode(
UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds)
{
Super::TickNode(OwnerComp, NodeMemory, DeltaSeconds);
APS_AI_Behavior_AIController* AIC = Cast<APS_AI_Behavior_AIController>(OwnerComp.GetAIOwner());
if (AIC)
{
AIC->UpdateGazeTarget(DeltaSeconds);
}
}
FString UPS_AI_Behavior_BTService_UpdateGaze::GetStaticDescription() const
{
return TEXT("Updates gaze target from behavior state + proximity.\nBridges to PS_AI_ConvAgent GazeComponent (if present).");
}

View File

@ -18,6 +18,8 @@
#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" #include "BehaviorTree/Blackboard/BlackboardKeyType_Bool.h"
#include "DrawDebugHelpers.h"
#include "GameFramework/SpectatorPawn.h"
APS_AI_Behavior_AIController::APS_AI_Behavior_AIController() APS_AI_Behavior_AIController::APS_AI_Behavior_AIController()
{ {
@ -80,6 +82,7 @@ void APS_AI_Behavior_AIController::OnPossess(APawn* InPawn)
SetupBlackboard(); SetupBlackboard();
StartBehavior(); StartBehavior();
TryBindConversationAgent(); TryBindConversationAgent();
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' — BT started, TeamId=%d."),
*GetName(), *InPawn->GetName(), TeamId); *GetName(), *InPawn->GetName(), TeamId);
@ -94,6 +97,11 @@ void APS_AI_Behavior_AIController::OnUnPossess()
Brain->StopLogic(TEXT("Unpossessed")); Brain->StopLogic(TEXT("Unpossessed"));
} }
ClearGazeTarget();
CachedGazeComponent = nullptr;
CachedConvAgentComponent = nullptr;
ProximityGazeTarget = nullptr;
PersonalityComp = nullptr; PersonalityComp = nullptr;
Super::OnUnPossess(); Super::OnUnPossess();
} }
@ -269,6 +277,9 @@ void APS_AI_Behavior_AIController::HandleDeath()
{ {
UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] HandleDeath — shutting down AI systems."), *GetName()); UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] HandleDeath — shutting down AI systems."), *GetName());
ClearGazeTarget();
ProximityGazeTarget = nullptr;
// 1. Stop the Behavior Tree (no more services, tasks, or decorators) // 1. Stop the Behavior Tree (no more services, tasks, or decorators)
if (UBrainComponent* Brain = GetBrainComponent()) if (UBrainComponent* Brain = GetBrainComponent())
{ {
@ -477,3 +488,272 @@ void APS_AI_Behavior_AIController::TryBindConversationAgent()
*GetName()); *GetName());
} }
} }
// ─── Gaze Bridge ────────────────────────────────────────────────────────────
void APS_AI_Behavior_AIController::TryBindGazeComponent()
{
APawn* MyPawn = GetPawn();
if (!MyPawn) return;
// Soft lookup via reflection — no compile dependency on PS_AI_ConvAgent
static UClass* GazeClass = nullptr;
if (!GazeClass)
{
GazeClass = LoadClass<UActorComponent>(nullptr,
TEXT("/Script/PS_AI_ConvAgent.PS_AI_ConvAgent_GazeComponent"));
}
if (!GazeClass) return; // PS_AI_ConvAgent plugin not loaded
UActorComponent* GazeComp = MyPawn->FindComponentByClass(GazeClass);
if (!GazeComp) return;
CachedGazeComponent = GazeComp;
// Cache property pointers
GazeProp_TargetActor = CastField<FObjectProperty>(
GazeClass->FindPropertyByName(TEXT("TargetActor")));
GazeProp_bActive = CastField<FBoolProperty>(
GazeClass->FindPropertyByName(TEXT("bActive")));
GazeProp_bEnableBodyTracking = CastField<FBoolProperty>(
GazeClass->FindPropertyByName(TEXT("bEnableBodyTracking")));
// Also cache the ElevenLabsComponent for conversation detection
static UClass* ConvClass = nullptr;
if (!ConvClass)
{
ConvClass = LoadClass<UActorComponent>(nullptr,
TEXT("/Script/PS_AI_ConvAgent.PS_AI_ConvAgent_ElevenLabsComponent"));
}
if (ConvClass)
{
UActorComponent* ConvComp = MyPawn->FindComponentByClass(ConvClass);
if (ConvComp)
{
CachedConvAgentComponent = ConvComp;
ConvProp_bNetIsConversing = CastField<FBoolProperty>(
ConvClass->FindPropertyByName(TEXT("bNetIsConversing")));
}
}
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] Gaze bridge bound: TargetActor=%s, BodyTracking=%s, Conversation=%s"),
*GetName(),
GazeProp_TargetActor ? TEXT("OK") : TEXT("MISS"),
GazeProp_bEnableBodyTracking ? TEXT("OK") : TEXT("MISS"),
ConvProp_bNetIsConversing ? TEXT("OK") : TEXT("N/A"));
}
void APS_AI_Behavior_AIController::SetGazeTarget(AActor* Target, bool bEnableBody)
{
UActorComponent* GazeComp = CachedGazeComponent.Get();
if (!GazeComp) return;
if (GazeProp_TargetActor)
{
GazeProp_TargetActor->SetObjectPropertyValue_InContainer(GazeComp, Target);
}
if (GazeProp_bActive)
{
GazeProp_bActive->SetPropertyValue_InContainer(GazeComp, true);
}
if (GazeProp_bEnableBodyTracking)
{
GazeProp_bEnableBodyTracking->SetPropertyValue_InContainer(GazeComp, bEnableBody);
}
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Gaze → '%s' (body=%d)"),
*GetName(), Target ? *Target->GetName() : TEXT("null"), bEnableBody ? 1 : 0);
}
void APS_AI_Behavior_AIController::ClearGazeTarget()
{
UActorComponent* GazeComp = CachedGazeComponent.Get();
if (!GazeComp || !GazeProp_TargetActor) return;
GazeProp_TargetActor->SetObjectPropertyValue_InContainer(GazeComp, static_cast<UObject*>(nullptr));
if (GazeProp_bEnableBodyTracking)
{
GazeProp_bEnableBodyTracking->SetPropertyValue_InContainer(GazeComp, false);
}
UE_LOG(LogPS_AI_Behavior, Verbose, TEXT("[%s] Gaze → cleared"), *GetName());
}
bool APS_AI_Behavior_AIController::IsConversationActiveOnPawn() const
{
UActorComponent* ConvComp = CachedConvAgentComponent.Get();
if (!ConvComp || !ConvProp_bNetIsConversing) return false;
return ConvProp_bNetIsConversing->GetPropertyValue_InContainer(ConvComp);
}
void APS_AI_Behavior_AIController::UpdateGazeTarget(float DeltaSeconds)
{
if (!CachedGazeComponent.IsValid()) return;
// Tick cooldown
if (ProximityGazeCooldownTimer > 0.0f)
{
ProximityGazeCooldownTimer -= DeltaSeconds;
}
const EPS_AI_Behavior_State CurrentState = GetBehaviorState();
// ── Priority 1: Combat → disable gaze (aim animation handles it) ───
if (CurrentState == EPS_AI_Behavior_State::Combat ||
CurrentState == EPS_AI_Behavior_State::TakingCover)
{
ClearGazeTarget();
ProximityGazeTarget = nullptr;
ProximityGazeDuration = 0.0f;
return;
}
// ── Priority 1b: Alerted → look at ThreatActor (not shooting yet) ──
if (CurrentState == EPS_AI_Behavior_State::Alerted)
{
AActor* ThreatActor = Blackboard
? Cast<AActor>(Blackboard->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor))
: nullptr;
if (ThreatActor)
{
APawn* ThreatPawn = UPS_AI_Behavior_PerceptionComponent::FindOwningPawn(ThreatActor);
AActor* GazeActor = ThreatPawn ? static_cast<AActor*>(ThreatPawn) : ThreatActor;
SetGazeTarget(GazeActor, /*bEnableBody=*/ false);
ProximityGazeTarget = nullptr;
ProximityGazeDuration = 0.0f;
return;
}
}
// ── Priority 2: Conversation active → do NOT touch gaze ─────────
if (IsConversationActiveOnPawn())
{
ProximityGazeTarget = nullptr;
ProximityGazeDuration = 0.0f;
return;
}
// ── Priority 3/4: Proximity gaze ────────────────────────────────
if (!bEnableProximityGaze || CurrentState == EPS_AI_Behavior_State::Dead)
{
ClearGazeTarget();
return;
}
APawn* MyPawn = GetPawn();
if (!MyPawn) return;
// Check current locked target
AActor* LockedTarget = ProximityGazeTarget.Get();
if (LockedTarget)
{
const float Dist = FVector::Dist(MyPawn->GetActorLocation(), LockedTarget->GetActorLocation());
ProximityGazeDuration += DeltaSeconds;
bool bRelease = (Dist > GazeProximityRadius);
bRelease |= (ProximityGazeDuration >= GazeMaxDuration);
bRelease |= !IsValid(LockedTarget);
if (bRelease)
{
ProximityGazeCooldownActor = LockedTarget;
ProximityGazeCooldownTimer = GazeCooldown;
ProximityGazeTarget = nullptr;
ProximityGazeDuration = 0.0f;
ClearGazeTarget();
return;
}
return; // Still locked
}
// Scan for new proximity target from perceived actors (within sight cone)
if (!BehaviorPerception)
{
ClearGazeTarget();
return;
}
TArray<AActor*> PerceivedActors;
BehaviorPerception->GetCurrentlyPerceivedActors(nullptr, PerceivedActors);
const FVector MyLoc = MyPawn->GetActorLocation();
AActor* BestTarget = nullptr;
float BestDistSq = GazeProximityRadius * GazeProximityRadius;
for (AActor* RawActor : PerceivedActors)
{
APawn* CandidatePawn = UPS_AI_Behavior_PerceptionComponent::FindOwningPawn(RawActor);
if (!CandidatePawn || CandidatePawn == MyPawn) continue;
if (CandidatePawn->IsA<ASpectatorPawn>()) continue;
// Skip actor on cooldown
if (CandidatePawn == ProximityGazeCooldownActor.Get() && ProximityGazeCooldownTimer > 0.0f) continue;
const FVector CandidateLoc = CandidatePawn->GetActorLocation();
const float DistSq = FVector::DistSquared(MyLoc, CandidateLoc);
if (DistSq > BestDistSq) continue;
// Skip actors behind the NPC (only look at actors in front)
const FVector DirToCandidate = (CandidateLoc - MyLoc).GetSafeNormal2D();
const FVector MyForward = MyPawn->GetActorForwardVector().GetSafeNormal2D();
if (FVector::DotProduct(MyForward, DirToCandidate) < 0.0f) continue;
// Skip hostile actors (handled by Priority 1)
const ETeamAttitude::Type Attitude = GetTeamAttitudeTowards(*CandidatePawn);
if (Attitude == ETeamAttitude::Hostile) continue;
BestDistSq = DistSq;
BestTarget = CandidatePawn;
}
if (BestTarget)
{
ProximityGazeTarget = BestTarget;
ProximityGazeDuration = 0.0f;
UE_LOG(LogPS_AI_Behavior, Log,
TEXT("[%s] Gaze proximity: target='%s' at %s, myLoc=%s, dist=%.0f"),
*GetName(), *BestTarget->GetName(),
*BestTarget->GetActorLocation().ToString(),
*MyLoc.ToString(),
FVector::Dist(MyLoc, BestTarget->GetActorLocation()));
SetGazeTarget(BestTarget, /*bEnableBody=*/ false); // Head + eyes only
}
else
{
ClearGazeTarget();
}
#if ENABLE_DRAW_DEBUG
// Draw gaze debug line if PersonalityComponent has bDebug enabled
if (PersonalityComp && PersonalityComp->bDebug)
{
AActor* GazeTarget = ProximityGazeTarget.Get();
// In combat, the gaze target is the ThreatActor
if (!GazeTarget && Blackboard &&
(CurrentState == EPS_AI_Behavior_State::Combat ||
CurrentState == EPS_AI_Behavior_State::Alerted ||
CurrentState == EPS_AI_Behavior_State::TakingCover))
{
GazeTarget = Cast<AActor>(Blackboard->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor));
}
if (GazeTarget)
{
const FVector HeadLoc = MyPawn->GetActorLocation() + FVector(0, 0, 160.0f);
const FVector TargetLoc = GazeTarget->GetActorLocation() + FVector(0, 0, 60.0f);
DrawDebugLine(GetWorld(), HeadLoc, TargetLoc,
FColor::Magenta, false, 0.0f, 0, 0.5f);
DrawDebugString(GetWorld(), HeadLoc + FVector(0, 0, 20.0f),
FString::Printf(TEXT("GAZE: %s [%.1fs]"),
*GazeTarget->GetName(), ProximityGazeDuration),
nullptr, FColor::Magenta, 0.0f, true);
}
}
#endif
}

View File

@ -0,0 +1,33 @@
// Copyright Asterion. All Rights Reserved.
#pragma once
#include "CoreMinimal.h"
#include "BehaviorTree/BTService.h"
#include "PS_AI_Behavior_BTService_UpdateGaze.generated.h"
/**
* BT Service: Updates the NPC's gaze target based on behavior state and proximity.
*
* Bridges PS_AI_Behavior to PS_AI_ConvAgent's GazeComponent via runtime reflection.
* No compile-time dependency on PS_AI_ConvAgent silently skips if not present.
*
* Priority order:
* 1. Combat/Alerted look at ThreatActor (head + eyes + body)
* 2. Conversation active skip (managed by ConvAgent)
* 3. Proximity closest perceived actor within radius (head + eyes only)
*
* Place on the root Selector alongside UpdateThreat and EvaluateReaction.
*/
UCLASS(meta = (DisplayName = "PS AI: Update Gaze"))
class PS_AI_BEHAVIOR_API UPS_AI_Behavior_BTService_UpdateGaze : public UBTService
{
GENERATED_BODY()
public:
UPS_AI_Behavior_BTService_UpdateGaze();
protected:
virtual void TickNode(UBehaviorTreeComponent& OwnerComp, uint8* NodeMemory, float DeltaSeconds) override;
virtual FString GetStaticDescription() const override;
};

View File

@ -71,6 +71,24 @@ public:
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Patrol") UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Patrol")
TArray<FVector> PatrolPoints; TArray<FVector> PatrolPoints;
// ─── Gaze Configuration ────────────────────────────────────────────
/** Detection radius for proximity gaze (cm). NPC glances at nearby actors within this range. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS AI Behavior|Gaze", meta = (ClampMin = "100.0", ClampMax = "1500.0"))
float GazeProximityRadius = 400.0f;
/** Maximum duration (seconds) the NPC looks at a proximity target before releasing. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS AI Behavior|Gaze", meta = (ClampMin = "0.5", ClampMax = "30.0"))
float GazeMaxDuration = 3.0f;
/** Cooldown (seconds) before re-targeting the same actor after releasing. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS AI Behavior|Gaze", meta = (ClampMin = "0.0", ClampMax = "30.0"))
float GazeCooldown = 5.0f;
/** Enable/disable proximity gaze (NPC glances at nearby actors in Idle/Patrol). */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "PS AI Behavior|Gaze")
bool bEnableProximityGaze = true;
// ─── Component Access ─────────────────────────────────────────────── // ─── Component Access ───────────────────────────────────────────────
UFUNCTION(BlueprintCallable, Category = "PS AI Behavior") UFUNCTION(BlueprintCallable, Category = "PS AI Behavior")
@ -116,4 +134,39 @@ private:
* Uses UObject reflection no compile-time dependency on PS_AI_ConvAgent. * Uses UObject reflection no compile-time dependency on PS_AI_ConvAgent.
*/ */
void TryBindConversationAgent(); void TryBindConversationAgent();
// ─── Gaze Bridge (runtime reflection to PS_AI_ConvAgent) ────────
/** Discover GazeComponent on the Pawn and cache property pointers. */
void TryBindGazeComponent();
/** Update the gaze target based on behavior state and proximity. Called by BTService_UpdateGaze. */
void UpdateGazeTarget(float DeltaSeconds);
/** Set the GazeComponent's target actor via reflection. */
void SetGazeTarget(AActor* Target, bool bEnableBody);
/** Clear the GazeComponent's target (smooth return to neutral). */
void ClearGazeTarget();
/** Check if a conversation is active on the Pawn (via reflection on ElevenLabsComponent). */
bool IsConversationActiveOnPawn() const;
// Cached reflection pointers (null if PS_AI_ConvAgent not loaded)
TWeakObjectPtr<UActorComponent> CachedGazeComponent;
FObjectProperty* GazeProp_TargetActor = nullptr;
FBoolProperty* GazeProp_bActive = nullptr;
FBoolProperty* GazeProp_bEnableBodyTracking = nullptr;
// Conversation detection cache
TWeakObjectPtr<UActorComponent> CachedConvAgentComponent;
FBoolProperty* ConvProp_bNetIsConversing = nullptr;
// Proximity gaze state
TWeakObjectPtr<AActor> ProximityGazeTarget;
float ProximityGazeDuration = 0.0f;
float ProximityGazeCooldownTimer = 0.0f;
TWeakObjectPtr<AActor> ProximityGazeCooldownActor;
friend class UPS_AI_Behavior_BTService_UpdateGaze;
}; };

View File

@ -456,6 +456,15 @@ void UPS_AI_ConvAgent_GazeComponent::TickComponent(
} }
// ── 3. Compute DeltaYaw after body interp ────────────────────────── // ── 3. Compute DeltaYaw after body interp ──────────────────────────
// When body tracking is disabled (e.g. proximity gaze during spline patrol),
// sync SmoothedBodyYaw and TargetBodyWorldYaw to current actor rotation.
// This ensures the head offset is always relative to the actual body facing,
// not a stale cached value from a previous frame.
if (!bEnableBodyTracking)
{
SmoothedBodyYaw = Owner->GetActorRotation().Yaw;
TargetBodyWorldYaw = SmoothedBodyYaw;
}
float DeltaYaw = 0.0f; float DeltaYaw = 0.0f;
if (!HorizontalDir.IsNearlyZero(1.0f)) if (!HorizontalDir.IsNearlyZero(1.0f))