fix 1
This commit is contained in:
@@ -1595,6 +1595,85 @@ MeshFilter:
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 490537900}
|
||||
m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0}
|
||||
--- !u!1 &532753891
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 532753892}
|
||||
- component: {fileID: 532753894}
|
||||
- component: {fileID: 532753893}
|
||||
m_Layer: 5
|
||||
m_Name: AcknowledgedTick
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!224 &532753892
|
||||
RectTransform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 532753891}
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 1979220485}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 0, y: 0}
|
||||
m_AnchoredPosition: {x: 0, y: 0}
|
||||
m_SizeDelta: {x: 500, y: 0}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!114 &532753893
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 532753891}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 5f7201a12d95ffc409449d95f23cf332, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_Material: {fileID: 0}
|
||||
m_Color: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1}
|
||||
m_RaycastTarget: 1
|
||||
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_Maskable: 1
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_FontData:
|
||||
m_Font: {fileID: 10102, guid: 0000000000000000e000000000000000, type: 0}
|
||||
m_FontSize: 30
|
||||
m_FontStyle: 1
|
||||
m_BestFit: 0
|
||||
m_MinSize: 3
|
||||
m_MaxSize: 40
|
||||
m_Alignment: 0
|
||||
m_AlignByGeometry: 0
|
||||
m_RichText: 1
|
||||
m_HorizontalOverflow: 0
|
||||
m_VerticalOverflow: 0
|
||||
m_LineSpacing: 1
|
||||
m_Text: "\u5BA2\u6237\u7AEF\u4F4D\u7F6E\uFF1A(1.00, 2.00, 3.00)"
|
||||
--- !u!222 &532753894
|
||||
CanvasRenderer:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 532753891}
|
||||
m_CullTransparentMesh: 1
|
||||
--- !u!1 &632206104
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -2339,6 +2418,8 @@ MonoBehaviour:
|
||||
_serverTickText: {fileID: 953008836}
|
||||
_startTickOffsetText: {fileID: 1665502818}
|
||||
_clientTickText: {fileID: 652355035}
|
||||
_correctionText: {fileID: 1413607043}
|
||||
_acknowledgedTickText: {fileID: 532753893}
|
||||
--- !u!1 &805112150
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -4427,6 +4508,85 @@ MeshFilter:
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1360915757}
|
||||
m_Mesh: {fileID: 10202, guid: 0000000000000000e000000000000000, type: 0}
|
||||
--- !u!1 &1413607041
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
serializedVersion: 6
|
||||
m_Component:
|
||||
- component: {fileID: 1413607042}
|
||||
- component: {fileID: 1413607044}
|
||||
- component: {fileID: 1413607043}
|
||||
m_Layer: 5
|
||||
m_Name: CorrectionText
|
||||
m_TagString: Untagged
|
||||
m_Icon: {fileID: 0}
|
||||
m_NavMeshLayer: 0
|
||||
m_StaticEditorFlags: 0
|
||||
m_IsActive: 1
|
||||
--- !u!224 &1413607042
|
||||
RectTransform:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1413607041}
|
||||
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
|
||||
m_LocalPosition: {x: 0, y: 0, z: 0}
|
||||
m_LocalScale: {x: 1, y: 1, z: 1}
|
||||
m_ConstrainProportionsScale: 0
|
||||
m_Children: []
|
||||
m_Father: {fileID: 1979220485}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 0}
|
||||
m_AnchorMax: {x: 0, y: 0}
|
||||
m_AnchoredPosition: {x: 0, y: 0}
|
||||
m_SizeDelta: {x: 500, y: 0}
|
||||
m_Pivot: {x: 0.5, y: 0.5}
|
||||
--- !u!114 &1413607043
|
||||
MonoBehaviour:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1413607041}
|
||||
m_Enabled: 1
|
||||
m_EditorHideFlags: 0
|
||||
m_Script: {fileID: 11500000, guid: 5f7201a12d95ffc409449d95f23cf332, type: 3}
|
||||
m_Name:
|
||||
m_EditorClassIdentifier:
|
||||
m_Material: {fileID: 0}
|
||||
m_Color: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1}
|
||||
m_RaycastTarget: 1
|
||||
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
|
||||
m_Maskable: 1
|
||||
m_OnCullStateChanged:
|
||||
m_PersistentCalls:
|
||||
m_Calls: []
|
||||
m_FontData:
|
||||
m_Font: {fileID: 10102, guid: 0000000000000000e000000000000000, type: 0}
|
||||
m_FontSize: 30
|
||||
m_FontStyle: 1
|
||||
m_BestFit: 0
|
||||
m_MinSize: 3
|
||||
m_MaxSize: 40
|
||||
m_Alignment: 0
|
||||
m_AlignByGeometry: 0
|
||||
m_RichText: 1
|
||||
m_HorizontalOverflow: 0
|
||||
m_VerticalOverflow: 0
|
||||
m_LineSpacing: 1
|
||||
m_Text: "\u5BA2\u6237\u7AEF\u4F4D\u7F6E\uFF1A(1.00, 2.00, 3.00)"
|
||||
--- !u!222 &1413607044
|
||||
CanvasRenderer:
|
||||
m_ObjectHideFlags: 0
|
||||
m_CorrespondingSourceObject: {fileID: 0}
|
||||
m_PrefabInstance: {fileID: 0}
|
||||
m_PrefabAsset: {fileID: 0}
|
||||
m_GameObject: {fileID: 1413607041}
|
||||
m_CullTransparentMesh: 1
|
||||
--- !u!1 &1422544908
|
||||
GameObject:
|
||||
m_ObjectHideFlags: 0
|
||||
@@ -5659,6 +5819,8 @@ RectTransform:
|
||||
m_Children:
|
||||
- {fileID: 287018088}
|
||||
- {fileID: 21534629}
|
||||
- {fileID: 1413607042}
|
||||
- {fileID: 532753892}
|
||||
m_Father: {fileID: 789249236}
|
||||
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
|
||||
m_AnchorMin: {x: 0, y: 1}
|
||||
|
||||
@@ -64,12 +64,16 @@ public readonly struct ControlledPlayerCorrectionResult
|
||||
Vector3 position,
|
||||
Quaternion rotation,
|
||||
bool usedHardSnap,
|
||||
ControlledPlayerVisualCorrectionState nextState)
|
||||
ControlledPlayerVisualCorrectionState nextState,
|
||||
float positionError,
|
||||
float rotationErrorDegrees)
|
||||
{
|
||||
Position = position;
|
||||
Rotation = rotation;
|
||||
UsedHardSnap = usedHardSnap;
|
||||
NextState = nextState;
|
||||
PositionError = positionError;
|
||||
RotationErrorDegrees = rotationErrorDegrees;
|
||||
}
|
||||
|
||||
public Vector3 Position { get; }
|
||||
@@ -79,6 +83,10 @@ public readonly struct ControlledPlayerCorrectionResult
|
||||
public bool UsedHardSnap { get; }
|
||||
|
||||
public ControlledPlayerVisualCorrectionState NextState { get; }
|
||||
|
||||
public float PositionError { get; }
|
||||
|
||||
public float RotationErrorDegrees { get; }
|
||||
}
|
||||
|
||||
public static class ControlledPlayerCorrection
|
||||
@@ -114,17 +122,17 @@ public static class ControlledPlayerCorrection
|
||||
|
||||
if (positionError <= Mathf.Epsilon && rotationError <= Mathf.Epsilon)
|
||||
{
|
||||
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, false, ControlledPlayerVisualCorrectionState.None);
|
||||
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, false, ControlledPlayerVisualCorrectionState.None, positionError, rotationError);
|
||||
}
|
||||
|
||||
if (boundedPositionCorrection <= Mathf.Epsilon && boundedRotationCorrection <= Mathf.Epsilon)
|
||||
{
|
||||
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None);
|
||||
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None, positionError, rotationError);
|
||||
}
|
||||
|
||||
if (positionError > settings.SnapPositionThreshold || rotationError > settings.SnapRotationThresholdDegrees)
|
||||
{
|
||||
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None);
|
||||
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None, positionError, rotationError);
|
||||
}
|
||||
|
||||
var remainingStepBudget = activeCorrection.IsActive
|
||||
@@ -132,7 +140,7 @@ public static class ControlledPlayerCorrection
|
||||
: settings.MaxCorrectionSteps;
|
||||
if (remainingStepBudget <= 0)
|
||||
{
|
||||
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None);
|
||||
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None, positionError, rotationError);
|
||||
}
|
||||
|
||||
var correctedPosition = Vector3.MoveTowards(currentPosition, targetPosition, boundedPositionCorrection);
|
||||
@@ -141,19 +149,21 @@ public static class ControlledPlayerCorrection
|
||||
var nextRotationError = Quaternion.Angle(correctedRotation, targetRotation);
|
||||
if (nextPositionError <= Mathf.Epsilon && nextRotationError <= Mathf.Epsilon)
|
||||
{
|
||||
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, false, ControlledPlayerVisualCorrectionState.None);
|
||||
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, false, ControlledPlayerVisualCorrectionState.None, positionError, rotationError);
|
||||
}
|
||||
|
||||
remainingStepBudget--;
|
||||
if (remainingStepBudget <= 0)
|
||||
{
|
||||
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None);
|
||||
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None, positionError, rotationError);
|
||||
}
|
||||
|
||||
return new ControlledPlayerCorrectionResult(
|
||||
correctedPosition,
|
||||
correctedRotation,
|
||||
false,
|
||||
new ControlledPlayerVisualCorrectionState(targetPosition, targetRotation, remainingStepBudget));
|
||||
new ControlledPlayerVisualCorrectionState(targetPosition, targetRotation, remainingStepBudget),
|
||||
positionError,
|
||||
rotationError);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -111,6 +111,10 @@ 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;
|
||||
@@ -207,7 +211,7 @@ public class MovementComponent : MonoBehaviour
|
||||
}
|
||||
|
||||
Simulate(_cachedMoveInput);
|
||||
_predictionBuffer.AccumulateLatest(Time.fixedDeltaTime);
|
||||
_predictionBuffer.AccumulateLatest(kServerSimulationStepSeconds);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -229,9 +233,11 @@ public class MovementComponent : MonoBehaviour
|
||||
return;
|
||||
}
|
||||
|
||||
var predictedPosition = _rigid.position;
|
||||
var predictedRotation = _rigid.rotation;
|
||||
var correction = ControlledPlayerCorrection.Resolve(
|
||||
_rigid.position,
|
||||
_rigid.rotation,
|
||||
predictedPosition,
|
||||
predictedRotation,
|
||||
snapshot.Position,
|
||||
snapshot.RotationQuaternion,
|
||||
new ControlledPlayerCorrectionSettings(kServerSimulationStepSeconds, _speed, TurnSpeedDegreesPerSecond),
|
||||
@@ -243,6 +249,16 @@ public class MovementComponent : MonoBehaviour
|
||||
_rigid.velocity = correction.UsedHardSnap ? snapshot.Velocity : Vector3.zero;
|
||||
_rigid.angularVelocity = Vector3.zero;
|
||||
ReplayPendingInputs(replayInputs);
|
||||
|
||||
if (MainUI.Instance != null)
|
||||
{
|
||||
MainUI.Instance.OnCorrectionMagnitudeChanged?.Invoke(
|
||||
predictedPosition,
|
||||
snapshot.Position,
|
||||
correction.PositionError,
|
||||
correction.RotationErrorDegrees);
|
||||
MainUI.Instance.OnAcknowledgedMoveTickChanged?.Invoke(_predictionBuffer.LastAcknowledgedMoveTick ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
private Vector3 CaptureMovement()
|
||||
@@ -311,14 +327,15 @@ public class MovementComponent : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
if (_currentTickOffset < 0)
|
||||
if (_currentTickOffset < -kTickOffsetThreshold)
|
||||
{
|
||||
_sendInterval = 0.052f;
|
||||
}
|
||||
if (_currentTickOffset > 0)
|
||||
else if (_currentTickOffset > kTickOffsetThreshold)
|
||||
{
|
||||
_sendInterval = 0.048f;
|
||||
}
|
||||
// Within [-kTickOffsetThreshold, +kTickOffsetThreshold]: no correction, keep current interval
|
||||
}
|
||||
|
||||
private void ReplayPendingInputs(IReadOnlyList<PredictedMoveStep> replayInputs)
|
||||
|
||||
@@ -11,12 +11,16 @@ public class MainUI : MonoBehaviour
|
||||
[SerializeField] private Text _serverTickText;
|
||||
[SerializeField] private Text _startTickOffsetText;
|
||||
[SerializeField] private Text _clientTickText;
|
||||
[SerializeField] private Text _correctionText;
|
||||
[SerializeField] private Text _acknowledgedTickText;
|
||||
|
||||
public UnityAction<Vector3> OnServerPosChanged;
|
||||
public UnityAction<Vector3> OnClientPosChanged;
|
||||
public UnityAction<long> OnServerTickChanged;
|
||||
public UnityAction<long> OnStartTickOffsetChanged;
|
||||
public UnityAction<long> OnClientTickChanged;
|
||||
public UnityAction<Vector3, Vector3, float, float> OnCorrectionMagnitudeChanged;
|
||||
public UnityAction<long> OnAcknowledgedMoveTickChanged;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
@@ -30,6 +34,8 @@ public class MainUI : MonoBehaviour
|
||||
OnServerTickChanged += UpdateServerTickText;
|
||||
OnClientTickChanged += UpdateClientTickText;
|
||||
OnStartTickOffsetChanged += UpdateStartTickOffsetText;
|
||||
OnCorrectionMagnitudeChanged += UpdateCorrectionText;
|
||||
OnAcknowledgedMoveTickChanged += UpdateAcknowledgedTickText;
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
@@ -39,6 +45,8 @@ public class MainUI : MonoBehaviour
|
||||
OnServerTickChanged -= UpdateServerTickText;
|
||||
OnClientTickChanged -= UpdateClientTickText;
|
||||
OnStartTickOffsetChanged -= UpdateStartTickOffsetText;
|
||||
OnCorrectionMagnitudeChanged -= UpdateCorrectionText;
|
||||
OnAcknowledgedMoveTickChanged -= UpdateAcknowledgedTickText;
|
||||
}
|
||||
|
||||
private void UpdateServerPositionText(Vector3 pos)
|
||||
@@ -65,4 +73,14 @@ public class MainUI : MonoBehaviour
|
||||
{
|
||||
_clientTickText.text = "客户端Tick:" + tick;
|
||||
}
|
||||
|
||||
private void UpdateCorrectionText(Vector3 predictedPos, Vector3 authoritativePos, float positionError, float rotationError)
|
||||
{
|
||||
_correctionText.text = $"校正:pos差={positionError:F4} rot差={rotationError:F2}°";
|
||||
}
|
||||
|
||||
private void UpdateAcknowledgedTickText(long tick)
|
||||
{
|
||||
_acknowledgedTickText.text = "AckTick:" + tick;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -602,6 +602,176 @@ namespace Tests.EditMode.Network
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReplayPendingInputs_NonZeroTurn_MatchesLivePrediction()
|
||||
{
|
||||
// Arrange: verify that live step-by-step prediction and ReplayPendingInputs
|
||||
// produce identical trajectories for non-zero turn input (turn=0.5, throttle=1, 0.10s).
|
||||
var gameObject = new GameObject("replay-test");
|
||||
try
|
||||
{
|
||||
var rigidbody = gameObject.AddComponent<Rigidbody>();
|
||||
rigidbody.useGravity = false;
|
||||
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);
|
||||
|
||||
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
|
||||
|
||||
var turnInput = 0.5f;
|
||||
var throttleInput = 1f;
|
||||
var stepDuration = 0.05f;
|
||||
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;
|
||||
|
||||
// Reset.
|
||||
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
|
||||
|
||||
// Act — replay path: ReplayPendingInputs with same total duration.
|
||||
var replayInputs = new List<PredictedMoveStep>
|
||||
{
|
||||
new PredictedMoveStep(
|
||||
new MoveInput { PlayerId = "player-1", Tick = 1, TurnInput = turnInput, ThrottleInput = throttleInput },
|
||||
stepDuration * steps)
|
||||
};
|
||||
InvokeReplayPendingInputs(movement, replayInputs);
|
||||
var replayPosition = rigidbody.position;
|
||||
var replayRotation = rigidbody.rotation;
|
||||
|
||||
// Assert: both paths must produce identical trajectories.
|
||||
Assert.That(Vector3.Distance(replayPosition, livePosition), Is.LessThan(0.0001f),
|
||||
"Replay produced a different position than live prediction for non-zero turn input.");
|
||||
Assert.That(Quaternion.Angle(replayRotation, liveRotation), Is.LessThan(0.01f),
|
||||
"Replay produced a different rotation than live prediction for non-zero turn input.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClientPredictionBuffer_LastAcknowledgedMoveTick_IsExposed()
|
||||
{
|
||||
// Arrange: buffer with inputs at ticks 10, 11, 12.
|
||||
var buffer = new ClientPredictionBuffer();
|
||||
buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 10, ThrottleInput = 1f });
|
||||
buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 11, ThrottleInput = 1f });
|
||||
buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 12, ThrottleInput = 1f });
|
||||
|
||||
// Act: apply authoritative state acknowledging tick 11.
|
||||
buffer.TryApplyAuthoritativeState(
|
||||
new PlayerState { PlayerId = "player-1", Tick = 11, AcknowledgedMoveTick = 11 },
|
||||
out _);
|
||||
|
||||
// Assert: LastAcknowledgedMoveTick is correctly exposed.
|
||||
Assert.That(buffer.LastAcknowledgedMoveTick, Is.EqualTo(11),
|
||||
"LastAcknowledgedMoveTick was not correctly set after authoritative state application.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ControlledPlayerCorrection_CorrectionMagnitude_IsExposed()
|
||||
{
|
||||
// Arrange: small position and rotation error.
|
||||
var currentPos = Vector3.zero;
|
||||
var currentRot = Quaternion.identity;
|
||||
var targetPos = new Vector3(0.5f, 0f, 0f);
|
||||
var targetRot = Quaternion.Euler(0f, 10f, 0f);
|
||||
var settings = new ControlledPlayerCorrectionSettings(0.05f, 10f, 180f);
|
||||
|
||||
// Act.
|
||||
var result = ControlledPlayerCorrection.Resolve(currentPos, currentRot, targetPos, targetRot, settings);
|
||||
|
||||
// Assert: PositionError and RotationErrorDegrees are exposed and meaningful.
|
||||
Assert.That(result.PositionError, Is.GreaterThan(0f),
|
||||
"PositionError should be greater than zero for non-zero position divergence.");
|
||||
Assert.That(result.RotationErrorDegrees, Is.GreaterThan(0f),
|
||||
"RotationErrorDegrees should be greater than zero for non-zero rotation divergence.");
|
||||
Assert.That(result.PositionError, Is.EqualTo(Vector3.Distance(currentPos, targetPos)).Within(0.0001f),
|
||||
"PositionError should equal the distance between current and target positions.");
|
||||
Assert.That(result.RotationErrorDegrees, Is.EqualTo(Quaternion.Angle(currentRot, targetRot)).Within(0.01f),
|
||||
"RotationErrorDegrees should equal the angle between current and target rotations.");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void MovementComponent_SetServerTick_DoesNotOscillateWithinDeadBand()
|
||||
{
|
||||
// Arrange: set up MovementComponent and initialize controlled state.
|
||||
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);
|
||||
|
||||
// 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.
|
||||
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.");
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
[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()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user