fix
This commit is contained in:
parent
79474b53aa
commit
90f1832397
|
|
@ -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<PredictedMoveStep> replayInputs)
|
||||
|
|
|
|||
|
|
@ -26,6 +26,18 @@ namespace Network.NetworkApplication
|
|||
|
||||
public long? LastAcknowledgedMoveTick { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Time of the last received authoritative state, used to compute
|
||||
/// actual elapsed wall-clock time for accumulation synchronization.
|
||||
/// </summary>
|
||||
private float _lastAuthoritativeStateTime = float.NegativeInfinity;
|
||||
|
||||
/// <summary>
|
||||
/// Returns the wall-clock time of the last authoritative state arrival.
|
||||
/// Valid only after TryApplyAuthoritativeState has been called at least once.
|
||||
/// </summary>
|
||||
public float LastAuthoritativeStateTime => _lastAuthoritativeStateTime;
|
||||
|
||||
public IReadOnlyList<PredictedMoveStep> 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<PredictedMoveStep> replayInputs)
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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<PredictedMoveStep> 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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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>();
|
||||
rigidbody.useGravity = false;
|
||||
rigidbody.interpolation = RigidbodyInterpolation.None;
|
||||
var movement = gameObject.AddComponent<MovementComponent>();
|
||||
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()
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue