From 098a1dd68ce1b8cea3389c7dafabfc2f937ed3f7 Mon Sep 17 00:00:00 2001 From: SepComet <202308010230@stu.csust.edu.cn> Date: Wed, 8 Apr 2026 13:08:36 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=BE=93=E5=85=A5=E5=9B=9E?= =?UTF-8?q?=E6=94=BE=E9=80=BB=E8=BE=91=20+=20=E8=B0=83=E6=95=B4=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E7=BB=93=E6=9E=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ClientPredictionBuffer.cs | 53 +++----- .../NetworkApplication/PredictedMoveStep.cs | 18 +++ .../PredictedMoveStep.cs.meta | 3 + Assets/Scripts/Player/Input.meta | 8 ++ Assets/Scripts/Player/Input/IInputSource.cs | 11 ++ .../Scripts/Player/Input/IInputSource.cs.meta | 3 + .../Player/{ => Input}/InputComponent.cs | 127 +----------------- .../Player/{ => Input}/InputComponent.cs.meta | 0 .../Player/Input/SimulatedInputSource.cs | 82 +++++++++++ .../Player/Input/SimulatedInputSource.cs.meta | 3 + .../Scripts/Player/Input/UnityInputSource.cs | 34 +++++ .../Player/Input/UnityInputSource.cs.meta | 3 + Assets/Scripts/Player/MovementComponent.cs | 106 ++++++++++++++- .../Player/MovementResolverComponent.cs | 65 +++++---- .../Network/ClientGameplayFlowTests.cs | 13 +- .../EditMode/Network/SyncStrategyTests.cs | 26 +++- 16 files changed, 369 insertions(+), 186 deletions(-) create mode 100644 Assets/Scripts/Network/NetworkApplication/PredictedMoveStep.cs create mode 100644 Assets/Scripts/Network/NetworkApplication/PredictedMoveStep.cs.meta create mode 100644 Assets/Scripts/Player/Input.meta create mode 100644 Assets/Scripts/Player/Input/IInputSource.cs create mode 100644 Assets/Scripts/Player/Input/IInputSource.cs.meta rename Assets/Scripts/Player/{ => Input}/InputComponent.cs (55%) rename Assets/Scripts/Player/{ => Input}/InputComponent.cs.meta (100%) create mode 100644 Assets/Scripts/Player/Input/SimulatedInputSource.cs create mode 100644 Assets/Scripts/Player/Input/SimulatedInputSource.cs.meta create mode 100644 Assets/Scripts/Player/Input/UnityInputSource.cs create mode 100644 Assets/Scripts/Player/Input/UnityInputSource.cs.meta diff --git a/Assets/Scripts/Network/NetworkApplication/ClientPredictionBuffer.cs b/Assets/Scripts/Network/NetworkApplication/ClientPredictionBuffer.cs index 689fd9f..d16a4a9 100644 --- a/Assets/Scripts/Network/NetworkApplication/ClientPredictionBuffer.cs +++ b/Assets/Scripts/Network/NetworkApplication/ClientPredictionBuffer.cs @@ -1,23 +1,9 @@ using System; using System.Collections.Generic; -using System.Linq; using Network.Defines; namespace Network.NetworkApplication { - public readonly struct PredictedMoveStep - { - public PredictedMoveStep(MoveInput input, float simulatedDurationSeconds) - { - Input = input ?? throw new ArgumentNullException(nameof(input)); - SimulatedDurationSeconds = simulatedDurationSeconds < 0f ? 0f : simulatedDurationSeconds; - } - - public MoveInput Input { get; } - - public float SimulatedDurationSeconds { get; } - } - public sealed class ClientPredictionBuffer { private readonly List pendingInputs = new(); @@ -64,33 +50,38 @@ namespace Network.NetworkApplication pendingInputs.Add(new PredictedMoveStep(input, 0f)); } - public void AccumulateLatest(float simulatedDurationSeconds) + public bool TryGetNextUnsimulatedInput(out PredictedMoveStep predictedMoveStep) { - if (pendingInputs.Count == 0 || simulatedDurationSeconds <= 0f) + for (var i = 0; i < pendingInputs.Count; i++) { - return; + if (pendingInputs[i].SimulatedDurationSeconds <= 0f) + { + predictedMoveStep = pendingInputs[i]; + return true; + } } - var latest = pendingInputs[^1]; - pendingInputs[^1] = - new PredictedMoveStep(latest.Input, latest.SimulatedDurationSeconds + simulatedDurationSeconds); + predictedMoveStep = default; + return false; } - /// - /// Accumulate pending input duration using the actual elapsed wall-clock time - /// since the last authoritative state, not the fixed simulation cadence. - /// This synchronizes accumulation with the server's 20Hz authoritative cadence. - /// - public void AccumulateWithElapsedTime(float elapsedSinceLastState) + public void MarkInputSimulated(long tick, float simulatedDurationSeconds) { - if (pendingInputs.Count == 0 || elapsedSinceLastState <= 0f || !float.IsFinite(elapsedSinceLastState)) + if (simulatedDurationSeconds <= 0f) { return; } - var latest = pendingInputs[^1]; - pendingInputs[^1] = - new PredictedMoveStep(latest.Input, latest.SimulatedDurationSeconds + elapsedSinceLastState); + for (var i = 0; i < pendingInputs.Count; i++) + { + if (pendingInputs[i].Input.Tick != tick) + { + continue; + } + + pendingInputs[i] = new PredictedMoveStep(pendingInputs[i].Input, simulatedDurationSeconds); + return; + } } public bool TryApplyAuthoritativeState(PlayerState state, float currentTime, @@ -110,7 +101,7 @@ namespace Network.NetworkApplication LastAuthoritativeTick = state.Tick; LastAcknowledgedMoveTick = state.AcknowledgedMoveTick; pendingInputs.RemoveAll(input => input.Input.Tick <= state.AcknowledgedMoveTick); - replayInputs = pendingInputs.ToArray(); + replayInputs = pendingInputs.FindAll(input => input.SimulatedDurationSeconds > 0f); // Reset the elapsed-time tracker so the next accumulation period // starts from this authoritative state's arrival time. diff --git a/Assets/Scripts/Network/NetworkApplication/PredictedMoveStep.cs b/Assets/Scripts/Network/NetworkApplication/PredictedMoveStep.cs new file mode 100644 index 0000000..36ce1c2 --- /dev/null +++ b/Assets/Scripts/Network/NetworkApplication/PredictedMoveStep.cs @@ -0,0 +1,18 @@ +using System; +using Network.Defines; + +namespace Network.NetworkApplication +{ + public readonly struct PredictedMoveStep + { + public PredictedMoveStep(MoveInput input, float simulatedDurationSeconds) + { + Input = input ?? throw new ArgumentNullException(nameof(input)); + SimulatedDurationSeconds = simulatedDurationSeconds < 0f ? 0f : simulatedDurationSeconds; + } + + public MoveInput Input { get; } + + public float SimulatedDurationSeconds { get; } + } +} diff --git a/Assets/Scripts/Network/NetworkApplication/PredictedMoveStep.cs.meta b/Assets/Scripts/Network/NetworkApplication/PredictedMoveStep.cs.meta new file mode 100644 index 0000000..995bd1a --- /dev/null +++ b/Assets/Scripts/Network/NetworkApplication/PredictedMoveStep.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 20280892ecfd4b25bff064d07668accc +timeCreated: 1775619625 \ No newline at end of file diff --git a/Assets/Scripts/Player/Input.meta b/Assets/Scripts/Player/Input.meta new file mode 100644 index 0000000..93d8ab5 --- /dev/null +++ b/Assets/Scripts/Player/Input.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 309fec203b04d2b4cb87f9c2873c0449 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Scripts/Player/Input/IInputSource.cs b/Assets/Scripts/Player/Input/IInputSource.cs new file mode 100644 index 0000000..8adf798 --- /dev/null +++ b/Assets/Scripts/Player/Input/IInputSource.cs @@ -0,0 +1,11 @@ +using UnityEngine; + +/// +/// 输入源接口,用于解耦输入捕获 +/// +public interface IInputSource +{ + Vector3 GetPlanarInput(); + bool ConsumeShootInput(); + Vector3 GetAimDirection(); +} diff --git a/Assets/Scripts/Player/Input/IInputSource.cs.meta b/Assets/Scripts/Player/Input/IInputSource.cs.meta new file mode 100644 index 0000000..4b7a18e --- /dev/null +++ b/Assets/Scripts/Player/Input/IInputSource.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: e7d6671c80a243619f1f3dc34ca92d15 +timeCreated: 1775619222 \ No newline at end of file diff --git a/Assets/Scripts/Player/InputComponent.cs b/Assets/Scripts/Player/Input/InputComponent.cs similarity index 55% rename from Assets/Scripts/Player/InputComponent.cs rename to Assets/Scripts/Player/Input/InputComponent.cs index 860d910..e93f5b6 100644 --- a/Assets/Scripts/Player/InputComponent.cs +++ b/Assets/Scripts/Player/Input/InputComponent.cs @@ -1,129 +1,8 @@ using System; -using System.Collections.Generic; using Network.Defines; using UnityEngine; using Vector3 = UnityEngine.Vector3; -/// -/// 输入源接口,用于解耦输入捕获 -/// -public interface IInputSource -{ - Vector3 GetPlanarInput(); - bool ConsumeShootInput(); - Vector3 GetAimDirection(); -} - -/// -/// 真实的 Unity 输入源 -/// -public class UnityInputSource : IInputSource -{ - private readonly Transform _cameraTransform; - - public UnityInputSource(Transform cameraTransform) - { - _cameraTransform = cameraTransform; - } - - public Vector3 GetPlanarInput() - { - return new Vector3(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical")); - } - - public bool ConsumeShootInput() - { - return Input.GetMouseButtonDown(0); - } - - public Vector3 GetAimDirection() - { - if (_cameraTransform != null) - { - return _cameraTransform.forward; - } - return Vector3.forward; - } -} - -/// -/// 模拟输入源(测试用),提供预设的输入序列 -/// -public class SimulatedInputSource : IInputSource -{ - private readonly (float turn, float throttle)[] _inputSequence; - private int _index; - private Vector3 _lastAimDirection = Vector3.forward; - private bool _shootTriggered; - - public SimulatedInputSource((float turn, float throttle)[] sequence) - { - _inputSequence = sequence; - _index = 0; - } - - public Vector3 GetPlanarInput() - { - if (_index >= _inputSequence.Length) - { - return Vector3.zero; - } - var (turn, throttle) = _inputSequence[_index]; - return new Vector3(turn, 0f, throttle); - } - - public bool ConsumeShootInput() - { - if (_shootTriggered) - { - _shootTriggered = false; - return true; - } - return false; - } - - public Vector3 GetAimDirection() - { - return _lastAimDirection; - } - - /// - /// 推进到下一个输入 - /// - public void Advance() - { - if (_index < _inputSequence.Length) - { - _index++; - } - } - - /// - /// 是否还有更多输入 - /// - public bool HasMore => _index < _inputSequence.Length; - - /// - /// 设置射击触发(下次 ConsumeShootInput 返回 true) - /// - public void SetShootTriggered() - { - _shootTriggered = true; - } - - /// - /// 设置瞄准方向 - /// - public void SetAimDirection(Vector3 direction) - { - _lastAimDirection = direction; - } - - /// - /// 获取当前输入索引 - /// - public int CurrentIndex => _index; -} /// /// 输入组件,负责从 IInputSource 获取输入、发送 MoveInput 到服务器、管理 tick @@ -142,8 +21,8 @@ public class InputComponent : MonoBehaviour private bool _wasMovingLastFrame; private long _tick; - public event System.Action OnMoveInputCreated; - public event System.Action OnShootInputCreated; + public event Action OnMoveInputCreated; + public event Action OnShootInputCreated; public long CurrentTick => _tick; @@ -191,7 +70,7 @@ public class InputComponent : MonoBehaviour _currentInput = _inputSource?.GetPlanarInput() ?? Vector3.zero; // 检测移动状态变化 - var hasMovement = ClientGameplayInputFlow.HasPlanarInput(_currentInput); + bool hasMovement = ClientGameplayInputFlow.HasPlanarInput(_currentInput); if (hasMovement) { _stopMessagePending = false; diff --git a/Assets/Scripts/Player/InputComponent.cs.meta b/Assets/Scripts/Player/Input/InputComponent.cs.meta similarity index 100% rename from Assets/Scripts/Player/InputComponent.cs.meta rename to Assets/Scripts/Player/Input/InputComponent.cs.meta diff --git a/Assets/Scripts/Player/Input/SimulatedInputSource.cs b/Assets/Scripts/Player/Input/SimulatedInputSource.cs new file mode 100644 index 0000000..df30f29 --- /dev/null +++ b/Assets/Scripts/Player/Input/SimulatedInputSource.cs @@ -0,0 +1,82 @@ +using UnityEngine; + +/// +/// 模拟输入源(测试用),提供预设的输入序列 +/// +public class SimulatedInputSource : IInputSource +{ + private readonly (float turn, float throttle)[] _inputSequence; + private int _index; + private Vector3 _lastAimDirection = Vector3.forward; + private bool _shootTriggered; + + public SimulatedInputSource((float turn, float throttle)[] sequence) + { + _inputSequence = sequence; + _index = 0; + } + + public Vector3 GetPlanarInput() + { + if (_index >= _inputSequence.Length) + { + return Vector3.zero; + } + + var (turn, throttle) = _inputSequence[_index]; + return new Vector3(turn, 0f, throttle); + } + + public bool ConsumeShootInput() + { + if (_shootTriggered) + { + _shootTriggered = false; + return true; + } + + return false; + } + + public Vector3 GetAimDirection() + { + return _lastAimDirection; + } + + /// + /// 推进到下一个输入 + /// + public void Advance() + { + if (_index < _inputSequence.Length) + { + _index++; + } + } + + /// + /// 是否还有更多输入 + /// + public bool HasMore => _index < _inputSequence.Length; + + /// + /// 设置射击触发(下次 ConsumeShootInput 返回 true) + /// + public void SetShootTriggered() + { + _shootTriggered = true; + } + + /// + /// 设置瞄准方向 + /// + public void SetAimDirection(Vector3 direction) + { + _lastAimDirection = direction; + } + + /// + /// 获取当前输入索引 + /// + public int CurrentIndex => _index; +} diff --git a/Assets/Scripts/Player/Input/SimulatedInputSource.cs.meta b/Assets/Scripts/Player/Input/SimulatedInputSource.cs.meta new file mode 100644 index 0000000..809b453 --- /dev/null +++ b/Assets/Scripts/Player/Input/SimulatedInputSource.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 53e3773e493842e8861ac7522a6227a9 +timeCreated: 1775619270 \ No newline at end of file diff --git a/Assets/Scripts/Player/Input/UnityInputSource.cs b/Assets/Scripts/Player/Input/UnityInputSource.cs new file mode 100644 index 0000000..568aa2e --- /dev/null +++ b/Assets/Scripts/Player/Input/UnityInputSource.cs @@ -0,0 +1,34 @@ +using UnityEngine; + +/// +/// 真实的 Unity 输入源 +/// +public class UnityInputSource : IInputSource +{ + private readonly Transform _cameraTransform; + + public UnityInputSource(Transform cameraTransform) + { + _cameraTransform = cameraTransform; + } + + public Vector3 GetPlanarInput() + { + return new Vector3(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical")); + } + + public bool ConsumeShootInput() + { + return Input.GetMouseButtonDown(0); + } + + public Vector3 GetAimDirection() + { + if (_cameraTransform != null) + { + return _cameraTransform.forward; + } + + return Vector3.forward; + } +} diff --git a/Assets/Scripts/Player/Input/UnityInputSource.cs.meta b/Assets/Scripts/Player/Input/UnityInputSource.cs.meta new file mode 100644 index 0000000..9460b99 --- /dev/null +++ b/Assets/Scripts/Player/Input/UnityInputSource.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 5d7f0a25d2b54decbf2c2386e5c0ebd2 +timeCreated: 1775619246 \ No newline at end of file diff --git a/Assets/Scripts/Player/MovementComponent.cs b/Assets/Scripts/Player/MovementComponent.cs index e123ab2..c9b69bf 100644 --- a/Assets/Scripts/Player/MovementComponent.cs +++ b/Assets/Scripts/Player/MovementComponent.cs @@ -3,23 +3,37 @@ using UnityEngine; public class MovementComponent : MonoBehaviour { [SerializeField] private Rigidbody _rigid; - private const float InterpolationAlpha = 0.15f; + [SerializeField] private float _followMoveSpeed = 2f; + [SerializeField] private float _followTurnSpeedDegreesPerSecond = 180f; + [SerializeField] private float _correctionDecayMoveSpeed = 4f; + [SerializeField] private float _correctionDecayTurnSpeedDegreesPerSecond = 360f; + private const float RemoteInterpolationAlpha = 0.15f; + private const float UnexpectedTurnLogCooldownSeconds = 0.25f; private bool _isControlled; private Vector3 _currentPosition; private Quaternion _currentRotation; private Vector3 _targetPosition; private Quaternion _targetRotation; + private Vector3 _correctionPositionOffset; + private Quaternion _correctionRotationOffset = Quaternion.identity; + private float _expectedTurnInput; + private float _lastUnexpectedTurnLogTime = float.NegativeInfinity; private void Awake() { _rigid ??= GetComponent(); } - public void Init(bool isControlled) + public void Init(bool isControlled, float followMoveSpeed = 2f, float followTurnSpeedDegreesPerSecond = 180f) { _rigid ??= GetComponent(); _isControlled = isControlled; + _followMoveSpeed = Mathf.Max(0f, followMoveSpeed); + _followTurnSpeedDegreesPerSecond = Mathf.Max(0f, followTurnSpeedDegreesPerSecond); + _correctionDecayMoveSpeed = Mathf.Max(_followMoveSpeed * 2f, _followMoveSpeed); + _correctionDecayTurnSpeedDegreesPerSecond = + Mathf.Max(_followTurnSpeedDegreesPerSecond * 2f, _followTurnSpeedDegreesPerSecond); _rigid.interpolation = isControlled ? RigidbodyInterpolation.None : RigidbodyInterpolation.Interpolate; _rigid.isKinematic = !isControlled; _rigid.velocity = Vector3.zero; @@ -29,16 +43,45 @@ public class MovementComponent : MonoBehaviour _currentRotation = _rigid.rotation; _targetPosition = _rigid.position; _targetRotation = _rigid.rotation; + _correctionPositionOffset = Vector3.zero; + _correctionRotationOffset = Quaternion.identity; } private void Update() { - _currentPosition = Vector3.Lerp(_currentPosition, _targetPosition, InterpolationAlpha); - _currentRotation = Quaternion.Slerp(_currentRotation, _targetRotation, InterpolationAlpha); + var beforeRotation = _currentRotation; + + if (_isControlled) + { + _correctionPositionOffset = Vector3.MoveTowards( + _correctionPositionOffset, + Vector3.zero, + _correctionDecayMoveSpeed * Time.deltaTime); + _correctionRotationOffset = Quaternion.RotateTowards( + _correctionRotationOffset, + Quaternion.identity, + _correctionDecayTurnSpeedDegreesPerSecond * Time.deltaTime); + + var desiredPosition = _targetPosition + _correctionPositionOffset; + var desiredRotation = _targetRotation * _correctionRotationOffset; + + _currentPosition = Vector3.MoveTowards(_currentPosition, desiredPosition, _followMoveSpeed * Time.deltaTime); + _currentRotation = Quaternion.RotateTowards( + _currentRotation, + desiredRotation, + _followTurnSpeedDegreesPerSecond * Time.deltaTime); + } + else + { + _currentPosition = Vector3.Lerp(_currentPosition, _targetPosition, RemoteInterpolationAlpha); + _currentRotation = Quaternion.Slerp(_currentRotation, _targetRotation, RemoteInterpolationAlpha); + } _rigid.position = _currentPosition; _rigid.rotation = _currentRotation; + LogUnexpectedTurnIfNeeded(beforeRotation, _currentRotation); + if (_isControlled && MainUI.Instance != null) { MainUI.Instance.OnClientPosChanged(_currentPosition); @@ -59,13 +102,68 @@ public class MovementComponent : MonoBehaviour _targetRotation = rotation; } + public void SetExpectedTurnInput(float expectedTurnInput) + { + _expectedTurnInput = expectedTurnInput; + } + + public void BlendToPoseFromCurrent(Vector3 position, Quaternion rotation) + { + _targetPosition = position; + _targetRotation = rotation; + _correctionPositionOffset = _currentPosition - position; + _correctionRotationOffset = Quaternion.Inverse(rotation) * _currentRotation; + } + public void SnapToPose(Vector3 position, Quaternion rotation) { _currentPosition = position; _currentRotation = rotation; _targetPosition = position; _targetRotation = rotation; + _correctionPositionOffset = Vector3.zero; + _correctionRotationOffset = Quaternion.identity; _rigid.position = position; _rigid.rotation = rotation; } + + private void LogUnexpectedTurnIfNeeded(Quaternion beforeRotation, Quaternion afterRotation) + { + if (!_isControlled || Mathf.Abs(_expectedTurnInput) < 0.01f) + { + return; + } + + var beforeError = Quaternion.Angle(beforeRotation, _targetRotation); + var afterError = Quaternion.Angle(afterRotation, _targetRotation); + if (beforeError < 0.1f && afterError < 0.1f) + { + return; + } + + var deltaYaw = Mathf.DeltaAngle(beforeRotation.eulerAngles.y, afterRotation.eulerAngles.y); + if (Mathf.Abs(deltaYaw) < 0.01f) + { + return; + } + + if (afterError <= beforeError + 0.05f) + { + return; + } + + if (Time.time - _lastUnexpectedTurnLogTime < UnexpectedTurnLogCooldownSeconds) + { + return; + } + + _lastUnexpectedTurnLogTime = Time.time; + Debug.LogWarning( + $"[UnexpectedTurnAwayFromTarget] expectedTurn={_expectedTurnInput:F2} deltaYaw={deltaYaw:F2} " + + $"beforeYaw={beforeRotation.eulerAngles.y:F2} afterYaw={afterRotation.eulerAngles.y:F2} " + + $"beforeError={beforeError:F2} afterError={afterError:F2} " + + $"current=({_currentPosition.x:F3},{_currentPosition.y:F3},{_currentPosition.z:F3}) " + + $"target=({_targetPosition.x:F3},{_targetPosition.y:F3},{_targetPosition.z:F3}) " + + $"correctionRot={_correctionRotationOffset.eulerAngles.y:F2}"); + } } diff --git a/Assets/Scripts/Player/MovementResolverComponent.cs b/Assets/Scripts/Player/MovementResolverComponent.cs index feb4d74..85a8aca 100644 --- a/Assets/Scripts/Player/MovementResolverComponent.cs +++ b/Assets/Scripts/Player/MovementResolverComponent.cs @@ -7,7 +7,8 @@ using Vector3 = UnityEngine.Vector3; public class MovementResolverComponent : MonoBehaviour { private const float ServerSimulationStepSeconds = 0.05f; - private const float SnapThreshold = 0.5f; + [SerializeField] private float SnapThreshold = 0.5f; + private const float TurnSpeedDegreesPerSecond = 180f; [SerializeField] private int _speed = 2; [SerializeField] private MovementComponent _movement; @@ -54,7 +55,7 @@ public class MovementResolverComponent : MonoBehaviour if (_movement != null) { - _movement.Init(isControlled); + _movement.Init(isControlled, _speed, TurnSpeedDegreesPerSecond); _authoritativePosition = _movement.CurrentPosition; _authoritativeRotation = _movement.CurrentRotation; _predictedPosition = _movement.CurrentPosition; @@ -118,14 +119,14 @@ public class MovementResolverComponent : MonoBehaviour _simulationAccumulator += Time.fixedDeltaTime; while (_simulationAccumulator >= ServerSimulationStepSeconds) { - var pendingCount = _predictionBuffer.PendingInputs.Count; - if (pendingCount == 0) + if (!_predictionBuffer.TryGetNextUnsimulatedInput(out var nextInput)) { _simulationAccumulator = 0f; break; } - Simulate(GetLatestPredictedInput()); + Simulate(nextInput.Input); + _predictionBuffer.MarkInputSimulated(nextInput.Input.Tick, ServerSimulationStepSeconds); _simulationAccumulator -= ServerSimulationStepSeconds; } @@ -139,12 +140,17 @@ public class MovementResolverComponent : MonoBehaviour } } - private void Simulate(Vector3 input) + private void Simulate(MoveInput moveInput) { - TankMovementKinematics.ApplyStep(_speed, input.x, input.z, ServerSimulationStepSeconds, + var simulationTurnInput = ToSimulationTurnInput(moveInput.TurnInput); + TankMovementKinematics.ApplyStep( + _speed, + simulationTurnInput, + moveInput.ThrottleInput, + ServerSimulationStepSeconds, ref _predictedPosition, ref _predictedRotation); + _movement.SetExpectedTurnInput(simulationTurnInput); _movement.SetTargetPose(_predictedPosition, _predictedRotation); - _predictionBuffer.AccumulateLatest(ServerSimulationStepSeconds); if (MainUI.Instance != null) { @@ -186,13 +192,20 @@ public class MovementResolverComponent : MonoBehaviour ReplayPendingInputs(replayInputs); var error = Vector3.Distance(_movement.CurrentPosition, _predictedPosition); - if (error > SnapThreshold) + var shouldSnap = error > SnapThreshold; + Debug.Log( + $"[Reconcile] tick={snapshot.SourceState.Tick} ack={snapshot.AcknowledgedMoveTick} " + + $"error={error:F3} threshold={SnapThreshold:F3} snap={shouldSnap} " + + $"current=({_movement.CurrentPosition.x:F3},{_movement.CurrentPosition.y:F3},{_movement.CurrentPosition.z:F3}) " + + $"predicted=({_predictedPosition.x:F3},{_predictedPosition.y:F3},{_predictedPosition.z:F3}) " + + $"authoritative=({_authoritativePosition.x:F3},{_authoritativePosition.y:F3},{_authoritativePosition.z:F3})"); + if (shouldSnap) { _movement.SnapToPose(_predictedPosition, _predictedRotation); } else { - _movement.SetTargetPose(_predictedPosition, _predictedRotation); + _movement.BlendToPoseFromCurrent(_predictedPosition, _predictedRotation); } _simulationAccumulator = 0f; @@ -218,35 +231,41 @@ public class MovementResolverComponent : MonoBehaviour } } - 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 replayInputs) { + var lastSimulationTurnInput = 0f; foreach (var replayInput in replayInputs) { var remaining = replayInput.SimulatedDurationSeconds; while (remaining > 0f) { var step = Mathf.Min(remaining, ServerSimulationStepSeconds); + var beforeYaw = _predictedRotation.eulerAngles.y; + var simulationTurnInput = ToSimulationTurnInput(replayInput.Input.TurnInput); + lastSimulationTurnInput = simulationTurnInput; TankMovementKinematics.ApplyStep( _speed, - replayInput.Input.TurnInput, + simulationTurnInput, replayInput.Input.ThrottleInput, step, ref _predictedPosition, ref _predictedRotation); + var afterYaw = _predictedRotation.eulerAngles.y; + Debug.Log( + $"[ReplayStep] authTick={_lastAuthoritativeState?.SourceState?.Tick ?? 0} " + + $"inputTick={replayInput.Input.Tick} netTurn={replayInput.Input.TurnInput:F2} simTurn={simulationTurnInput:F2} " + + $"throttle={replayInput.Input.ThrottleInput:F2} step={step:F3} " + + $"yaw={beforeYaw:F2}->{afterYaw:F2} " + + $"predicted=({_predictedPosition.x:F3},{_predictedPosition.y:F3},{_predictedPosition.z:F3})"); remaining -= step; } } + + _movement.SetExpectedTurnInput(lastSimulationTurnInput); + } + + private static float ToSimulationTurnInput(float networkTurnInput) + { + return -networkTurnInput; } } diff --git a/Assets/Tests/EditMode/Network/ClientGameplayFlowTests.cs b/Assets/Tests/EditMode/Network/ClientGameplayFlowTests.cs index de64478..df2ce05 100644 --- a/Assets/Tests/EditMode/Network/ClientGameplayFlowTests.cs +++ b/Assets/Tests/EditMode/Network/ClientGameplayFlowTests.cs @@ -223,14 +223,23 @@ namespace Tests.EditMode.Network TurnInput = 0f, ThrottleInput = 1f }); - predictionBuffer.AccumulateLatest(0.1f); + predictionBuffer.Record(new MoveInput + { + PlayerId = "player-1", + Tick = 2, + TurnInput = 0f, + ThrottleInput = 1f + }); + predictionBuffer.MarkInputSimulated(1, 0.05f); + predictionBuffer.MarkInputSimulated(2, 0.05f); 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.Count, Is.EqualTo(2)); Assert.That(predictionBuffer.PendingInputs[0].Input.Tick, Is.EqualTo(1)); + Assert.That(predictionBuffer.PendingInputs[1].Input.Tick, Is.EqualTo(2)); } finally { diff --git a/Assets/Tests/EditMode/Network/SyncStrategyTests.cs b/Assets/Tests/EditMode/Network/SyncStrategyTests.cs index 1ed1869..1bc4c0f 100644 --- a/Assets/Tests/EditMode/Network/SyncStrategyTests.cs +++ b/Assets/Tests/EditMode/Network/SyncStrategyTests.cs @@ -89,6 +89,7 @@ namespace Tests.EditMode.Network 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 }); + buffer.MarkInputSimulated(12, 0.05f); var accepted = buffer.TryApplyAuthoritativeState( new PlayerState { PlayerId = "player-1", Tick = 11, AcknowledgedMoveTick = 11 }, @@ -100,10 +101,25 @@ namespace Tests.EditMode.Network Assert.That(buffer.LastAcknowledgedMoveTick, Is.EqualTo(11)); Assert.That(replayInputs.Count, Is.EqualTo(1)); Assert.That(replayInputs[0].Input.Tick, Is.EqualTo(12)); - Assert.That(replayInputs[0].SimulatedDurationSeconds, Is.EqualTo(0f)); + Assert.That(replayInputs[0].SimulatedDurationSeconds, Is.EqualTo(0.05f).Within(0.0001f)); Assert.That(buffer.PendingInputs.Count, Is.EqualTo(1)); } + [Test] + public void ClientPredictionBuffer_TryGetNextUnsimulatedInput_UsesOldestPendingMoveInput() + { + 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.MarkInputSimulated(10, 0.05f); + + var found = buffer.TryGetNextUnsimulatedInput(out var nextInput); + + Assert.That(found, Is.True); + Assert.That(nextInput.Input.Tick, Is.EqualTo(11)); + Assert.That(nextInput.SimulatedDurationSeconds, Is.EqualTo(0f)); + } + [Test] public void ClientPredictionBuffer_StaleAuthoritativeState_IsIgnored() { @@ -802,10 +818,16 @@ namespace Tests.EditMode.Network rotation = Quaternion.identity; for (var i = 0; i < steps; i++) { - TankMovementKinematics.ApplyStep(10, turnInput, throttleInput, stepDuration, ref position, ref rotation); + TankMovementKinematics.ApplyStep(10, ToSimulationTurnInput(turnInput), throttleInput, stepDuration, + ref position, ref rotation); } } + private static float ToSimulationTurnInput(float networkTurnInput) + { + return -networkTurnInput; + } + private static void ResetMovementState(Rigidbody rigidbody, Vector3 position, Quaternion rotation) { rigidbody.position = position;