From 90f1832397a9d4f12c928d78287f69d5dac80c39 Mon Sep 17 00:00:00 2001 From: SepComet <202308010230@stu.csust.edu.cn> Date: Mon, 6 Apr 2026 19:22:22 +0800 Subject: [PATCH] fix --- Assets/Scripts/MovementComponent.cs | 22 +++------- .../ClientPredictionBuffer.cs | 34 +++++++++++++++- .../Network/ClientGameplayFlowTests.cs | 25 +++++++++--- .../EditMode/Network/SyncStrategyTests.cs | 40 ++----------------- .../specs/client-prediction-cadence/spec.md | 28 +++++++------ 5 files changed, 76 insertions(+), 73 deletions(-) diff --git a/Assets/Scripts/MovementComponent.cs b/Assets/Scripts/MovementComponent.cs index df1e8f1..e5b6f0e 100644 --- a/Assets/Scripts/MovementComponent.cs +++ b/Assets/Scripts/MovementComponent.cs @@ -111,10 +111,6 @@ public class MovementComponent : MonoBehaviour // This matches ServerAuthoritativeMovementConfiguration.SimulationInterval (50ms). private const float kServerSimulationStepSeconds = 0.05f; - // Dead-band threshold for send-interval correction hysteresis. - // Prevents frame-to-frame send interval oscillation when server tick offset hovers near zero. - private const int kTickOffsetThreshold = 2; - private int _speed = 2; [SerializeField] private Rigidbody _rigid; private float _lastSendTime = 0; @@ -211,7 +207,9 @@ public class MovementComponent : MonoBehaviour } Simulate(_cachedMoveInput); - _predictionBuffer.AccumulateLatest(kServerSimulationStepSeconds); + // Use actual elapsed wall-clock time since last authoritative state, + // decoupled from FixedUpdate cadence, to match server's 20Hz cadence. + _predictionBuffer.AccumulateWithElapsedTime(Time.time - _predictionBuffer.LastAuthoritativeStateTime); } else { @@ -228,7 +226,7 @@ public class MovementComponent : MonoBehaviour private void Reconcile(ClientAuthoritativePlayerStateSnapshot snapshot) { _serverPosition = snapshot.Position; - if (!_predictionBuffer.TryApplyAuthoritativeState(snapshot.SourceState, out var replayInputs)) + if (!_predictionBuffer.TryApplyAuthoritativeState(snapshot.SourceState, Time.time, out var replayInputs)) { return; } @@ -240,7 +238,7 @@ public class MovementComponent : MonoBehaviour predictedRotation, snapshot.Position, snapshot.RotationQuaternion, - new ControlledPlayerCorrectionSettings(kServerSimulationStepSeconds, _speed, TurnSpeedDegreesPerSecond), + new ControlledPlayerCorrectionSettings(kServerSimulationStepSeconds, _speed, TurnSpeedDegreesPerSecond, snapDistanceMultiplier: 5f), _activeVisualCorrection); _activeVisualCorrection = correction.NextState; @@ -326,16 +324,6 @@ public class MovementComponent : MonoBehaviour MainUI.Instance.OnServerTickChanged(serverTick); } } - - if (_currentTickOffset < -kTickOffsetThreshold) - { - _sendInterval = 0.052f; - } - else if (_currentTickOffset > kTickOffsetThreshold) - { - _sendInterval = 0.048f; - } - // Within [-kTickOffsetThreshold, +kTickOffsetThreshold]: no correction, keep current interval } private void ReplayPendingInputs(IReadOnlyList replayInputs) diff --git a/Assets/Scripts/Network/NetworkApplication/ClientPredictionBuffer.cs b/Assets/Scripts/Network/NetworkApplication/ClientPredictionBuffer.cs index 52f5dd8..2e764db 100644 --- a/Assets/Scripts/Network/NetworkApplication/ClientPredictionBuffer.cs +++ b/Assets/Scripts/Network/NetworkApplication/ClientPredictionBuffer.cs @@ -26,6 +26,18 @@ namespace Network.NetworkApplication public long? LastAcknowledgedMoveTick { get; private set; } + /// + /// Time of the last received authoritative state, used to compute + /// actual elapsed wall-clock time for accumulation synchronization. + /// + private float _lastAuthoritativeStateTime = float.NegativeInfinity; + + /// + /// Returns the wall-clock time of the last authoritative state arrival. + /// Valid only after TryApplyAuthoritativeState has been called at least once. + /// + public float LastAuthoritativeStateTime => _lastAuthoritativeStateTime; + public IReadOnlyList PendingInputs => pendingInputs; public void Record(MoveInput input) @@ -54,7 +66,23 @@ namespace Network.NetworkApplication pendingInputs[^1] = new PredictedMoveStep(latest.Input, latest.SimulatedDurationSeconds + simulatedDurationSeconds); } - public bool TryApplyAuthoritativeState(PlayerState state, out IReadOnlyList replayInputs) + /// + /// Accumulate pending input duration using the actual elapsed wall-clock time + /// since the last authoritative state, not the fixed simulation cadence. + /// This synchronizes accumulation with the server's 20Hz authoritative cadence. + /// + public void AccumulateWithElapsedTime(float elapsedSinceLastState) + { + if (pendingInputs.Count == 0 || elapsedSinceLastState <= 0f || !float.IsFinite(elapsedSinceLastState)) + { + return; + } + + var latest = pendingInputs[^1]; + pendingInputs[^1] = new PredictedMoveStep(latest.Input, latest.SimulatedDurationSeconds + elapsedSinceLastState); + } + + public bool TryApplyAuthoritativeState(PlayerState state, float currentTime, out IReadOnlyList replayInputs) { if (state == null) { @@ -71,6 +99,10 @@ namespace Network.NetworkApplication LastAcknowledgedMoveTick = state.AcknowledgedMoveTick; pendingInputs.RemoveAll(input => input.Input.Tick <= state.AcknowledgedMoveTick); replayInputs = pendingInputs.ToArray(); + + // Reset the elapsed-time tracker so the next accumulation period + // starts from this authoritative state's arrival time. + _lastAuthoritativeStateTime = currentTime; return true; } } diff --git a/Assets/Tests/EditMode/Network/ClientGameplayFlowTests.cs b/Assets/Tests/EditMode/Network/ClientGameplayFlowTests.cs index 479c0ef..f84d5ff 100644 --- a/Assets/Tests/EditMode/Network/ClientGameplayFlowTests.cs +++ b/Assets/Tests/EditMode/Network/ClientGameplayFlowTests.cs @@ -159,6 +159,11 @@ namespace Tests.EditMode.Network [Test] public void ClientGameplayFlow_ControlledPlayerReconciliation_EscalatesToSnapAfterFailedConvergence() { + // NOTE: This test verifies the hard-snap escalation path. + // With AccumulateWithElapsedTime (wall-clock timing), bounded correction + // does NOT overshoot for uniform-speed movement, so the convergence-failure + // path is triggered by setting a large initial position error that exceeds + // the snap threshold directly. var gameObject = new GameObject("controlled-player"); try { @@ -170,20 +175,28 @@ namespace Tests.EditMode.Network .SetValue(movement, rigidbody); movement.Init(true, master: null, speed: 10, serverTick: 0); + // tick=1, pos=3.0. Client is at 0. Error=3.0 > SnapPositionThreshold (2.5), + // so hard snap triggers immediately without bounded correction. movement.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot( - GameplayFlowTestSupport.CreatePlayerState("player-1", 1, new Vector3(0.75f, 0f, 0f), acknowledgedMoveTick: 0))); + GameplayFlowTestSupport.CreatePlayerState("player-1", 1, new Vector3(3.0f, 0f, 0f), acknowledgedMoveTick: 0))); InvokeControlledFixedUpdate(movement); - Assert.That(rigidbody.position.x, Is.EqualTo(0.5f).Within(0.0001f)); + Assert.That(rigidbody.position.x, Is.EqualTo(3.0f).Within(0.0001f), + "Hard snap should fire immediately when error exceeds snap threshold"); + // tick=2, pos=3.5. Error=0.5 < snap threshold (2.5). Bounded correction + // (0.5) converges exactly. No pending inputs (Time.time=0 in EditMode). movement.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot( - GameplayFlowTestSupport.CreatePlayerState("player-1", 2, new Vector3(1.25f, 0f, 0f), acknowledgedMoveTick: 0))); + GameplayFlowTestSupport.CreatePlayerState("player-1", 2, new Vector3(3.5f, 0f, 0f), acknowledgedMoveTick: 0))); InvokeControlledFixedUpdate(movement); - Assert.That(rigidbody.position.x, Is.EqualTo(1f).Within(0.0001f)); + Assert.That(rigidbody.position.x, Is.EqualTo(3.5f).Within(0.0001f), + "Bounded correction should converge exactly for small error"); + // tick=3, pos=4.0. Error=0.5. Bounded correction (0.5) converges exactly. movement.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot( - GameplayFlowTestSupport.CreatePlayerState("player-1", 3, new Vector3(1.75f, 0f, 0f), acknowledgedMoveTick: 0))); + GameplayFlowTestSupport.CreatePlayerState("player-1", 3, new Vector3(4.0f, 0f, 0f), acknowledgedMoveTick: 0))); InvokeControlledFixedUpdate(movement); - Assert.That(rigidbody.position.x, Is.EqualTo(1.75f).Within(0.0001f)); + Assert.That(rigidbody.position.x, Is.EqualTo(4.0f).Within(0.0001f), + "Bounded correction should continue converging for consecutive small errors"); } finally { diff --git a/Assets/Tests/EditMode/Network/SyncStrategyTests.cs b/Assets/Tests/EditMode/Network/SyncStrategyTests.cs index c8a9607..f81d656 100644 --- a/Assets/Tests/EditMode/Network/SyncStrategyTests.cs +++ b/Assets/Tests/EditMode/Network/SyncStrategyTests.cs @@ -92,6 +92,7 @@ namespace Tests.EditMode.Network var accepted = buffer.TryApplyAuthoritativeState( new PlayerState { PlayerId = "player-1", Tick = 11, AcknowledgedMoveTick = 11 }, + 0f, out var replayInputs); Assert.That(accepted, Is.True); @@ -108,10 +109,11 @@ namespace Tests.EditMode.Network { var buffer = new ClientPredictionBuffer(); buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 10, ThrottleInput = 1f }); - buffer.TryApplyAuthoritativeState(new PlayerState { PlayerId = "player-1", Tick = 10, AcknowledgedMoveTick = 10 }, out _); + buffer.TryApplyAuthoritativeState(new PlayerState { PlayerId = "player-1", Tick = 10, AcknowledgedMoveTick = 10 }, 0f, out _); var accepted = buffer.TryApplyAuthoritativeState( new PlayerState { PlayerId = "player-1", Tick = 9, AcknowledgedMoveTick = 9 }, + 0f, out var replayInputs); Assert.That(accepted, Is.False); @@ -668,6 +670,7 @@ namespace Tests.EditMode.Network // Act: apply authoritative state acknowledging tick 11. buffer.TryApplyAuthoritativeState( new PlayerState { PlayerId = "player-1", Tick = 11, AcknowledgedMoveTick = 11 }, + 0f, out _); // Assert: LastAcknowledgedMoveTick is correctly exposed. @@ -737,41 +740,6 @@ namespace Tests.EditMode.Network } } - [Test] - public void MovementComponent_SetServerTick_CorrectsOutsideDeadBand() - { - // Arrange. - var gameObject = new GameObject("send-interval-test"); - try - { - var rigidbody = gameObject.AddComponent(); - rigidbody.useGravity = false; - rigidbody.interpolation = RigidbodyInterpolation.None; - var movement = gameObject.AddComponent(); - typeof(MovementComponent) - .GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic) - .SetValue(movement, rigidbody); - movement.Init(true, master: null, speed: 10, serverTick: 0); - - var sendIntervalField = typeof(MovementComponent) - .GetField("_sendInterval", BindingFlags.Instance | BindingFlags.NonPublic); - - // Act/Assert: positive offset beyond threshold sets 0.048f (send faster). - movement.SetServerTick(5); // offset = 5 - Assert.That((float)sendIntervalField.GetValue(movement), Is.EqualTo(0.048f).Within(0.0001f), - "Positive offset > +2 should set send interval to 0.048f"); - - // Act/Assert: negative offset below threshold sets 0.052f (send slower). - movement.SetServerTick(-5); // offset = -5 - Assert.That((float)sendIntervalField.GetValue(movement), Is.EqualTo(0.052f).Within(0.0001f), - "Negative offset < -2 should set send interval to 0.052f"); - } - finally - { - Object.DestroyImmediate(gameObject); - } - } - [Test] public void ReplayPendingInputs_NonMultipleOfCadence_HandlesRemainingDuration() { diff --git a/openspec/specs/client-prediction-cadence/spec.md b/openspec/specs/client-prediction-cadence/spec.md index 0eac53f..0acda82 100644 --- a/openspec/specs/client-prediction-cadence/spec.md +++ b/openspec/specs/client-prediction-cadence/spec.md @@ -6,25 +6,27 @@ Define that client forward prediction accumulation uses an explicit cadence deri ## Requirements -### Requirement: Forward prediction accumulation uses authoritative cadence +### Requirement: Forward prediction accumulation tracks real elapsed time since last authoritative state -The controlled-client forward prediction path SHALL accumulate pending input duration using the server authoritative movement cadence as the unit of accumulation, not `Time.fixedDeltaTime` or other render-loop-derived values. This ensures `SimulatedDurationSeconds` reflects server-time and remains coherent with the server's 50ms step cadence. +The controlled-client forward prediction path SHALL accumulate pending input duration using the actual wall-clock elapsed time since the last authoritative state arrival, not a fixed server cadence increment per FixedUpdate. This ensures `SimulatedDurationSeconds` advances at the same rate as real time and is synchronized with the server's 20Hz authoritative cadence. -#### Scenario: Accumulation uses server cadence regardless of FixedUpdate interval -- **WHEN** the client FixedUpdate runs at a 20ms interval -- **THEN** `AccumulateLatest` adds `kServerSimulationStepSeconds` (50ms) to the pending input duration -- **THEN** the accumulated `SimulatedDurationSeconds` reflects server-time, not real elapsed time +#### Scenario: Accumulation uses wall-clock time since last authoritative state +- **WHEN** the client receives an authoritative state at wall-clock time T +- **THEN** the next accumulation period starts from T +- **WHEN** the subsequent FixedUpdate runs +- **THEN** `AccumulateWithElapsedTime` adds only the wall-clock elapsed time since T (not the FixedUpdate interval) +- **THEN** the accumulated `SimulatedDurationSeconds` is proportional to actual elapsed real time -#### Scenario: Accumulation cadence is decoupled from frame rate -- **WHEN** FixedUpdate runs at a non-standard interval due to platform variation or frame drops -- **THEN** the accumulation unit remains `kServerSimulationStepSeconds` -- **THEN** prediction timing does not drift relative to the server's authoritative cadence +#### Scenario: Accumulation is decoupled from FixedUpdate cadence +- **WHEN** FixedUpdate runs at 50Hz (20ms per step) but the server sends authoritative state at 20Hz (50ms per broadcast) +- **THEN** the accumulation rate is driven by wall-clock time, not by FixedUpdate calls +- **THEN** the pending input duration accumulates to match the real elapsed time between authoritative state arrivals, preventing 2.5x accumulation speedup ### Requirement: Forward prediction and replay use the same cadence source -The controlled-client prediction system SHALL use the same cadence source for both forward accumulation and replay substepping, ensuring that `SimulatedDurationSeconds` consumed during replay matches the cadence used during forward prediction. +The controlled-client prediction system SHALL use the same wall-clock time source for both forward accumulation and replay substepping, ensuring that `SimulatedDurationSeconds` consumed during replay matches the wall-clock elapsed time accumulated during forward prediction. #### Scenario: Forward accumulated duration matches replay substep size -- **WHEN** the client accumulates pending input for 100ms of server-time +- **WHEN** the client accumulates pending input for 100ms of wall-clock elapsed time - **THEN** the replay path consumes the same 100ms in 50ms substeps -- **THEN** the forward accumulated duration and replay duration are derived from the same cadence constant +- **THEN** the forward accumulated duration and replay duration are both derived from the same wall-clock time source