补充输入重放

输入重放是指当收到一个服务器状态包时以那个包为初始状态,然后重新计算所有未确认的输入来得到当前帧的状态,然后再对当前角色的位置进行插值移动。服务器状态包只能给出前几帧的权威数据,而当前帧的状态则需要通过输入回放来得到
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
+258
View File
@@ -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:
@@ -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,11 @@
fileFormatVersion: 2
guid: db8117151f564304bae153aa55c0a960
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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;
}
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: fcfebc0989c4bf04cacf1c633d49a8bb
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+96
View File
@@ -0,0 +1,96 @@
using Network.Defines;
using Network.NetworkApplication;
using UnityEngine;
public class Player : MonoBehaviour
{
//[SerializeField] private float _moveSpeed = 10f;
public string PlayerId { get; private set; } = "1001";
public ClientAuthoritativePlayerStateSnapshot AuthoritativeState => _authoritativeState.Current;
public ClientCombatPresentationSnapshot CombatPresentation => _authoritativeState.CombatPresentation;
[SerializeField] private MeshRenderer _meshRenderer;
[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;
this._isControlled = true;
int idx = Random.Range(0, _materials.Length);
_meshRenderer.material = _materials[idx];
_playerUI.Init(this);
_movementResolver.Init(true, this, bootstrap);
}
public void RemoteInit(string playerId, UnityEngine.Vector3 pos)
{
this.PlayerId = playerId;
this._isControlled = false;
int idx = Random.Range(0, _materials.Length);
_meshRenderer.material = _materials[idx];
Destroy(_camera.gameObject);
this.transform.position = pos;
_playerUI.Init(this);
_movementResolver.Init(false, this);
}
private void OnApplicationQuit()
{
if (!string.IsNullOrEmpty(this.PlayerId) && _isControlled)
{
NetworkManager.Instance.SendLogoutRequest(this.PlayerId);
}
}
public void SyncPosition(PlayerState movement)
{
if (!_authoritativeState.TryAccept(movement, out var snapshot))
{
return;
}
_playerUI?.SyncAuthoritativeState(snapshot, CombatPresentation);
_movementResolver?.OnAuthoritativeState(snapshot);
}
public bool ApplyCombatEvent(CombatEvent combatEvent)
{
if (!_authoritativeState.TryApplyCombatEvent(combatEvent, PlayerId, out var snapshot, out var combatSnapshot))
{
return false;
}
_playerUI?.SyncAuthoritativeState(snapshot, combatSnapshot);
return true;
}
public void SyncTick(long serverTick)
{
_movementResolver.SetServerTick(serverTick);
}
}
+11
View File
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: ef97bb6040fda094b8aeab1bff8f13f8
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -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;
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 22558181f2430ca4b80f5787ec62c68d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: