单帧测试进行中
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
using Network.Defines;
|
||||
using Network.NetworkApplication;
|
||||
using UnityEngine;
|
||||
using Vector3 = UnityEngine.Vector3;
|
||||
|
||||
public static class ClientGameplayInputFlow
|
||||
{
|
||||
public static bool HasPlanarInput(Vector3 input)
|
||||
{
|
||||
return new Vector2(input.x, input.z).sqrMagnitude > 0f;
|
||||
}
|
||||
|
||||
public static bool TryCreateMoveInput(string playerId, long tick, Vector3 input, bool stopMessagePending,
|
||||
out MoveInput message)
|
||||
{
|
||||
if (!HasPlanarInput(input) && !stopMessagePending)
|
||||
{
|
||||
message = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
message = new MoveInput
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Tick = tick,
|
||||
TurnInput = -input.x,
|
||||
ThrottleInput = input.z
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryCreateShootInput(
|
||||
string playerId,
|
||||
long tick,
|
||||
bool fireTriggered,
|
||||
Vector3 aimDirection,
|
||||
out ShootInput message,
|
||||
string targetId = "")
|
||||
{
|
||||
if (!fireTriggered)
|
||||
{
|
||||
message = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
message = CreateShootInput(playerId, tick, aimDirection, targetId);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static ShootInput CreateShootInput(string playerId, long tick, Vector3 aimDirection, string targetId = "")
|
||||
{
|
||||
var planarDirection = new Vector3(aimDirection.x, 0f, aimDirection.z);
|
||||
if (planarDirection.sqrMagnitude <= 0f)
|
||||
{
|
||||
planarDirection = Vector3.forward;
|
||||
}
|
||||
else
|
||||
{
|
||||
planarDirection.Normalize();
|
||||
}
|
||||
|
||||
return new ShootInput
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Tick = tick,
|
||||
DirX = planarDirection.x,
|
||||
DirY = planarDirection.z,
|
||||
TargetId = targetId ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
public static void SendShootInput(
|
||||
MessageManager messageManager,
|
||||
string playerId,
|
||||
long tick,
|
||||
Vector3 aimDirection,
|
||||
string targetId = "")
|
||||
{
|
||||
if (messageManager == null)
|
||||
{
|
||||
throw new System.ArgumentNullException(nameof(messageManager));
|
||||
}
|
||||
|
||||
SendShootInput(messageManager, CreateShootInput(playerId, tick, aimDirection, targetId));
|
||||
}
|
||||
|
||||
public static void SendShootInput(MessageManager messageManager, ShootInput message)
|
||||
{
|
||||
if (messageManager == null)
|
||||
{
|
||||
throw new System.ArgumentNullException(nameof(messageManager));
|
||||
}
|
||||
|
||||
if (message == null)
|
||||
{
|
||||
throw new System.ArgumentNullException(nameof(message));
|
||||
}
|
||||
|
||||
messageManager.SendMessage(message, MessageType.ShootInput);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e3a66b8917bed9648a9d99ee35525e6e
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,258 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Network.Defines;
|
||||
using UnityEngine;
|
||||
using Vector3 = UnityEngine.Vector3;
|
||||
|
||||
/// <summary>
|
||||
/// 输入源接口,用于解耦输入捕获
|
||||
/// </summary>
|
||||
public interface IInputSource
|
||||
{
|
||||
Vector3 GetPlanarInput();
|
||||
bool ConsumeShootInput();
|
||||
Vector3 GetAimDirection();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 真实的 Unity 输入源
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟输入源(测试用),提供预设的输入序列
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 推进到下一个输入
|
||||
/// </summary>
|
||||
public void Advance()
|
||||
{
|
||||
if (_index < _inputSequence.Length)
|
||||
{
|
||||
_index++;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 是否还有更多输入
|
||||
/// </summary>
|
||||
public bool HasMore => _index < _inputSequence.Length;
|
||||
|
||||
/// <summary>
|
||||
/// 设置射击触发(下次 ConsumeShootInput 返回 true)
|
||||
/// </summary>
|
||||
public void SetShootTriggered()
|
||||
{
|
||||
_shootTriggered = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置瞄准方向
|
||||
/// </summary>
|
||||
public void SetAimDirection(Vector3 direction)
|
||||
{
|
||||
_lastAimDirection = direction;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前输入索引
|
||||
/// </summary>
|
||||
public int CurrentIndex => _index;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 输入组件,负责从 IInputSource 获取输入、发送 MoveInput 到服务器、管理 tick
|
||||
/// Update() 是唯一调用 SendMoveInput / SendShootInput 的地方
|
||||
/// </summary>
|
||||
public class InputComponent : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private string _playerId = "player";
|
||||
[SerializeField] private float _sendInterval = 0.05f; // 50ms 发送间隔
|
||||
|
||||
private IInputSource _inputSource;
|
||||
private Vector3 _currentInput;
|
||||
private Vector3 _lastAimDirection = Vector3.forward;
|
||||
private float _lastSendTime;
|
||||
private bool _stopMessagePending;
|
||||
private bool _wasMovingLastFrame;
|
||||
private long _tick;
|
||||
|
||||
public event System.Action<MoveInput> OnMoveInputCreated;
|
||||
public event System.Action<ShootInput> OnShootInputCreated;
|
||||
|
||||
public long CurrentTick => _tick;
|
||||
|
||||
/// <summary>
|
||||
/// 设置玩家 ID(由 MovementComponent.Init 调用)
|
||||
/// </summary>
|
||||
public void InjectPlayerId(string playerId)
|
||||
{
|
||||
_playerId = playerId;
|
||||
}
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
var camera = Camera.main;
|
||||
_inputSource = new UnityInputSource(camera?.transform);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 设置自定义输入源(用于替换默认的 UnityInputSource)
|
||||
/// </summary>
|
||||
public void SetInputSource(IInputSource source)
|
||||
{
|
||||
_inputSource = source ?? throw new ArgumentNullException(nameof(source));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 获取当前输入源(用于测试)
|
||||
/// </summary>
|
||||
public IInputSource GetInputSource()
|
||||
{
|
||||
return _inputSource;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 重置 tick(用于测试)
|
||||
/// </summary>
|
||||
public void ResetTick(long tick = 0)
|
||||
{
|
||||
_tick = tick;
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
// 从输入源获取输入
|
||||
_currentInput = _inputSource?.GetPlanarInput() ?? Vector3.zero;
|
||||
|
||||
// 检测移动状态变化
|
||||
var hasMovement = ClientGameplayInputFlow.HasPlanarInput(_currentInput);
|
||||
if (hasMovement)
|
||||
{
|
||||
_stopMessagePending = false;
|
||||
}
|
||||
else if (_wasMovingLastFrame)
|
||||
{
|
||||
_stopMessagePending = true;
|
||||
}
|
||||
|
||||
_wasMovingLastFrame = hasMovement;
|
||||
|
||||
// 处理射击输入
|
||||
var shootInput = GetShootInput();
|
||||
if (shootInput != null && NetworkManager.Instance != null)
|
||||
{
|
||||
NetworkManager.Instance.SendShootInput(shootInput);
|
||||
OnShootInputCreated?.Invoke(shootInput);
|
||||
}
|
||||
|
||||
// 定期发送移动输入
|
||||
if (Time.time - _lastSendTime > _sendInterval)
|
||||
{
|
||||
SendMoveInput();
|
||||
}
|
||||
}
|
||||
|
||||
private void SendMoveInput()
|
||||
{
|
||||
if (!ClientGameplayInputFlow.TryCreateMoveInput(_playerId, _tick, _currentInput,
|
||||
_stopMessagePending, out var moveInput))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (NetworkManager.Instance != null)
|
||||
{
|
||||
NetworkManager.Instance.SendMoveInput(moveInput);
|
||||
}
|
||||
|
||||
OnMoveInputCreated?.Invoke(moveInput);
|
||||
_stopMessagePending = false;
|
||||
_lastSendTime = Time.time;
|
||||
_tick++;
|
||||
}
|
||||
|
||||
private ShootInput GetShootInput()
|
||||
{
|
||||
var shootTriggered = _inputSource?.ConsumeShootInput() ?? false;
|
||||
var aimDirection = _inputSource?.GetAimDirection() ?? Vector3.forward;
|
||||
|
||||
if (!shootTriggered)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var planarForward = Vector3.ProjectOnPlane(aimDirection, Vector3.up);
|
||||
if (ClientGameplayInputFlow.HasPlanarInput(planarForward))
|
||||
{
|
||||
_lastAimDirection = planarForward;
|
||||
}
|
||||
|
||||
return ClientGameplayInputFlow.CreateShootInput(_playerId, _tick, _lastAimDirection);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 76493c52bd37a4a4db167d10a4dd6369
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
+143
-162
@@ -4,116 +4,23 @@ using Network.NetworkApplication;
|
||||
using UnityEngine;
|
||||
using Vector3 = UnityEngine.Vector3;
|
||||
|
||||
public static class ClientGameplayInputFlow
|
||||
{
|
||||
public static bool HasPlanarInput(Vector3 input)
|
||||
{
|
||||
return new Vector2(input.x, input.z).sqrMagnitude > 0f;
|
||||
}
|
||||
|
||||
public static bool TryCreateMoveInput(string playerId, long tick, Vector3 input, bool stopMessagePending, out MoveInput message)
|
||||
{
|
||||
if (!HasPlanarInput(input) && !stopMessagePending)
|
||||
{
|
||||
message = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
message = new MoveInput
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Tick = tick,
|
||||
TurnInput = -input.x,
|
||||
ThrottleInput = input.z
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
public static bool TryCreateShootInput(
|
||||
string playerId,
|
||||
long tick,
|
||||
bool fireTriggered,
|
||||
Vector3 aimDirection,
|
||||
out ShootInput message,
|
||||
string targetId = "")
|
||||
{
|
||||
if (!fireTriggered)
|
||||
{
|
||||
message = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
message = CreateShootInput(playerId, tick, aimDirection, targetId);
|
||||
return true;
|
||||
}
|
||||
|
||||
public static ShootInput CreateShootInput(string playerId, long tick, Vector3 aimDirection, string targetId = "")
|
||||
{
|
||||
var planarDirection = new Vector3(aimDirection.x, 0f, aimDirection.z);
|
||||
if (planarDirection.sqrMagnitude <= 0f)
|
||||
{
|
||||
planarDirection = Vector3.forward;
|
||||
}
|
||||
else
|
||||
{
|
||||
planarDirection.Normalize();
|
||||
}
|
||||
|
||||
return new ShootInput
|
||||
{
|
||||
PlayerId = playerId,
|
||||
Tick = tick,
|
||||
DirX = planarDirection.x,
|
||||
DirY = planarDirection.z,
|
||||
TargetId = targetId ?? string.Empty
|
||||
};
|
||||
}
|
||||
|
||||
public static void SendShootInput(
|
||||
MessageManager messageManager,
|
||||
string playerId,
|
||||
long tick,
|
||||
Vector3 aimDirection,
|
||||
string targetId = "")
|
||||
{
|
||||
if (messageManager == null)
|
||||
{
|
||||
throw new System.ArgumentNullException(nameof(messageManager));
|
||||
}
|
||||
|
||||
SendShootInput(messageManager, CreateShootInput(playerId, tick, aimDirection, targetId));
|
||||
}
|
||||
|
||||
public static void SendShootInput(MessageManager messageManager, ShootInput message)
|
||||
{
|
||||
if (messageManager == null)
|
||||
{
|
||||
throw new System.ArgumentNullException(nameof(messageManager));
|
||||
}
|
||||
|
||||
if (message == null)
|
||||
{
|
||||
throw new System.ArgumentNullException(nameof(message));
|
||||
}
|
||||
|
||||
messageManager.SendMessage(message, MessageType.ShootInput);
|
||||
}
|
||||
}
|
||||
|
||||
public class MovementComponent : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private float _sendInterval = 0.05f;
|
||||
[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 int _speed = 2;
|
||||
[SerializeField] private Rigidbody _rigid;
|
||||
private float _lastSendTime = 0;
|
||||
private bool _isControlled = false;
|
||||
|
||||
private Vector3 _serverPosition;
|
||||
@@ -124,14 +31,12 @@ public class MovementComponent : MonoBehaviour
|
||||
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 _cachedMoveInput;
|
||||
private Vector3 _lastAimDirection = Vector3.forward;
|
||||
private bool _wasMovingLastFrame;
|
||||
private bool _stopMessagePending;
|
||||
|
||||
public void Init(bool isControlled, Player master, ClientMovementBootstrap bootstrap)
|
||||
{
|
||||
@@ -148,49 +53,60 @@ public class MovementComponent : MonoBehaviour
|
||||
_rigid.isKinematic = !isControlled;
|
||||
_rigid.velocity = Vector3.zero;
|
||||
_rigid.angularVelocity = Vector3.zero;
|
||||
if (serverTick != 0 && _isControlled && MainUI.Instance != null) MainUI.Instance.OnStartTickOffsetChanged(serverTick);
|
||||
|
||||
// 设置 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)
|
||||
{
|
||||
_cachedMoveInput = CaptureMovement();
|
||||
var hasMovement = ClientGameplayInputFlow.HasPlanarInput(_cachedMoveInput);
|
||||
if (hasMovement)
|
||||
if (_inputComponent != null)
|
||||
{
|
||||
_stopMessagePending = false;
|
||||
}
|
||||
else if (_wasMovingLastFrame)
|
||||
{
|
||||
_stopMessagePending = true;
|
||||
}
|
||||
|
||||
_wasMovingLastFrame = hasMovement;
|
||||
|
||||
var shootInput = CaptureShootInput();
|
||||
if (shootInput != null)
|
||||
{
|
||||
NetworkManager.Instance.SendShootInput(shootInput);
|
||||
}
|
||||
|
||||
if (Time.time - _lastSendTime > _sendInterval)
|
||||
{
|
||||
if (ClientGameplayInputFlow.TryCreateMoveInput(_master.PlayerId, Tick, _cachedMoveInput, _stopMessagePending, out var moveInput))
|
||||
{
|
||||
NetworkManager.Instance.SendMoveInput(moveInput);
|
||||
_predictionBuffer.Record(moveInput);
|
||||
_stopMessagePending = false;
|
||||
}
|
||||
|
||||
_lastSendTime = Time.time;
|
||||
Tick++;
|
||||
|
||||
MainUI.Instance.OnClientTickChanged(Tick);
|
||||
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)
|
||||
@@ -206,10 +122,17 @@ public class MovementComponent : MonoBehaviour
|
||||
_hasServerState = false;
|
||||
}
|
||||
|
||||
Simulate(_cachedMoveInput);
|
||||
// Use actual elapsed wall-clock time since last authoritative state,
|
||||
// decoupled from FixedUpdate cadence, to match server's 20Hz cadence.
|
||||
_predictionBuffer.AccumulateWithElapsedTime(Time.time - _predictionBuffer.LastAuthoritativeStateTime);
|
||||
// 累积时间,按服务端 50ms 步长进行模拟
|
||||
_simulationAccumulator += Time.fixedDeltaTime;
|
||||
while (_simulationAccumulator >= kServerSimulationStepSeconds)
|
||||
{
|
||||
// 使用最近发送的 MoveInput(来自 predictionBuffer)而非实时输入,
|
||||
// 确保客户端与服务端的输入时序一致
|
||||
Simulate(GetLatestPredictedInput());
|
||||
_simulationAccumulator -= kServerSimulationStepSeconds;
|
||||
}
|
||||
|
||||
// 注意:模拟时间现在在 Simulate() 内部通过 AccumulateLatest 累加
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -238,7 +161,8 @@ public class MovementComponent : MonoBehaviour
|
||||
predictedRotation,
|
||||
snapshot.Position,
|
||||
snapshot.RotationQuaternion,
|
||||
new ControlledPlayerCorrectionSettings(kServerSimulationStepSeconds, _speed, TurnSpeedDegreesPerSecond, snapDistanceMultiplier: 5f),
|
||||
new ControlledPlayerCorrectionSettings(kServerSimulationStepSeconds, _speed, TurnSpeedDegreesPerSecond,
|
||||
snapDistanceMultiplier: 5f),
|
||||
_activeVisualCorrection);
|
||||
|
||||
_activeVisualCorrection = correction.NextState;
|
||||
@@ -246,7 +170,17 @@ public class MovementComponent : MonoBehaviour
|
||||
_rigid.rotation = correction.Rotation;
|
||||
_rigid.velocity = correction.UsedHardSnap ? snapshot.Velocity : Vector3.zero;
|
||||
_rigid.angularVelocity = Vector3.zero;
|
||||
ReplayPendingInputs(replayInputs);
|
||||
|
||||
// 位置已被校正到服务器位置,不需要再 ReplayPendingInputs
|
||||
// 因为 pendingInputs 中的 SimulatedDurationSeconds 是累积的模拟时间,
|
||||
// 如果用来 replay 会导致多余的移动。清空 pendingInputs 让客户端从校正位置重新开始
|
||||
if (replayInputs.Count > 0)
|
||||
{
|
||||
_predictionBuffer.ClearPendingInputs();
|
||||
}
|
||||
|
||||
// 清零 accumulator 防止 FixedUpdate 中再次 Simulate 导致重复移动
|
||||
_simulationAccumulator = 0f;
|
||||
|
||||
if (MainUI.Instance != null)
|
||||
{
|
||||
@@ -259,23 +193,6 @@ public class MovementComponent : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
private Vector3 CaptureMovement()
|
||||
{
|
||||
return new Vector3(Input.GetAxisRaw("Horizontal"), 0f, Input.GetAxisRaw("Vertical"));
|
||||
}
|
||||
|
||||
private ShootInput CaptureShootInput()
|
||||
{
|
||||
return ClientGameplayInputFlow.TryCreateShootInput(
|
||||
_master.PlayerId,
|
||||
Tick,
|
||||
Input.GetMouseButtonDown(0),
|
||||
ResolveAimDirection(),
|
||||
out var shootInput)
|
||||
? shootInput
|
||||
: null;
|
||||
}
|
||||
|
||||
private Vector3 ResolveAimDirection()
|
||||
{
|
||||
var planarForward = Vector3.ProjectOnPlane(_rigid.transform.forward, Vector3.up);
|
||||
@@ -285,18 +202,29 @@ public class MovementComponent : MonoBehaviour
|
||||
return planarForward;
|
||||
}
|
||||
|
||||
return ClientGameplayInputFlow.HasPlanarInput(_lastAimDirection) ? _lastAimDirection : ResolveHeadingForward(UnityYawToHeading(_rigid.rotation.eulerAngles.y));
|
||||
return ClientGameplayInputFlow.HasPlanarInput(_lastAimDirection)
|
||||
? _lastAimDirection
|
||||
: ResolveHeadingForward(UnityYawToHeading(_rigid.rotation.eulerAngles.y));
|
||||
}
|
||||
|
||||
private void Simulate(Vector3 input)
|
||||
{
|
||||
ApplyTankMovement(-input.x, input.z, Time.fixedDeltaTime);
|
||||
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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -305,7 +233,32 @@ public class MovementComponent : MonoBehaviour
|
||||
if (_isControlled)
|
||||
{
|
||||
_lastAuthoritativeState = snapshot;
|
||||
_hasServerState = true;
|
||||
|
||||
// 打印服务端状态,用于与客户端计算结果对比
|
||||
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
|
||||
{
|
||||
@@ -326,6 +279,28 @@ public class MovementComponent : MonoBehaviour
|
||||
}
|
||||
}
|
||||
|
||||
/// <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)
|
||||
@@ -363,13 +338,19 @@ public class MovementComponent : MonoBehaviour
|
||||
|
||||
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));
|
||||
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);
|
||||
_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)
|
||||
|
||||
@@ -40,6 +40,15 @@ namespace Network.NetworkApplication
|
||||
|
||||
public IReadOnlyList<PredictedMoveStep> PendingInputs => pendingInputs;
|
||||
|
||||
/// <summary>
|
||||
/// 清空所有 pending inputs。
|
||||
/// 用于 Reconcile 后清理已重放的输入,避免它们的时间被继续累积。
|
||||
/// </summary>
|
||||
public void ClearPendingInputs()
|
||||
{
|
||||
pendingInputs.Clear();
|
||||
}
|
||||
|
||||
public void Record(MoveInput input)
|
||||
{
|
||||
if (input == null)
|
||||
@@ -63,7 +72,8 @@ namespace Network.NetworkApplication
|
||||
}
|
||||
|
||||
var latest = pendingInputs[^1];
|
||||
pendingInputs[^1] = new PredictedMoveStep(latest.Input, latest.SimulatedDurationSeconds + simulatedDurationSeconds);
|
||||
pendingInputs[^1] =
|
||||
new PredictedMoveStep(latest.Input, latest.SimulatedDurationSeconds + simulatedDurationSeconds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -79,10 +89,12 @@ namespace Network.NetworkApplication
|
||||
}
|
||||
|
||||
var latest = pendingInputs[^1];
|
||||
pendingInputs[^1] = new PredictedMoveStep(latest.Input, latest.SimulatedDurationSeconds + elapsedSinceLastState);
|
||||
pendingInputs[^1] =
|
||||
new PredictedMoveStep(latest.Input, latest.SimulatedDurationSeconds + elapsedSinceLastState);
|
||||
}
|
||||
|
||||
public bool TryApplyAuthoritativeState(PlayerState state, float currentTime, out IReadOnlyList<PredictedMoveStep> replayInputs)
|
||||
public bool TryApplyAuthoritativeState(PlayerState state, float currentTime,
|
||||
out IReadOnlyList<PredictedMoveStep> replayInputs)
|
||||
{
|
||||
if (state == null)
|
||||
{
|
||||
@@ -105,5 +117,20 @@ namespace Network.NetworkApplication
|
||||
_lastAuthoritativeStateTime = currentTime;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 只清除已确认的旧输入,不触发 replay,不更新 LastAuthoritativeTick。
|
||||
/// 用于在校正被禁用时,保持 predictionBuffer 的输入与服务端同步。
|
||||
/// </summary>
|
||||
public void PruneAcknowledgedInputs(long acknowledgedMoveTick)
|
||||
{
|
||||
if (acknowledgedMoveTick <= 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
pendingInputs.RemoveAll(input => input.Input.Tick <= acknowledgedMoveTick);
|
||||
LastAcknowledgedMoveTick = acknowledgedMoveTick;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,7 +185,7 @@ namespace Network.NetworkHost
|
||||
}
|
||||
|
||||
if (!movementCoordinator.TryGetState(acceptedPeer, out attackerState) &&
|
||||
!movementCoordinator.EnsureState(acceptedPeer, input.PlayerId, out attackerState))
|
||||
!movementCoordinator.EnsureState(acceptedPeer, input.PlayerId, null, out attackerState))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ namespace Network.NetworkHost
|
||||
|
||||
public TimeSpan SimulationInterval => configuration.SimulationInterval;
|
||||
|
||||
public float MoveSpeed => configuration.MoveSpeed;
|
||||
|
||||
public IReadOnlyList<ServerAuthoritativeMovementState> States
|
||||
{
|
||||
get
|
||||
@@ -47,7 +49,7 @@ namespace Network.NetworkHost
|
||||
}
|
||||
}
|
||||
|
||||
public bool EnsureState(IPEndPoint remoteEndPoint, string playerId, out ServerAuthoritativeMovementState state)
|
||||
public bool EnsureState(IPEndPoint remoteEndPoint, string playerId, float? speed, out ServerAuthoritativeMovementState state)
|
||||
{
|
||||
if (remoteEndPoint == null)
|
||||
{
|
||||
@@ -73,14 +75,21 @@ namespace Network.NetworkHost
|
||||
return false;
|
||||
}
|
||||
|
||||
if (speed.HasValue)
|
||||
{
|
||||
existingState.Speed = speed.Value;
|
||||
}
|
||||
|
||||
state = CloneState(existingState);
|
||||
return true;
|
||||
}
|
||||
|
||||
var resolvedSpeed = speed ?? configuration.MoveSpeed;
|
||||
var createdState = new ServerAuthoritativeMovementState(
|
||||
normalizedSender,
|
||||
playerId,
|
||||
configuration.DefaultHp);
|
||||
configuration.DefaultHp,
|
||||
resolvedSpeed);
|
||||
statesByPeer.Add(key, createdState);
|
||||
state = CloneState(createdState);
|
||||
return true;
|
||||
@@ -343,7 +352,8 @@ namespace Network.NetworkHost
|
||||
Rotation = state.Rotation,
|
||||
IsDead = state.IsDead,
|
||||
InputX = state.InputX,
|
||||
InputY = state.InputY
|
||||
InputY = state.InputY,
|
||||
Speed = state.Speed
|
||||
};
|
||||
}
|
||||
|
||||
@@ -396,11 +406,11 @@ namespace Network.NetworkHost
|
||||
}
|
||||
|
||||
var rotationRadians = state.Rotation * (MathF.PI / 180f);
|
||||
var forwardX = MathF.Cos(rotationRadians);
|
||||
var forwardZ = MathF.Sin(rotationRadians);
|
||||
state.VelocityX = forwardX * (throttleInput * configuration.MoveSpeed);
|
||||
var forwardX = MathF.Sin(rotationRadians);
|
||||
var forwardZ = MathF.Cos(rotationRadians);
|
||||
state.VelocityX = forwardX * (throttleInput * state.Speed);
|
||||
state.VelocityY = 0f;
|
||||
state.VelocityZ = forwardZ * (throttleInput * configuration.MoveSpeed);
|
||||
state.VelocityZ = forwardZ * (throttleInput * state.Speed);
|
||||
|
||||
var candidatePositionX = state.PositionX + (state.VelocityX * deltaSeconds);
|
||||
var candidatePositionY = state.PositionY + (state.VelocityY * deltaSeconds);
|
||||
|
||||
@@ -5,12 +5,13 @@ namespace Network.NetworkHost
|
||||
{
|
||||
public sealed class ServerAuthoritativeMovementState
|
||||
{
|
||||
public ServerAuthoritativeMovementState(IPEndPoint remoteEndPoint, string playerId, int hp)
|
||||
public ServerAuthoritativeMovementState(IPEndPoint remoteEndPoint, string playerId, int hp, float speed = 5f)
|
||||
{
|
||||
RemoteEndPoint = remoteEndPoint ?? throw new ArgumentNullException(nameof(remoteEndPoint));
|
||||
PlayerId = playerId ?? throw new ArgumentNullException(nameof(playerId));
|
||||
Hp = hp;
|
||||
IsDead = hp <= 0;
|
||||
Speed = speed;
|
||||
}
|
||||
|
||||
public IPEndPoint RemoteEndPoint { get; }
|
||||
@@ -44,5 +45,7 @@ namespace Network.NetworkHost
|
||||
public float InputX { get; internal set; }
|
||||
|
||||
public float InputY { get; internal set; }
|
||||
|
||||
public float Speed { get; internal set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,8 @@ namespace Network.NetworkHost
|
||||
|
||||
public MultiSessionManager SessionCoordinator { get; }
|
||||
|
||||
public float AuthoritativeMoveSpeed => authoritativeMovementCoordinator.MoveSpeed;
|
||||
|
||||
public TimeSpan AuthoritativeMovementCadence => authoritativeMovementCoordinator.SimulationInterval;
|
||||
|
||||
public IReadOnlyList<ManagedNetworkSession> ManagedSessions => SessionCoordinator.Sessions;
|
||||
@@ -268,14 +270,21 @@ namespace Network.NetworkHost
|
||||
public void NotifyLoginSucceeded(IPEndPoint remoteEndPoint)
|
||||
{
|
||||
SessionCoordinator.NotifyLoginSucceeded(remoteEndPoint);
|
||||
BootstrapAuthoritativeMovementState(remoteEndPoint);
|
||||
BootstrapAuthoritativeMovementState(remoteEndPoint, null);
|
||||
PublishMetricsSessionSnapshot(remoteEndPoint);
|
||||
}
|
||||
|
||||
public void NotifyLoginSucceeded(IPEndPoint remoteEndPoint, string playerId)
|
||||
{
|
||||
NotifyLoginSucceeded(remoteEndPoint, playerId, null);
|
||||
}
|
||||
|
||||
public void NotifyLoginSucceeded(IPEndPoint remoteEndPoint, string playerId, float? speed)
|
||||
{
|
||||
RememberPlayerId(remoteEndPoint, playerId);
|
||||
NotifyLoginSucceeded(remoteEndPoint);
|
||||
SessionCoordinator.NotifyLoginSucceeded(remoteEndPoint);
|
||||
BootstrapAuthoritativeMovementState(remoteEndPoint, speed);
|
||||
PublishMetricsSessionSnapshot(remoteEndPoint);
|
||||
}
|
||||
|
||||
public void NotifyLoginFailed(IPEndPoint remoteEndPoint, string reason = null)
|
||||
@@ -363,14 +372,14 @@ namespace Network.NetworkHost
|
||||
}
|
||||
}
|
||||
|
||||
private void BootstrapAuthoritativeMovementState(IPEndPoint remoteEndPoint)
|
||||
private void BootstrapAuthoritativeMovementState(IPEndPoint remoteEndPoint, float? speed)
|
||||
{
|
||||
if (!TryGetKnownPlayerId(remoteEndPoint, out var playerId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
authoritativeMovementCoordinator.EnsureState(remoteEndPoint, playerId, out _);
|
||||
authoritativeMovementCoordinator.EnsureState(remoteEndPoint, playerId, speed, out _);
|
||||
}
|
||||
|
||||
private void RememberPlayerId(IPEndPoint remoteEndPoint, string playerId)
|
||||
|
||||
+138
-1
@@ -1,6 +1,10 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using Network.Defines;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Events;
|
||||
using UnityEngine.UI;
|
||||
using Vector3 = UnityEngine.Vector3;
|
||||
|
||||
public class MainUI : MonoBehaviour
|
||||
{
|
||||
@@ -14,6 +18,10 @@ public class MainUI : MonoBehaviour
|
||||
[SerializeField] private Text _correctionText;
|
||||
[SerializeField] private Text _acknowledgedTickText;
|
||||
|
||||
[Header("测试按钮")] [SerializeField] private Button _testOneFrameButton;
|
||||
[SerializeField] private Button _testFiveFramesButton;
|
||||
[SerializeField] private Button _testIntermittentButton;
|
||||
|
||||
public UnityAction<Vector3> OnServerPosChanged;
|
||||
public UnityAction<Vector3> OnClientPosChanged;
|
||||
public UnityAction<long> OnServerTickChanged;
|
||||
@@ -22,6 +30,10 @@ public class MainUI : MonoBehaviour
|
||||
public UnityAction<Vector3, Vector3, float, float> OnCorrectionMagnitudeChanged;
|
||||
public UnityAction<long> OnAcknowledgedMoveTickChanged;
|
||||
|
||||
private const float MoveSpeed = 4f;
|
||||
private const float TurnSpeed = 180f;
|
||||
private const float DeltaTime = 0.05f; // 50ms 模拟步长
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
Instance = this;
|
||||
@@ -36,6 +48,10 @@ public class MainUI : MonoBehaviour
|
||||
OnStartTickOffsetChanged += UpdateStartTickOffsetText;
|
||||
OnCorrectionMagnitudeChanged += UpdateCorrectionText;
|
||||
OnAcknowledgedMoveTickChanged += UpdateAcknowledgedTickText;
|
||||
|
||||
_testOneFrameButton?.onClick.AddListener(OnTestOneFrameClicked);
|
||||
_testFiveFramesButton?.onClick.AddListener(OnTestFiveFramesClicked);
|
||||
_testIntermittentButton?.onClick.AddListener(OnTestIntermittentClicked);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
@@ -47,8 +63,128 @@ public class MainUI : MonoBehaviour
|
||||
OnStartTickOffsetChanged -= UpdateStartTickOffsetText;
|
||||
OnCorrectionMagnitudeChanged -= UpdateCorrectionText;
|
||||
OnAcknowledgedMoveTickChanged -= UpdateAcknowledgedTickText;
|
||||
|
||||
_testOneFrameButton?.onClick.RemoveListener(OnTestOneFrameClicked);
|
||||
_testFiveFramesButton?.onClick.RemoveListener(OnTestFiveFramesClicked);
|
||||
_testIntermittentButton?.onClick.RemoveListener(OnTestIntermittentClicked);
|
||||
}
|
||||
|
||||
// ========== 测试按钮事件 ==========
|
||||
|
||||
private void OnTestOneFrameClicked()
|
||||
{
|
||||
StartCoroutine(RunMovementTestCoroutine(
|
||||
playerId: MasterManager.Instance?.LocalPlayerId ?? "player",
|
||||
inputSequence: new[] { (0f, 1f) }, // 1帧:前进
|
||||
expectedTotalMovement: MoveSpeed * DeltaTime // 0.2
|
||||
));
|
||||
}
|
||||
|
||||
private void OnTestFiveFramesClicked()
|
||||
{
|
||||
StartCoroutine(RunMovementTestCoroutine(
|
||||
playerId: MasterManager.Instance?.LocalPlayerId ?? "player",
|
||||
inputSequence: new[]
|
||||
{
|
||||
(0f, 1f),
|
||||
(0f, 1f),
|
||||
(0f, 1f),
|
||||
(0f, 1f),
|
||||
(0f, 1f)
|
||||
}, // 5帧:连续前进
|
||||
expectedTotalMovement: MoveSpeed * DeltaTime * 5 // 1.0
|
||||
));
|
||||
}
|
||||
|
||||
private void OnTestIntermittentClicked()
|
||||
{
|
||||
StartCoroutine(RunMovementTestCoroutine(
|
||||
playerId: MasterManager.Instance?.LocalPlayerId ?? "player",
|
||||
inputSequence: new[]
|
||||
{
|
||||
(0f, 1f), // 帧1:前进
|
||||
(0f, 1f), // 帧2:前进
|
||||
(0f, 0f), // 帧3:停止
|
||||
(0f, 1f), // 帧4:前进
|
||||
(0f, 1f), // 帧5:前进
|
||||
},
|
||||
expectedTotalMovement: MoveSpeed * DeltaTime * 4 // 0.8(只有4帧在移动)
|
||||
));
|
||||
}
|
||||
|
||||
private IEnumerator RunMovementTestCoroutine(
|
||||
string playerId,
|
||||
(float turn, float throttle)[] inputSequence,
|
||||
float expectedTotalMovement)
|
||||
{
|
||||
if (NetworkManager.Instance == null)
|
||||
{
|
||||
Debug.LogError("[Test] NetworkManager.Instance is null");
|
||||
yield break;
|
||||
}
|
||||
|
||||
var player = MasterManager.Instance?.GetCurrentPlayer();
|
||||
if (player == null)
|
||||
{
|
||||
Debug.LogError("[Test] Current player is null, make sure you're logged in");
|
||||
yield break;
|
||||
}
|
||||
|
||||
var movementComponent = player.GetComponent<MovementComponent>();
|
||||
if (movementComponent == null)
|
||||
{
|
||||
Debug.LogError("[Test] MovementComponent not found on player");
|
||||
yield break;
|
||||
}
|
||||
|
||||
var inputComponent = player.GetComponent<InputComponent>();
|
||||
if (inputComponent == null)
|
||||
{
|
||||
Debug.LogError("[Test] InputComponent not found on player");
|
||||
yield break;
|
||||
}
|
||||
|
||||
// 关闭服务器校正,只打印日志不应用位置
|
||||
//movementComponent.SetApplyServerCorrection(false);
|
||||
|
||||
// 保存原始输入源并替换为模拟输入源
|
||||
var originalInputSource = inputComponent.GetInputSource();
|
||||
var simulatedInputSource = new SimulatedInputSource(inputSequence);
|
||||
inputComponent.SetInputSource(simulatedInputSource);
|
||||
//inputComponent.ResetTick(1); // 从 tick 1 开始
|
||||
|
||||
// ========== 运行帧模拟 ==========
|
||||
// 每个输入需要两帧:一帧 Update() 发送 MoveInput,一帧 Advance() 推进
|
||||
for (int i = 0; i < inputSequence.Length; i++)
|
||||
{
|
||||
var (turnInput, throttleInput) = inputSequence[i];
|
||||
|
||||
// yield return null 会触发一帧 Update(),SimulatedInputSource 提供当前输入
|
||||
yield return null;
|
||||
|
||||
Debug.Log($"[Test Tick {i + 1}] Turn={turnInput}, Throttle={throttleInput}");
|
||||
|
||||
// 发送后推进到下一个输入
|
||||
simulatedInputSource.Advance();
|
||||
}
|
||||
|
||||
// 等待一段时间让服务器返回状态
|
||||
yield return new WaitForSeconds(0.5f);
|
||||
|
||||
// 恢复原始输入源
|
||||
inputComponent.SetInputSource(originalInputSource);
|
||||
|
||||
// 恢复服务器校正
|
||||
//movementComponent.SetApplyServerCorrection(true);
|
||||
|
||||
Debug.Log($"========================================");
|
||||
Debug.Log($"[Test: {playerId}]");
|
||||
Debug.Log($"Expected Z Movement: {expectedTotalMovement:F4}");
|
||||
Debug.Log($"Client Position: {player.transform.position}");
|
||||
Debug.Log($"========================================");
|
||||
}
|
||||
|
||||
|
||||
private void UpdateServerPositionText(Vector3 pos)
|
||||
{
|
||||
_serverPositionText.text = "服务端位置:" + pos.ToString();
|
||||
@@ -74,7 +210,8 @@ public class MainUI : MonoBehaviour
|
||||
_clientTickText.text = "客户端Tick:" + tick;
|
||||
}
|
||||
|
||||
private void UpdateCorrectionText(Vector3 predictedPos, Vector3 authoritativePos, float positionError, float rotationError)
|
||||
private void UpdateCorrectionText(Vector3 predictedPos, Vector3 authoritativePos, float positionError,
|
||||
float rotationError)
|
||||
{
|
||||
_correctionText.text = $"校正:pos差={positionError:F4} rot差={rotationError:F2}°";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user