From ff6d3d0c947f095bb721bb23a0d27b4864c7b742 Mon Sep 17 00:00:00 2001 From: Erik Date: Wed, 29 Apr 2026 10:14:35 +0200 Subject: [PATCH] fix(anim): Phase L.1c clamp approach velocity to prevent overshoot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-observed residual after f794832: creature stops to attack but still runs slightly through the player before stopping. Cause: at 4 m/s body velocity (RunAnimSpeed × ~1.0 speedMod) and a 60 fps tick (~16 ms), the body advances ~6.4 cm per tick. When dist falls just below the 0.6 m DistanceToObject arrival threshold, the arrival predicate fires and zeroes velocity — but the body has already advanced one full tick INTO the threshold zone. That last tick is the "running through" the user sees, especially when combined with a player visual radius of ~0.5 m. Fix: cap horizontal velocity in the steering branch so the body lands EXACTLY at the arrival threshold instead of overshooting it. Pure function in RemoteMoveToDriver (ClampApproachVelocity) so it's testable; called from GameWindow.cs after apply_current_movement sets RunForward velocity from the active cycle. The clamp is a strict scale-down of the X/Y components; Z is left to gravity / terrain handling. No-op for the flee branch — fleeing has no overshoot risk by definition. Tests: 1416 → 1420. Four new clamp scenarios: exact-landing (FP tolerance), would-overshoot scale-down, already-at-threshold zeroing, flee no-op. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 17 +++++ .../Physics/RemoteMoveToDriver.cs | 52 ++++++++++++++ .../Physics/RemoteMoveToDriverTests.cs | 72 +++++++++++++++++++ 3 files changed, 141 insertions(+) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 1633f4a..9ce420c 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -5226,6 +5226,23 @@ public sealed class GameWindow : IDisposable // updated orientation rotates that into the right // world direction toward the target. rm.Motion.apply_current_movement(cancelMoveTo: false, allowJump: false); + + // Clamp horizontal velocity so we don't overshoot + // the arrival threshold during the final tick of + // approach. Without this, a 4 m/s body advances + // ~6 cm/tick and visibly runs slightly through + // the target before the swing UM lands. + float arrivalThreshold = rm.MoveToMoveTowards + ? rm.MoveToDistanceToObject + : rm.MoveToMinDistance; + rm.Body.Velocity = AcDream.Core.Physics.RemoteMoveToDriver + .ClampApproachVelocity( + rm.Body.Position, + rm.Body.Velocity, + rm.MoveToDestinationWorld, + arrivalThreshold, + (float)dt, + rm.MoveToMoveTowards); } } } diff --git a/src/AcDream.Core/Physics/RemoteMoveToDriver.cs b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs index 0981666..90a0388 100644 --- a/src/AcDream.Core/Physics/RemoteMoveToDriver.cs +++ b/src/AcDream.Core/Physics/RemoteMoveToDriver.cs @@ -240,6 +240,58 @@ public static class RemoteMoveToDriver originZ); } + /// + /// Cap horizontal velocity so the body lands exactly at + /// rather than overshooting past + /// it during the final tick of approach. Without this clamp, a body + /// running at RunAnimSpeed × speedMod ≈ 4 m/s can overshoot + /// the 0.6 m arrival window by up to one tick's advance (~6 cm at + /// 60 fps) — visible as the creature "running slightly through" the + /// player it's about to attack (user-reported 2026-04-28). + /// + /// + /// The clamp is a strict scale-down of the horizontal component + /// (X/Y); the vertical component (Z) is left to gravity / terrain + /// handling. false (flee branch) is a + /// no-op since fleeing has no overshoot risk — the body wants to + /// move AWAY from the destination. + /// + /// + public static Vector3 ClampApproachVelocity( + Vector3 bodyPosition, + Vector3 currentVelocity, + Vector3 destinationWorld, + float arrivalThreshold, + float dt, + bool moveTowards) + { + if (!moveTowards || dt <= 0f) return currentVelocity; + + float dx = destinationWorld.X - bodyPosition.X; + float dy = destinationWorld.Y - bodyPosition.Y; + float dist = MathF.Sqrt(dx * dx + dy * dy); + float remaining = MathF.Max(0f, dist - arrivalThreshold); + + float vxy = MathF.Sqrt(currentVelocity.X * currentVelocity.X + + currentVelocity.Y * currentVelocity.Y); + if (vxy < 1e-3f) return currentVelocity; + + float advance = vxy * dt; + if (advance <= remaining) return currentVelocity; + + // Already inside or right at the threshold: zero horizontal + // velocity, keep Z. (The arrival predicate in Drive() should + // have fired this tick, but this is the belt-and-braces guard.) + if (remaining < 1e-3f) + return new Vector3(0f, 0f, currentVelocity.Z); + + float scale = remaining / advance; + return new Vector3( + currentVelocity.X * scale, + currentVelocity.Y * scale, + currentVelocity.Z); + } + /// Wrap an angle in radians to [-π, π]. private static float WrapPi(float r) { diff --git a/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs index ece3f9b..39182cb 100644 --- a/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs +++ b/tests/AcDream.Core.Tests/Physics/RemoteMoveToDriverTests.cs @@ -205,6 +205,78 @@ public class RemoteMoveToDriverTests Assert.Equal(bodyRot, newOrient); } + [Fact] + public void ClampApproachVelocity_NoOverShoot_LandsExactlyAtThreshold() + { + // Body 1 m from destination, running at 4 m/s, dt = 0.1 s. + // Naive advance = 0.4 m → would end at 0.6 m from dest, exactly + // on the threshold. With threshold=0.6 and remaining=0.4, the + // clamp should let the full velocity through (advance == remaining). + var bodyPos = new Vector3(0f, 0f, 0f); + var dest = new Vector3(0f, 1f, 0f); + var vel = new Vector3(0f, 4f, 0f); + + var clamped = RemoteMoveToDriver.ClampApproachVelocity( + bodyPos, vel, dest, arrivalThreshold: 0.6f, dt: 0.1f, moveTowards: true); + + // Within float-precision: 4 m/s × 0.1 s = 0.4 m, exactly the + // remaining distance. The clamp may apply a 0.99999×-style + // tiny scale due to FP rounding — accept anything ≥ 99.9% of + // the input as "no meaningful overshoot prevention applied." + Assert.InRange(clamped.Y, 4f * 0.999f, 4f); + Assert.Equal(0f, clamped.X); + Assert.Equal(0f, clamped.Z); + } + + [Fact] + public void ClampApproachVelocity_WouldOverShoot_ScalesDownToExactLanding() + { + // Body 1 m from destination, running at 4 m/s, dt = 0.2 s. + // Naive advance = 0.8 m → would overshoot 0.6 m threshold by 0.4 m. + // remaining = 0.4 m, advance = 0.8 m → scale = 0.5. + // Velocity should be halved → 2 m/s. + var bodyPos = new Vector3(0f, 0f, 0f); + var dest = new Vector3(0f, 1f, 0f); + var vel = new Vector3(0f, 4f, 0f); + + var clamped = RemoteMoveToDriver.ClampApproachVelocity( + bodyPos, vel, dest, arrivalThreshold: 0.6f, dt: 0.2f, moveTowards: true); + + Assert.InRange(clamped.Y, 2f - Epsilon, 2f + Epsilon); + Assert.Equal(0f, clamped.X); + } + + [Fact] + public void ClampApproachVelocity_AlreadyAtThreshold_ZeroesHorizontal() + { + // Body exactly 0.6 m from dest with threshold 0.6 → remaining ≈ 0. + // Any horizontal velocity would overshoot; clamp must zero it. + var bodyPos = new Vector3(0f, 0f, 0f); + var dest = new Vector3(0f, 0.6f, 0f); + var vel = new Vector3(0f, 4f, 0.5f); // some Z to confirm Z is preserved + + var clamped = RemoteMoveToDriver.ClampApproachVelocity( + bodyPos, vel, dest, arrivalThreshold: 0.6f, dt: 0.016f, moveTowards: true); + + Assert.Equal(0f, clamped.X); + Assert.Equal(0f, clamped.Y); + Assert.Equal(0.5f, clamped.Z); // gravity / Z handling unaffected + } + + [Fact] + public void ClampApproachVelocity_FleeBranch_NoOp() + { + // moveTowards=false (flee): no overshoot risk, return velocity unchanged. + var bodyPos = Vector3.Zero; + var dest = new Vector3(0f, 1f, 0f); + var vel = new Vector3(0f, -4f, 0f); + + var clamped = RemoteMoveToDriver.ClampApproachVelocity( + bodyPos, vel, dest, arrivalThreshold: 5f, dt: 0.5f, moveTowards: false); + + Assert.Equal(vel, clamped); + } + [Fact] public void OriginToWorld_AppliesLandblockGridShift() {