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 e6dabfe..4543693 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 @@ -405,11 +405,7 @@ void UPS_AI_ConvAgent_GazeComponent::TickComponent( const FVector ToTarget = TargetPos - EyeOrigin; - // ── 2. Body: smooth interp toward sticky target ──────────────────── - // - // TargetBodyWorldYaw is persistent — only updated when head+eyes - // can't reach the target. Same sticky pattern as TargetHeadYaw. - + // ── 1b. Horizontal direction / world yaw to the target ───────────── const FVector HorizontalDir = FVector(ToTarget.X, ToTarget.Y, 0.0f); float TargetWorldYaw = 0.0f; if (!HorizontalDir.IsNearlyZero(1.0f)) @@ -417,11 +413,21 @@ void UPS_AI_ConvAgent_GazeComponent::TickComponent( TargetWorldYaw = HorizontalDir.Rotation().Yaw; } - // Body smoothly interpolates toward its persistent target. - // Server/standalone: directly rotates the actor. - // Client: does NOT rotate — accepts replicated rotation from server. - if (bEnableBodyTracking && Owner->HasAuthority()) + // ── 2. BODY (authority only): lazy turn ──────────────────────────── + // Rotate the actor to face the target only once head+eyes can no longer + // cover it. Clients NEVER rotate the body — they receive it replicated. + // Body turn state (TargetBodyWorldYaw) lives on the authority only. + if (bEnableBodyTracking && Owner->HasAuthority() && !HorizontalDir.IsNearlyZero(1.0f)) { + const float BodyFacingYaw = Owner->GetActorRotation().Yaw + MeshForwardYawOffset; + const float DeltaToTarget = FMath::FindDeltaAngleDegrees(BodyFacingYaw, TargetWorldYaw); + + // Commit to facing the target once it leaves the head+eyes range. + if (FMath::Abs(DeltaToTarget) > MaxHeadYaw + MaxEyeHorizontal) + { + TargetBodyWorldYaw = TargetWorldYaw - MeshForwardYawOffset; + } + const float BodyDelta = FMath::FindDeltaAngleDegrees( Owner->GetActorRotation().Yaw, TargetBodyWorldYaw); if (FMath::Abs(BodyDelta) > 0.1f) @@ -431,122 +437,49 @@ void UPS_AI_ConvAgent_GazeComponent::TickComponent( } } - // ── Smoothed body yaw for cascade computations ────────────────── - // Server: direct copy (no lag). Client: interpolate toward replicated - // rotation to avoid step artifacts from discrete ~30Hz network updates. - // The client also overrides the actor's rotation with the smoothed value - // so the body mesh doesn't visually jump at network tick rate. - if (Owner->HasAuthority()) + // Smoothed mirror of the (authoritative/replicated) body yaw — DEBUG + // only. NEVER written back to the actor: the old client override + // (SetActorRotation) corrupted the head input and made the client head + // diverge by up to MaxHeadYaw. The head reads the real actor yaw below. { - SmoothedBodyYaw = Owner->GetActorRotation().Yaw; - } - else - { - const float ReplicatedYaw = Owner->GetActorRotation().Yaw; - const float ClientDelta = FMath::FindDeltaAngleDegrees(SmoothedBodyYaw, ReplicatedYaw); - SmoothedBodyYaw += FMath::FInterpTo(0.0f, ClientDelta, SafeDeltaTime, BodyInterpSpeed * 3.0f); - - // Override actor rotation with smoothed value so the body mesh - // shows the interpolated rotation instead of the raw replicated steps. - // This is local-only — replication will overwrite on next network update, - // and SmoothedBodyYaw will absorb the correction smoothly. - FRotator SmoothedRot = Owner->GetActorRotation(); - SmoothedRot.Yaw = SmoothedBodyYaw; - Owner->SetActorRotation(SmoothedRot); + const float ActorYaw = Owner->GetActorRotation().Yaw; + const float YawDelta = FMath::FindDeltaAngleDegrees(SmoothedBodyYaw, ActorYaw); + SmoothedBodyYaw += FMath::FInterpTo(0.0f, YawDelta, SafeDeltaTime, BodyInterpSpeed * 3.0f); } - // ── 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. + // When body tracking is off, keep the body target synced (debug only). if (!bEnableBodyTracking) { - SmoothedBodyYaw = Owner->GetActorRotation().Yaw; - TargetBodyWorldYaw = SmoothedBodyYaw; + TargetBodyWorldYaw = Owner->GetActorRotation().Yaw; } + // ── 3. HEAD + EYES — STATELESS cascade ───────────────────────────── + // Pure function of the ACTUAL body facing (authoritative on the server, + // replicated on the client) and the target direction. No sticky / + // path-dependent state, so server and client converge to the same pose + // regardless of how the body rotation arrived (smooth vs ~30Hz steps). + // FInterpTo only eases the visual; it never latches. + const float BodyFacingYaw = Owner->GetActorRotation().Yaw + MeshForwardYawOffset; + float DeltaYaw = 0.0f; if (!HorizontalDir.IsNearlyZero(1.0f)) { - const float CurrentFacingYaw = SmoothedBodyYaw + MeshForwardYawOffset; - DeltaYaw = FMath::FindDeltaAngleDegrees(CurrentFacingYaw, TargetWorldYaw); + DeltaYaw = FMath::FindDeltaAngleDegrees(BodyFacingYaw, TargetWorldYaw); } - // ── 4. Pitch from 3D direction ───────────────────────────────────── - const float HorizontalDist = HorizontalDir.Size(); const float TargetPitch = (HorizontalDist > 1.0f) ? FMath::RadiansToDegrees(FMath::Atan2(ToTarget.Z, HorizontalDist)) : 0.0f; - // ── 5. BODY overflow: check against persistent TargetBodyWorldYaw ── - // - // Same pattern as head: check from body's TARGET position (not current). - // Body triggers when head+eyes combined range is exceeded. - - const float BodyTargetFacing = TargetBodyWorldYaw + MeshForwardYawOffset; - const float DeltaFromBodyTarget = FMath::FindDeltaAngleDegrees( - BodyTargetFacing, TargetWorldYaw); - - bool bBodyOverflowed = false; - if (bEnableBodyTracking && FMath::Abs(DeltaFromBodyTarget) > MaxHeadYaw + MaxEyeHorizontal) - { - // Body realigns to face target - TargetBodyWorldYaw = TargetWorldYaw - MeshForwardYawOffset; - // Head returns to ~0° since body will face target directly - TargetHeadYaw = 0.0f; - bBodyOverflowed = true; - } - - // ── 6. HEAD: realign when eyes overflow (check against body TARGET) ── - // - // Head overflow is checked relative to where the BODY IS GOING - // (TargetBodyWorldYaw), not where it currently is. This prevents - // the head from overcompensating during body interpolation — - // otherwise the head turns to track while body catches up, then - // snaps back when body arrives (two-step animation artifact). - // - // GUARD: Skip eye overflow check when body just overflowed in this - // same frame. Body overflow already set TargetHeadYaw=0 (head will - // recenter as body turns). Allowing eye overflow to also fire would - // snap TargetHeadYaw to a second value in the same frame, causing - // a visible 1-2 frame jerk as FInterpTo chases two targets. - - if (!bBodyOverflowed) - { - const float HeadDeltaYaw = FMath::FindDeltaAngleDegrees( - TargetBodyWorldYaw + MeshForwardYawOffset, TargetWorldYaw); - - const float EyeDeltaYaw = HeadDeltaYaw - TargetHeadYaw; - - if (FMath::Abs(EyeDeltaYaw) > MaxEyeHorizontal) - { - TargetHeadYaw = FMath::Clamp(HeadDeltaYaw, -MaxHeadYaw, MaxHeadYaw); - } - } - - // Head smoothly interpolates toward its persistent target + // Head takes as much of the yaw gap as its range allows; eyes the rest. + // (TargetHeadYaw/Pitch are kept only as debug mirrors — no longer sticky.) + TargetHeadYaw = FMath::Clamp(DeltaYaw, -MaxHeadYaw, MaxHeadYaw); CurrentHeadYaw = FMath::FInterpTo(CurrentHeadYaw, TargetHeadYaw, SafeDeltaTime, HeadInterpSpeed); - - // Eyes = remaining gap (during transients, eyes may sit at MaxEye while - // the head catches up — ARKit normalization keeps visual deflection small) CurrentEyeYaw = FMath::Clamp(DeltaYaw - CurrentHeadYaw, -MaxEyeHorizontal, MaxEyeHorizontal); - // ── 5. PITCH: relative cascade (Eyes → Head, no body pitch) ──────── - - // Same pattern: check against persistent TargetHeadPitch - const float EyeDeltaPitch = TargetPitch - TargetHeadPitch; - - if (FMath::Abs(EyeDeltaPitch) > MaxEyeVertical) - { - // Eyes overflow → head realigns toward target pitch - TargetHeadPitch = FMath::Clamp(TargetPitch, -MaxHeadPitch, MaxHeadPitch); - } - + TargetHeadPitch = FMath::Clamp(TargetPitch, -MaxHeadPitch, MaxHeadPitch); CurrentHeadPitch = FMath::FInterpTo(CurrentHeadPitch, TargetHeadPitch, SafeDeltaTime, HeadInterpSpeed); - - // Eyes = remaining pitch gap CurrentEyePitch = FMath::Clamp(TargetPitch - CurrentHeadPitch, -MaxEyeVertical, MaxEyeVertical); } else