补充输入重放

输入重放是指当收到一个服务器状态包时以那个包为初始状态,然后重新计算所有未确认的输入来得到当前帧的状态,然后再对当前角色的位置进行插值移动。服务器状态包只能给出前几帧的权威数据,而当前帧的状态则需要通过输入回放来得到
This commit is contained in:
SepComet
2026-04-08 10:33:42 +08:00
parent da2b93e59c
commit 75289b5690
29 changed files with 850 additions and 573 deletions
+18 -4
View File
@@ -425,6 +425,7 @@ GameObject:
- component: {fileID: 8938569698484985372}
- component: {fileID: -1362768914916555191}
- component: {fileID: -8136683798838004576}
- component: {fileID: -5923497047956986603}
m_Layer: 0
m_Name: Player
m_TagString: Untagged
@@ -520,6 +521,7 @@ MonoBehaviour:
- {fileID: 2100000, guid: 2955df004504a714e947e2971499d036, type: 2}
_camera: {fileID: 6308356813655391139}
_movement: {fileID: 5069958635149219085}
_movementResolver: {fileID: -5923497047956986603}
_playerUI: {fileID: 6308356813253026696}
_isControlled: 0
--- !u!114 &5069958635149219085
@@ -534,11 +536,7 @@ MonoBehaviour:
m_Script: {fileID: 11500000, guid: db8117151f564304bae153aa55c0a960, type: 3}
m_Name:
m_EditorClassIdentifier:
_speed: 2
_rigid: {fileID: -1362768914916555191}
_inputComponent: {fileID: 8938569698484985372}
_applyServerCorrection: 1
_lerpRate: 0.1
--- !u!114 &8938569698484985372
MonoBehaviour:
m_ObjectHideFlags: 0
@@ -601,3 +599,19 @@ BoxCollider:
serializedVersion: 3
m_Size: {x: 1, y: 1, z: 1}
m_Center: {x: 0, y: 0, z: 0}
--- !u!114 &-5923497047956986603
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 6308356814245253661}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fcfebc0989c4bf04cacf1c633d49a8bb, type: 3}
m_Name:
m_EditorClassIdentifier:
_speed: 2
_movement: {fileID: 5069958635149219085}
_inputComponent: {fileID: 8938569698484985372}
_applyServerCorrection: 1
-399
View File
@@ -1,399 +0,0 @@
using System.Collections.Generic;
using Network.Defines;
using Network.NetworkApplication;
using UnityEngine;
using Vector3 = UnityEngine.Vector3;
public class MovementComponent : MonoBehaviour
{
[SerializeField] private int _speed = 2;
[SerializeField] private Rigidbody _rigid;
[SerializeField] private InputComponent _inputComponent;
private Player _master;
private const float TurnSpeedDegreesPerSecond = 180f;
// 测试时设为 false,可接收服务器状态日志但不应用校正
[SerializeField] private bool _applyServerCorrection = true;
private const float UnityYawOffsetDegrees = 90f;
// Server authoritative movement cadence used for replay substepping.
// This matches ServerAuthoritativeMovementConfiguration.SimulationInterval (50ms).
private const float kServerSimulationStepSeconds = 0.05f;
private bool _isControlled = false;
private Vector3 _serverPosition;
private bool _hasServerState = false;
private ClientAuthoritativePlayerStateSnapshot _lastAuthoritativeState;
private ControlledPlayerVisualCorrectionState _activeVisualCorrection;
public long Tick { get; private set; } = 0;
private long _startTickOffset = 0;
private long _currentTickOffset = 0;
private float _simulationAccumulator = 0f;
private readonly ClientPredictionBuffer _predictionBuffer = new ClientPredictionBuffer();
private readonly RemotePlayerSnapshotInterpolator _remoteSnapshotInterpolator = new();
[SerializeField] private float _lerpRate = 0.1f;
private Vector3 _lastAimDirection = Vector3.forward;
public void Init(bool isControlled, Player master, ClientMovementBootstrap bootstrap)
{
Init(isControlled, master, bootstrap.AuthoritativeMoveSpeed, bootstrap.ServerTick);
}
public void Init(bool isControlled, Player master, int speed = 0, long serverTick = 0)
{
_master = master;
_isControlled = isControlled;
_speed = speed;
_startTickOffset = serverTick;
_rigid.interpolation = isControlled ? RigidbodyInterpolation.None : RigidbodyInterpolation.Interpolate;
_rigid.isKinematic = !isControlled;
_rigid.velocity = Vector3.zero;
_rigid.angularVelocity = Vector3.zero;
// 设置 InputComponent 的 playerId
if (_inputComponent != null)
{
_inputComponent.InjectPlayerId(master.PlayerId);
}
if (serverTick != 0 && _isControlled && MainUI.Instance != null)
MainUI.Instance.OnStartTickOffsetChanged(serverTick);
}
private void Update()
{
if (_isControlled)
{
if (_inputComponent != null)
{
MainUI.Instance.OnClientTickChanged(_inputComponent.CurrentTick);
}
}
}
private void Start()
{
// 订阅 InputComponent 的事件来记录预测输入
if (_inputComponent != null)
{
_inputComponent.OnMoveInputCreated += HandleMoveInputCreated;
}
}
private void OnDestroy()
{
if (_inputComponent != null)
{
_inputComponent.OnMoveInputCreated -= HandleMoveInputCreated;
}
}
private void HandleMoveInputCreated(MoveInput moveInput)
{
// 记录到预测缓冲区,用于后续的服务器状态校正和回放
_predictionBuffer.Record(moveInput);
}
/// <summary>
/// 测试用:设置是否应用服务器状态校正(默认 true)
/// 设为 false 时只打印服务器状态日志,不影响本地位置
/// </summary>
public void SetApplyServerCorrection(bool apply)
{
_applyServerCorrection = apply;
}
private void FixedUpdate()
{
if (_isControlled)
{
if (_hasServerState)
{
if (MainUI.Instance != null)
{
MainUI.Instance.OnServerPosChanged(_serverPosition);
}
Reconcile(_lastAuthoritativeState);
_hasServerState = false;
}
// 累积时间,按服务端 50ms 步长进行模拟
_simulationAccumulator += Time.fixedDeltaTime;
while (_simulationAccumulator >= kServerSimulationStepSeconds)
{
var pendingCount = _predictionBuffer.PendingInputs.Count;
if (pendingCount == 0)
{
// 没有待处理的输入,清零累积时间,跳出循环
_simulationAccumulator = 0f;
break;
}
Debug.Log(
$"[SimulateLoop] frame={Time.frameCount} accum={_simulationAccumulator:F4} pendingCount={pendingCount}");
// 使用最近发送的 MoveInput(来自 predictionBuffer)而非实时输入,
// 确保客户端与服务端的输入时序一致
Simulate(GetLatestPredictedInput());
_simulationAccumulator -= kServerSimulationStepSeconds;
}
// 注意:模拟时间现在在 Simulate() 内部通过 AccumulateLatest 累加
}
else
{
var sample = _remoteSnapshotInterpolator.Sample(Time.time);
if (sample.HasValue)
{
_rigid.MovePosition(sample.Position);
_rigid.MoveRotation(sample.Rotation);
_rigid.velocity = sample.Velocity;
}
}
}
private void Reconcile(ClientAuthoritativePlayerStateSnapshot snapshot)
{
_serverPosition = snapshot.Position;
if (!_predictionBuffer.TryApplyAuthoritativeState(snapshot.SourceState, Time.time, out var replayInputs))
{
return;
}
var predictedPosition = _rigid.position;
var predictedRotation = _rigid.rotation;
var correction = ControlledPlayerCorrection.Resolve(
predictedPosition,
predictedRotation,
snapshot.Position,
snapshot.RotationQuaternion,
new ControlledPlayerCorrectionSettings(kServerSimulationStepSeconds, _speed, TurnSpeedDegreesPerSecond,
snapDistanceMultiplier: 5f),
_activeVisualCorrection);
_activeVisualCorrection = correction.NextState;
_rigid.position = correction.Position;
_rigid.rotation = correction.Rotation;
_rigid.velocity = correction.UsedHardSnap ? snapshot.Velocity : Vector3.zero;
_rigid.angularVelocity = Vector3.zero;
// 位置已被校正到服务器位置,不需要再 ReplayPendingInputs
// 因为 pendingInputs 中的 SimulatedDurationSeconds 是累积的模拟时间,
// 如果用来 replay 会导致多余的移动。清空 pendingInputs 让客户端从校正位置重新开始
if (replayInputs.Count > 0)
{
_predictionBuffer.ClearPendingInputs();
}
// 清零 accumulator 防止 FixedUpdate 中再次 Simulate 导致重复移动
_simulationAccumulator = 0f;
if (MainUI.Instance != null)
{
MainUI.Instance.OnCorrectionMagnitudeChanged?.Invoke(
predictedPosition,
snapshot.Position,
correction.PositionError,
correction.RotationErrorDegrees);
MainUI.Instance.OnAcknowledgedMoveTickChanged?.Invoke(_predictionBuffer.LastAcknowledgedMoveTick ?? 0);
}
}
private Vector3 ResolveAimDirection()
{
var planarForward = Vector3.ProjectOnPlane(_rigid.transform.forward, Vector3.up);
if (ClientGameplayInputFlow.HasPlanarInput(planarForward))
{
_lastAimDirection = planarForward;
return planarForward;
}
return ClientGameplayInputFlow.HasPlanarInput(_lastAimDirection)
? _lastAimDirection
: ResolveHeadingForward(UnityYawToHeading(_rigid.rotation.eulerAngles.y));
}
private void Simulate(Vector3 input)
{
Debug.Log(
$"[Simulate] frame={Time.frameCount} input=({input.x:F2},{input.z:F2}) accum={_simulationAccumulator:F4}");
ApplyTankMovement(input.x, input.z, kServerSimulationStepSeconds);
//ApplyTankMovement(-input.x, input.z, kServerSimulationStepSeconds);
// 每次 Simulate 后累加模拟时间(用于 Reconcile 时的重放)
_predictionBuffer.AccumulateLatest(kServerSimulationStepSeconds);
if (_isControlled)
{
if (MainUI.Instance != null)
{
MainUI.Instance.OnClientPosChanged(_rigid.position);
}
// 打印客户端当前状态,用于与服务端状态对比
Debug.Log($"[ClientState] Tick={Tick} " +
$"Pos=({_rigid.position.x:F3}, {_rigid.position.y:F3}, {_rigid.position.z:F3}) " +
$"Rot={_rigid.rotation.eulerAngles.y:F2}");
}
}
public void OnAuthoritativeState(ClientAuthoritativePlayerStateSnapshot snapshot)
{
if (_isControlled)
{
_lastAuthoritativeState = snapshot;
// 打印服务端状态,用于与客户端计算结果对比
Debug.Log($"[ServerState] Tick={snapshot.SourceState.Tick} " +
$"Pos=({snapshot.SourceState.Position.X:F3}, {snapshot.SourceState.Position.Y:F3}, {snapshot.SourceState.Position.Z:F3}) " +
$"Rot={snapshot.SourceState.Rotation:F2} " +
$"Vel=({snapshot.SourceState.Velocity.X:F3}, {snapshot.SourceState.Velocity.Y:F3}, {snapshot.SourceState.Velocity.Z:F3}) " +
$"AckTick={snapshot.AcknowledgedMoveTick}");
// 清理已确认的旧输入,确保客户端使用正确的(已确认的)输入
var pendingBefore = _predictionBuffer.PendingInputs.Count;
_predictionBuffer.PruneAcknowledgedInputs(snapshot.AcknowledgedMoveTick);
var pendingAfter = _predictionBuffer.PendingInputs.Count;
Debug.Log(
$"[Prune] AckTick={snapshot.AcknowledgedMoveTick} removed {pendingBefore - pendingAfter}/{pendingBefore} inputs, remaining={pendingAfter}");
// 收到服务器状态后,必须清空 pendingInputs
// 因为 pendingInputs 中的 SimulatedDurationSeconds 是累积的模拟时间,
// 如果不清理,客户端会继续用这些输入移动(测试模式下位置不被服务器校正)
_predictionBuffer.ClearPendingInputs();
_simulationAccumulator = 0f;
// 只有开启校正时才设置 _hasServerState,否则只打印日志不应用
if (_applyServerCorrection)
{
_hasServerState = true;
}
}
else
{
_lastAuthoritativeState = snapshot;
_remoteSnapshotInterpolator.TryAddSnapshot(snapshot, Time.time);
}
}
public void SetServerTick(long serverTick)
{
_currentTickOffset = serverTick - Tick - _startTickOffset;
if (_isControlled)
{
if (MainUI.Instance != null)
{
MainUI.Instance.OnServerTickChanged(serverTick);
}
}
}
/// <summary>
/// 获取最近发送的 MoveInput,用于与服务器输入时序对齐。
/// 如果没有记录的输入,返回零向量(停止状态)。
/// </summary>
private Vector3 GetLatestPredictedInput()
{
var pending = _predictionBuffer.PendingInputs;
if (pending.Count == 0)
{
Debug.Log("[MoveInput] No pending inputs, using zero (stop)");
return Vector3.zero;
}
var latest = pending[^1];
Debug.Log(
$"[MoveInput] Using tick={latest.Input.Tick} TurnInput={latest.Input.TurnInput} ThrottleInput={latest.Input.ThrottleInput} ({pending.Count} pending)");
// MoveInput 的 TurnInput/ThrottleInput 转回 Unity 的 x/z 格式
// 注意 TurnInput 在 MoveInput 里是正数=右,正数=-input.x=左(需要取反)
// ThrottleInput 在 MoveInput 里正数=前进,正数=input.z=前
return new Vector3(-latest.Input.TurnInput, 0f, latest.Input.ThrottleInput);
}
private void ReplayPendingInputs(IReadOnlyList<PredictedMoveStep> replayInputs)
{
foreach (var replayInput in replayInputs)
{
var remaining = replayInput.SimulatedDurationSeconds;
while (remaining > 0f)
{
// Use the server's fixed cadence (50ms) as the substep size to ensure
// replay trajectory matches live FixedUpdate prediction exactly.
var step = Mathf.Min(remaining, kServerSimulationStepSeconds);
ApplyTankMovement(
replayInput.Input.TurnInput,
replayInput.Input.ThrottleInput,
step);
remaining -= step;
}
}
if (_isControlled)
{
if (MainUI.Instance != null)
{
MainUI.Instance.OnClientPosChanged(_rigid.position);
}
}
}
private void ApplyTankMovement(float turnInput, float throttleInput, float deltaTime)
{
if (deltaTime <= 0f)
{
_rigid.velocity = Vector3.zero;
return;
}
var clampedTurnInput = Mathf.Clamp(turnInput, -1f, 1f);
var clampedThrottleInput = Mathf.Clamp(throttleInput, -1f, 1f);
var heading = NormalizeDegrees(UnityYawToHeading(_rigid.rotation.eulerAngles.y) +
(clampedTurnInput * TurnSpeedDegreesPerSecond * deltaTime));
_rigid.rotation = Quaternion.Euler(0f, HeadingToUnityYaw(heading), 0f);
var forward = ResolveHeadingForward(heading);
var velocity = forward * (clampedThrottleInput * _speed);
Vector3 targetPos = _rigid.position + velocity * deltaTime;
_rigid.MovePosition(targetPos);
// _rigid.velocity = velocity;
// _rigid.position += velocity * deltaTime;
// 调试日志:打印每步计算细节
Debug.Log($"[MoveStep] _speed={_speed} deltaTime={deltaTime:F4} throttle={clampedThrottleInput} " +
$"heading={heading:F2} velocity=({velocity.x:F3}, {velocity.y:F3}, {velocity.z:F3}) " +
$"pos=({_rigid.position.x:F3}, {_rigid.position.y:F3}, {_rigid.position.z:F3})");
}
private static Vector3 ResolveHeadingForward(float headingDegrees)
{
var rotationRadians = headingDegrees * Mathf.Deg2Rad;
return new Vector3(Mathf.Cos(rotationRadians), 0f, Mathf.Sin(rotationRadians));
}
private static float HeadingToUnityYaw(float headingDegrees)
{
return NormalizeDegrees(UnityYawOffsetDegrees - headingDegrees);
}
private static float UnityYawToHeading(float unityYawDegrees)
{
return NormalizeDegrees(UnityYawOffsetDegrees - unityYawDegrees);
}
private static float NormalizeDegrees(float degrees)
{
var normalized = degrees % 360f;
if (normalized < 0f)
{
normalized += 360f;
}
return normalized;
}
}
@@ -1,20 +0,0 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
public class OfflineMovementComponent : MonoBehaviour
{
[SerializeField] private int _speed;
[SerializeField] private Rigidbody rigid;
private Vector3 _cachedInput;
private void Update()
{
_cachedInput = new Vector3(Input.GetAxisRaw("Horizontal"), 0, Input.GetAxisRaw("Vertical"));
}
private void FixedUpdate()
{
rigid.velocity = _cachedInput * _speed;
}
}
+8
View File
@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 10d331d181503b542868b5608961489e
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,71 @@
using UnityEngine;
public class MovementComponent : MonoBehaviour
{
[SerializeField] private Rigidbody _rigid;
private const float InterpolationAlpha = 0.15f;
private bool _isControlled;
private Vector3 _currentPosition;
private Quaternion _currentRotation;
private Vector3 _targetPosition;
private Quaternion _targetRotation;
private void Awake()
{
_rigid ??= GetComponent<Rigidbody>();
}
public void Init(bool isControlled)
{
_rigid ??= GetComponent<Rigidbody>();
_isControlled = isControlled;
_rigid.interpolation = isControlled ? RigidbodyInterpolation.None : RigidbodyInterpolation.Interpolate;
_rigid.isKinematic = !isControlled;
_rigid.velocity = Vector3.zero;
_rigid.angularVelocity = Vector3.zero;
_currentPosition = _rigid.position;
_currentRotation = _rigid.rotation;
_targetPosition = _rigid.position;
_targetRotation = _rigid.rotation;
}
private void Update()
{
_currentPosition = Vector3.Lerp(_currentPosition, _targetPosition, InterpolationAlpha);
_currentRotation = Quaternion.Slerp(_currentRotation, _targetRotation, InterpolationAlpha);
_rigid.position = _currentPosition;
_rigid.rotation = _currentRotation;
if (_isControlled && MainUI.Instance != null)
{
MainUI.Instance.OnClientPosChanged(_currentPosition);
}
}
public Vector3 CurrentPosition => _currentPosition;
public Quaternion CurrentRotation => _currentRotation;
public Vector3 TargetPosition => _targetPosition;
public Quaternion TargetRotation => _targetRotation;
public void SetTargetPose(Vector3 position, Quaternion rotation)
{
_targetPosition = position;
_targetRotation = rotation;
}
public void SnapToPose(Vector3 position, Quaternion rotation)
{
_currentPosition = position;
_currentRotation = rotation;
_targetPosition = position;
_targetRotation = rotation;
_rigid.position = position;
_rigid.rotation = rotation;
}
}
@@ -0,0 +1,252 @@
using System.Collections.Generic;
using Network.Defines;
using Network.NetworkApplication;
using UnityEngine;
using Vector3 = UnityEngine.Vector3;
public class MovementResolverComponent : MonoBehaviour
{
private const float ServerSimulationStepSeconds = 0.05f;
private const float SnapThreshold = 0.5f;
[SerializeField] private int _speed = 2;
[SerializeField] private MovementComponent _movement;
[SerializeField] private InputComponent _inputComponent;
[SerializeField] private bool _applyServerCorrection = true;
private Player _master;
private bool _isControlled;
private Vector3 _serverPosition;
private ClientAuthoritativePlayerStateSnapshot _lastAuthoritativeState;
private Vector3 _authoritativePosition;
private Quaternion _authoritativeRotation;
private Vector3 _predictedPosition;
private Quaternion _predictedRotation;
public long Tick { get; private set; }
private long _startTickOffset;
private long _currentTickOffset;
private float _simulationAccumulator;
private readonly ClientPredictionBuffer _predictionBuffer = new ClientPredictionBuffer();
private readonly RemotePlayerSnapshotInterpolator _remoteSnapshotInterpolator = new();
private void Awake()
{
_movement ??= GetComponent<MovementComponent>();
_inputComponent ??= GetComponent<InputComponent>();
}
public void Init(bool isControlled, Player master, ClientMovementBootstrap bootstrap)
{
Init(isControlled, master, bootstrap.AuthoritativeMoveSpeed, bootstrap.ServerTick);
}
public void Init(bool isControlled, Player master, int speed = 0, long serverTick = 0)
{
_movement ??= GetComponent<MovementComponent>();
_inputComponent ??= GetComponent<InputComponent>();
_master = master;
_isControlled = isControlled;
_speed = speed;
_startTickOffset = serverTick;
if (_movement != null)
{
_movement.Init(isControlled);
_authoritativePosition = _movement.CurrentPosition;
_authoritativeRotation = _movement.CurrentRotation;
_predictedPosition = _movement.CurrentPosition;
_predictedRotation = _movement.CurrentRotation;
}
if (_inputComponent != null && master != null)
{
_inputComponent.InjectPlayerId(master.PlayerId);
}
if (serverTick != 0 && _isControlled && MainUI.Instance != null)
{
MainUI.Instance.OnStartTickOffsetChanged(serverTick);
}
}
private void Update()
{
if (_isControlled && _inputComponent != null && MainUI.Instance != null)
{
MainUI.Instance.OnClientTickChanged(_inputComponent.CurrentTick);
}
}
private void Start()
{
if (_inputComponent != null)
{
_inputComponent.OnMoveInputCreated += HandleMoveInputCreated;
}
}
private void OnDestroy()
{
if (_inputComponent != null)
{
_inputComponent.OnMoveInputCreated -= HandleMoveInputCreated;
}
}
private void HandleMoveInputCreated(MoveInput moveInput)
{
_predictionBuffer.Record(moveInput);
}
public void SetApplyServerCorrection(bool apply)
{
_applyServerCorrection = apply;
}
private void FixedUpdate()
{
if (_movement == null)
{
return;
}
if (_isControlled)
{
_simulationAccumulator += Time.fixedDeltaTime;
while (_simulationAccumulator >= ServerSimulationStepSeconds)
{
var pendingCount = _predictionBuffer.PendingInputs.Count;
if (pendingCount == 0)
{
_simulationAccumulator = 0f;
break;
}
Simulate(GetLatestPredictedInput());
_simulationAccumulator -= ServerSimulationStepSeconds;
}
return;
}
var sample = _remoteSnapshotInterpolator.Sample(Time.time);
if (sample.HasValue)
{
_movement.SnapToPose(sample.Position, sample.Rotation);
}
}
private void Simulate(Vector3 input)
{
TankMovementKinematics.ApplyStep(_speed, input.x, input.z, ServerSimulationStepSeconds,
ref _predictedPosition, ref _predictedRotation);
_movement.SetTargetPose(_predictedPosition, _predictedRotation);
_predictionBuffer.AccumulateLatest(ServerSimulationStepSeconds);
if (MainUI.Instance != null)
{
MainUI.Instance.OnClientPosChanged(_movement.CurrentPosition);
}
}
public void OnAuthoritativeState(ClientAuthoritativePlayerStateSnapshot snapshot)
{
if (_isControlled)
{
if (!_applyServerCorrection)
{
return;
}
_lastAuthoritativeState = snapshot;
Reconcile(snapshot);
return;
}
_lastAuthoritativeState = snapshot;
_remoteSnapshotInterpolator.TryAddSnapshot(snapshot, Time.time);
}
private void Reconcile(ClientAuthoritativePlayerStateSnapshot snapshot)
{
_serverPosition = snapshot.Position;
if (!_predictionBuffer.TryApplyAuthoritativeState(snapshot.SourceState, Time.time, out var replayInputs))
{
return;
}
_authoritativePosition = snapshot.Position;
_authoritativeRotation = snapshot.RotationQuaternion;
_predictedPosition = _authoritativePosition;
_predictedRotation = _authoritativeRotation;
ReplayPendingInputs(replayInputs);
var error = Vector3.Distance(_movement.CurrentPosition, _predictedPosition);
if (error > SnapThreshold)
{
_movement.SnapToPose(_predictedPosition, _predictedRotation);
}
else
{
_movement.SetTargetPose(_predictedPosition, _predictedRotation);
}
_simulationAccumulator = 0f;
if (MainUI.Instance != null)
{
MainUI.Instance.OnServerPosChanged(_serverPosition);
MainUI.Instance.OnCorrectionMagnitudeChanged?.Invoke(
_predictedPosition,
snapshot.Position,
error,
Quaternion.Angle(_predictedRotation, snapshot.RotationQuaternion));
MainUI.Instance.OnAcknowledgedMoveTickChanged?.Invoke(_predictionBuffer.LastAcknowledgedMoveTick ?? 0);
}
}
public void SetServerTick(long serverTick)
{
_currentTickOffset = serverTick - Tick - _startTickOffset;
if (_isControlled && MainUI.Instance != null)
{
MainUI.Instance.OnServerTickChanged(serverTick);
}
}
private Vector3 GetLatestPredictedInput()
{
var pending = _predictionBuffer.PendingInputs;
if (pending.Count == 0)
{
return Vector3.zero;
}
var latest = pending[^1];
return new Vector3(-latest.Input.TurnInput, 0f, latest.Input.ThrottleInput);
}
private void ReplayPendingInputs(IReadOnlyList<PredictedMoveStep> replayInputs)
{
foreach (var replayInput in replayInputs)
{
var remaining = replayInput.SimulatedDurationSeconds;
while (remaining > 0f)
{
var step = Mathf.Min(remaining, ServerSimulationStepSeconds);
TankMovementKinematics.ApplyStep(
_speed,
replayInput.Input.TurnInput,
replayInput.Input.ThrottleInput,
step,
ref _predictedPosition,
ref _predictedRotation);
remaining -= step;
}
}
}
}
@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: eff1752caaabe784fb85775608605426
guid: fcfebc0989c4bf04cacf1c633d49a8bb
MonoImporter:
externalObjects: {}
serializedVersion: 2
@@ -12,10 +12,26 @@ public class Player : MonoBehaviour
[SerializeField] private Material[] _materials;
[SerializeField] private Camera _camera;
[SerializeField] private MovementComponent _movement;
[SerializeField] private MovementResolverComponent _movementResolver;
[SerializeField] private PlayerUI _playerUI;
[SerializeField] private bool _isControlled;
private readonly ClientAuthoritativePlayerState _authoritativeState = new();
private void Awake()
{
_movement ??= GetComponent<MovementComponent>();
if (_movement == null)
{
_movement = gameObject.AddComponent<MovementComponent>();
}
_movementResolver ??= GetComponent<MovementResolverComponent>();
if (_movementResolver == null)
{
_movementResolver = gameObject.AddComponent<MovementResolverComponent>();
}
}
public void LocalInit(string playerId, ClientMovementBootstrap bootstrap)
{
this.PlayerId = playerId;
@@ -25,7 +41,7 @@ public class Player : MonoBehaviour
_meshRenderer.material = _materials[idx];
_playerUI.Init(this);
_movement.Init(true, this, bootstrap);
_movementResolver.Init(true, this, bootstrap);
}
public void RemoteInit(string playerId, UnityEngine.Vector3 pos)
@@ -40,7 +56,7 @@ public class Player : MonoBehaviour
this.transform.position = pos;
_playerUI.Init(this);
_movement.Init(false, this);
_movementResolver.Init(false, this);
}
private void OnApplicationQuit()
@@ -59,7 +75,7 @@ public class Player : MonoBehaviour
}
_playerUI?.SyncAuthoritativeState(snapshot, CombatPresentation);
_movement?.OnAuthoritativeState(snapshot);
_movementResolver?.OnAuthoritativeState(snapshot);
}
public bool ApplyCombatEvent(CombatEvent combatEvent)
@@ -75,6 +91,6 @@ public class Player : MonoBehaviour
public void SyncTick(long serverTick)
{
_movement.SetServerTick(serverTick);
_movementResolver.SetServerTick(serverTick);
}
}
@@ -0,0 +1,53 @@
using UnityEngine;
public static class TankMovementKinematics
{
private const float TurnSpeedDegreesPerSecond = 180f;
private const float UnityYawOffsetDegrees = 90f;
public static void ApplyStep(int speed, float turnInput, float throttleInput, float deltaTime,
ref Vector3 position, ref Quaternion rotation)
{
if (deltaTime <= 0f)
{
return;
}
var clampedTurnInput = Mathf.Clamp(turnInput, -1f, 1f);
var clampedThrottleInput = Mathf.Clamp(throttleInput, -1f, 1f);
var heading = NormalizeDegrees(UnityYawToHeading(rotation.eulerAngles.y) +
(clampedTurnInput * TurnSpeedDegreesPerSecond * deltaTime));
rotation = Quaternion.Euler(0f, HeadingToUnityYaw(heading), 0f);
var forward = ResolveHeadingForward(heading);
var velocity = forward * (clampedThrottleInput * speed);
position += velocity * deltaTime;
}
public static Vector3 ResolveHeadingForward(float headingDegrees)
{
var rotationRadians = headingDegrees * Mathf.Deg2Rad;
return new Vector3(Mathf.Cos(rotationRadians), 0f, Mathf.Sin(rotationRadians));
}
public static float HeadingToUnityYaw(float headingDegrees)
{
return NormalizeDegrees(UnityYawOffsetDegrees - headingDegrees);
}
public static float UnityYawToHeading(float unityYawDegrees)
{
return NormalizeDegrees(UnityYawOffsetDegrees - unityYawDegrees);
}
public static float NormalizeDegrees(float degrees)
{
var normalized = degrees % 360f;
if (normalized < 0f)
{
normalized += 360f;
}
return normalized;
}
}
@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: cf8f141cd490efa4aa23369000c805db
guid: 22558181f2430ca4b80f5787ec62c68d
MonoImporter:
externalObjects: {}
serializedVersion: 2
-53
View File
@@ -1,53 +0,0 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.UI;
enum CardState
{
Cooling,
Ready,
WaitingSun
}
public class Card : MonoBehaviour
{
private CardState cardState = CardState.Cooling;
public GameObject sunflower;
public GameObject sunflowerGray;
public Image sunflowerMask;
private void Update()
{
switch (cardState)
{
case CardState.Cooling:
CoolingUpdate();
break;
case CardState.Ready:
ReadyUpdate();
break;
case CardState.WaitingSun:
WaitingSunUpdate();
break;
default:
break;
}
}
void CoolingUpdate()
{
}
void ReadyUpdate()
{
}
void WaitingSunUpdate()
{
}
}
@@ -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]