process TODO.md step4

This commit is contained in:
SepComet
2026-03-28 15:50:44 +08:00
parent 4a152e765b
commit f0064e2ae5
22 changed files with 850 additions and 62 deletions
@@ -0,0 +1,62 @@
using System;
using Network.Defines;
using UnityEngine;
using Vector3 = UnityEngine.Vector3;
public sealed class ClientAuthoritativePlayerState
{
public ClientAuthoritativePlayerStateSnapshot Current { get; private set; }
public bool TryAccept(PlayerState state, out ClientAuthoritativePlayerStateSnapshot snapshot)
{
if (state == null)
{
throw new ArgumentNullException(nameof(state));
}
if (Current != null && state.Tick <= Current.Tick)
{
snapshot = Current;
return false;
}
snapshot = new ClientAuthoritativePlayerStateSnapshot(state);
Current = snapshot;
return true;
}
}
public sealed class ClientAuthoritativePlayerStateSnapshot
{
public ClientAuthoritativePlayerStateSnapshot(PlayerState state)
{
if (state == null)
{
throw new ArgumentNullException(nameof(state));
}
SourceState = state.Clone();
PlayerId = SourceState.PlayerId ?? string.Empty;
Tick = SourceState.Tick;
Position = SourceState.Position != null ? SourceState.Position.ToVector3() : Vector3.zero;
Velocity = SourceState.Velocity != null ? SourceState.Velocity.ToVector3() : Vector3.zero;
Rotation = SourceState.Rotation;
Hp = SourceState.Hp;
}
public PlayerState SourceState { get; }
public string PlayerId { get; }
public long Tick { get; }
public Vector3 Position { get; }
public Vector3 Velocity { get; }
public float Rotation { get; }
public int Hp { get; }
public Quaternion RotationQuaternion => Quaternion.Euler(0f, Rotation, 0f);
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 973b463b11350f445801c3673c585625
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+37 -33
View File
@@ -63,16 +63,14 @@ public class MovementComponent : MonoBehaviour
private Vector3 _serverPosition;
private bool _hasServerState = false;
private PlayerState _lastServerState;
private ClientAuthoritativePlayerStateSnapshot _lastAuthoritativeState;
public long Tick { get; private set; } = 0;
private long _startTickOffset = 0;
private long _currentTickOffset = 0;
private readonly ClientPredictionBuffer _predictionBuffer = new ClientPredictionBuffer();
private Vector3 _serverPos;
private Vector3 _currentPos;
private float _lerpTime;
private readonly RemotePlayerSnapshotInterpolator _remoteSnapshotInterpolator = new();
[SerializeField] private float _lerpRate = 0.1f;
private Vector3 _cachedMoveInput;
private Vector3 _lastAimDirection = Vector3.forward;
@@ -88,7 +86,7 @@ public class MovementComponent : MonoBehaviour
_rigid.interpolation = RigidbodyInterpolation.Interpolate;
_rigid.isKinematic = !isControlled;
_rigid.velocity = Vector3.zero;
if (serverTick != 0 && _isControlled) MainUI.Instance.OnStartTickOffsetChanged(serverTick);
if (serverTick != 0 && _isControlled && MainUI.Instance != null) MainUI.Instance.OnStartTickOffsetChanged(serverTick);
}
private void Update()
@@ -138,8 +136,12 @@ public class MovementComponent : MonoBehaviour
{
if (_hasServerState)
{
MainUI.Instance.OnServerPosChanged(_serverPosition);
Reconcile(_lastServerState);
if (MainUI.Instance != null)
{
MainUI.Instance.OnServerPosChanged(_serverPosition);
}
Reconcile(_lastAuthoritativeState);
_hasServerState = false;
}
@@ -147,21 +149,27 @@ public class MovementComponent : MonoBehaviour
}
else
{
_lerpTime += Time.fixedDeltaTime / 0.05f;
_rigid.MovePosition(Vector3.Lerp(_currentPos, _serverPos, _lerpTime));
var sample = _remoteSnapshotInterpolator.Sample(Time.time);
if (sample.HasValue)
{
_rigid.MovePosition(sample.Position);
_rigid.MoveRotation(sample.Rotation);
_rigid.velocity = sample.Velocity;
}
}
}
private void Reconcile(PlayerState state)
private void Reconcile(ClientAuthoritativePlayerStateSnapshot snapshot)
{
if (!_predictionBuffer.TryApplyAuthoritativeState(state, out var replayInputs))
if (!_predictionBuffer.TryApplyAuthoritativeState(snapshot.SourceState, out var replayInputs))
{
return;
}
_serverPosition = state.Position.ToVector3();
_serverPosition = snapshot.Position;
_rigid.position = Vector3.Lerp(_rigid.position, _serverPosition, _lerpRate);
_rigid.velocity = Vector3.zero;
_rigid.rotation = Quaternion.Slerp(_rigid.rotation, snapshot.RotationQuaternion, _lerpRate);
_rigid.velocity = snapshot.Velocity;
ReplayPendingInputs(replayInputs);
}
@@ -197,34 +205,24 @@ public class MovementComponent : MonoBehaviour
_rigid.velocity = _speed * input;
if (_isControlled)
{
MainUI.Instance.OnClientPosChanged(_rigid.position);
if (MainUI.Instance != null)
{
MainUI.Instance.OnClientPosChanged(_rigid.position);
}
}
}
public void OnServerState(PlayerState state)
public void OnAuthoritativeState(ClientAuthoritativePlayerStateSnapshot snapshot)
{
if (_isControlled)
{
if (_predictionBuffer.LastAuthoritativeTick.HasValue &&
state.Tick <= _predictionBuffer.LastAuthoritativeTick.Value)
{
return;
}
_lastServerState = state;
_lastAuthoritativeState = snapshot;
_hasServerState = true;
}
else
{
if (_lastServerState != null && state.Tick < _lastServerState.Tick)
{
return;
}
_lastServerState = state;
_serverPos = state.Position.ToVector3();
_currentPos = _rigid.position;
_lerpTime = 0f;
_lastAuthoritativeState = snapshot;
_remoteSnapshotInterpolator.TryAddSnapshot(snapshot, Time.time);
}
}
@@ -233,7 +231,10 @@ public class MovementComponent : MonoBehaviour
_currentTickOffset = serverTick - Tick - _startTickOffset;
if (_isControlled)
{
MainUI.Instance.OnServerTickChanged(serverTick);
if (MainUI.Instance != null)
{
MainUI.Instance.OnServerTickChanged(serverTick);
}
}
if (_currentTickOffset < 0)
@@ -255,7 +256,10 @@ public class MovementComponent : MonoBehaviour
if (_isControlled)
{
MainUI.Instance.OnClientPosChanged(_rigid.position);
if (MainUI.Instance != null)
{
MainUI.Instance.OnClientPosChanged(_rigid.position);
}
}
}
}
+9 -2
View File
@@ -5,12 +5,14 @@ public class Player : MonoBehaviour
{
//[SerializeField] private float _moveSpeed = 10f;
public string PlayerId { get; private set; } = "1001";
public ClientAuthoritativePlayerStateSnapshot AuthoritativeState => _authoritativeState.Current;
[SerializeField] private MeshRenderer _meshRenderer;
[SerializeField] private Material[] _materials;
[SerializeField] private Camera _camera;
[SerializeField] private MovementComponent _movement;
[SerializeField] private PlayerUI _playerUI;
[SerializeField] private bool _isControlled;
private readonly ClientAuthoritativePlayerState _authoritativeState = new();
public void LocalInit(string playerId, int speed, long serverTick)
{
@@ -49,8 +51,13 @@ public class Player : MonoBehaviour
public void SyncPosition(PlayerState movement)
{
if (this._movement == null) return;
_movement.OnServerState(movement);
if (!_authoritativeState.TryAccept(movement, out var snapshot))
{
return;
}
_playerUI?.SyncAuthoritativeState(snapshot);
_movement?.OnAuthoritativeState(snapshot);
}
public void SyncTick(long serverTick)
@@ -0,0 +1,191 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using Vector3 = UnityEngine.Vector3;
public sealed class RemotePlayerSnapshotInterpolator
{
// Keep remote rendering roughly two movement send intervals behind receive time so the client
// usually has both an older and newer authoritative sample to interpolate between.
public const float DefaultInterpolationDelaySeconds = 0.1f;
public const int DefaultMaxBufferedSnapshots = 6;
private readonly List<BufferedSnapshot> _snapshots = new();
private readonly float _interpolationDelaySeconds;
private readonly int _maxBufferedSnapshots;
public RemotePlayerSnapshotInterpolator(
float interpolationDelaySeconds = DefaultInterpolationDelaySeconds,
int maxBufferedSnapshots = DefaultMaxBufferedSnapshots)
{
if (interpolationDelaySeconds < 0f)
{
throw new ArgumentOutOfRangeException(nameof(interpolationDelaySeconds));
}
if (maxBufferedSnapshots < 1)
{
throw new ArgumentOutOfRangeException(nameof(maxBufferedSnapshots));
}
_interpolationDelaySeconds = interpolationDelaySeconds;
_maxBufferedSnapshots = maxBufferedSnapshots;
}
public int BufferedSnapshotCount => _snapshots.Count;
public long LatestBufferedTick => _snapshots.Count == 0 ? -1 : _snapshots[^1].Snapshot.Tick;
public bool TryAddSnapshot(ClientAuthoritativePlayerStateSnapshot snapshot, float receivedAtSeconds)
{
if (snapshot == null)
{
throw new ArgumentNullException(nameof(snapshot));
}
if (_snapshots.Count > 0 && snapshot.Tick <= _snapshots[^1].Snapshot.Tick)
{
return false;
}
_snapshots.Add(new BufferedSnapshot(snapshot, receivedAtSeconds));
TrimOverflow();
return true;
}
public RemotePlayerInterpolationSample Sample(float nowSeconds)
{
if (_snapshots.Count == 0)
{
return RemotePlayerInterpolationSample.None;
}
// Sample against a fixed delayed render timestamp. If the delay cannot be bracketed by two
// buffered authoritative samples, clamp to the newest accepted snapshot instead of predicting.
var renderTime = nowSeconds - _interpolationDelaySeconds;
TrimConsumedSamples(renderTime);
if (_snapshots.Count >= 2)
{
var from = _snapshots[0];
var to = _snapshots[1];
if (from.ReceivedAtSeconds <= renderTime && renderTime <= to.ReceivedAtSeconds)
{
var duration = to.ReceivedAtSeconds - from.ReceivedAtSeconds;
var t = duration <= Mathf.Epsilon ? 1f : Mathf.Clamp01((renderTime - from.ReceivedAtSeconds) / duration);
return RemotePlayerInterpolationSample.Interpolated(
Vector3.Lerp(from.Snapshot.Position, to.Snapshot.Position, t),
Quaternion.Slerp(from.Snapshot.RotationQuaternion, to.Snapshot.RotationQuaternion, t),
Vector3.Lerp(from.Snapshot.Velocity, to.Snapshot.Velocity, t),
to.Snapshot,
from.Snapshot,
t);
}
}
return RemotePlayerInterpolationSample.Latest(_snapshots[^1].Snapshot);
}
private void TrimConsumedSamples(float renderTime)
{
while (_snapshots.Count >= 2 && _snapshots[1].ReceivedAtSeconds <= renderTime)
{
_snapshots.RemoveAt(0);
}
}
private void TrimOverflow()
{
while (_snapshots.Count > _maxBufferedSnapshots)
{
_snapshots.RemoveAt(0);
}
}
private readonly struct BufferedSnapshot
{
public BufferedSnapshot(ClientAuthoritativePlayerStateSnapshot snapshot, float receivedAtSeconds)
{
Snapshot = snapshot;
ReceivedAtSeconds = receivedAtSeconds;
}
public ClientAuthoritativePlayerStateSnapshot Snapshot { get; }
public float ReceivedAtSeconds { get; }
}
}
public readonly struct RemotePlayerInterpolationSample
{
private RemotePlayerInterpolationSample(
bool hasValue,
bool usedInterpolation,
Vector3 position,
Quaternion rotation,
Vector3 velocity,
ClientAuthoritativePlayerStateSnapshot latestSnapshot,
ClientAuthoritativePlayerStateSnapshot fromSnapshot,
float alpha)
{
HasValue = hasValue;
UsedInterpolation = usedInterpolation;
Position = position;
Rotation = rotation;
Velocity = velocity;
LatestSnapshot = latestSnapshot;
FromSnapshot = fromSnapshot;
Alpha = alpha;
}
public static RemotePlayerInterpolationSample None { get; } =
new(false, false, Vector3.zero, Quaternion.identity, Vector3.zero, null, null, 0f);
public bool HasValue { get; }
public bool UsedInterpolation { get; }
public Vector3 Position { get; }
public Quaternion Rotation { get; }
public Vector3 Velocity { get; }
public ClientAuthoritativePlayerStateSnapshot LatestSnapshot { get; }
public ClientAuthoritativePlayerStateSnapshot FromSnapshot { get; }
public float Alpha { get; }
public static RemotePlayerInterpolationSample Latest(ClientAuthoritativePlayerStateSnapshot snapshot)
{
return new RemotePlayerInterpolationSample(
true,
false,
snapshot.Position,
snapshot.RotationQuaternion,
snapshot.Velocity,
snapshot,
snapshot,
1f);
}
public static RemotePlayerInterpolationSample Interpolated(
Vector3 position,
Quaternion rotation,
Vector3 velocity,
ClientAuthoritativePlayerStateSnapshot latestSnapshot,
ClientAuthoritativePlayerStateSnapshot fromSnapshot,
float alpha)
{
return new RemotePlayerInterpolationSample(
true,
true,
position,
rotation,
velocity,
latestSnapshot,
fromSnapshot,
alpha);
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 3c88aa7375c5dd94882fe09f5208af9b
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+23 -2
View File
@@ -14,7 +14,12 @@ public class PlayerUI : MonoBehaviour
_canvas = this.transform.GetComponent<Canvas>();
_mainCamera = Camera.main;
this._master = master;
this._text.text = _master.PlayerId;
RefreshText();
}
public void SyncAuthoritativeState(ClientAuthoritativePlayerStateSnapshot snapshot)
{
RefreshText(snapshot);
}
private void FixedUpdate()
@@ -41,4 +46,20 @@ public class PlayerUI : MonoBehaviour
{
_isVisible = false;
}
}
private void RefreshText(ClientAuthoritativePlayerStateSnapshot snapshot = null)
{
if (_text == null || _master == null)
{
return;
}
if (snapshot == null)
{
_text.text = _master.PlayerId;
return;
}
_text.text = $"{_master.PlayerId}\nHP:{snapshot.Hp} Tick:{snapshot.Tick}";
}
}
@@ -89,6 +89,140 @@ namespace Tests.EditMode.Network
Assert.That(buffer.LastAuthoritativeTick, Is.EqualTo(10));
}
[Test]
public void ClientAuthoritativePlayerState_NewerSnapshot_ReplacesOwnedStateAndPreservesFields()
{
var owner = new ClientAuthoritativePlayerState();
var accepted = owner.TryAccept(
new PlayerState
{
PlayerId = "player-1",
Tick = 14,
Position = new global::Network.Defines.Vector3 { X = 5f, Y = 0f, Z = -3f },
Velocity = new global::Network.Defines.Vector3 { X = 1.5f, Y = 0f, Z = 0.25f },
Rotation = 90f,
Hp = 73
},
out var snapshot);
Assert.That(accepted, Is.True);
Assert.That(owner.Current, Is.SameAs(snapshot));
Assert.That(snapshot.PlayerId, Is.EqualTo("player-1"));
Assert.That(snapshot.Tick, Is.EqualTo(14));
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(90f).Within(0.01f));
Assert.That(snapshot.Hp, Is.EqualTo(73));
}
[Test]
public void ClientAuthoritativePlayerState_StaleSnapshot_IsRejected()
{
var owner = new ClientAuthoritativePlayerState();
owner.TryAccept(new PlayerState { PlayerId = "player-1", Tick = 10, Hp = 95 }, out var current);
var accepted = owner.TryAccept(
new PlayerState { PlayerId = "player-1", Tick = 9, Hp = 10 },
out var staleResult);
Assert.That(accepted, Is.False);
Assert.That(owner.Current, Is.SameAs(current));
Assert.That(staleResult, Is.SameAs(current));
Assert.That(owner.Current.Hp, Is.EqualTo(95));
}
[Test]
public void ClientAuthoritativePlayerStateSnapshot_ClonesSourceMessage()
{
var source = new PlayerState
{
PlayerId = "player-1",
Tick = 3,
Position = new global::Network.Defines.Vector3 { X = 1f, Y = 0f, Z = 2f },
Rotation = 45f,
Hp = 88
};
var snapshot = new ClientAuthoritativePlayerStateSnapshot(source);
source.Tick = 4;
source.Hp = 10;
source.Position.X = 99f;
Assert.That(snapshot.Tick, Is.EqualTo(3));
Assert.That(snapshot.Hp, Is.EqualTo(88));
Assert.That(snapshot.Position, Is.EqualTo(new Vector3(1f, 0f, 2f)));
Assert.That(snapshot.SourceState.Tick, Is.EqualTo(3));
Assert.That(snapshot.SourceState.Hp, Is.EqualTo(88));
}
[Test]
public void RemotePlayerSnapshotInterpolator_StaleOrDuplicateSnapshots_AreRejected()
{
var interpolator = new RemotePlayerSnapshotInterpolator();
var firstAccepted = interpolator.TryAddSnapshot(CreateSnapshot(10, new Vector3(1f, 0f, 0f)), 1f);
var duplicateAccepted = interpolator.TryAddSnapshot(CreateSnapshot(10, new Vector3(2f, 0f, 0f)), 1.1f);
var staleAccepted = interpolator.TryAddSnapshot(CreateSnapshot(9, new Vector3(3f, 0f, 0f)), 1.2f);
Assert.That(firstAccepted, Is.True);
Assert.That(duplicateAccepted, Is.False);
Assert.That(staleAccepted, Is.False);
Assert.That(interpolator.BufferedSnapshotCount, Is.EqualTo(1));
Assert.That(interpolator.LatestBufferedTick, Is.EqualTo(10));
}
[Test]
public void RemotePlayerSnapshotInterpolator_BufferOverflow_TrimsOldestSnapshots()
{
var interpolator = new RemotePlayerSnapshotInterpolator(maxBufferedSnapshots: 3);
interpolator.TryAddSnapshot(CreateSnapshot(1, new Vector3(1f, 0f, 0f)), 0f);
interpolator.TryAddSnapshot(CreateSnapshot(2, new Vector3(2f, 0f, 0f)), 0.05f);
interpolator.TryAddSnapshot(CreateSnapshot(3, new Vector3(3f, 0f, 0f)), 0.1f);
interpolator.TryAddSnapshot(CreateSnapshot(4, new Vector3(4f, 0f, 0f)), 0.15f);
Assert.That(interpolator.BufferedSnapshotCount, Is.EqualTo(3));
Assert.That(interpolator.LatestBufferedTick, Is.EqualTo(4));
var sample = interpolator.Sample(0.3f);
Assert.That(interpolator.BufferedSnapshotCount, Is.EqualTo(1));
Assert.That(sample.LatestSnapshot.Tick, Is.EqualTo(4));
Assert.That(sample.UsedInterpolation, Is.False);
}
[Test]
public void RemotePlayerSnapshotInterpolator_BracketedRenderTime_InterpolatesBetweenSnapshots()
{
var interpolator = new RemotePlayerSnapshotInterpolator();
interpolator.TryAddSnapshot(CreateSnapshot(10, new Vector3(0f, 0f, 0f), 0f), 0f);
interpolator.TryAddSnapshot(CreateSnapshot(11, new Vector3(10f, 0f, 0f), 90f), 0.05f);
var sample = interpolator.Sample(0.125f);
Assert.That(sample.HasValue, Is.True);
Assert.That(sample.UsedInterpolation, Is.True);
Assert.That(sample.Position.x, Is.EqualTo(5f).Within(0.001f));
Assert.That(sample.Alpha, Is.EqualTo(0.5f).Within(0.001f));
Assert.That(sample.Rotation.eulerAngles.y, Is.EqualTo(45f).Within(0.01f));
Assert.That(sample.LatestSnapshot.Tick, Is.EqualTo(11));
}
[Test]
public void RemotePlayerSnapshotInterpolator_WithoutUsableBracket_ClampsToLatestSnapshot()
{
var interpolator = new RemotePlayerSnapshotInterpolator();
interpolator.TryAddSnapshot(CreateSnapshot(12, new Vector3(2f, 0f, -1f), 15f), 0.2f);
var sample = interpolator.Sample(0.35f);
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(15f).Within(0.01f));
Assert.That(sample.LatestSnapshot.Tick, Is.EqualTo(12));
}
[Test]
public void ClockSyncState_RejectsOlderSamples()
{
@@ -161,6 +295,19 @@ namespace Tests.EditMode.Network
}.ToByteArray();
}
private static ClientAuthoritativePlayerStateSnapshot CreateSnapshot(long tick, Vector3 position, float rotation = 0f)
{
return new ClientAuthoritativePlayerStateSnapshot(new PlayerState
{
PlayerId = "player-1",
Tick = tick,
Position = new global::Network.Defines.Vector3 { X = position.x, Y = position.y, Z = position.z },
Velocity = new global::Network.Defines.Vector3 { X = 0f, Y = 0f, Z = 0f },
Rotation = rotation,
Hp = 100
});
}
private sealed class FakeTransport : ITransport
{
public event System.Action<byte[], IPEndPoint> OnReceive;