clearup
This commit is contained in:
@@ -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:
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System.Net;
|
||||
using System.Reflection;
|
||||
using Network.Defines;
|
||||
using Network.NetworkApplication;
|
||||
using NUnit.Framework;
|
||||
@@ -125,6 +126,71 @@ namespace Tests.EditMode.Network
|
||||
Assert.That(combatPresentation.IsDead, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClientGameplayFlow_ControlledPlayerReconciliation_ReplacesActiveCorrectionForConsecutiveSmallSnapshots()
|
||||
{
|
||||
var gameObject = new GameObject("controlled-player");
|
||||
try
|
||||
{
|
||||
var rigidbody = gameObject.AddComponent<Rigidbody>();
|
||||
rigidbody.useGravity = false;
|
||||
var movement = gameObject.AddComponent<MovementComponent>();
|
||||
typeof(MovementComponent)
|
||||
.GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(movement, rigidbody);
|
||||
movement.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
|
||||
movement.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
|
||||
GameplayFlowTestSupport.CreatePlayerState("player-1", 1, new Vector3(0.75f, 0f, 0f), acknowledgedMoveTick: 0)));
|
||||
InvokeControlledFixedUpdate(movement);
|
||||
Assert.That(rigidbody.position.x, Is.EqualTo(0.5f).Within(0.0001f));
|
||||
|
||||
movement.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
|
||||
GameplayFlowTestSupport.CreatePlayerState("player-1", 2, new Vector3(1f, 0f, 0f), acknowledgedMoveTick: 0)));
|
||||
InvokeControlledFixedUpdate(movement);
|
||||
Assert.That(rigidbody.position.x, Is.EqualTo(1f).Within(0.0001f));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClientGameplayFlow_ControlledPlayerReconciliation_EscalatesToSnapAfterFailedConvergence()
|
||||
{
|
||||
var gameObject = new GameObject("controlled-player");
|
||||
try
|
||||
{
|
||||
var rigidbody = gameObject.AddComponent<Rigidbody>();
|
||||
rigidbody.useGravity = false;
|
||||
var movement = gameObject.AddComponent<MovementComponent>();
|
||||
typeof(MovementComponent)
|
||||
.GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(movement, rigidbody);
|
||||
movement.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
|
||||
movement.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
|
||||
GameplayFlowTestSupport.CreatePlayerState("player-1", 1, new Vector3(0.75f, 0f, 0f), acknowledgedMoveTick: 0)));
|
||||
InvokeControlledFixedUpdate(movement);
|
||||
Assert.That(rigidbody.position.x, Is.EqualTo(0.5f).Within(0.0001f));
|
||||
|
||||
movement.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
|
||||
GameplayFlowTestSupport.CreatePlayerState("player-1", 2, new Vector3(1.25f, 0f, 0f), acknowledgedMoveTick: 0)));
|
||||
InvokeControlledFixedUpdate(movement);
|
||||
Assert.That(rigidbody.position.x, Is.EqualTo(1f).Within(0.0001f));
|
||||
|
||||
movement.OnAuthoritativeState(new ClientAuthoritativePlayerStateSnapshot(
|
||||
GameplayFlowTestSupport.CreatePlayerState("player-1", 3, new Vector3(1.75f, 0f, 0f), acknowledgedMoveTick: 0)));
|
||||
InvokeControlledFixedUpdate(movement);
|
||||
Assert.That(rigidbody.position.x, Is.EqualTo(1.75f).Within(0.0001f));
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClientGameplayHarness_RemotePlayerStateFlow_RejectsStaleSnapshots_AndUsesInterpolationOrLatestClamp()
|
||||
{
|
||||
@@ -157,5 +223,11 @@ namespace Tests.EditMode.Network
|
||||
Assert.That(clamped.LatestSnapshot.Tick, Is.EqualTo(11));
|
||||
Assert.That(clamped.Position, Is.EqualTo(new Vector3(10f, 0f, 0f)));
|
||||
}
|
||||
private static void InvokeControlledFixedUpdate(MovementComponent movement)
|
||||
{
|
||||
typeof(MovementComponent)
|
||||
.GetMethod("FixedUpdate", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.Invoke(movement, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,6 +96,7 @@ namespace Tests.EditMode.Network
|
||||
TransferBroadcastMessages(serverTransports[9001], clientSyncTransport, ServerSender);
|
||||
clientRuntime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
|
||||
|
||||
Assert.That(serverRuntime.AuthoritativeMovementCadence, Is.EqualTo(TimeSpan.FromMilliseconds(50)));
|
||||
Assert.That(serverRuntime.TryGetAuthoritativeMovementState(ClientPeer, out var localServerState), Is.True);
|
||||
Assert.That(localServerState.PlayerId, Is.EqualTo("player-a"));
|
||||
Assert.That(localServerState.PositionX, Is.EqualTo(0.5f).Within(0.0001f));
|
||||
@@ -105,6 +106,7 @@ namespace Tests.EditMode.Network
|
||||
|
||||
Assert.That(clientHarness.TryGetState("player-a", out var localClientState), Is.True);
|
||||
Assert.That(localClientState.Tick, Is.EqualTo(1));
|
||||
Assert.That(localClientState.AcknowledgedMoveTick, Is.EqualTo(1));
|
||||
Assert.That(localClientState.Position.x, Is.EqualTo(0.5f).Within(0.0001f));
|
||||
Assert.That(clientHarness.TryGetState("player-b", out var remoteClientState), Is.True);
|
||||
Assert.That(remoteClientState.Hp, Is.EqualTo(70));
|
||||
@@ -177,6 +179,7 @@ namespace Tests.EditMode.Network
|
||||
|
||||
Assert.That(clientHarness.TryGetState("player-a", out var idleLocalState), Is.True);
|
||||
Assert.That(idleLocalState.Tick, Is.EqualTo(1));
|
||||
Assert.That(idleLocalState.AcknowledgedMoveTick, Is.EqualTo(0));
|
||||
Assert.That(idleLocalState.Position.x, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(idleLocalState.Position.z, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(idleLocalState.Hp, Is.EqualTo(100));
|
||||
|
||||
@@ -213,7 +213,7 @@ namespace Tests.EditMode.Network
|
||||
}.ToByteArray();
|
||||
}
|
||||
|
||||
public static PlayerState CreatePlayerState(string playerId, long tick, Vector3 position, int hp = 100, float rotation = 0f, Vector3? velocity = null)
|
||||
public static PlayerState CreatePlayerState(string playerId, long tick, Vector3 position, int hp = 100, float rotation = 0f, Vector3? velocity = null, long acknowledgedMoveTick = 0)
|
||||
{
|
||||
var resolvedVelocity = velocity ?? Vector3.zero;
|
||||
return new PlayerState
|
||||
@@ -223,7 +223,8 @@ namespace Tests.EditMode.Network
|
||||
Position = new global::Network.Defines.Vector3 { X = position.x, Y = position.y, Z = position.z },
|
||||
Velocity = new global::Network.Defines.Vector3 { X = resolvedVelocity.x, Y = resolvedVelocity.y, Z = resolvedVelocity.z },
|
||||
Rotation = rotation,
|
||||
Hp = hp
|
||||
Hp = hp,
|
||||
AcknowledgedMoveTick = acknowledgedMoveTick
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,55 @@ namespace Tests.EditMode.Network
|
||||
private static readonly IPEndPoint PeerA = new(IPAddress.Loopback, 9101);
|
||||
private static readonly IPEndPoint PeerB = new(IPAddress.Loopback, 9102);
|
||||
|
||||
[Test]
|
||||
public void UpdateAuthoritativeMovement_UsesConfiguredSimulationCadence_AndExposesItOnRuntime()
|
||||
{
|
||||
var createdTransports = new Dictionary<int, FakeTransport>();
|
||||
var configuration = new ServerRuntimeConfiguration(9000)
|
||||
{
|
||||
Dispatcher = new MainThreadNetworkDispatcher(),
|
||||
TransportFactory = port => CreateTransport(createdTransports, port),
|
||||
AuthoritativeMovement = new ServerAuthoritativeMovementConfiguration
|
||||
{
|
||||
MoveSpeed = 4f,
|
||||
SimulationInterval = TimeSpan.FromMilliseconds(50),
|
||||
BroadcastInterval = TimeSpan.FromMilliseconds(100)
|
||||
}
|
||||
};
|
||||
|
||||
using var runtime = ServerRuntimeEntryPoint.StartAsync(configuration).GetAwaiter().GetResult();
|
||||
|
||||
runtime.Host.NotifyLoginStarted(PeerA);
|
||||
runtime.Host.NotifyLoginSucceeded(PeerA, "player-a");
|
||||
createdTransports[9000].EmitReceive(BuildEnvelope(MessageType.MoveInput, new MoveInput
|
||||
{
|
||||
PlayerId = "player-a",
|
||||
Tick = 1,
|
||||
TurnInput = 0f,
|
||||
ThrottleInput = 1f
|
||||
}), PeerA);
|
||||
|
||||
runtime.DrainPendingMessagesAsync().GetAwaiter().GetResult();
|
||||
runtime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(49));
|
||||
|
||||
Assert.That(runtime.AuthoritativeMovementCadence, Is.EqualTo(TimeSpan.FromMilliseconds(50)));
|
||||
Assert.That(runtime.TryGetAuthoritativeMovementState(PeerA, out var stateBeforeCadence), Is.True);
|
||||
Assert.That(stateBeforeCadence.PositionX, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(createdTransports[9000].BroadcastMessages.Count, Is.EqualTo(0));
|
||||
|
||||
runtime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(1));
|
||||
|
||||
Assert.That(runtime.TryGetAuthoritativeMovementState(PeerA, out var stateAfterFirstStep), Is.True);
|
||||
Assert.That(stateAfterFirstStep.PositionX, Is.EqualTo(0.2f).Within(0.0001f));
|
||||
Assert.That(createdTransports[9000].BroadcastMessages.Count, Is.EqualTo(0));
|
||||
|
||||
runtime.UpdateAuthoritativeMovement(TimeSpan.FromMilliseconds(50));
|
||||
|
||||
Assert.That(runtime.TryGetAuthoritativeMovementState(PeerA, out var stateAfterSecondStep), Is.True);
|
||||
Assert.That(stateAfterSecondStep.PositionX, Is.EqualTo(0.4f).Within(0.0001f));
|
||||
Assert.That(createdTransports[9000].BroadcastMessages.Count, Is.EqualTo(1));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UpdateAuthoritativeMovement_AcceptsLatestTickPerPeer_AndKeepsStaleFilteringIndependent()
|
||||
{
|
||||
@@ -118,6 +167,7 @@ namespace Tests.EditMode.Network
|
||||
var firstBroadcast = ParsePlayerState(createdTransports[9001].BroadcastMessages[0]);
|
||||
Assert.That(firstBroadcast.PlayerId, Is.EqualTo("player-a"));
|
||||
Assert.That(firstBroadcast.Tick, Is.EqualTo(1));
|
||||
Assert.That(firstBroadcast.AcknowledgedMoveTick, Is.EqualTo(1));
|
||||
Assert.That(firstBroadcast.Position.X, Is.EqualTo(1f).Within(0.0001f));
|
||||
Assert.That(firstBroadcast.Velocity.X, Is.EqualTo(10f).Within(0.0001f));
|
||||
Assert.That(firstBroadcast.Velocity.Z, Is.EqualTo(0f).Within(0.0001f));
|
||||
@@ -141,6 +191,7 @@ namespace Tests.EditMode.Network
|
||||
|
||||
var secondBroadcast = ParsePlayerState(createdTransports[9001].BroadcastMessages[1]);
|
||||
Assert.That(secondBroadcast.Tick, Is.EqualTo(2));
|
||||
Assert.That(secondBroadcast.AcknowledgedMoveTick, Is.EqualTo(2));
|
||||
Assert.That(secondBroadcast.Position.X, Is.EqualTo(1f).Within(0.0001f));
|
||||
Assert.That(secondBroadcast.Velocity.X, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(secondBroadcast.Velocity.Z, Is.EqualTo(0f).Within(0.0001f));
|
||||
@@ -186,6 +237,7 @@ namespace Tests.EditMode.Network
|
||||
var broadcast = ParsePlayerState(createdTransports[9001].BroadcastMessages[0]);
|
||||
Assert.That(broadcast.PlayerId, Is.EqualTo("player-a"));
|
||||
Assert.That(broadcast.Tick, Is.EqualTo(1));
|
||||
Assert.That(broadcast.AcknowledgedMoveTick, Is.EqualTo(0));
|
||||
Assert.That(broadcast.Position.X, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(broadcast.Position.Z, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(broadcast.Velocity.X, Is.EqualTo(0f).Within(0.0001f));
|
||||
@@ -228,6 +280,7 @@ namespace Tests.EditMode.Network
|
||||
|
||||
var broadcast = ParsePlayerState(createdTransports[9000].BroadcastMessages[0]);
|
||||
Assert.That(broadcast.Tick, Is.EqualTo(1));
|
||||
Assert.That(broadcast.AcknowledgedMoveTick, Is.EqualTo(5));
|
||||
Assert.That(broadcast.Position.X, Is.EqualTo(-0.3f).Within(0.0001f));
|
||||
Assert.That(broadcast.Position.Z, Is.EqualTo(0f).Within(0.0001f));
|
||||
Assert.That(broadcast.Velocity.X, Is.EqualTo(-6f).Within(0.0001f));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Reflection;
|
||||
using Google.Protobuf;
|
||||
using Network.Defines;
|
||||
using Network.NetworkApplication;
|
||||
@@ -90,11 +91,12 @@ namespace Tests.EditMode.Network
|
||||
buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 12, ThrottleInput = 1f });
|
||||
|
||||
var accepted = buffer.TryApplyAuthoritativeState(
|
||||
new PlayerState { PlayerId = "player-1", Tick = 11 },
|
||||
new PlayerState { PlayerId = "player-1", Tick = 11, AcknowledgedMoveTick = 11 },
|
||||
out var replayInputs);
|
||||
|
||||
Assert.That(accepted, Is.True);
|
||||
Assert.That(buffer.LastAuthoritativeTick, Is.EqualTo(11));
|
||||
Assert.That(buffer.LastAcknowledgedMoveTick, Is.EqualTo(11));
|
||||
Assert.That(replayInputs.Count, Is.EqualTo(1));
|
||||
Assert.That(replayInputs[0].Input.Tick, Is.EqualTo(12));
|
||||
Assert.That(replayInputs[0].SimulatedDurationSeconds, Is.EqualTo(0f));
|
||||
@@ -106,15 +108,121 @@ namespace Tests.EditMode.Network
|
||||
{
|
||||
var buffer = new ClientPredictionBuffer();
|
||||
buffer.Record(new MoveInput { PlayerId = "player-1", Tick = 10, ThrottleInput = 1f });
|
||||
buffer.TryApplyAuthoritativeState(new PlayerState { PlayerId = "player-1", Tick = 10 }, out _);
|
||||
buffer.TryApplyAuthoritativeState(new PlayerState { PlayerId = "player-1", Tick = 10, AcknowledgedMoveTick = 10 }, out _);
|
||||
|
||||
var accepted = buffer.TryApplyAuthoritativeState(
|
||||
new PlayerState { PlayerId = "player-1", Tick = 9 },
|
||||
new PlayerState { PlayerId = "player-1", Tick = 9, AcknowledgedMoveTick = 9 },
|
||||
out var replayInputs);
|
||||
|
||||
Assert.That(accepted, Is.False);
|
||||
Assert.That(replayInputs, Is.Empty);
|
||||
Assert.That(buffer.LastAuthoritativeTick, Is.EqualTo(10));
|
||||
Assert.That(buffer.LastAcknowledgedMoveTick, Is.EqualTo(10));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ClientMovementBootstrap_LoginResponse_UsesServerConfirmedMovementParameters()
|
||||
{
|
||||
var bootstrap = ClientMovementBootstrap.FromLoginResponse(new LoginResponse
|
||||
{
|
||||
Speed = 12,
|
||||
ServerTick = 34
|
||||
});
|
||||
|
||||
Assert.That(bootstrap.AuthoritativeMoveSpeed, Is.EqualTo(12));
|
||||
Assert.That(bootstrap.ServerTick, Is.EqualTo(34));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ControlledPlayerCorrection_SmallError_UsesBoundedCorrection()
|
||||
{
|
||||
var result = ControlledPlayerCorrection.Resolve(
|
||||
Vector3.zero,
|
||||
Quaternion.identity,
|
||||
new Vector3(0.75f, 0f, 0f),
|
||||
Quaternion.Euler(0f, 15f, 0f),
|
||||
new ControlledPlayerCorrectionSettings(0.05f, 10f, 180f));
|
||||
|
||||
Assert.That(result.UsedHardSnap, Is.False);
|
||||
Assert.That(result.Position, Is.EqualTo(new Vector3(0.5f, 0f, 0f)));
|
||||
Assert.That(result.Rotation.eulerAngles.y, Is.EqualTo(9f).Within(0.01f));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ControlledPlayerCorrection_LargeError_UsesHardSnap()
|
||||
{
|
||||
var targetRotation = Quaternion.Euler(0f, 40f, 0f);
|
||||
var result = ControlledPlayerCorrection.Resolve(
|
||||
Vector3.zero,
|
||||
Quaternion.identity,
|
||||
new Vector3(2f, 0f, 0f),
|
||||
targetRotation,
|
||||
new ControlledPlayerCorrectionSettings(0.05f, 10f, 180f));
|
||||
|
||||
Assert.That(result.UsedHardSnap, Is.True);
|
||||
Assert.That(result.Position, Is.EqualTo(new Vector3(2f, 0f, 0f)));
|
||||
Assert.That(result.Rotation.eulerAngles.y, Is.EqualTo(targetRotation.eulerAngles.y).Within(0.01f));
|
||||
Assert.That(result.NextState.IsActive, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ControlledPlayerCorrection_RepeatedSmallCorrections_UpdateActiveCorrectionState()
|
||||
{
|
||||
var settings = new ControlledPlayerCorrectionSettings(0.05f, 10f, 180f);
|
||||
var first = ControlledPlayerCorrection.Resolve(
|
||||
Vector3.zero,
|
||||
Quaternion.identity,
|
||||
new Vector3(0.75f, 0f, 0f),
|
||||
Quaternion.identity,
|
||||
settings);
|
||||
var second = ControlledPlayerCorrection.Resolve(
|
||||
first.Position,
|
||||
first.Rotation,
|
||||
new Vector3(1f, 0f, 0f),
|
||||
Quaternion.identity,
|
||||
settings,
|
||||
first.NextState);
|
||||
|
||||
Assert.That(first.UsedHardSnap, Is.False);
|
||||
Assert.That(first.NextState.IsActive, Is.True);
|
||||
Assert.That(first.NextState.RemainingStepBudget, Is.EqualTo(2));
|
||||
Assert.That(second.UsedHardSnap, Is.False);
|
||||
Assert.That(second.Position.x, Is.EqualTo(1f).Within(0.0001f));
|
||||
Assert.That(second.NextState.IsActive, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ControlledPlayerCorrection_RepeatedNonConvergentSmallCorrections_EventuallyUseHardSnap()
|
||||
{
|
||||
var settings = new ControlledPlayerCorrectionSettings(0.05f, 10f, 180f);
|
||||
var first = ControlledPlayerCorrection.Resolve(
|
||||
Vector3.zero,
|
||||
Quaternion.identity,
|
||||
new Vector3(0.75f, 0f, 0f),
|
||||
Quaternion.identity,
|
||||
settings);
|
||||
var second = ControlledPlayerCorrection.Resolve(
|
||||
first.Position,
|
||||
first.Rotation,
|
||||
new Vector3(1.25f, 0f, 0f),
|
||||
Quaternion.identity,
|
||||
settings,
|
||||
first.NextState);
|
||||
var third = ControlledPlayerCorrection.Resolve(
|
||||
second.Position,
|
||||
second.Rotation,
|
||||
new Vector3(1.75f, 0f, 0f),
|
||||
Quaternion.identity,
|
||||
settings,
|
||||
second.NextState);
|
||||
|
||||
Assert.That(first.UsedHardSnap, Is.False);
|
||||
Assert.That(second.UsedHardSnap, Is.False);
|
||||
Assert.That(second.NextState.IsActive, Is.True);
|
||||
Assert.That(second.NextState.RemainingStepBudget, Is.EqualTo(1));
|
||||
Assert.That(third.UsedHardSnap, Is.True);
|
||||
Assert.That(third.Position.x, Is.EqualTo(1.75f).Within(0.0001f));
|
||||
Assert.That(third.NextState.IsActive, Is.False);
|
||||
}
|
||||
|
||||
[Test]
|
||||
@@ -129,7 +237,8 @@ namespace Tests.EditMode.Network
|
||||
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
|
||||
Hp = 73,
|
||||
AcknowledgedMoveTick = 9
|
||||
},
|
||||
out var snapshot);
|
||||
|
||||
@@ -137,6 +246,7 @@ namespace Tests.EditMode.Network
|
||||
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.AcknowledgedMoveTick, Is.EqualTo(9));
|
||||
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));
|
||||
@@ -360,7 +470,7 @@ namespace Tests.EditMode.Network
|
||||
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.Rotation.eulerAngles.y, Is.EqualTo(75f).Within(0.01f));
|
||||
Assert.That(sample.LatestSnapshot.Tick, Is.EqualTo(12));
|
||||
}
|
||||
|
||||
@@ -392,6 +502,172 @@ namespace Tests.EditMode.Network
|
||||
Assert.That(runtime.ClockSync.CurrentServerTick, Is.EqualTo(88));
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReplayPendingInputs_StepByStepMatchesAccumulated_ForZeroTurnInput()
|
||||
{
|
||||
// Arrange: set up MovementComponent with initial state.
|
||||
var gameObject = new GameObject("replay-test");
|
||||
try
|
||||
{
|
||||
var rigidbody = gameObject.AddComponent<Rigidbody>();
|
||||
rigidbody.useGravity = false;
|
||||
var movement = gameObject.AddComponent<MovementComponent>();
|
||||
typeof(MovementComponent)
|
||||
.GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(movement, rigidbody);
|
||||
movement.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
|
||||
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
|
||||
|
||||
var turnInput = 0f;
|
||||
var throttleInput = 1f;
|
||||
var stepDuration = 0.05f;
|
||||
var totalDuration = stepDuration * 3; // 0.15s
|
||||
|
||||
// Act — step-by-step path (live prediction shape).
|
||||
ApplyTankMovementStepByStep(movement, turnInput, throttleInput, stepDuration, steps: 3);
|
||||
var stepByStepPosition = rigidbody.position;
|
||||
var stepByStepRotation = rigidbody.rotation;
|
||||
|
||||
// Reset to initial state.
|
||||
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
|
||||
|
||||
// Act — accumulated replay shape.
|
||||
var accumulatedReplayInputs = new List<PredictedMoveStep>
|
||||
{
|
||||
new PredictedMoveStep(
|
||||
new MoveInput { PlayerId = "player-1", Tick = 1, TurnInput = turnInput, ThrottleInput = throttleInput },
|
||||
totalDuration)
|
||||
};
|
||||
InvokeReplayPendingInputs(movement, accumulatedReplayInputs);
|
||||
var accumulatedPosition = rigidbody.position;
|
||||
var accumulatedRotation = rigidbody.rotation;
|
||||
|
||||
// Assert: for straight movement (turn=0), both paths should be identical.
|
||||
Assert.That(Vector3.Distance(accumulatedPosition, stepByStepPosition), Is.LessThan(0.0001f),
|
||||
"Accumulated replay produced a different position than step-by-step for straight movement.");
|
||||
Assert.That(Quaternion.Angle(accumulatedRotation, stepByStepRotation), Is.LessThan(0.01f),
|
||||
"Accumulated replay produced a different rotation than step-by-step for straight movement.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReplayPendingInputs_StepByStepDiffersFromAccumulated_ForNonZeroTurnInput()
|
||||
{
|
||||
// Arrange: use many small steps and a large turn input to make non-linearity visible.
|
||||
var gameObject = new GameObject("replay-test");
|
||||
try
|
||||
{
|
||||
var rigidbody = gameObject.AddComponent<Rigidbody>();
|
||||
rigidbody.useGravity = false;
|
||||
var movement = gameObject.AddComponent<MovementComponent>();
|
||||
typeof(MovementComponent)
|
||||
.GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(movement, rigidbody);
|
||||
movement.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
|
||||
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
|
||||
|
||||
// Use 20 substeps of 0.05s (1 second total) with full turn.
|
||||
// This amplifies the non-linearity so the one-shot and step-by-step diverge.
|
||||
var turnInput = 1f;
|
||||
var throttleInput = 1f;
|
||||
var stepDuration = 0.05f;
|
||||
var steps = 20;
|
||||
var totalDuration = stepDuration * steps;
|
||||
|
||||
// Act — step-by-step (correct approach).
|
||||
ApplyTankMovementStepByStep(movement, turnInput, throttleInput, stepDuration, steps);
|
||||
var stepByStepPosition = rigidbody.position;
|
||||
|
||||
// Reset.
|
||||
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
|
||||
|
||||
// Act — ONE big step simulating the old buggy accumulated behavior.
|
||||
ApplyTankMovementStepByStep(movement, turnInput, throttleInput, totalDuration, steps: 1);
|
||||
var oneShotPosition = rigidbody.position;
|
||||
|
||||
// Assert: for non-zero turn with many steps, the old one-shot and correct step-by-step MUST differ.
|
||||
Assert.That(Vector3.Distance(oneShotPosition, stepByStepPosition), Is.GreaterThan(0.001f),
|
||||
"One-shot accumulated and step-by-step produced the same result for turn input — " +
|
||||
"the non-linearity should cause a visible divergence with many steps.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ReplayPendingInputs_NonMultipleOfCadence_HandlesRemainingDuration()
|
||||
{
|
||||
// Arrange: simulate 0.12s — not a multiple of 50ms.
|
||||
var gameObject = new GameObject("replay-test");
|
||||
try
|
||||
{
|
||||
var rigidbody = gameObject.AddComponent<Rigidbody>();
|
||||
rigidbody.useGravity = false;
|
||||
var movement = gameObject.AddComponent<MovementComponent>();
|
||||
typeof(MovementComponent)
|
||||
.GetField("_rigid", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.SetValue(movement, rigidbody);
|
||||
movement.Init(true, master: null, speed: 10, serverTick: 0);
|
||||
|
||||
ResetMovementState(rigidbody, Vector3.zero, Quaternion.identity);
|
||||
|
||||
var turnInput = 0f;
|
||||
var throttleInput = 1f;
|
||||
var totalDuration = 0.12f; // 0.05 + 0.05 + 0.02
|
||||
|
||||
var replayInputs = new List<PredictedMoveStep>
|
||||
{
|
||||
new PredictedMoveStep(
|
||||
new MoveInput { PlayerId = "player-1", Tick = 1, TurnInput = turnInput, ThrottleInput = throttleInput },
|
||||
totalDuration)
|
||||
};
|
||||
InvokeReplayPendingInputs(movement, replayInputs);
|
||||
var finalPosition = rigidbody.position;
|
||||
|
||||
// Expected: 0.12s at speed=10 → 1.2 units forward.
|
||||
var expectedPosition = new Vector3(0f, 0f, 1.2f);
|
||||
Assert.That(finalPosition.z, Is.EqualTo(expectedPosition.z).Within(0.0001f),
|
||||
"Non-multiple of cadence (0.12s) had remaining duration lost or misapplied.");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Object.DestroyImmediate(gameObject);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyTankMovementStepByStep(MovementComponent movement, float turnInput, float throttleInput, float stepDuration, int steps)
|
||||
{
|
||||
var method = typeof(MovementComponent)
|
||||
.GetMethod("ApplyTankMovement", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
for (var i = 0; i < steps; i++)
|
||||
{
|
||||
method.Invoke(movement, new object[] { turnInput, throttleInput, stepDuration });
|
||||
}
|
||||
}
|
||||
|
||||
private static void ResetMovementState(Rigidbody rigidbody, Vector3 position, Quaternion rotation)
|
||||
{
|
||||
rigidbody.position = position;
|
||||
rigidbody.rotation = rotation;
|
||||
rigidbody.velocity = Vector3.zero;
|
||||
rigidbody.angularVelocity = Vector3.zero;
|
||||
}
|
||||
|
||||
private static void InvokeReplayPendingInputs(MovementComponent movement, IReadOnlyList<PredictedMoveStep> inputs)
|
||||
{
|
||||
typeof(MovementComponent)
|
||||
.GetMethod("ReplayPendingInputs", BindingFlags.Instance | BindingFlags.NonPublic)
|
||||
.Invoke(movement, new object[] { inputs });
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ServerNetworkHost_RejectsStaleMoveInputPerPeerWithoutCrossPeerInterference()
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user