This commit is contained in:
SepComet
2026-04-06 11:58:36 +08:00
parent b1b38b485e
commit aebc4011c7
84 changed files with 5796 additions and 238 deletions
@@ -98,6 +98,7 @@ public sealed class ClientAuthoritativePlayerStateSnapshot
SourceState = state.Clone();
PlayerId = SourceState.PlayerId ?? string.Empty;
Tick = SourceState.Tick;
AcknowledgedMoveTick = SourceState.AcknowledgedMoveTick;
Position = SourceState.Position != null ? SourceState.Position.ToVector3() : Vector3.zero;
Velocity = SourceState.Velocity != null ? SourceState.Velocity.ToVector3() : Vector3.zero;
Rotation = SourceState.Rotation;
@@ -110,6 +111,8 @@ public sealed class ClientAuthoritativePlayerStateSnapshot
public long Tick { get; }
public long AcknowledgedMoveTick { get; }
public Vector3 Position { get; }
public Vector3 Velocity { get; }
@@ -0,0 +1,159 @@
using UnityEngine;
using Vector3 = UnityEngine.Vector3;
public readonly struct ControlledPlayerCorrectionSettings
{
public ControlledPlayerCorrectionSettings(
float authoritativeCadenceSeconds,
float moveSpeed,
float turnSpeedDegreesPerSecond,
float snapDistanceMultiplier = 3f,
float snapAngleMultiplier = 15f)
{
AuthoritativeCadenceSeconds = Mathf.Max(0f, authoritativeCadenceSeconds);
MoveSpeed = Mathf.Max(0f, moveSpeed);
TurnSpeedDegreesPerSecond = Mathf.Max(0f, turnSpeedDegreesPerSecond);
SnapDistanceMultiplier = Mathf.Max(1f, snapDistanceMultiplier);
SnapAngleMultiplier = Mathf.Max(1f, snapAngleMultiplier);
}
public float AuthoritativeCadenceSeconds { get; }
public float MoveSpeed { get; }
public float TurnSpeedDegreesPerSecond { get; }
public float SnapDistanceMultiplier { get; }
public float SnapAngleMultiplier { get; }
public float MaxBoundedPositionCorrection => MoveSpeed * AuthoritativeCadenceSeconds;
public float MaxBoundedRotationCorrectionDegrees => TurnSpeedDegreesPerSecond * AuthoritativeCadenceSeconds;
public float SnapPositionThreshold => MaxBoundedPositionCorrection * SnapDistanceMultiplier;
public float SnapRotationThresholdDegrees => MaxBoundedRotationCorrectionDegrees * SnapAngleMultiplier;
public int MaxCorrectionSteps => Mathf.Max(1, Mathf.CeilToInt(SnapDistanceMultiplier));
}
public readonly struct ControlledPlayerVisualCorrectionState
{
public static ControlledPlayerVisualCorrectionState None => default;
public ControlledPlayerVisualCorrectionState(Vector3 targetPosition, Quaternion targetRotation, int remainingStepBudget)
{
TargetPosition = targetPosition;
TargetRotation = targetRotation;
RemainingStepBudget = Mathf.Max(0, remainingStepBudget);
}
public Vector3 TargetPosition { get; }
public Quaternion TargetRotation { get; }
public int RemainingStepBudget { get; }
public bool IsActive => RemainingStepBudget > 0;
}
public readonly struct ControlledPlayerCorrectionResult
{
public ControlledPlayerCorrectionResult(
Vector3 position,
Quaternion rotation,
bool usedHardSnap,
ControlledPlayerVisualCorrectionState nextState)
{
Position = position;
Rotation = rotation;
UsedHardSnap = usedHardSnap;
NextState = nextState;
}
public Vector3 Position { get; }
public Quaternion Rotation { get; }
public bool UsedHardSnap { get; }
public ControlledPlayerVisualCorrectionState NextState { get; }
}
public static class ControlledPlayerCorrection
{
public static ControlledPlayerCorrectionResult Resolve(
Vector3 currentPosition,
Quaternion currentRotation,
Vector3 targetPosition,
Quaternion targetRotation,
ControlledPlayerCorrectionSettings settings)
{
return Resolve(
currentPosition,
currentRotation,
targetPosition,
targetRotation,
settings,
ControlledPlayerVisualCorrectionState.None);
}
public static ControlledPlayerCorrectionResult Resolve(
Vector3 currentPosition,
Quaternion currentRotation,
Vector3 targetPosition,
Quaternion targetRotation,
ControlledPlayerCorrectionSettings settings,
ControlledPlayerVisualCorrectionState activeCorrection)
{
var positionError = Vector3.Distance(currentPosition, targetPosition);
var rotationError = Quaternion.Angle(currentRotation, targetRotation);
var boundedPositionCorrection = settings.MaxBoundedPositionCorrection;
var boundedRotationCorrection = settings.MaxBoundedRotationCorrectionDegrees;
if (positionError <= Mathf.Epsilon && rotationError <= Mathf.Epsilon)
{
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, false, ControlledPlayerVisualCorrectionState.None);
}
if (boundedPositionCorrection <= Mathf.Epsilon && boundedRotationCorrection <= Mathf.Epsilon)
{
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None);
}
if (positionError > settings.SnapPositionThreshold || rotationError > settings.SnapRotationThresholdDegrees)
{
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None);
}
var remainingStepBudget = activeCorrection.IsActive
? activeCorrection.RemainingStepBudget
: settings.MaxCorrectionSteps;
if (remainingStepBudget <= 0)
{
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None);
}
var correctedPosition = Vector3.MoveTowards(currentPosition, targetPosition, boundedPositionCorrection);
var correctedRotation = Quaternion.RotateTowards(currentRotation, targetRotation, boundedRotationCorrection);
var nextPositionError = Vector3.Distance(correctedPosition, targetPosition);
var nextRotationError = Quaternion.Angle(correctedRotation, targetRotation);
if (nextPositionError <= Mathf.Epsilon && nextRotationError <= Mathf.Epsilon)
{
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, false, ControlledPlayerVisualCorrectionState.None);
}
remainingStepBudget--;
if (remainingStepBudget <= 0)
{
return new ControlledPlayerCorrectionResult(targetPosition, targetRotation, true, ControlledPlayerVisualCorrectionState.None);
}
return new ControlledPlayerCorrectionResult(
correctedPosition,
correctedRotation,
false,
new ControlledPlayerVisualCorrectionState(targetPosition, targetRotation, remainingStepBudget));
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 9f70a048da9736c498daa681ba153fb0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
+5 -3
View File
@@ -1,5 +1,6 @@
using System.Collections.Generic;
using Network.Defines;
using Network.NetworkApplication;
using UnityEngine;
using Vector3 = UnityEngine.Vector3;
@@ -20,6 +21,7 @@ public class MasterManager : MonoBehaviour
public void InitPlayersState(LoginResponse response)
{
var localBootstrap = ClientMovementBootstrap.FromLoginResponse(response);
for (int i = 0; i < response.Positions.Count; i++)
{
string id = response.PlayerId[i];
@@ -31,17 +33,17 @@ public class MasterManager : MonoBehaviour
}
else
{
RegisterLocalPlayer(response.Speed, response.ServerTick);
RegisterLocalPlayer(localBootstrap);
var ui = GameObject.Find("RegisterCanvas");
ui.SetActive(false);
}
}
}
private void RegisterLocalPlayer(int speed, long serverTick)
private void RegisterLocalPlayer(ClientMovementBootstrap bootstrap)
{
Player player = GameObject.Instantiate(_playerPrefab, _playerParent).GetComponent<Player>();
player.LocalInit(LocalPlayerId, speed, serverTick);
player.LocalInit(LocalPlayerId, bootstrap);
_players.Add(LocalPlayerId, player);
}
+36 -5
View File
@@ -106,6 +106,11 @@ public class MovementComponent : MonoBehaviour
private Player _master;
private const float TurnSpeedDegreesPerSecond = 180f;
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;
@@ -114,6 +119,7 @@ public class MovementComponent : MonoBehaviour
private Vector3 _serverPosition;
private bool _hasServerState = false;
private ClientAuthoritativePlayerStateSnapshot _lastAuthoritativeState;
private ControlledPlayerVisualCorrectionState _activeVisualCorrection;
public long Tick { get; private set; } = 0;
private long _startTickOffset = 0;
@@ -127,6 +133,11 @@ public class MovementComponent : MonoBehaviour
private bool _wasMovingLastFrame;
private bool _stopMessagePending;
public void Init(bool isControlled, Player master, ClientMovementBootstrap bootstrap)
{
Init(isControlled, master, bootstrap.AuthoritativeMoveSpeed, bootstrap.ServerTick);
}
public void Init(bool isControlled, Player master, int speed = 0, long serverTick = 0)
{
_master = master;
@@ -212,15 +223,24 @@ public class MovementComponent : MonoBehaviour
private void Reconcile(ClientAuthoritativePlayerStateSnapshot snapshot)
{
_serverPosition = snapshot.Position;
if (!_predictionBuffer.TryApplyAuthoritativeState(snapshot.SourceState, out var replayInputs))
{
return;
}
_serverPosition = snapshot.Position;
_rigid.position = _serverPosition;
_rigid.rotation = snapshot.RotationQuaternion;
_rigid.velocity = snapshot.Velocity;
var correction = ControlledPlayerCorrection.Resolve(
_rigid.position,
_rigid.rotation,
snapshot.Position,
snapshot.RotationQuaternion,
new ControlledPlayerCorrectionSettings(kServerSimulationStepSeconds, _speed, TurnSpeedDegreesPerSecond),
_activeVisualCorrection);
_activeVisualCorrection = correction.NextState;
_rigid.position = correction.Position;
_rigid.rotation = correction.Rotation;
_rigid.velocity = correction.UsedHardSnap ? snapshot.Velocity : Vector3.zero;
_rigid.angularVelocity = Vector3.zero;
ReplayPendingInputs(replayInputs);
}
@@ -305,7 +325,18 @@ public class MovementComponent : MonoBehaviour
{
foreach (var replayInput in replayInputs)
{
ApplyTankMovement(replayInput.Input.TurnInput, replayInput.Input.ThrottleInput, replayInput.SimulatedDurationSeconds);
var remaining = replayInput.SimulatedDurationSeconds;
while (remaining > 0f)
{
// Use the server's fixed cadence (50ms) as the substep size to ensure
// replay trajectory matches live FixedUpdate prediction exactly.
var step = Mathf.Min(remaining, kServerSimulationStepSeconds);
ApplyTankMovement(
replayInput.Input.TurnInput,
replayInput.Input.ThrottleInput,
step);
remaining -= step;
}
}
if (_isControlled)
+38 -1
View File
@@ -63,7 +63,7 @@ namespace Network.Defines {
new pbr::GeneratedClrTypeInfo(typeof(global::Network.Defines.MoveInput), global::Network.Defines.MoveInput.Parser, new[]{ "PlayerId", "Tick", "TurnInput", "ThrottleInput" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::Network.Defines.ShootInput), global::Network.Defines.ShootInput.Parser, new[]{ "PlayerId", "Tick", "DirX", "DirY", "TargetId" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::Network.Defines.CombatEvent), global::Network.Defines.CombatEvent.Parser, new[]{ "Tick", "EventType", "AttackerId", "TargetId", "Damage", "HitPosition" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::Network.Defines.PlayerState), global::Network.Defines.PlayerState.Parser, new[]{ "PlayerId", "Position", "Velocity", "Rotation", "Tick", "Hp" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::Network.Defines.PlayerState), global::Network.Defines.PlayerState.Parser, new[]{ "PlayerId", "Position", "Velocity", "Rotation", "Tick", "Hp", "AcknowledgedMoveTick" }, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::Network.Defines.Heartbeat), global::Network.Defines.Heartbeat.Parser, null, null, null, null, null),
new pbr::GeneratedClrTypeInfo(typeof(global::Network.Defines.HeartbeatResponse), global::Network.Defines.HeartbeatResponse.Parser, new[]{ "ServerTick" }, null, null, null, null)
}));
@@ -2679,6 +2679,7 @@ namespace Network.Defines {
rotation_ = other.rotation_;
tick_ = other.tick_;
hp_ = other.hp_;
acknowledgedMoveTick_ = other.acknowledgedMoveTick_;
_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
}
@@ -2760,6 +2761,18 @@ namespace Network.Defines {
}
}
/// <summary>Field number for the "acknowledged_move_tick" field.</summary>
public const int AcknowledgedMoveTickFieldNumber = 7;
private long acknowledgedMoveTick_;
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public long AcknowledgedMoveTick {
get { return acknowledgedMoveTick_; }
set {
acknowledgedMoveTick_ = value;
}
}
[global::System.Diagnostics.DebuggerNonUserCodeAttribute]
[global::System.CodeDom.Compiler.GeneratedCode("protoc", null)]
public override bool Equals(object other) {
@@ -2781,6 +2794,7 @@ namespace Network.Defines {
if (!pbc::ProtobufEqualityComparers.BitwiseSingleEqualityComparer.Equals(Rotation, other.Rotation)) return false;
if (Tick != other.Tick) return false;
if (Hp != other.Hp) return false;
if (AcknowledgedMoveTick != other.AcknowledgedMoveTick) return false;
return Equals(_unknownFields, other._unknownFields);
}
@@ -2794,6 +2808,7 @@ namespace Network.Defines {
if (Rotation != 0F) hash ^= pbc::ProtobufEqualityComparers.BitwiseSingleEqualityComparer.GetHashCode(Rotation);
if (Tick != 0L) hash ^= Tick.GetHashCode();
if (Hp != 0) hash ^= Hp.GetHashCode();
if (AcknowledgedMoveTick != 0L) hash ^= AcknowledgedMoveTick.GetHashCode();
if (_unknownFields != null) {
hash ^= _unknownFields.GetHashCode();
}
@@ -2836,6 +2851,10 @@ namespace Network.Defines {
output.WriteRawTag(48);
output.WriteInt32(Hp);
}
if (AcknowledgedMoveTick != 0L) {
output.WriteRawTag(56);
output.WriteInt64(AcknowledgedMoveTick);
}
if (_unknownFields != null) {
_unknownFields.WriteTo(output);
}
@@ -2870,6 +2889,10 @@ namespace Network.Defines {
output.WriteRawTag(48);
output.WriteInt32(Hp);
}
if (AcknowledgedMoveTick != 0L) {
output.WriteRawTag(56);
output.WriteInt64(AcknowledgedMoveTick);
}
if (_unknownFields != null) {
_unknownFields.WriteTo(ref output);
}
@@ -2898,6 +2921,9 @@ namespace Network.Defines {
if (Hp != 0) {
size += 1 + pb::CodedOutputStream.ComputeInt32Size(Hp);
}
if (AcknowledgedMoveTick != 0L) {
size += 1 + pb::CodedOutputStream.ComputeInt64Size(AcknowledgedMoveTick);
}
if (_unknownFields != null) {
size += _unknownFields.CalculateSize();
}
@@ -2934,6 +2960,9 @@ namespace Network.Defines {
if (other.Hp != 0) {
Hp = other.Hp;
}
if (other.AcknowledgedMoveTick != 0L) {
AcknowledgedMoveTick = other.AcknowledgedMoveTick;
}
_unknownFields = pb::UnknownFieldSet.MergeFrom(_unknownFields, other._unknownFields);
}
@@ -2983,6 +3012,10 @@ namespace Network.Defines {
Hp = input.ReadInt32();
break;
}
case 56: {
AcknowledgedMoveTick = input.ReadInt64();
break;
}
}
}
#endif
@@ -3032,6 +3065,10 @@ namespace Network.Defines {
Hp = input.ReadInt32();
break;
}
case 56: {
AcknowledgedMoveTick = input.ReadInt64();
break;
}
}
}
}
@@ -75,6 +75,7 @@ message PlayerState {
float rotation = 4;
int64 tick = 5;
int32 hp = 6;
int64 acknowledged_move_tick = 7;
}
message Heartbeat {
@@ -0,0 +1,28 @@
using System;
using Network.Defines;
namespace Network.NetworkApplication
{
public readonly struct ClientMovementBootstrap
{
public ClientMovementBootstrap(int authoritativeMoveSpeed, long serverTick)
{
AuthoritativeMoveSpeed = authoritativeMoveSpeed;
ServerTick = serverTick;
}
public int AuthoritativeMoveSpeed { get; }
public long ServerTick { get; }
public static ClientMovementBootstrap FromLoginResponse(LoginResponse response)
{
if (response == null)
{
throw new ArgumentNullException(nameof(response));
}
return new ClientMovementBootstrap(response.Speed, response.ServerTick);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 0f82d57bd033abb439004c7ad1b201b0
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -24,6 +24,8 @@ namespace Network.NetworkApplication
public long? LastAuthoritativeTick { get; private set; }
public long? LastAcknowledgedMoveTick { get; private set; }
public IReadOnlyList<PredictedMoveStep> PendingInputs => pendingInputs;
public void Record(MoveInput input)
@@ -66,9 +68,10 @@ namespace Network.NetworkApplication
}
LastAuthoritativeTick = state.Tick;
pendingInputs.RemoveAll(input => input.Input.Tick <= state.Tick);
LastAcknowledgedMoveTick = state.AcknowledgedMoveTick;
pendingInputs.RemoveAll(input => input.Input.Tick <= state.AcknowledgedMoveTick);
replayInputs = pendingInputs.ToArray();
return true;
}
}
}
}
@@ -8,6 +8,8 @@ namespace Network.NetworkHost
public float TurnSpeedDegreesPerSecond { get; set; } = 180f;
public TimeSpan SimulationInterval { get; set; } = TimeSpan.FromMilliseconds(50);
public TimeSpan BroadcastInterval { get; set; } = TimeSpan.FromMilliseconds(50);
public int DefaultHp { get; set; } = 100;
@@ -24,6 +26,11 @@ namespace Network.NetworkHost
throw new ArgumentOutOfRangeException(nameof(TurnSpeedDegreesPerSecond), "Turn speed must be finite and non-negative.");
}
if (SimulationInterval <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(SimulationInterval), "Simulation interval must be positive.");
}
if (BroadcastInterval <= TimeSpan.Zero)
{
throw new ArgumentOutOfRangeException(nameof(BroadcastInterval), "Broadcast interval must be positive.");
@@ -17,6 +17,7 @@ namespace Network.NetworkHost
private readonly IAuthoritativeMovementWorldValidator worldValidator;
private readonly Dictionary<string, ServerAuthoritativeMovementState> statesByPeer = new();
private long nextBroadcastTick = 1;
private TimeSpan accumulatedSimulationTime;
private TimeSpan accumulatedBroadcastTime;
public ServerAuthoritativeMovementCoordinator(
@@ -31,6 +32,8 @@ namespace Network.NetworkHost
this.worldValidator = worldValidator ?? throw new ArgumentNullException(nameof(worldValidator));
}
public TimeSpan SimulationInterval => configuration.SimulationInterval;
public IReadOnlyList<ServerAuthoritativeMovementState> States
{
get
@@ -153,25 +156,30 @@ namespace Network.NetworkHost
lock (gate)
{
foreach (var state in statesByPeer.Values)
accumulatedSimulationTime += elapsed;
while (accumulatedSimulationTime >= configuration.SimulationInterval)
{
IntegrateState(state, elapsed);
}
accumulatedBroadcastTime += elapsed;
while (accumulatedBroadcastTime >= configuration.BroadcastInterval)
{
accumulatedBroadcastTime -= configuration.BroadcastInterval;
pendingBroadcasts ??= new List<PendingBroadcast>();
accumulatedSimulationTime -= configuration.SimulationInterval;
foreach (var state in statesByPeer.Values)
{
state.LastBroadcastTick = nextBroadcastTick;
pendingBroadcasts.Add(new PendingBroadcast(
state.RemoteEndPoint,
BuildPlayerState(state, nextBroadcastTick)));
IntegrateState(state, configuration.SimulationInterval);
}
nextBroadcastTick++;
accumulatedBroadcastTime += configuration.SimulationInterval;
while (accumulatedBroadcastTime >= configuration.BroadcastInterval)
{
accumulatedBroadcastTime -= configuration.BroadcastInterval;
pendingBroadcasts ??= new List<PendingBroadcast>();
foreach (var state in statesByPeer.Values)
{
state.LastBroadcastTick = nextBroadcastTick;
pendingBroadcasts.Add(new PendingBroadcast(
state.RemoteEndPoint,
BuildPlayerState(state, nextBroadcastTick)));
}
nextBroadcastTick++;
}
}
}
@@ -299,6 +307,7 @@ namespace Network.NetworkHost
lock (gate)
{
statesByPeer.Clear();
accumulatedSimulationTime = TimeSpan.Zero;
accumulatedBroadcastTime = TimeSpan.Zero;
}
}
@@ -460,7 +469,8 @@ namespace Network.NetworkHost
Z = state.VelocityZ
},
Rotation = state.Rotation,
Hp = state.Hp
Hp = state.Hp,
AcknowledgedMoveTick = state.LastAcceptedMoveTick
};
}
@@ -71,6 +71,8 @@ namespace Network.NetworkHost
public MultiSessionManager SessionCoordinator { get; }
public TimeSpan AuthoritativeMovementCadence => authoritativeMovementCoordinator.SimulationInterval;
public IReadOnlyList<ManagedNetworkSession> ManagedSessions => SessionCoordinator.Sessions;
public IReadOnlyList<ServerAuthoritativeMovementState> AuthoritativeMovementStates => authoritativeMovementCoordinator.States;
@@ -21,6 +21,8 @@ namespace Network.NetworkHost
public bool IsRunning { get; private set; }
public TimeSpan AuthoritativeMovementCadence => host.AuthoritativeMovementCadence;
public IReadOnlyList<ManagedNetworkSession> ManagedSessions => host.ManagedSessions;
public IReadOnlyList<ServerAuthoritativeMovementState> AuthoritativeMovementStates => host.AuthoritativeMovementStates;
+3 -2
View File
@@ -1,4 +1,5 @@
using Network.Defines;
using Network.NetworkApplication;
using UnityEngine;
public class Player : MonoBehaviour
@@ -15,7 +16,7 @@ public class Player : MonoBehaviour
[SerializeField] private bool _isControlled;
private readonly ClientAuthoritativePlayerState _authoritativeState = new();
public void LocalInit(string playerId, int speed, long serverTick)
public void LocalInit(string playerId, ClientMovementBootstrap bootstrap)
{
this.PlayerId = playerId;
this._isControlled = true;
@@ -24,7 +25,7 @@ public class Player : MonoBehaviour
_meshRenderer.material = _materials[idx];
_playerUI.Init(this);
_movement.Init(true, this, speed, serverTick);
_movement.Init(true, this, bootstrap);
}
public void RemoteInit(string playerId, UnityEngine.Vector3 pos)