feat(anim): local player + remote stop-detection polish

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) <noreply@anthropic.com>
This commit is contained in:
Erik 2026-04-19 10:36:28 +02:00
parent 3f41872d88
commit 11649da1cf

View file

@ -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)