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 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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue