diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTService_UpdateGaze.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTService_UpdateGaze.cpp new file mode 100644 index 0000000..7b17eb3 --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/BT/PS_AI_Behavior_BTService_UpdateGaze.cpp @@ -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(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)."); +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_AIController.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_AIController.cpp index c6bf8eb..e2927ac 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_AIController.cpp +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Private/PS_AI_Behavior_AIController.cpp @@ -18,6 +18,8 @@ #include "BehaviorTree/Blackboard/BlackboardKeyType_Vector.h" #include "BehaviorTree/Blackboard/BlackboardKeyType_String.h" #include "BehaviorTree/Blackboard/BlackboardKeyType_Bool.h" +#include "DrawDebugHelpers.h" +#include "GameFramework/SpectatorPawn.h" APS_AI_Behavior_AIController::APS_AI_Behavior_AIController() { @@ -80,6 +82,7 @@ void APS_AI_Behavior_AIController::OnPossess(APawn* InPawn) SetupBlackboard(); StartBehavior(); TryBindConversationAgent(); + TryBindGazeComponent(); UE_LOG(LogPS_AI_Behavior, Log, TEXT("[%s] Possessed Pawn '%s' — BT started, TeamId=%d."), *GetName(), *InPawn->GetName(), TeamId); @@ -94,6 +97,11 @@ void APS_AI_Behavior_AIController::OnUnPossess() Brain->StopLogic(TEXT("Unpossessed")); } + ClearGazeTarget(); + CachedGazeComponent = nullptr; + CachedConvAgentComponent = nullptr; + ProximityGazeTarget = nullptr; + PersonalityComp = nullptr; 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()); + ClearGazeTarget(); + ProximityGazeTarget = nullptr; + // 1. Stop the Behavior Tree (no more services, tasks, or decorators) if (UBrainComponent* Brain = GetBrainComponent()) { @@ -477,3 +488,272 @@ void APS_AI_Behavior_AIController::TryBindConversationAgent() *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(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( + GazeClass->FindPropertyByName(TEXT("TargetActor"))); + GazeProp_bActive = CastField( + GazeClass->FindPropertyByName(TEXT("bActive"))); + GazeProp_bEnableBodyTracking = CastField( + GazeClass->FindPropertyByName(TEXT("bEnableBodyTracking"))); + + // Also cache the ElevenLabsComponent for conversation detection + static UClass* ConvClass = nullptr; + if (!ConvClass) + { + ConvClass = LoadClass(nullptr, + TEXT("/Script/PS_AI_ConvAgent.PS_AI_ConvAgent_ElevenLabsComponent")); + } + if (ConvClass) + { + UActorComponent* ConvComp = MyPawn->FindComponentByClass(ConvClass); + if (ConvComp) + { + CachedConvAgentComponent = ConvComp; + ConvProp_bNetIsConversing = CastField( + 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(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(Blackboard->GetValueAsObject(PS_AI_Behavior_BB::ThreatActor)) + : nullptr; + + if (ThreatActor) + { + APawn* ThreatPawn = UPS_AI_Behavior_PerceptionComponent::FindOwningPawn(ThreatActor); + AActor* GazeActor = ThreatPawn ? static_cast(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 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()) 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(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 +} diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTService_UpdateGaze.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTService_UpdateGaze.h new file mode 100644 index 0000000..6bf26cf --- /dev/null +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/BT/PS_AI_Behavior_BTService_UpdateGaze.h @@ -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; +}; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_AIController.h b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_AIController.h index d97f4cd..33791bb 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_AIController.h +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_Behavior/Source/PS_AI_Behavior/Public/PS_AI_Behavior_AIController.h @@ -71,6 +71,24 @@ public: UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Patrol") TArray 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 ─────────────────────────────────────────────── UFUNCTION(BlueprintCallable, Category = "PS AI Behavior") @@ -116,4 +134,39 @@ private: * Uses UObject reflection — no compile-time dependency on PS_AI_ConvAgent. */ 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 CachedGazeComponent; + FObjectProperty* GazeProp_TargetActor = nullptr; + FBoolProperty* GazeProp_bActive = nullptr; + FBoolProperty* GazeProp_bEnableBodyTracking = nullptr; + + // Conversation detection cache + TWeakObjectPtr CachedConvAgentComponent; + FBoolProperty* ConvProp_bNetIsConversing = nullptr; + + // Proximity gaze state + TWeakObjectPtr ProximityGazeTarget; + float ProximityGazeDuration = 0.0f; + float ProximityGazeCooldownTimer = 0.0f; + TWeakObjectPtr ProximityGazeCooldownActor; + + friend class UPS_AI_Behavior_BTService_UpdateGaze; }; diff --git a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_GazeComponent.cpp b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_GazeComponent.cpp index c00da04..e6dabfe 100644 --- a/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_GazeComponent.cpp +++ b/Unreal/PS_AI_Agent/Plugins/PS_AI_ConvAgent/Source/PS_AI_ConvAgent/Private/PS_AI_ConvAgent_GazeComponent.cpp @@ -456,6 +456,15 @@ void UPS_AI_ConvAgent_GazeComponent::TickComponent( } // ── 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; if (!HorizontalDir.IsNearlyZero(1.0f))