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:
parent
3f41872d88
commit
11649da1cf
1 changed files with 69 additions and 12 deletions
|
|
@ -217,6 +217,7 @@ public sealed class GameWindow : IDisposable
|
||||||
private bool _playerMode;
|
private bool _playerMode;
|
||||||
private uint _playerServerGuid;
|
private uint _playerServerGuid;
|
||||||
private uint? _playerCurrentAnimCommand;
|
private uint? _playerCurrentAnimCommand;
|
||||||
|
private float _playerCurrentAnimSpeed = 1f;
|
||||||
private uint? _playerMotionTableId; // server-sent MotionTable override for the player's character
|
private uint? _playerMotionTableId; // server-sent MotionTable override for the player's character
|
||||||
// Accumulated mouse X delta for player turning; written in mouse-move
|
// Accumulated mouse X delta for player turning; written in mouse-move
|
||||||
// callback, consumed + reset in OnUpdate each frame.
|
// 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
|
// cycle but hasn't moved meaningfully in this many ms, swap them
|
||||||
// to Ready. Retail observer pattern — server never broadcasts an
|
// to Ready. Retail observer pattern — server never broadcasts an
|
||||||
// explicit stop; observer infers from position deltas.
|
// 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;
|
var now = System.DateTime.UtcNow;
|
||||||
|
|
||||||
foreach (var kv in _animatedEntities)
|
foreach (var kv in _animatedEntities)
|
||||||
|
|
@ -3309,15 +3320,43 @@ public sealed class GameWindow : IDisposable
|
||||||
|| motionLo == 0x10; // SideStepLeft
|
|| motionLo == 0x10; // SideStepLeft
|
||||||
if (inLocomotion
|
if (inLocomotion
|
||||||
&& serverGuid != 0
|
&& serverGuid != 0
|
||||||
&& serverGuid != _playerServerGuid
|
&& serverGuid != _playerServerGuid)
|
||||||
&& _remoteLastMove.TryGetValue(serverGuid, out var last)
|
|
||||||
&& (now - last.Time).TotalMilliseconds > StopIdleMs)
|
|
||||||
{
|
{
|
||||||
uint curStyle = ae.Sequencer.CurrentStyle;
|
bool shouldStop = false;
|
||||||
uint ready = (curStyle & 0xFF000000u) != 0
|
|
||||||
? ((curStyle & 0xFF000000u) | 0x01000003u)
|
// Signal 1: no server-side position change in StopIdleMs.
|
||||||
: 0x41000003u;
|
if (_remoteLastMove.TryGetValue(serverGuid, out var last)
|
||||||
ae.Sequencer.SetCycle(curStyle, ready);
|
&& (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
|
else
|
||||||
animCommand = 0x41000003u; // Ready (idle)
|
animCommand = 0x41000003u; // Ready (idle)
|
||||||
|
|
||||||
// Fast path: no change.
|
// Fast path: no command change AND speed delta is negligible. If
|
||||||
if (animCommand == _playerCurrentAnimCommand) return;
|
// 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;
|
_playerCurrentAnimCommand = animCommand;
|
||||||
|
_playerCurrentAnimSpeed = newSpeed;
|
||||||
|
|
||||||
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return;
|
if (!_entitiesByServerGuid.TryGetValue(_playerServerGuid, out var pe)) return;
|
||||||
|
|
||||||
|
|
@ -3619,10 +3665,21 @@ public sealed class GameWindow : IDisposable
|
||||||
|
|
||||||
// Sequencer path: SetCycle handles adjust_motion internally
|
// Sequencer path: SetCycle handles adjust_motion internally
|
||||||
// (TurnLeft→TurnRight with negative speed, etc.)
|
// (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)
|
if (ae.Sequencer is not null)
|
||||||
{
|
{
|
||||||
uint fullStyle = 0x80000000u | (uint)NonCombatStance;
|
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)
|
// Legacy path: update the manual slerp fields (for entities without sequencer)
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue