From 11649da1cfe2ed348fb712b669371ca535b5d5c3 Mon Sep 17 00:00:00 2001 From: Erik Date: Sun, 19 Apr 2026 10:36:28 +0200 Subject: [PATCH] feat(anim): local player + remote stop-detection polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two tightly-related refinements that complete the speed-scaling and observer-stop story: 1. Local player animation speed now reflects ForwardSpeed. UpdatePlayerAnimation previously called SetCycle(style, motion) with the default speedMod=1.0, so the local character's anim played at fixed rate regardless of RunRate. Now: - Pass result.ForwardSpeed through to SetCycle, so a 1.5× RunRate player's run loop plays at 1.5× framerate (same timing as the server-broadcast value remote observers see). - Fast-path tracks both _playerCurrentAnimCommand AND _playerCurrentAnimSpeed; a speed-only change still goes through SetCycle, which then hits the rescale-in-place fast-path via MultiplyCyclicFramerate. Retail matches: the footsteps plant at the right world positions because animation rate × physics rate stay aligned. 2. Remote stop-detection is more responsive. Previously the 400ms stale-position heuristic was the sole stop signal. Added a second signal: EMA observed velocity below 0.2 m/s means the entity is stationary regardless of how recent the last UpdatePosition was (common case: server IS sending position updates but all at the same coordinates). Both signals gate on sequencer CurrentVelocity also being low, so we don't flip-flop when the motion data itself carries non-zero velocity but the entity happens to be paused mid-stride. Stop-idle timer also tightened from 400ms → 300ms to match typical NPC heartbeat cadence. Tests unchanged — both changes are small behavior tweaks around the already-tested speed-scaling path. 654 tests green. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/AcDream.App/Rendering/GameWindow.cs | 81 +++++++++++++++++++++---- 1 file changed, 69 insertions(+), 12 deletions(-) diff --git a/src/AcDream.App/Rendering/GameWindow.cs b/src/AcDream.App/Rendering/GameWindow.cs index 28901ff..b9af871 100644 --- a/src/AcDream.App/Rendering/GameWindow.cs +++ b/src/AcDream.App/Rendering/GameWindow.cs @@ -217,6 +217,7 @@ public sealed class GameWindow : IDisposable private bool _playerMode; private uint _playerServerGuid; private uint? _playerCurrentAnimCommand; + private float _playerCurrentAnimSpeed = 1f; private uint? _playerMotionTableId; // server-sent MotionTable override for the player's character // Accumulated mouse X delta for player turning; written in mouse-move // callback, consumed + reset in OnUpdate each frame. @@ -3279,7 +3280,17 @@ public sealed class GameWindow : IDisposable // cycle but hasn't moved meaningfully in this many ms, swap them // to Ready. Retail observer pattern — server never broadcasts an // explicit stop; observer infers from position deltas. - const double StopIdleMs = 400.0; + // + // 300ms matches the interval between typical server-broadcast + // UpdatePositions for a stationary NPC (~3-5 Hz heartbeat). Any + // shorter and we'd false-positive between packets; longer and the + // stop animation lags visibly. + const double StopIdleMs = 300.0; + // Additional velocity-based stop detector: if the EMA observed + // velocity drops below this world m/s, the entity has clearly + // stopped. Catches the case where the server IS sending + // UpdatePositions but they're all repeating the same pos. + const float StopVelocityThreshold = 0.2f; var now = System.DateTime.UtcNow; foreach (var kv in _animatedEntities) @@ -3309,15 +3320,43 @@ public sealed class GameWindow : IDisposable || motionLo == 0x10; // SideStepLeft if (inLocomotion && serverGuid != 0 - && serverGuid != _playerServerGuid - && _remoteLastMove.TryGetValue(serverGuid, out var last) - && (now - last.Time).TotalMilliseconds > StopIdleMs) + && serverGuid != _playerServerGuid) { - uint curStyle = ae.Sequencer.CurrentStyle; - uint ready = (curStyle & 0xFF000000u) != 0 - ? ((curStyle & 0xFF000000u) | 0x01000003u) - : 0x41000003u; - ae.Sequencer.SetCycle(curStyle, ready); + bool shouldStop = false; + + // Signal 1: no server-side position change in StopIdleMs. + if (_remoteLastMove.TryGetValue(serverGuid, out var last) + && (now - last.Time).TotalMilliseconds > StopIdleMs) + { + shouldStop = true; + } + + // Signal 2: observed velocity has decayed below threshold. + // This catches the case where UpdatePositions are arriving + // at rate but each one is the same position (server-side + // stationary). EMA keeps the velocity average reflecting + // the current truth. + if (!shouldStop + && _remoteDeadReckon.TryGetValue(serverGuid, out var dr) + && (now - dr.LastServerPosTime).TotalMilliseconds < 600.0 + && dr.ObservedVelocity.Length() < StopVelocityThreshold) + { + // Only trigger stop-via-velocity if the sequencer's + // own velocity is also low — otherwise the cycle's + // MotionData has non-zero forward velocity and we'd + // flip-flop (stop → start → stop). + if (ae.Sequencer.CurrentVelocity.Length() < 0.5f) + shouldStop = true; + } + + if (shouldStop) + { + uint curStyle = ae.Sequencer.CurrentStyle; + uint ready = (curStyle & 0xFF000000u) != 0 + ? ((curStyle & 0xFF000000u) | 0x01000003u) + : 0x41000003u; + ae.Sequencer.SetCycle(curStyle, ready); + } } } @@ -3568,9 +3607,16 @@ public sealed class GameWindow : IDisposable else animCommand = 0x41000003u; // Ready (idle) - // Fast path: no change. - if (animCommand == _playerCurrentAnimCommand) return; + // Fast path: no command change AND speed delta is negligible. If + // command is unchanged but speed changed, we must still propagate + // so the sequencer can MultiplyCyclicFramerate — keeping the run + // loop smooth without restart. + float newSpeed = result.ForwardSpeed ?? 1f; + bool sameCmd = animCommand == _playerCurrentAnimCommand; + bool sameSpeed = MathF.Abs(newSpeed - _playerCurrentAnimSpeed) < 1e-3f; + if (sameCmd && sameSpeed) return; _playerCurrentAnimCommand = animCommand; + _playerCurrentAnimSpeed = newSpeed; if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return; @@ -3619,10 +3665,21 @@ public sealed class GameWindow : IDisposable // Sequencer path: SetCycle handles adjust_motion internally // (TurnLeft→TurnRight with negative speed, etc.) + // + // Speed scaling: use the MovementResult's ForwardSpeed for + // locomotion cycles. This mirrors what the server broadcasts for + // remote observers, and keeps our own character's animation rate + // in sync with movement velocity (a 1.5× RunRate player's anim + // plays 1.5× as fast — matching retail). if (ae.Sequencer is not null) { uint fullStyle = 0x80000000u | (uint)NonCombatStance; - ae.Sequencer.SetCycle(fullStyle, animCommand); + float animSpeed = 1f; + if (result.ForwardSpeed is { } fs && fs > 0f) + { + animSpeed = fs; + } + ae.Sequencer.SetCycle(fullStyle, animCommand, animSpeed); } // Legacy path: update the manual slerp fields (for entities without sequencer)