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)