补充输入重放
输入重放是指当收到一个服务器状态包时以那个包为初始状态,然后重新计算所有未确认的输入来得到当前帧的状态,然后再对当前角色的位置进行插值移动。服务器状态包只能给出前几帧的权威数据,而当前帧的状态则需要通过输入回放来得到
This commit is contained in:
@@ -135,20 +135,28 @@ namespace Tests.EditMode.Network
|
||||
var rigidbody = gameObject.AddComponent<Rigidbody>();
|
||||
rigidbody.useGravity = false;
|
||||
var movement = gameObject.AddComponent<MovementComponent>();
|
||||
var resolver = gameObject.AddComponent<MovementResolverComponent>();
|
||||
typeof(MovementComponent)
|
||||
.GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(movement, rigidbody);
|
||||
movement.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
typeof(MovementResolverComponent)
|
||||
.GetField("_movement", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(resolver, movement);
|
||||
resolver.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
|
||||
movement.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
|
||||
GameplayFlowTestSupport.CreatePlayerState("player-1", 1, new Vector3(0.75f, 0f, 0f), acknowledgedMoveTick: 0)));
|
||||
InvokeControlledFixedUpdate(movement);
|
||||
Assert.That(rigidbody.position.x, Is.EqualTo(0.5f).Within(0.0001f));
|
||||
resolver.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
|
||||
GameplayFlowTestSupport.CreatePlayerState("player-1", 1, new Vector3(0.25f, 0f, 0f), acknowledgedMoveTick: 0)));
|
||||
InvokeControlledUpdate(movement);
|
||||
Assert.That(rigidbody.position.x, Is.EqualTo(0.0375f).Within(0.0001f));
|
||||
Assert.That(GetPrivateVector3(resolver, "_predictedPosition").x, Is.EqualTo(0.25f).Within(0.0001f));
|
||||
|
||||
movement.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
|
||||
GameplayFlowTestSupport.CreatePlayerState("player-1", 2, new Vector3(1f, 0f, 0f), acknowledgedMoveTick: 0)));
|
||||
InvokeControlledFixedUpdate(movement);
|
||||
Assert.That(rigidbody.position.x, Is.EqualTo(1f).Within(0.0001f));
|
||||
resolver.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
|
||||
GameplayFlowTestSupport.CreatePlayerState("player-1", 2, new Vector3(0.5f, 0f, 0f), acknowledgedMoveTick: 0)));
|
||||
InvokeControlledUpdate(movement);
|
||||
Assert.That(GetPrivateVector3(resolver, "_predictedPosition").x, Is.EqualTo(0.5f).Within(0.0001f));
|
||||
Assert.That(movement.TargetPosition.x, Is.EqualTo(0.5f).Within(0.0001f));
|
||||
Assert.That(rigidbody.position.x, Is.GreaterThan(0.0375f));
|
||||
Assert.That(rigidbody.position.x, Is.LessThan(0.5f));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -157,46 +165,72 @@ namespace Tests.EditMode.Network
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClientGameplayFlow_ControlledPlayerReconciliation_EscalatesToSnapAfterFailedConvergence()
|
||||
public void ClientGameplayFlow_ControlledPlayerReconciliation_EscalatesToSnapForLargeDivergence()
|
||||
{
|
||||
// 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
|
||||
{
|
||||
var rigidbody = gameObject.AddComponent<Rigidbody>();
|
||||
rigidbody.useGravity = false;
|
||||
var movement = gameObject.AddComponent<MovementComponent>();
|
||||
var resolver = gameObject.AddComponent<MovementResolverComponent>();
|
||||
typeof(MovementComponent)
|
||||
.GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(movement, rigidbody);
|
||||
movement.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
typeof(MovementResolverComponent)
|
||||
.GetField("_movement", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(resolver, movement);
|
||||
resolver.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(
|
||||
resolver.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
|
||||
GameplayFlowTestSupport.CreatePlayerState("player-1", 1, new Vector3(3.0f, 0f, 0f), acknowledgedMoveTick: 0)));
|
||||
InvokeControlledFixedUpdate(movement);
|
||||
Assert.That(rigidbody.position.x, Is.EqualTo(3.0f).Within(0.0001f),
|
||||
"Hard snap should fire immediately when error exceeds snap threshold");
|
||||
"Large divergence should snap the visible pose immediately to predicted pose.");
|
||||
Assert.That(GetPrivateVector3(resolver, "_predictedPosition").x, Is.EqualTo(3.0f).Within(0.0001f));
|
||||
Assert.That(movement.TargetPosition.x, Is.EqualTo(3.0f).Within(0.0001f));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
// 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(3.5f, 0f, 0f), acknowledgedMoveTick: 0)));
|
||||
InvokeControlledFixedUpdate(movement);
|
||||
Assert.That(rigidbody.position.x, Is.EqualTo(3.5f).Within(0.0001f),
|
||||
"Bounded correction should converge exactly for small error");
|
||||
[Test]
|
||||
public void ClientGameplayFlow_ControlledPlayerReconciliation_RebuildsPredictionImmediatelyAndPreservesUnacknowledgedInputs()
|
||||
{
|
||||
var gameObject = new GameObject("controlled-player");
|
||||
try
|
||||
{
|
||||
var rigidbody = gameObject.AddComponent<Rigidbody>();
|
||||
rigidbody.useGravity = false;
|
||||
var movement = gameObject.AddComponent<MovementComponent>();
|
||||
var resolver = gameObject.AddComponent<MovementResolverComponent>();
|
||||
typeof(MovementComponent)
|
||||
.GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(movement, rigidbody);
|
||||
typeof(MovementResolverComponent)
|
||||
.GetField("_movement", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(resolver, movement);
|
||||
resolver.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
|
||||
// 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(4.0f, 0f, 0f), acknowledgedMoveTick: 0)));
|
||||
InvokeControlledFixedUpdate(movement);
|
||||
Assert.That(rigidbody.position.x, Is.EqualTo(4.0f).Within(0.0001f),
|
||||
"Bounded correction should continue converging for consecutive small errors");
|
||||
var predictionBuffer = (ClientPredictionBuffer)typeof(MovementResolverComponent)
|
||||
.GetField("_predictionBuffer", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.GetValue(resolver);
|
||||
predictionBuffer.Record(new MoveInput
|
||||
{
|
||||
PlayerId = "player-1",
|
||||
Tick = 1,
|
||||
TurnInput = 0f,
|
||||
ThrottleInput = 1f
|
||||
});
|
||||
predictionBuffer.AccumulateLatest(0.1f);
|
||||
|
||||
resolver.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
|
||||
GameplayFlowTestSupport.CreatePlayerState("player-1", 1, Vector3.zero, acknowledgedMoveTick: 0)));
|
||||
|
||||
Assert.That(GetPrivateVector3(resolver, "_predictedPosition").z, Is.EqualTo(1f).Within(0.0001f));
|
||||
Assert.That(predictionBuffer.PendingInputs.Count, Is.EqualTo(1));
|
||||
Assert.That(predictionBuffer.PendingInputs[0].Input.Tick, Is.EqualTo(1));
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -236,10 +270,17 @@ namespace Tests.EditMode.Network
|
||||
Assert.That(clamped.LatestSnapshot.Tick, Is.EqualTo(11));
|
||||
Assert.That(clamped.Position, Is.EqualTo(new Vector3(10f, 0f, 0f)));
|
||||
}
|
||||
private static void InvokeControlledFixedUpdate(MovementComponent movement)
|
||||
private static Vector3 GetPrivateVector3(MovementResolverComponent resolver, string fieldName)
|
||||
{
|
||||
return (Vector3)typeof(MovementResolverComponent)
|
||||
.GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.GetValue(resolver);
|
||||
}
|
||||
|
||||
private static void InvokeControlledUpdate(MovementComponent movement)
|
||||
{
|
||||
typeof(MovementComponent)
|
||||
.GetMethod("FixedUpdate", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.GetMethod("Update", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.Invoke(movement, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ namespace Tests.EditMode.Network
|
||||
clientRuntime.MessageManager,
|
||||
"player-a",
|
||||
3,
|
||||
Vector3.right);
|
||||
Vector3.forward);
|
||||
|
||||
Assert.That(clientReliableTransport.SentMessages.Count, Is.EqualTo(1));
|
||||
var outboundEnvelope = Envelope.Parser.ParseFrom(clientReliableTransport.SentMessages[0]);
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace Tests.EditMode.Network
|
||||
private static readonly IPEndPoint PeerB = new(IPAddress.Loopback, 9102);
|
||||
|
||||
[Test]
|
||||
public void UpdateAuthoritativeMovement_UsesConfiguredSimulationCadence_AndExposesItOnRuntime()
|
||||
public void UpdateAuthoritativeMovement_UsesConfiguredSimulationCadence_AndRequiresFreshInputEachStep()
|
||||
{
|
||||
var createdTransports = new Dictionary<int, FakeTransport>();
|
||||
var configuration = new ServerRuntimeConfiguration(9000)
|
||||
@@ -61,7 +61,8 @@ namespace Tests.EditMode.Network
|
||||
runtime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(50));
|
||||
|
||||
Assert.That(runtime.TryGetAuthoritativeMovementState(PeerA, out var stateAfterSecondStep), Is.True);
|
||||
Assert.That(stateAfterSecondStep.PositionZ, Is.EqualTo(0.4f).Within(0.0001f));
|
||||
Assert.That(stateAfterSecondStep.PositionZ, Is.EqualTo(0.2f).Within(0.0001f));
|
||||
Assert.That(stateAfterSecondStep.VelocityZ, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(createdTransports[9000].BroadcastMessages.Count, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
@@ -168,8 +169,8 @@ namespace Tests.EditMode.Network
|
||||
Assert.That(firstBroadcast.PlayerId, Is.EqualTo("player-a"));
|
||||
Assert.That(firstBroadcast.Tick, Is.EqualTo(1));
|
||||
Assert.That(firstBroadcast.AcknowledgedMoveTick, Is.EqualTo(1));
|
||||
Assert.That(firstBroadcast.Position.Z, Is.EqualTo(1f).Within(0.0001f));
|
||||
Assert.That(firstBroadcast.Velocity.Z, Is.EqualTo(10f).Within(0.0001f));
|
||||
Assert.That(firstBroadcast.Position.Z, Is.EqualTo(0.5f).Within(0.0001f));
|
||||
Assert.That(firstBroadcast.Velocity.Z, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(firstBroadcast.Velocity.X, Is.EqualTo(0f).Within(0.0001f));
|
||||
|
||||
createdTransports[9001].EmitReceive(BuildEnvelope(MessageType.MoveInput, new MoveInput
|
||||
@@ -192,7 +193,7 @@ namespace Tests.EditMode.Network
|
||||
var secondBroadcast = ParsePlayerState(createdTransports[9001].BroadcastMessages[1]);
|
||||
Assert.That(secondBroadcast.Tick, Is.EqualTo(2));
|
||||
Assert.That(secondBroadcast.AcknowledgedMoveTick, Is.EqualTo(2));
|
||||
Assert.That(secondBroadcast.Position.Z, Is.EqualTo(1f).Within(0.0001f));
|
||||
Assert.That(secondBroadcast.Position.Z, Is.EqualTo(0.5f).Within(0.0001f));
|
||||
Assert.That(secondBroadcast.Velocity.Z, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(secondBroadcast.Velocity.X, Is.EqualTo(0f).Within(0.0001f));
|
||||
}
|
||||
|
||||
@@ -252,7 +252,7 @@ namespace Tests.EditMode.Network
|
||||
Assert.That(snapshot.Position, Is.EqualTo(new Vector3(5f, 0f, -3f)));
|
||||
Assert.That(snapshot.Velocity, Is.EqualTo(new Vector3(1.5f, 0f, 0.25f)));
|
||||
Assert.That(snapshot.Rotation, Is.EqualTo(90f));
|
||||
Assert.That(snapshot.RotationQuaternion.eulerAngles.y, Is.EqualTo(0f).Within(0.01f));
|
||||
Assert.That(snapshot.RotationQuaternion.eulerAngles.y, Is.EqualTo(90f).Within(0.01f));
|
||||
Assert.That(snapshot.Hp, Is.EqualTo(73));
|
||||
}
|
||||
|
||||
@@ -472,7 +472,7 @@ namespace Tests.EditMode.Network
|
||||
Assert.That(sample.HasValue, Is.True);
|
||||
Assert.That(sample.UsedInterpolation, Is.False);
|
||||
Assert.That(sample.Position, Is.EqualTo(new Vector3(2f, 0f, -1f)));
|
||||
Assert.That(sample.Rotation.eulerAngles.y, Is.EqualTo(75f).Within(0.01f));
|
||||
Assert.That(sample.Rotation.eulerAngles.y, Is.EqualTo(15f).Within(0.01f));
|
||||
Assert.That(sample.LatestSnapshot.Tick, Is.EqualTo(12));
|
||||
}
|
||||
|
||||
@@ -514,10 +514,14 @@ namespace Tests.EditMode.Network
|
||||
var rigidbody = gameObject.AddComponent<Rigidbody>();
|
||||
rigidbody.useGravity = false;
|
||||
var movement = gameObject.AddComponent<MovementComponent>();
|
||||
var resolver = gameObject.AddComponent<MovementResolverComponent>();
|
||||
typeof(MovementComponent)
|
||||
.GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(movement, rigidbody);
|
||||
movement.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
typeof(MovementResolverComponent)
|
||||
.GetField("_movement", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(resolver, movement);
|
||||
resolver.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
|
||||
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
|
||||
|
||||
@@ -527,9 +531,8 @@ namespace Tests.EditMode.Network
|
||||
var totalDuration = stepDuration * 3; // 0.15s
|
||||
|
||||
// Act — step-by-step path (live prediction shape).
|
||||
ApplyTankMovementStepByStep(movement, turnInput, throttleInput, stepDuration, steps: 3);
|
||||
var stepByStepPosition = rigidbody.position;
|
||||
var stepByStepRotation = rigidbody.rotation;
|
||||
ApplyTankMovementStepByStep(turnInput, throttleInput, stepDuration, steps: 3,
|
||||
out var stepByStepPosition, out var stepByStepRotation);
|
||||
|
||||
// Reset to initial state.
|
||||
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
|
||||
@@ -541,9 +544,9 @@ namespace Tests.EditMode.Network
|
||||
new MoveInput { PlayerId = "player-1", Tick = 1, TurnInput = turnInput, ThrottleInput = throttleInput },
|
||||
totalDuration)
|
||||
};
|
||||
InvokeReplayPendingInputs(movement, accumulatedReplayInputs);
|
||||
var accumulatedPosition = rigidbody.position;
|
||||
var accumulatedRotation = rigidbody.rotation;
|
||||
InvokeReplayPendingInputs(resolver, accumulatedReplayInputs);
|
||||
var accumulatedPosition = GetPrivateVector3(resolver, "_predictedPosition");
|
||||
var accumulatedRotation = GetPrivateQuaternion(resolver, "_predictedRotation");
|
||||
|
||||
// Assert: for straight movement (turn=0), both paths should be identical.
|
||||
Assert.That(Vector3.Distance(accumulatedPosition, stepByStepPosition), Is.LessThan(0.0001f),
|
||||
@@ -567,10 +570,14 @@ namespace Tests.EditMode.Network
|
||||
var rigidbody = gameObject.AddComponent<Rigidbody>();
|
||||
rigidbody.useGravity = false;
|
||||
var movement = gameObject.AddComponent<MovementComponent>();
|
||||
var resolver = gameObject.AddComponent<MovementResolverComponent>();
|
||||
typeof(MovementComponent)
|
||||
.GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(movement, rigidbody);
|
||||
movement.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
typeof(MovementResolverComponent)
|
||||
.GetField("_movement", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(resolver, movement);
|
||||
resolver.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
|
||||
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
|
||||
|
||||
@@ -583,15 +590,15 @@ namespace Tests.EditMode.Network
|
||||
var totalDuration = stepDuration * steps;
|
||||
|
||||
// Act — step-by-step (correct approach).
|
||||
ApplyTankMovementStepByStep(movement, turnInput, throttleInput, stepDuration, steps);
|
||||
var stepByStepPosition = rigidbody.position;
|
||||
ApplyTankMovementStepByStep(turnInput, throttleInput, stepDuration, steps,
|
||||
out var stepByStepPosition, out _);
|
||||
|
||||
// Reset.
|
||||
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
|
||||
|
||||
// Act — ONE big step simulating the old buggy accumulated behavior.
|
||||
ApplyTankMovementStepByStep(movement, turnInput, throttleInput, totalDuration, steps: 1);
|
||||
var oneShotPosition = rigidbody.position;
|
||||
ApplyTankMovementStepByStep(turnInput, throttleInput, totalDuration, steps: 1,
|
||||
out var oneShotPosition, out _);
|
||||
|
||||
// Assert: for non-zero turn with many steps, the old one-shot and correct step-by-step MUST differ.
|
||||
Assert.That(Vector3.Distance(oneShotPosition, stepByStepPosition), Is.GreaterThan(0.001f),
|
||||
@@ -615,10 +622,14 @@ namespace Tests.EditMode.Network
|
||||
var rigidbody = gameObject.AddComponent<Rigidbody>();
|
||||
rigidbody.useGravity = false;
|
||||
var movement = gameObject.AddComponent<MovementComponent>();
|
||||
var resolver = gameObject.AddComponent<MovementResolverComponent>();
|
||||
typeof(MovementComponent)
|
||||
.GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(movement, rigidbody);
|
||||
movement.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
typeof(MovementResolverComponent)
|
||||
.GetField("_movement", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(resolver, movement);
|
||||
resolver.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
|
||||
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
|
||||
|
||||
@@ -628,9 +639,8 @@ namespace Tests.EditMode.Network
|
||||
var steps = 2; // 0.10s total
|
||||
|
||||
// Act — live prediction: step-by-step ApplyTankMovement (correct shape).
|
||||
ApplyTankMovementStepByStep(movement, turnInput, throttleInput, stepDuration, steps);
|
||||
var livePosition = rigidbody.position;
|
||||
var liveRotation = rigidbody.rotation;
|
||||
ApplyTankMovementStepByStep(turnInput, throttleInput, stepDuration, steps,
|
||||
out var livePosition, out var liveRotation);
|
||||
|
||||
// Reset.
|
||||
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
|
||||
@@ -642,9 +652,9 @@ namespace Tests.EditMode.Network
|
||||
new MoveInput { PlayerId = "player-1", Tick = 1, TurnInput = turnInput, ThrottleInput = throttleInput },
|
||||
stepDuration * steps)
|
||||
};
|
||||
InvokeReplayPendingInputs(movement, replayInputs);
|
||||
var replayPosition = rigidbody.position;
|
||||
var replayRotation = rigidbody.rotation;
|
||||
InvokeReplayPendingInputs(resolver, replayInputs);
|
||||
var replayPosition = GetPrivateVector3(resolver, "_predictedPosition");
|
||||
var replayRotation = GetPrivateQuaternion(resolver, "_predictedRotation");
|
||||
|
||||
// Assert: both paths must produce identical trajectories.
|
||||
Assert.That(Vector3.Distance(replayPosition, livePosition), Is.LessThan(0.0001f),
|
||||
@@ -703,9 +713,9 @@ namespace Tests.EditMode.Network
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MovementComponent_SetServerTick_DoesNotOscillateWithinDeadBand()
|
||||
public void MovementComponent_SetServerTick_TracksLatestServerOffset()
|
||||
{
|
||||
// Arrange: set up MovementComponent and initialize controlled state.
|
||||
// Arrange: set up movement resolver and initialize controlled state.
|
||||
var gameObject = new GameObject("send-interval-test");
|
||||
try
|
||||
{
|
||||
@@ -713,25 +723,25 @@ namespace Tests.EditMode.Network
|
||||
rigidbody.useGravity = false;
|
||||
rigidbody.interpolation = RigidbodyInterpolation.None;
|
||||
var movement = gameObject.AddComponent<MovementComponent>();
|
||||
var resolver = gameObject.AddComponent<MovementResolverComponent>();
|
||||
typeof(MovementComponent)
|
||||
.GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(movement, rigidbody);
|
||||
movement.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
typeof(MovementResolverComponent)
|
||||
.GetField("_movement", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(resolver, movement);
|
||||
resolver.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
|
||||
var sendIntervalField = typeof(MovementComponent)
|
||||
.GetField("_sendInterval", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
var currentOffsetField = typeof(MovementResolverComponent)
|
||||
.GetField("_currentTickOffset", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
// Set an initial interval as baseline.
|
||||
sendIntervalField.SetValue(movement, 0.05f);
|
||||
|
||||
// Act/Assert: offsets within [-2, +2] dead-band do not change the interval.
|
||||
// Simulate offset hovering around zero.
|
||||
// Act/Assert: server tick updates the cached latest offset deterministically.
|
||||
for (var i = -2; i <= 2; i++)
|
||||
{
|
||||
movement.SetServerTick(i); // Tick=0, so offset = i - 0 - 0 = i
|
||||
var interval = (float)sendIntervalField.GetValue(movement);
|
||||
Assert.That(interval, Is.EqualTo(0.05f).Within(0.0001f),
|
||||
$"Offset {i} should not trigger send interval correction within dead-band.");
|
||||
resolver.SetServerTick(i); // Tick=0, so offset = i - 0 - 0 = i
|
||||
var currentOffset = (long)currentOffsetField.GetValue(resolver);
|
||||
Assert.That(currentOffset, Is.EqualTo(i),
|
||||
$"Server tick {i} should map to the same cached offset when Tick and start offset are zero.");
|
||||
}
|
||||
}
|
||||
finally
|
||||
@@ -750,10 +760,14 @@ namespace Tests.EditMode.Network
|
||||
var rigidbody = gameObject.AddComponent<Rigidbody>();
|
||||
rigidbody.useGravity = false;
|
||||
var movement = gameObject.AddComponent<MovementComponent>();
|
||||
var resolver = gameObject.AddComponent<MovementResolverComponent>();
|
||||
typeof(MovementComponent)
|
||||
.GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(movement, rigidbody);
|
||||
movement.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
typeof(MovementResolverComponent)
|
||||
.GetField("_movement", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(resolver, movement);
|
||||
resolver.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
|
||||
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
|
||||
|
||||
@@ -767,8 +781,8 @@ namespace Tests.EditMode.Network
|
||||
new MoveInput { PlayerId = "player-1", Tick = 1, TurnInput = turnInput, ThrottleInput = throttleInput },
|
||||
totalDuration)
|
||||
};
|
||||
InvokeReplayPendingInputs(movement, replayInputs);
|
||||
var finalPosition = rigidbody.position;
|
||||
InvokeReplayPendingInputs(resolver, replayInputs);
|
||||
var finalPosition = GetPrivateVector3(resolver, "_predictedPosition");
|
||||
|
||||
// Expected: 0.12s at speed=10 → 1.2 units forward.
|
||||
var expectedPosition = new Vector3(0f, 0f, 1.2f);
|
||||
@@ -781,13 +795,14 @@ namespace Tests.EditMode.Network
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyTankMovementStepByStep(MovementComponent movement, float turnInput, float throttleInput, float stepDuration, int steps)
|
||||
private static void ApplyTankMovementStepByStep(float turnInput, float throttleInput, float stepDuration, int steps,
|
||||
out Vector3 position, out Quaternion rotation)
|
||||
{
|
||||
var method = typeof(MovementComponent)
|
||||
.GetMethod("ApplyTankMovement", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
position = Vector3.zero;
|
||||
rotation = Quaternion.identity;
|
||||
for (var i = 0; i < steps; i++)
|
||||
{
|
||||
method.Invoke(movement, new object[] { turnInput, throttleInput, stepDuration });
|
||||
TankMovementKinematics.ApplyStep(10, turnInput, throttleInput, stepDuration, ref position, ref rotation);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -799,11 +814,34 @@ namespace Tests.EditMode.Network
|
||||
rigidbody.angularVelocity = Vector3.zero;
|
||||
}
|
||||
|
||||
private static void InvokeReplayPendingInputs(MovementComponent movement, IReadOnlyList<PredictedMoveStep> inputs)
|
||||
private static void InvokeReplayPendingInputs(MovementResolverComponent resolver, IReadOnlyList<PredictedMoveStep> inputs)
|
||||
{
|
||||
typeof(MovementComponent)
|
||||
SetPrivateField(resolver, "_predictedPosition", Vector3.zero);
|
||||
SetPrivateField(resolver, "_predictedRotation", Quaternion.identity);
|
||||
typeof(MovementResolverComponent)
|
||||
.GetMethod("ReplayPendingInputs", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.Invoke(movement, new object[] { inputs });
|
||||
.Invoke(resolver, new object[] { inputs });
|
||||
}
|
||||
|
||||
private static Vector3 GetPrivateVector3(MovementResolverComponent resolver, string fieldName)
|
||||
{
|
||||
return (Vector3)typeof(MovementResolverComponent)
|
||||
.GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.GetValue(resolver);
|
||||
}
|
||||
|
||||
private static Quaternion GetPrivateQuaternion(MovementResolverComponent resolver, string fieldName)
|
||||
{
|
||||
return (Quaternion)typeof(MovementResolverComponent)
|
||||
.GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.GetValue(resolver);
|
||||
}
|
||||
|
||||
private static void SetPrivateField(MovementResolverComponent resolver, string fieldName, object value)
|
||||
{
|
||||
typeof(MovementResolverComponent)
|
||||
.GetField(fieldName, BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(resolver, value);
|
||||
}
|
||||
|
||||
[Test]
|
||||
|
||||
Reference in New Issue
Block a user