process TODO.md step7
This commit is contained in:
@@ -58,7 +58,8 @@ namespace Network.NetworkApplication
|
||||
Func<DateTimeOffset> utcNowProvider = null,
|
||||
IMessageDeliveryPolicyResolver deliveryPolicyResolver = null,
|
||||
SyncSequenceTracker syncSequenceTracker = null,
|
||||
Func<int, ITransport> transportFactory = null)
|
||||
Func<int, ITransport> transportFactory = null,
|
||||
ServerAuthoritativeMovementConfiguration authoritativeMovement = null)
|
||||
{
|
||||
ValidateDualPortConfiguration(reliablePort, syncPort);
|
||||
|
||||
@@ -82,7 +83,8 @@ namespace Network.NetworkApplication
|
||||
utcNowProvider,
|
||||
syncTransport,
|
||||
deliveryPolicyResolver,
|
||||
syncSequenceTracker);
|
||||
syncSequenceTracker,
|
||||
authoritativeMovement);
|
||||
}
|
||||
|
||||
public static Task<ServerRuntimeHandle> StartServerRuntimeAsync(ServerRuntimeConfiguration configuration)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using System;
|
||||
|
||||
namespace Network.NetworkHost
|
||||
{
|
||||
public sealed class ServerAuthoritativeMovementConfiguration
|
||||
{
|
||||
public float MoveSpeed { get; set; } = 5f;
|
||||
|
||||
public TimeSpan BroadcastInterval { get; set; } = TimeSpan.FromMilliseconds(50);
|
||||
|
||||
public int DefaultHp { get; set; } = 100;
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (float.IsNaN(MoveSpeed) || float.IsInfinity(MoveSpeed) || MoveSpeed < 0f)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(MoveSpeed), "Move speed must be finite and non-negative.");
|
||||
}
|
||||
|
||||
if (BroadcastInterval <= TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(BroadcastInterval), "Broadcast interval must be positive.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8b8b5d0a5ff84ab696e3b302f92314e1
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,301 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using Network.Defines;
|
||||
using Network.NetworkApplication;
|
||||
|
||||
namespace Network.NetworkHost
|
||||
{
|
||||
internal sealed class ServerAuthoritativeMovementCoordinator
|
||||
{
|
||||
private readonly object gate = new();
|
||||
private readonly MessageManager messageManager;
|
||||
private readonly ServerNetworkHost host;
|
||||
private readonly ServerAuthoritativeMovementConfiguration configuration;
|
||||
private readonly Dictionary<string, ServerAuthoritativeMovementState> statesByPeer = new();
|
||||
private long nextBroadcastTick = 1;
|
||||
private TimeSpan accumulatedBroadcastTime;
|
||||
|
||||
public ServerAuthoritativeMovementCoordinator(
|
||||
ServerNetworkHost host,
|
||||
MessageManager messageManager,
|
||||
ServerAuthoritativeMovementConfiguration configuration)
|
||||
{
|
||||
this.host = host ?? throw new ArgumentNullException(nameof(host));
|
||||
this.messageManager = messageManager ?? throw new ArgumentNullException(nameof(messageManager));
|
||||
this.configuration = configuration ?? throw new ArgumentNullException(nameof(configuration));
|
||||
}
|
||||
|
||||
public IReadOnlyList<ServerAuthoritativeMovementState> States
|
||||
{
|
||||
get
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
return statesByPeer.Values
|
||||
.Select(CloneState)
|
||||
.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public Task HandleMoveInputAsync(byte[] payload, IPEndPoint sender)
|
||||
{
|
||||
if (payload == null || sender == null)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
MoveInput input;
|
||||
try
|
||||
{
|
||||
input = MoveInput.Parser.ParseFrom(payload);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(input.PlayerId) ||
|
||||
!IsFinite(input.MoveX) ||
|
||||
!IsFinite(input.MoveY))
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var normalizedSender = Normalize(sender);
|
||||
var key = normalizedSender.ToString();
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (statesByPeer.TryGetValue(key, out var existingState))
|
||||
{
|
||||
if (!string.Equals(existingState.PlayerId, input.PlayerId, StringComparison.Ordinal) ||
|
||||
input.Tick <= existingState.LastAcceptedMoveTick)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
ApplyInput(existingState, input);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
var state = new ServerAuthoritativeMovementState(
|
||||
normalizedSender,
|
||||
input.PlayerId,
|
||||
configuration.DefaultHp);
|
||||
ApplyInput(state, input);
|
||||
statesByPeer.Add(key, state);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
public void Update(TimeSpan elapsed)
|
||||
{
|
||||
if (elapsed < TimeSpan.Zero)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(elapsed), "Elapsed time must be non-negative.");
|
||||
}
|
||||
|
||||
List<PendingBroadcast> pendingBroadcasts = null;
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
foreach (var state in statesByPeer.Values)
|
||||
{
|
||||
IntegrateState(state, elapsed);
|
||||
}
|
||||
|
||||
accumulatedBroadcastTime += elapsed;
|
||||
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++;
|
||||
}
|
||||
}
|
||||
|
||||
if (pendingBroadcasts == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (var pendingBroadcast in pendingBroadcasts)
|
||||
{
|
||||
messageManager.BroadcastMessage(pendingBroadcast.PlayerState, MessageType.PlayerState);
|
||||
host.ObserveAuthoritativeState(pendingBroadcast.RemoteEndPoint, pendingBroadcast.PlayerState.Tick);
|
||||
}
|
||||
}
|
||||
|
||||
public bool TryGetState(IPEndPoint remoteEndPoint, out ServerAuthoritativeMovementState state)
|
||||
{
|
||||
var key = Normalize(remoteEndPoint).ToString();
|
||||
|
||||
lock (gate)
|
||||
{
|
||||
if (statesByPeer.TryGetValue(key, out state))
|
||||
{
|
||||
state = CloneState(state);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
state = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
public void RemoveState(IPEndPoint remoteEndPoint)
|
||||
{
|
||||
var key = Normalize(remoteEndPoint).ToString();
|
||||
lock (gate)
|
||||
{
|
||||
statesByPeer.Remove(key);
|
||||
}
|
||||
}
|
||||
|
||||
public void Clear()
|
||||
{
|
||||
lock (gate)
|
||||
{
|
||||
statesByPeer.Clear();
|
||||
accumulatedBroadcastTime = TimeSpan.Zero;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsFinite(float value)
|
||||
{
|
||||
return !float.IsNaN(value) && !float.IsInfinity(value);
|
||||
}
|
||||
|
||||
private static IPEndPoint Normalize(IPEndPoint remoteEndPoint)
|
||||
{
|
||||
if (remoteEndPoint == null)
|
||||
{
|
||||
throw new ArgumentNullException(nameof(remoteEndPoint));
|
||||
}
|
||||
|
||||
return new IPEndPoint(remoteEndPoint.Address, remoteEndPoint.Port);
|
||||
}
|
||||
|
||||
private static ServerAuthoritativeMovementState CloneState(ServerAuthoritativeMovementState state)
|
||||
{
|
||||
return new ServerAuthoritativeMovementState(state.RemoteEndPoint, state.PlayerId, state.Hp)
|
||||
{
|
||||
LastAcceptedMoveTick = state.LastAcceptedMoveTick,
|
||||
LastBroadcastTick = state.LastBroadcastTick,
|
||||
PositionX = state.PositionX,
|
||||
PositionY = state.PositionY,
|
||||
PositionZ = state.PositionZ,
|
||||
VelocityX = state.VelocityX,
|
||||
VelocityY = state.VelocityY,
|
||||
VelocityZ = state.VelocityZ,
|
||||
Rotation = state.Rotation,
|
||||
InputX = state.InputX,
|
||||
InputY = state.InputY
|
||||
};
|
||||
}
|
||||
|
||||
private static void ApplyInput(ServerAuthoritativeMovementState state, MoveInput input)
|
||||
{
|
||||
state.LastAcceptedMoveTick = input.Tick;
|
||||
state.InputX = input.MoveX;
|
||||
state.InputY = input.MoveY;
|
||||
|
||||
if (input.MoveX == 0f && input.MoveY == 0f)
|
||||
{
|
||||
state.VelocityX = 0f;
|
||||
state.VelocityY = 0f;
|
||||
state.VelocityZ = 0f;
|
||||
return;
|
||||
}
|
||||
|
||||
var length = MathF.Sqrt((input.MoveX * input.MoveX) + (input.MoveY * input.MoveY));
|
||||
if (length <= 0f)
|
||||
{
|
||||
state.VelocityX = 0f;
|
||||
state.VelocityY = 0f;
|
||||
state.VelocityZ = 0f;
|
||||
return;
|
||||
}
|
||||
|
||||
state.Rotation = MathF.Atan2(input.MoveY, input.MoveX) * (180f / MathF.PI);
|
||||
}
|
||||
|
||||
private void IntegrateState(ServerAuthoritativeMovementState state, TimeSpan elapsed)
|
||||
{
|
||||
if (state.InputX == 0f && state.InputY == 0f)
|
||||
{
|
||||
state.VelocityX = 0f;
|
||||
state.VelocityY = 0f;
|
||||
state.VelocityZ = 0f;
|
||||
return;
|
||||
}
|
||||
|
||||
var length = MathF.Sqrt((state.InputX * state.InputX) + (state.InputY * state.InputY));
|
||||
if (length <= 0f)
|
||||
{
|
||||
state.VelocityX = 0f;
|
||||
state.VelocityY = 0f;
|
||||
state.VelocityZ = 0f;
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedX = state.InputX / length;
|
||||
var normalizedY = state.InputY / length;
|
||||
state.VelocityX = normalizedX * configuration.MoveSpeed;
|
||||
state.VelocityY = 0f;
|
||||
state.VelocityZ = normalizedY * configuration.MoveSpeed;
|
||||
|
||||
var deltaSeconds = (float)elapsed.TotalSeconds;
|
||||
state.PositionX += state.VelocityX * deltaSeconds;
|
||||
state.PositionY += state.VelocityY * deltaSeconds;
|
||||
state.PositionZ += state.VelocityZ * deltaSeconds;
|
||||
}
|
||||
|
||||
private static PlayerState BuildPlayerState(ServerAuthoritativeMovementState state, long tick)
|
||||
{
|
||||
return new PlayerState
|
||||
{
|
||||
PlayerId = state.PlayerId,
|
||||
Tick = tick,
|
||||
Position = new Vector3
|
||||
{
|
||||
X = state.PositionX,
|
||||
Y = state.PositionY,
|
||||
Z = state.PositionZ
|
||||
},
|
||||
Velocity = new Vector3
|
||||
{
|
||||
X = state.VelocityX,
|
||||
Y = state.VelocityY,
|
||||
Z = state.VelocityZ
|
||||
},
|
||||
Rotation = state.Rotation,
|
||||
Hp = state.Hp
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class PendingBroadcast
|
||||
{
|
||||
public PendingBroadcast(IPEndPoint remoteEndPoint, PlayerState playerState)
|
||||
{
|
||||
RemoteEndPoint = remoteEndPoint;
|
||||
PlayerState = playerState;
|
||||
}
|
||||
|
||||
public IPEndPoint RemoteEndPoint { get; }
|
||||
|
||||
public PlayerState PlayerState { get; }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 91c6c9377bb94c4f88cc4f37dbed16ab
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,43 @@
|
||||
using System;
|
||||
using System.Net;
|
||||
|
||||
namespace Network.NetworkHost
|
||||
{
|
||||
public sealed class ServerAuthoritativeMovementState
|
||||
{
|
||||
public ServerAuthoritativeMovementState(IPEndPoint remoteEndPoint, string playerId, int hp)
|
||||
{
|
||||
RemoteEndPoint = remoteEndPoint ?? throw new ArgumentNullException(nameof(remoteEndPoint));
|
||||
PlayerId = playerId ?? throw new ArgumentNullException(nameof(playerId));
|
||||
Hp = hp;
|
||||
}
|
||||
|
||||
public IPEndPoint RemoteEndPoint { get; }
|
||||
|
||||
public string PlayerId { get; }
|
||||
|
||||
public long LastAcceptedMoveTick { get; internal set; }
|
||||
|
||||
public long LastBroadcastTick { get; internal set; }
|
||||
|
||||
public float PositionX { get; internal set; }
|
||||
|
||||
public float PositionY { get; internal set; }
|
||||
|
||||
public float PositionZ { get; internal set; }
|
||||
|
||||
public float VelocityX { get; internal set; }
|
||||
|
||||
public float VelocityY { get; internal set; }
|
||||
|
||||
public float VelocityZ { get; internal set; }
|
||||
|
||||
public float Rotation { get; internal set; }
|
||||
|
||||
public int Hp { get; internal set; }
|
||||
|
||||
public float InputX { get; internal set; }
|
||||
|
||||
public float InputY { get; internal set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0f2f1baf3a314f51a8d4b1caea7ee905
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -1,7 +1,8 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Net;
|
||||
using System.Threading.Tasks;
|
||||
using Network.Defines;
|
||||
using Network.NetworkApplication;
|
||||
using Network.NetworkTransport;
|
||||
|
||||
@@ -12,6 +13,7 @@ namespace Network.NetworkHost
|
||||
private readonly ITransport transport;
|
||||
private readonly ITransport syncTransport;
|
||||
private readonly MessageManager messageManager;
|
||||
private readonly ServerAuthoritativeMovementCoordinator authoritativeMovementCoordinator;
|
||||
|
||||
public ServerNetworkHost(
|
||||
ITransport transport,
|
||||
@@ -20,7 +22,8 @@ namespace Network.NetworkHost
|
||||
Func<DateTimeOffset> utcNowProvider = null,
|
||||
ITransport syncTransport = null,
|
||||
IMessageDeliveryPolicyResolver deliveryPolicyResolver = null,
|
||||
SyncSequenceTracker syncSequenceTracker = null)
|
||||
SyncSequenceTracker syncSequenceTracker = null,
|
||||
ServerAuthoritativeMovementConfiguration authoritativeMovement = null)
|
||||
{
|
||||
this.transport = transport ?? throw new ArgumentNullException(nameof(transport));
|
||||
this.syncTransport = syncTransport;
|
||||
@@ -37,6 +40,11 @@ namespace Network.NetworkHost
|
||||
deliveryPolicyResolver ?? new DefaultMessageDeliveryPolicyResolver(),
|
||||
this.syncTransport,
|
||||
syncSequenceTracker ?? new SyncSequenceTracker());
|
||||
authoritativeMovementCoordinator = new ServerAuthoritativeMovementCoordinator(
|
||||
this,
|
||||
messageManager,
|
||||
authoritativeMovement ?? new ServerAuthoritativeMovementConfiguration());
|
||||
messageManager.RegisterHandler(MessageType.MoveInput, authoritativeMovementCoordinator.HandleMoveInputAsync);
|
||||
}
|
||||
|
||||
public MessageManager MessageManager => messageManager;
|
||||
@@ -45,11 +53,12 @@ namespace Network.NetworkHost
|
||||
|
||||
public ITransport SyncTransport => syncTransport;
|
||||
|
||||
// Server-side lifecycle entry point: inspect and control per-peer session state here.
|
||||
public MultiSessionManager SessionCoordinator { get; }
|
||||
|
||||
public IReadOnlyList<ManagedNetworkSession> ManagedSessions => SessionCoordinator.Sessions;
|
||||
|
||||
public IReadOnlyList<ServerAuthoritativeMovementState> AuthoritativeMovementStates => authoritativeMovementCoordinator.States;
|
||||
|
||||
public event Action<MultiSessionLifecycleEvent> LifecycleChanged
|
||||
{
|
||||
add => SessionCoordinator.LifecycleChanged += value;
|
||||
@@ -77,6 +86,7 @@ namespace Network.NetworkHost
|
||||
}
|
||||
|
||||
SessionCoordinator.RemoveAllSessions("Transport stopped");
|
||||
authoritativeMovementCoordinator.Clear();
|
||||
PublishMetricsSessionSnapshots();
|
||||
}
|
||||
|
||||
@@ -91,11 +101,21 @@ namespace Network.NetworkHost
|
||||
PublishMetricsSessionSnapshots();
|
||||
}
|
||||
|
||||
public void UpdateAuthoritativeMovement(TimeSpan elapsed)
|
||||
{
|
||||
authoritativeMovementCoordinator.Update(elapsed);
|
||||
}
|
||||
|
||||
public bool TryGetSession(IPEndPoint remoteEndPoint, out ManagedNetworkSession session)
|
||||
{
|
||||
return SessionCoordinator.TryGetSession(remoteEndPoint, out session);
|
||||
}
|
||||
|
||||
public bool TryGetAuthoritativeMovementState(IPEndPoint remoteEndPoint, out ServerAuthoritativeMovementState state)
|
||||
{
|
||||
return authoritativeMovementCoordinator.TryGetState(remoteEndPoint, out state);
|
||||
}
|
||||
|
||||
public void NotifyLoginStarted(IPEndPoint remoteEndPoint)
|
||||
{
|
||||
SessionCoordinator.NotifyLoginStarted(remoteEndPoint);
|
||||
@@ -151,6 +171,8 @@ namespace Network.NetworkHost
|
||||
return false;
|
||||
}
|
||||
|
||||
authoritativeMovementCoordinator.RemoveState(remoteEndPoint);
|
||||
|
||||
RecordMetricsSessionSnapshot(transport, "server-host", session, ConnectionState.Disconnected);
|
||||
if (syncTransport != null && !ReferenceEquals(syncTransport, transport))
|
||||
{
|
||||
|
||||
@@ -32,6 +32,8 @@ namespace Network.NetworkHost
|
||||
|
||||
public Func<int, ITransport> TransportFactory { get; set; }
|
||||
|
||||
public ServerAuthoritativeMovementConfiguration AuthoritativeMovement { get; set; }
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
if (ReliablePort <= 0)
|
||||
@@ -39,20 +41,20 @@ namespace Network.NetworkHost
|
||||
throw new ArgumentOutOfRangeException(nameof(ReliablePort), "Reliable port must be positive.");
|
||||
}
|
||||
|
||||
if (!SyncPort.HasValue)
|
||||
if (SyncPort.HasValue)
|
||||
{
|
||||
return;
|
||||
if (SyncPort.Value <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(SyncPort), "Sync port must be positive.");
|
||||
}
|
||||
|
||||
if (SyncPort.Value == ReliablePort)
|
||||
{
|
||||
throw new ArgumentException("Sync port must differ from reliable port.", nameof(SyncPort));
|
||||
}
|
||||
}
|
||||
|
||||
if (SyncPort.Value <= 0)
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(SyncPort), "Sync port must be positive.");
|
||||
}
|
||||
|
||||
if (SyncPort.Value == ReliablePort)
|
||||
{
|
||||
throw new ArgumentException("Sync port must differ from reliable port.", nameof(SyncPort));
|
||||
}
|
||||
AuthoritativeMovement?.Validate();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,8 @@ namespace Network.NetworkHost
|
||||
configuration.UtcNowProvider,
|
||||
configuration.DeliveryPolicyResolver,
|
||||
configuration.SyncSequenceTracker,
|
||||
configuration.TransportFactory);
|
||||
configuration.TransportFactory,
|
||||
configuration.AuthoritativeMovement);
|
||||
|
||||
try
|
||||
{
|
||||
|
||||
@@ -23,6 +23,8 @@ namespace Network.NetworkHost
|
||||
|
||||
public IReadOnlyList<ManagedNetworkSession> ManagedSessions => host.ManagedSessions;
|
||||
|
||||
public IReadOnlyList<ServerAuthoritativeMovementState> AuthoritativeMovementStates => host.AuthoritativeMovementStates;
|
||||
|
||||
public event Action<MultiSessionLifecycleEvent> LifecycleChanged
|
||||
{
|
||||
add => host.LifecycleChanged += value;
|
||||
@@ -39,11 +41,21 @@ namespace Network.NetworkHost
|
||||
host.UpdateLifecycle();
|
||||
}
|
||||
|
||||
public void UpdateAuthoritativeMovement(TimeSpan elapsed)
|
||||
{
|
||||
host.UpdateAuthoritativeMovement(elapsed);
|
||||
}
|
||||
|
||||
public bool TryGetSession(IPEndPoint remoteEndPoint, out ManagedNetworkSession session)
|
||||
{
|
||||
return host.TryGetSession(remoteEndPoint, out session);
|
||||
}
|
||||
|
||||
public bool TryGetAuthoritativeMovementState(IPEndPoint remoteEndPoint, out ServerAuthoritativeMovementState state)
|
||||
{
|
||||
return host.TryGetAuthoritativeMovementState(remoteEndPoint, out state);
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
if (isStopped)
|
||||
|
||||
Reference in New Issue
Block a user