Files
Workspace/Assets/Scenes/ShrinkLanMovementDemoController.cs

1545 lines
53 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using Cysharp.Threading.Tasks;
using ShrinkNetwork;
using UnityEngine;
public sealed class ShrinkLanMovementDemoController : MonoBehaviour
{
private enum DemoTransportKind
{
Tcp = 0,
Kcp = 1
}
private sealed class DemoPlayerState
{
public string PlayerId = string.Empty;
public string DisplayName = string.Empty;
public Vector2 Position;
public Vector2 RenderPosition;
public Vector2 Velocity;
public long Version;
public bool IsGrounded;
public bool IsLocalPlayer;
public float LastStateReceivedAt;
}
private sealed class DemoPlayerActor
{
public string PlayerId = string.Empty;
public bool IsLocalAuthority;
public GameObject Root = null!;
public Rigidbody2D? Body;
public BoxCollider2D? Collider;
public SpriteRenderer? Renderer;
}
private const int DefaultPort = 17777;
private const float HorizontalMoveSpeed = 7.5f;
private const float JumpVelocity = 22f;
private const float SendInterval = 0f;
private const float HostHeartbeatInterval = 1f;
private const float HostHeartbeatTimeout = 4f;
private const float RemoteInterpolationSpeedX = 30f;
private const float RemoteInterpolationSpeedY = 18f;
private const float RemotePredictionLead = 0.02f;
private const float RemoteSnapDistance = 1.2f;
private const float CameraFollowSpeed = 10f;
private const float CameraHalfHeight = 5.5f;
private const float PlayerWidth = 0.9f;
private const float PlayerHeight = 1.6f;
private const float NetworkPositionThreshold = 0.03f;
private const float NetworkVelocityThreshold = 0.12f;
private const float GroundedZeroVelocityThreshold = 0.15f;
private const float LocalCorrectionSnapDistance = 2f;
private const float NetworkPositionQuantize = 0.01f;
private const float NetworkVelocityQuantize = 0.05f;
private const float WorldLeft = -24f;
private const float WorldRight = 24f;
private const float WorldBottom = -8f;
private const float WorldTop = 14f;
private const float GroundProbeHeight = 0.08f;
private const float GroundProbeShrink = 0.85f;
private static Sprite? s_runtimeSprite;
private readonly object _stateLock = new();
private readonly List<string> _logs = new();
private readonly Dictionary<string, DemoPlayerState> _players = new();
private readonly Dictionary<long, string> _hostSessionToPlayerId = new();
private readonly Dictionary<string, DemoPlayerActor> _playerActors = new();
private readonly List<Collider2D> _groundColliders = new();
private ShrinkNetworkService? _hostService;
private IShrinkNetworkTransport? _hostTransport;
private ShrinkNetworkService? _clientService;
private IShrinkNetworkTransport? _clientTransport;
private ShrinkNetworkSession? _localClientSession;
private GameObject? _runtimeWorldRoot;
private GameObject? _runtimeLevelRoot;
private Camera? _demoCamera;
private string? _localPlayerId;
private string _playerName = "玩家";
private string _joinAddress = "127.0.0.1";
private int _port = DefaultPort;
private DemoTransportKind _transportKind = DemoTransportKind.Tcp;
private bool _useDedicatedServerAuth;
private string _serverAuthToken = string.Empty;
private string _statusText = "未连接";
private string _lanAddressSummary = string.Empty;
private bool _isHosting;
private float _sendTimer;
private float _hostHeartbeatTimer;
private float _lastHeartbeatReceivedAt;
private float _moveInputX;
private bool _jumpQueued;
private bool _hasPendingMove;
private Vector2 _pendingMovePosition;
private Vector2 _pendingMoveVelocity;
private bool _pendingMoveGrounded;
private bool _hasLastDispatchedState;
private Vector2 _lastDispatchedPosition;
private Vector2 _lastDispatchedVelocity;
private bool _lastDispatchedGrounded;
private bool _isMoveSendInFlight;
private long _lastSentMoveVersion;
private Vector2 _logScroll;
private Vector2 _playerStateScroll;
private void Awake()
{
Application.runInBackground = true;
_lanAddressSummary = BuildLanAddressSummary();
EnsureRuntimeWorld();
}
private void Update()
{
EnsureRuntimeWorld();
TickHeartbeat();
CaptureLocalInput();
TickRemoteInterpolation();
SyncPlayerActors();
UpdateCameraFollow();
}
private void FixedUpdate()
{
TickLocalPlatformerPhysics();
}
private void OnDestroy()
{
ShutdownNetworking();
if (_runtimeWorldRoot)
Destroy(_runtimeWorldRoot);
}
private void OnGUI()
{
var leftWidth = 360f;
var padding = 16f;
var rightWidth = 320f;
var leftRect = new Rect(padding, padding, leftWidth, Screen.height - padding * 2f);
var rightRect = new Rect(Screen.width - rightWidth - padding, padding, rightWidth, 280f);
GUILayout.BeginArea(leftRect, GUI.skin.window);
GUILayout.Label("ShrinkSDK 局域网横板跳跃演示");
GUILayout.Label("本地玩家使用 Rigidbody2D 驱动左右移动、跳跃和重力;远端玩家显示为网络同步后的插值结果。");
GUILayout.Space(8f);
GUILayout.Label($"当前状态:{_statusText}");
GUILayout.Label($"当前协议:{GetTransportDisplayName(_transportKind)}");
GUILayout.Label($"局域网地址:{_lanAddressSummary}");
GUILayout.Label($"监听端口:{_port}");
GUILayout.Space(8f);
GUILayout.Label("玩家名称");
_playerName = GUILayout.TextField(_playerName);
GUILayout.Label("加入地址");
_joinAddress = GUILayout.TextField(_joinAddress);
GUILayout.Label("端口");
var portText = GUILayout.TextField(_port.ToString());
if (int.TryParse(portText, out var parsedPort))
_port = Mathf.Clamp(parsedPort, 1, 65535);
GUILayout.Space(8f);
GUILayout.Label("传输协议");
GUI.enabled = _hostService == null && _clientService == null;
_transportKind = (DemoTransportKind)GUILayout.Toolbar((int)_transportKind, new[] { "TCP", "KCP" });
GUI.enabled = true;
GUILayout.Space(8f);
GUI.enabled = _hostService == null && _clientService == null;
_useDedicatedServerAuth = GUILayout.Toggle(_useDedicatedServerAuth, "连接独立服务器时先做鉴权");
GUI.enabled = true;
GUILayout.Label("鉴权令牌");
_serverAuthToken = GUILayout.TextField(_serverAuthToken);
GUILayout.Space(8f);
GUI.enabled = _hostService == null && _clientService == null;
if (GUILayout.Button("启动内置 Host 并公开到局域网", GUILayout.Height(36f)))
StartHostAndJoinLocalAsync().Forget();
GUILayout.Label($"作用:当前实例启动内置 {GetTransportDisplayName(_transportKind)} Host,并让本地玩家自动加入房间。");
if (GUILayout.Button("加入局域网房间", GUILayout.Height(32f)))
JoinLanRoomAsync().Forget();
GUILayout.Label($"作用:通过 {GetTransportDisplayName(_transportKind)} 连接另一台主机公开的房间,验证编辑器和打包客户端之间的联机。");
GUI.enabled = _hostService != null || _clientService != null;
if (GUILayout.Button("断开并清空房间状态", GUILayout.Height(32f)))
ShutdownNetworking();
GUILayout.Label("作用:关闭 Host / Client 连接,销毁玩家实例并清空日志。");
GUI.enabled = true;
GUILayout.Space(10f);
GUILayout.Label("操作说明");
GUILayout.Label("移动:A / D 或左右方向键");
GUILayout.Label("跳跃:空格 / W / 上方向键");
GUILayout.Label("窗口失焦后仍会继续收包、物理更新和心跳检测。");
GUILayout.Label("建议:一端启动 Host,另一端填 Host 的局域网 IP 和同端口加入。");
GUILayout.Space(10f);
GUILayout.Label("事件日志");
_logScroll = GUILayout.BeginScrollView(_logScroll, GUILayout.Height(260f));
lock (_stateLock)
{
for (var i = 0; i < _logs.Count; i++)
GUILayout.Label(_logs[i]);
}
GUILayout.EndScrollView();
GUILayout.EndArea();
DrawPlayerStatePanel(rightRect);
}
private async UniTaskVoid StartHostAndJoinLocalAsync()
{
ShutdownNetworking();
try
{
_hostService = new ShrinkNetworkService(new ShrinkJsonNetworkSerializer(),
new ShrinkNetworkMessageRegistry(), new ShrinkNetworkRouter());
_hostTransport = CreateHostTransport(_transportKind, _port);
RegisterHostHandlers(_hostService);
_hostService.BindTransport(_hostTransport);
_isHosting = true;
CreateHostLocalPlayer();
_statusText = $"{GetTransportDisplayName(_transportKind)} Host 已启动,本地玩家已就位,局域网端口 {_port}";
AddLog($"{GetTransportDisplayName(_transportKind)} Host 已启动,监听 0.0.0.0:{_port}。");
await UniTask.CompletedTask;
}
catch (Exception ex)
{
AddLog($"启动 Host 失败:{ex.Message}");
_statusText = "启动 Host 失败";
ShutdownNetworking();
}
}
private async UniTaskVoid JoinLanRoomAsync()
{
ShutdownNetworking();
try
{
_isHosting = false;
_statusText = $"正在通过 {GetTransportDisplayName(_transportKind)} 连接 {_joinAddress}:{_port}";
await ConnectClientAsync(_joinAddress, _port);
}
catch (Exception ex)
{
AddLog($"加入房间失败:{ex.Message}");
_statusText = "加入房间失败";
ShutdownNetworking();
}
}
private async UniTask ConnectClientAsync(string address, int port)
{
_clientService = new ShrinkNetworkService(new ShrinkJsonNetworkSerializer(),
new ShrinkNetworkMessageRegistry(), new ShrinkNetworkRouter());
_clientTransport = CreateClientTransport(_transportKind, address, port);
RegisterClientHandlers(_clientService);
_clientService.OnSessionConnected += OnClientSessionConnected;
_clientService.OnSessionDisconnected += OnClientSessionDisconnected;
_clientService.BindTransport(_clientTransport);
await UniTask.CompletedTask;
}
private void RegisterHostHandlers(ShrinkNetworkService service)
{
service.AutoRegisterAttributedMessages();
service.RegisterRequestHandler<LanJoinRoomRequest, LanJoinRoomResponse>(HandleHostJoinRoomAsync);
service.RegisterHandler<LanMoveCommand>(HandleHostMoveCommandAsync);
service.OnSessionDisconnected += OnHostSessionDisconnected;
}
private void RegisterClientHandlers(ShrinkNetworkService service)
{
service.AutoRegisterAttributedMessages();
service.RegisterHandler<LanPlayerStateDelta>(HandleClientPlayerStateDeltaAsync);
service.RegisterHandler<LanPlayerLeftNotice>(HandleClientPlayerLeftAsync);
service.RegisterHandler<LanHeartbeatNotice>(HandleClientHeartbeatAsync);
}
private void ShutdownNetworking()
{
_localClientSession = null;
_localPlayerId = null;
_sendTimer = 0f;
_hostHeartbeatTimer = 0f;
_lastHeartbeatReceivedAt = 0f;
_moveInputX = 0f;
_jumpQueued = false;
_hasPendingMove = false;
_pendingMovePosition = Vector2.zero;
_pendingMoveVelocity = Vector2.zero;
_pendingMoveGrounded = false;
_hasLastDispatchedState = false;
_lastDispatchedPosition = Vector2.zero;
_lastDispatchedVelocity = Vector2.zero;
_lastDispatchedGrounded = false;
_isMoveSendInFlight = false;
_lastSentMoveVersion = 0;
_isHosting = false;
if (_clientService != null)
{
_clientService.OnSessionConnected -= OnClientSessionConnected;
_clientService.OnSessionDisconnected -= OnClientSessionDisconnected;
}
if (_hostService != null)
_hostService.OnSessionDisconnected -= OnHostSessionDisconnected;
_clientTransport?.Stop();
_hostTransport?.Stop();
_clientTransport = null;
_hostTransport = null;
_clientService = null;
_hostService = null;
lock (_stateLock)
{
_players.Clear();
_hostSessionToPlayerId.Clear();
_logs.Clear();
}
DestroyAllPlayerActors();
_statusText = "未连接";
}
private void OnClientSessionConnected(ShrinkNetworkSession session)
{
session.SetPeerKind(ShrinkNetworkPeerKind.Server);
_localClientSession = session;
_lastHeartbeatReceivedAt = Time.unscaledTime;
AddLog($"客户端已连接到 {session.RemoteAddress},正在加入房间。");
JoinRoomAfterConnectAsync(session).Forget();
}
private void OnClientSessionDisconnected(ShrinkNetworkSession session)
{
AddLog($"客户端连接已断开:{session.RemoteAddress}");
_statusText = "连接已断开";
_localClientSession = null;
}
private async UniTaskVoid JoinRoomAfterConnectAsync(ShrinkNetworkSession session)
{
try
{
if (_useDedicatedServerAuth)
{
_statusText = "正在进行独立服务器鉴权";
var authResponse = await session.RpcAsync<ServerAuthLoginRequest, ServerAuthLoginResponse>(
new ServerAuthLoginRequest
{
ClientName = string.IsNullOrWhiteSpace(_playerName) ? "玩家" : _playerName.Trim(),
Token = _serverAuthToken ?? string.Empty
});
if (!authResponse.IsAuthenticated)
{
AddLog($"独立服务器鉴权失败:{authResponse.ErrorMessage}");
_statusText = "独立服务器鉴权失败";
ShutdownNetworking();
return;
}
AddLog($"独立服务器鉴权成功:{authResponse.ServerMessage}");
if (authResponse.GrantedPermissions != null && authResponse.GrantedPermissions.Length > 0)
AddLog($"已授予权限:{string.Join(", ", authResponse.GrantedPermissions)}");
}
var response = await session.RpcAsync<LanJoinRoomRequest, LanJoinRoomResponse>(new LanJoinRoomRequest
{
PlayerName = string.IsNullOrWhiteSpace(_playerName) ? "玩家" : _playerName.Trim()
});
lock (_stateLock)
{
_players.Clear();
if (response.Players != null)
{
foreach (var dto in response.Players)
{
var position = new Vector2(dto.X, dto.Y);
_players[dto.PlayerId] = new DemoPlayerState
{
PlayerId = dto.PlayerId,
DisplayName = dto.DisplayName,
Position = position,
RenderPosition = position,
Velocity = new Vector2(dto.VX, dto.VY),
Version = dto.Version,
IsGrounded = dto.IsGrounded,
IsLocalPlayer = dto.PlayerId == response.PlayerId,
LastStateReceivedAt = Time.unscaledTime
};
}
}
}
_localPlayerId = response.PlayerId;
if (_players.TryGetValue(response.PlayerId, out var localPlayer))
{
_hasLastDispatchedState = true;
_lastDispatchedPosition = localPlayer.Position;
_lastDispatchedVelocity = localPlayer.Velocity;
_lastDispatchedGrounded = localPlayer.IsGrounded;
}
_statusText = _isHosting
? $"Host 模式运行中,本地玩家已加入,玩家ID={response.PlayerId}"
: $"已加入房间,玩家ID={response.PlayerId}";
AddLog($"加入房间成功,玩家ID={response.PlayerId}。");
SyncPlayerActors();
}
catch (Exception ex)
{
AddLog($"加入房间 RPC 失败:{ex.Message}");
_statusText = "加入房间失败";
}
}
private void CreateHostLocalPlayer()
{
var playerId = "Host-Local";
var displayName = string.IsNullOrWhiteSpace(_playerName) ? "房主" : _playerName.Trim();
var spawnPosition = GetSpawnPosition(0);
lock (_stateLock)
{
_players.Clear();
_players[playerId] = new DemoPlayerState
{
PlayerId = playerId,
DisplayName = displayName,
Position = spawnPosition,
RenderPosition = spawnPosition,
Velocity = Vector2.zero,
Version = 1,
IsGrounded = false,
IsLocalPlayer = true,
LastStateReceivedAt = Time.unscaledTime
};
}
_localPlayerId = playerId;
_hasLastDispatchedState = true;
_lastDispatchedPosition = spawnPosition;
_lastDispatchedVelocity = Vector2.zero;
_lastDispatchedGrounded = false;
AddLog($"本地主机玩家已创建:{displayName}{playerId})。");
SyncPlayerActors();
}
private static string GetTransportDisplayName(DemoTransportKind kind)
{
return kind switch
{
DemoTransportKind.Kcp => "KCP",
_ => "TCP"
};
}
private static IShrinkNetworkTransport CreateHostTransport(DemoTransportKind kind, int port)
{
return kind switch
{
DemoTransportKind.Kcp => new ShrinkKcpServerTransport(IPAddress.Any, port, CreateKcpOptions()),
_ => new ShrinkTcpServerTransport(IPAddress.Any, port)
};
}
private static IShrinkNetworkTransport CreateClientTransport(DemoTransportKind kind, string address, int port)
{
return kind switch
{
DemoTransportKind.Kcp => new ShrinkKcpClientTransport(address, port, CreateKcpOptions()),
_ => new ShrinkTcpClientTransport(address, port)
};
}
private static ShrinkKcpTransportOptions CreateKcpOptions()
{
return new ShrinkKcpTransportOptions
{
HandshakeRetryMs = 250,
ConnectTimeoutMs = 4000,
UpdateIntervalMs = 10,
Interval = 10,
IdleTimeoutMs = 15000,
NoDelay = true,
Resend = 2,
DisableCongestionControl = true
};
}
private async UniTask<LanJoinRoomResponse> HandleHostJoinRoomAsync(ShrinkNetworkContext context, LanJoinRoomRequest request)
{
var playerId = $"Player-{context.Session.SessionId}";
var displayName = string.IsNullOrWhiteSpace(request.PlayerName) ? playerId : request.PlayerName.Trim();
var spawnPosition = GetSpawnPosition(context.Session.SessionId);
lock (_stateLock)
{
_hostSessionToPlayerId[context.Session.SessionId] = playerId;
_players[playerId] = new DemoPlayerState
{
PlayerId = playerId,
DisplayName = displayName,
Position = spawnPosition,
RenderPosition = spawnPosition,
Velocity = Vector2.zero,
Version = 1,
IsGrounded = false,
IsLocalPlayer = false,
LastStateReceivedAt = Time.unscaledTime
};
}
AddLog($"Host 接纳玩家 {displayName}{playerId}),出生点 {spawnPosition}。");
var response = new LanJoinRoomResponse
{
PlayerId = playerId,
Players = SnapshotPlayers()
};
await BroadcastStateDeltaAsync(new LanPlayerStateDelta
{
PlayerId = playerId,
DisplayName = displayName,
X = spawnPosition.x,
Y = spawnPosition.y,
VX = 0f,
VY = 0f,
Version = 1,
IsGrounded = false
}, context.Session.SessionId);
return response;
}
private async UniTask HandleHostMoveCommandAsync(ShrinkNetworkContext context, LanMoveCommand command)
{
string? expectedPlayerId;
lock (_stateLock)
{
_hostSessionToPlayerId.TryGetValue(context.Session.SessionId, out expectedPlayerId);
}
if (!string.Equals(expectedPlayerId, command.PlayerId, StringComparison.Ordinal))
{
AddLog("[Host] 忽略移动命令:session 与 playerId 不匹配。");
return;
}
LanPlayerStateDelta? deltaToBroadcast = null;
lock (_stateLock)
{
if (!_players.TryGetValue(command.PlayerId, out var player))
{
AddLog($"[Host] 忽略移动命令:未找到玩家 {command.PlayerId}。");
return;
}
if (command.Version < player.Version)
{
AddLog($"[Host] 忽略移动命令:version {command.Version} 落后于当前 {player.Version}。");
return;
}
player.Position = ClampToWorld(new Vector2(command.X, command.Y));
player.RenderPosition = player.Position;
player.Velocity = new Vector2(command.VX, command.VY);
player.IsGrounded = command.IsGrounded;
player.Version = command.Version;
deltaToBroadcast = new LanPlayerStateDelta
{
PlayerId = player.PlayerId,
DisplayName = player.DisplayName,
X = player.Position.x,
Y = player.Position.y,
VX = player.Velocity.x,
VY = player.Velocity.y,
Version = player.Version,
IsGrounded = player.IsGrounded
};
}
if (deltaToBroadcast != null)
await BroadcastStateDeltaAsync(deltaToBroadcast, context.Session.SessionId);
}
private void OnHostSessionDisconnected(ShrinkNetworkSession session)
{
string? playerId = null;
string? displayName = null;
lock (_stateLock)
{
if (_hostSessionToPlayerId.TryGetValue(session.SessionId, out var mappedPlayerId))
{
playerId = mappedPlayerId;
_hostSessionToPlayerId.Remove(session.SessionId);
if (_players.TryGetValue(mappedPlayerId, out var player))
{
displayName = player.DisplayName;
_players.Remove(mappedPlayerId);
}
}
}
if (playerId == null)
return;
DestroyPlayerActor(playerId);
AddLog($"Host 检测到玩家离开:{displayName ?? playerId}");
BroadcastPlayerLeftAsync(new LanPlayerLeftNotice { PlayerId = playerId }).Forget();
}
private UniTask HandleClientPlayerStateDeltaAsync(ShrinkNetworkContext context, LanPlayerStateDelta delta)
{
if (delta.PlayerId == _localPlayerId)
return UniTask.CompletedTask;
var applied = false;
var previousVersion = -1L;
var position = new Vector2(delta.X, delta.Y);
var velocity = new Vector2(delta.VX, delta.VY);
lock (_stateLock)
{
if (_players.TryGetValue(delta.PlayerId, out var player))
{
previousVersion = player.Version;
if (delta.Version < player.Version)
return UniTask.CompletedTask;
player.DisplayName = delta.DisplayName;
player.Position = position;
player.RenderPosition = position;
player.Velocity = velocity;
player.Version = delta.Version;
player.IsGrounded = delta.IsGrounded;
player.LastStateReceivedAt = Time.unscaledTime;
applied = true;
}
else
{
_players[delta.PlayerId] = new DemoPlayerState
{
PlayerId = delta.PlayerId,
DisplayName = delta.DisplayName,
Position = position,
RenderPosition = position,
Velocity = velocity,
Version = delta.Version,
IsGrounded = delta.IsGrounded,
IsLocalPlayer = delta.PlayerId == _localPlayerId,
LastStateReceivedAt = Time.unscaledTime
};
applied = true;
}
}
if (applied)
{
if (delta.PlayerId == _localPlayerId &&
_playerActors.TryGetValue(delta.PlayerId, out var actor) &&
actor.Body is { } body)
{
if ((body.position - position).sqrMagnitude >= LocalCorrectionSnapDistance * LocalCorrectionSnapDistance)
{
body.position = position;
body.velocity = velocity;
AddLog($"[Client] 本地状态与远端偏差过大,执行一次硬校正:player={delta.PlayerId}");
}
return UniTask.CompletedTask;
}
}
return UniTask.CompletedTask;
}
private UniTask HandleClientPlayerLeftAsync(ShrinkNetworkContext context, LanPlayerLeftNotice notice)
{
lock (_stateLock)
{
_players.Remove(notice.PlayerId);
}
DestroyPlayerActor(notice.PlayerId);
AddLog($"玩家离开房间:{notice.PlayerId}");
return UniTask.CompletedTask;
}
private UniTask HandleClientHeartbeatAsync(ShrinkNetworkContext context, LanHeartbeatNotice notice)
{
_lastHeartbeatReceivedAt = Time.unscaledTime;
return UniTask.CompletedTask;
}
private async UniTask SendMoveAsync(Vector2 position, Vector2 velocity, bool isGrounded, long version)
{
if (_localClientSession == null || string.IsNullOrEmpty(_localPlayerId))
return;
try
{
await _localClientSession.SendAsync(new LanMoveCommand
{
PlayerId = _localPlayerId,
X = position.x,
Y = position.y,
VX = velocity.x,
VY = velocity.y,
IsGrounded = isGrounded,
Version = version
});
}
catch (Exception ex)
{
AddLog($"发送移动同步失败:{ex.Message}");
}
}
private async UniTask ApplyHostLocalMoveAsync(string playerId, Vector2 position, Vector2 velocity, bool isGrounded, long version)
{
LanPlayerStateDelta? deltaToBroadcast = null;
long? excludedSessionId = null;
lock (_stateLock)
{
foreach (var pair in _hostSessionToPlayerId)
{
if (string.Equals(pair.Value, playerId, StringComparison.Ordinal))
{
excludedSessionId = pair.Key;
break;
}
}
if (!_players.TryGetValue(playerId, out var player))
return;
player.Position = ClampToWorld(position);
player.RenderPosition = player.Position;
player.Velocity = velocity;
player.IsGrounded = isGrounded;
player.Version = version;
deltaToBroadcast = new LanPlayerStateDelta
{
PlayerId = player.PlayerId,
DisplayName = player.DisplayName,
X = player.Position.x,
Y = player.Position.y,
VX = player.Velocity.x,
VY = player.Velocity.y,
Version = player.Version,
IsGrounded = player.IsGrounded
};
}
if (deltaToBroadcast != null)
await BroadcastStateDeltaAsync(deltaToBroadcast, excludedSessionId);
}
private async UniTask BroadcastStateDeltaAsync(LanPlayerStateDelta delta, long? excludedSessionId = null)
{
var sessions = GetHostSessionsSnapshot();
foreach (var session in sessions)
{
if (excludedSessionId.HasValue && session.SessionId == excludedSessionId.Value)
continue;
await session.SendAsync(delta);
}
}
private async UniTaskVoid BroadcastPlayerLeftAsync(LanPlayerLeftNotice notice)
{
var sessions = GetHostSessionsSnapshot();
foreach (var session in sessions)
{
try
{
await session.SendAsync(notice);
}
catch
{
}
}
}
private async UniTaskVoid BroadcastHeartbeatAsync()
{
var sessions = GetHostSessionsSnapshot();
var notice = new LanHeartbeatNotice
{
ServerUnixMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds()
};
foreach (var session in sessions)
{
try
{
await session.SendAsync(notice);
}
catch
{
}
}
}
private IReadOnlyList<ShrinkNetworkSession> GetHostSessionsSnapshot()
{
if (_hostService == null)
return Array.Empty<ShrinkNetworkSession>();
return _hostService.Sessions.Values.ToArray();
}
private LanPlayerStateDto[] SnapshotPlayers()
{
lock (_stateLock)
{
return _players.Values
.Select(player => new LanPlayerStateDto
{
PlayerId = player.PlayerId,
DisplayName = player.DisplayName,
X = player.Position.x,
Y = player.Position.y,
VX = player.Velocity.x,
VY = player.Velocity.y,
Version = player.Version,
IsGrounded = player.IsGrounded
})
.ToArray();
}
}
private DemoPlayerState[] SnapshotPlayerStates()
{
lock (_stateLock)
{
return _players.Values
.Select(player => new DemoPlayerState
{
PlayerId = player.PlayerId,
DisplayName = player.DisplayName,
Position = player.Position,
RenderPosition = player.RenderPosition,
Velocity = player.Velocity,
Version = player.Version,
IsGrounded = player.IsGrounded,
IsLocalPlayer = player.IsLocalPlayer,
LastStateReceivedAt = player.LastStateReceivedAt
})
.ToArray();
}
}
private void DrawPlayerStatePanel(Rect rect)
{
GUILayout.BeginArea(rect, GUI.skin.window);
GUILayout.Label("玩家状态");
GUILayout.Label("右侧是 2D 关卡视角,绿色是本地玩家,蓝色是远端玩家。");
GUILayout.Space(6f);
var snapshot = SnapshotPlayerStates()
.OrderBy(player => player.PlayerId, StringComparer.Ordinal)
.ToArray();
_playerStateScroll = GUILayout.BeginScrollView(_playerStateScroll, GUILayout.Height(rect.height - 60f));
foreach (var player in snapshot)
{
var tag = player.IsLocalPlayer ? "本地" : "远端";
GUILayout.Label($"{player.DisplayName} [{tag}]");
GUILayout.Label(
$"位置=({player.Position.x:F2}, {player.Position.y:F2}) 速度=({player.Velocity.x:F2}, {player.Velocity.y:F2})");
GUILayout.Label($"落地={player.IsGrounded} 版本={player.Version}");
GUILayout.Space(6f);
}
GUILayout.EndScrollView();
GUILayout.EndArea();
}
private void EnsureRuntimeWorld()
{
if (_runtimeWorldRoot)
return;
_runtimeWorldRoot = new GameObject("ShrinkLanRuntimeWorld");
_runtimeLevelRoot = new GameObject("ShrinkLanRuntimeLevel");
_runtimeLevelRoot.transform.SetParent(_runtimeWorldRoot.transform, false);
EnsureDemoCamera();
BuildRuntimeLevel();
}
private void EnsureDemoCamera()
{
_demoCamera = Camera.main;
if (!_demoCamera)
{
var cameraObject = new GameObject("ShrinkLanDemoCamera");
cameraObject.transform.SetParent(_runtimeWorldRoot != null ? _runtimeWorldRoot.transform : null, false);
_demoCamera = cameraObject.AddComponent<Camera>();
cameraObject.tag = "MainCamera";
}
if (!_demoCamera)
return;
_demoCamera.orthographic = true;
_demoCamera.orthographicSize = CameraHalfHeight;
_demoCamera.clearFlags = CameraClearFlags.SolidColor;
_demoCamera.backgroundColor = new Color(0.78f, 0.9f, 1f, 1f);
var cameraTransform = _demoCamera.transform;
cameraTransform.position = new Vector3(0f, 0f, -10f);
cameraTransform.rotation = Quaternion.identity;
}
private void BuildRuntimeLevel()
{
_groundColliders.Clear();
CreateStaticPlatform("背景", new Vector2(0f, 1.5f), new Vector2(60f, 28f), new Color(0.72f, 0.87f, 1f, 1f),
sortingOrder: -10, withCollider: false);
CreateStaticPlatform("主地面", new Vector2(0f, -4.5f), new Vector2(64f, 2f), new Color(0.24f, 0.5f, 0.23f, 1f));
CreateStaticPlatform("平台A", new Vector2(-7f, -1.5f), new Vector2(6f, 0.8f), new Color(0.58f, 0.42f, 0.28f, 1f));
CreateStaticPlatform("平台B", new Vector2(1f, 0.5f), new Vector2(5f, 0.8f), new Color(0.58f, 0.42f, 0.28f, 1f));
CreateStaticPlatform("平台C", new Vector2(9f, -0.8f), new Vector2(7f, 0.8f), new Color(0.58f, 0.42f, 0.28f, 1f));
CreateStaticPlatform("平台D", new Vector2(16f, 2.2f), new Vector2(4.5f, 0.8f), new Color(0.58f, 0.42f, 0.28f, 1f));
}
private void CreateStaticPlatform(string name, Vector2 position, Vector2 size, Color color, int sortingOrder = -1, bool withCollider = true)
{
if (!_runtimeLevelRoot)
return;
var platform = new GameObject(name);
platform.transform.SetParent(_runtimeLevelRoot!.transform, false);
platform.transform.position = position;
platform.transform.localScale = new Vector3(size.x, size.y, 1f);
var renderer = platform.AddComponent<SpriteRenderer>();
renderer.sprite = GetRuntimeSprite();
renderer.color = color;
renderer.sortingOrder = sortingOrder;
if (!withCollider)
return;
var collider = platform.AddComponent<BoxCollider2D>();
collider.size = Vector2.one;
var body = platform.AddComponent<Rigidbody2D>();
body.bodyType = RigidbodyType2D.Static;
_groundColliders.Add(collider);
}
private void CaptureLocalInput()
{
if (string.IsNullOrEmpty(_localPlayerId) || (!_isHosting && _localClientSession == null))
{
_moveInputX = 0f;
_jumpQueued = false;
return;
}
_moveInputX = Input.GetAxisRaw("Horizontal");
if (Mathf.Abs(_moveInputX) < 0.001f)
_moveInputX = 0f;
if (Input.GetKeyDown(KeyCode.Space) || Input.GetKeyDown(KeyCode.W) || Input.GetKeyDown(KeyCode.UpArrow))
_jumpQueued = true;
}
private void TickLocalPlatformerPhysics()
{
if (string.IsNullOrEmpty(_localPlayerId) || (!_isHosting && _localClientSession == null))
return;
if (!_playerActors.TryGetValue(_localPlayerId, out var actor) || actor.Body == null || actor.Collider == null)
return;
var body = actor.Body;
var velocity = body.velocity;
velocity.x = _moveInputX * HorizontalMoveSpeed;
var isGroundedBeforeJump = IsActorGrounded(actor);
if (_jumpQueued && isGroundedBeforeJump)
{
velocity.y = JumpVelocity;
AddLog($"[Local] 执行跳跃: player={_localPlayerId}, jumpVelocity={JumpVelocity:F2}");
}
_jumpQueued = false;
body.velocity = velocity;
var position = ClampToWorld(body.position);
if ((position - body.position).sqrMagnitude > 0.0001f)
{
body.position = position;
var correctedVelocity = body.velocity;
if (position.x <= WorldLeft || position.x >= WorldRight)
correctedVelocity.x = 0f;
if (position.y <= WorldBottom || position.y >= WorldTop)
correctedVelocity.y = 0f;
body.velocity = correctedVelocity;
}
var grounded = IsActorGrounded(actor);
if (grounded)
{
var stabilizedVelocity = body.velocity;
if (Mathf.Abs(stabilizedVelocity.y) <= GroundedZeroVelocityThreshold)
stabilizedVelocity.y = 0f;
if (Mathf.Abs(stabilizedVelocity.x) <= NetworkVelocityThreshold && Mathf.Abs(_moveInputX) <= 0.001f)
stabilizedVelocity.x = 0f;
if ((stabilizedVelocity - body.velocity).sqrMagnitude > 0f)
body.velocity = stabilizedVelocity;
}
QueueLocalStateSync(body.position, body.velocity, grounded);
}
private bool IsActorGrounded(DemoPlayerActor actor)
{
if (actor.Collider == null)
return false;
var bounds = actor.Collider.bounds;
var checkCenter = new Vector2(bounds.center.x, bounds.min.y - GroundProbeHeight * 0.5f);
var checkSize = new Vector2(bounds.size.x * GroundProbeShrink, GroundProbeHeight);
var hits = Physics2D.OverlapBoxAll(checkCenter, checkSize, 0f);
for (var i = 0; i < hits.Length; i++)
{
var hit = hits[i];
if (!hit || hit == actor.Collider)
continue;
if (_groundColliders.Contains(hit))
return true;
}
return false;
}
private void QueueLocalStateSync(Vector2 position, Vector2 velocity, bool isGrounded)
{
if (string.IsNullOrEmpty(_localPlayerId))
return;
position = QuantizePosition(ClampToWorld(position));
velocity = QuantizeVelocity(StabilizeNetworkVelocity(velocity, isGrounded));
lock (_stateLock)
{
if (_players.TryGetValue(_localPlayerId, out var player))
{
player.Position = position;
player.RenderPosition = position;
player.Velocity = velocity;
player.IsGrounded = isGrounded;
}
}
if (!_hasLastDispatchedState)
{
_hasLastDispatchedState = true;
_lastDispatchedPosition = position;
_lastDispatchedVelocity = velocity;
_lastDispatchedGrounded = isGrounded;
return;
}
var positionChanged = (_lastDispatchedPosition - position).sqrMagnitude >
NetworkPositionThreshold * NetworkPositionThreshold;
var velocityChanged = (_lastDispatchedVelocity - velocity).sqrMagnitude >
NetworkVelocityThreshold * NetworkVelocityThreshold;
var groundedChanged = _lastDispatchedGrounded != isGrounded;
if (positionChanged || velocityChanged || groundedChanged)
{
_hasPendingMove = true;
_pendingMovePosition = position;
_pendingMoveVelocity = velocity;
_pendingMoveGrounded = isGrounded;
}
_sendTimer += Time.fixedDeltaTime;
var shouldFlushImmediately = _hasPendingMove &&
Mathf.Abs(velocity.x) < 0.01f &&
Mathf.Abs(velocity.y) < 0.01f &&
isGrounded;
if (SendInterval <= 0f || _sendTimer >= SendInterval || shouldFlushImmediately)
DispatchPendingMove();
}
private void DispatchPendingMove()
{
if (!_hasPendingMove || _isMoveSendInFlight || string.IsNullOrEmpty(_localPlayerId))
return;
_sendTimer = 0f;
_lastSentMoveVersion++;
var version = _lastSentMoveVersion;
var position = _pendingMovePosition;
var velocity = _pendingMoveVelocity;
var isGrounded = _pendingMoveGrounded;
var playerId = _localPlayerId!;
_hasLastDispatchedState = true;
_lastDispatchedPosition = position;
_lastDispatchedVelocity = velocity;
_lastDispatchedGrounded = isGrounded;
_hasPendingMove = false;
_isMoveSendInFlight = true;
FlushPendingMoveAsync(playerId, position, velocity, isGrounded, version).Forget();
}
private async UniTaskVoid FlushPendingMoveAsync(string playerId, Vector2 position, Vector2 velocity, bool isGrounded, long version)
{
try
{
if (_isHosting)
await ApplyHostLocalMoveAsync(playerId, position, velocity, isGrounded, version);
else
await SendMoveAsync(position, velocity, isGrounded, version);
}
finally
{
_isMoveSendInFlight = false;
if (_hasPendingMove)
DispatchPendingMove();
}
}
private void TickRemoteInterpolation()
{
if (Time.unscaledDeltaTime <= 0f)
return;
var blendX = 1f - Mathf.Exp(-RemoteInterpolationSpeedX * Time.unscaledDeltaTime);
var blendY = 1f - Mathf.Exp(-RemoteInterpolationSpeedY * Time.unscaledDeltaTime);
lock (_stateLock)
{
foreach (var player in _players.Values)
{
if (player.IsLocalPlayer)
{
player.RenderPosition = player.Position;
continue;
}
var predictedPosition = player.Position + player.Velocity * RemotePredictionLead;
predictedPosition = ClampToWorld(predictedPosition);
if ((player.RenderPosition - predictedPosition).sqrMagnitude >= RemoteSnapDistance * RemoteSnapDistance)
{
player.RenderPosition = predictedPosition;
continue;
}
player.RenderPosition = new Vector2(
Mathf.Lerp(player.RenderPosition.x, predictedPosition.x, blendX),
Mathf.Lerp(player.RenderPosition.y, predictedPosition.y, blendY));
}
}
}
private void SyncPlayerActors()
{
EnsureRuntimeWorld();
var snapshot = SnapshotPlayerStates();
var alivePlayerIds = new HashSet<string>(snapshot.Select(player => player.PlayerId), StringComparer.Ordinal);
foreach (var stalePlayerId in _playerActors.Keys.Where(id => !alivePlayerIds.Contains(id)).ToArray())
DestroyPlayerActor(stalePlayerId);
foreach (var player in snapshot)
{
if (!_playerActors.TryGetValue(player.PlayerId, out var actor) || actor.IsLocalAuthority != player.IsLocalPlayer)
{
DestroyPlayerActor(player.PlayerId);
actor = CreatePlayerActor(player);
_playerActors[player.PlayerId] = actor;
}
UpdatePlayerActor(actor, player);
}
}
private DemoPlayerActor CreatePlayerActor(DemoPlayerState state)
{
var root = new GameObject($"DemoPlayer-{state.PlayerId}");
if (_runtimeWorldRoot)
root.transform.SetParent(_runtimeWorldRoot!.transform, false);
root.transform.position = state.Position;
root.transform.localScale = new Vector3(PlayerWidth, PlayerHeight, 1f);
var renderer = root.AddComponent<SpriteRenderer>();
renderer.sprite = GetRuntimeSprite();
renderer.sortingOrder = 5;
Rigidbody2D? body = null;
BoxCollider2D? collider = null;
if (state.IsLocalPlayer)
{
collider = root.AddComponent<BoxCollider2D>();
collider.size = Vector2.one;
body = root.AddComponent<Rigidbody2D>();
body.bodyType = RigidbodyType2D.Dynamic;
body.gravityScale = 3f;
body.freezeRotation = true;
body.interpolation = RigidbodyInterpolation2D.Interpolate;
body.collisionDetectionMode = CollisionDetectionMode2D.Continuous;
body.position = state.Position;
body.velocity = state.Velocity;
}
return new DemoPlayerActor
{
PlayerId = state.PlayerId,
IsLocalAuthority = state.IsLocalPlayer,
Root = root,
Body = body,
Collider = collider,
Renderer = renderer
};
}
private void UpdatePlayerActor(DemoPlayerActor actor, DemoPlayerState state)
{
if (actor.Renderer is { } renderer)
renderer.color = state.IsLocalPlayer ? new Color(0.2f, 0.88f, 0.35f, 1f) : new Color(0.2f, 0.55f, 1f, 1f);
if (!state.IsLocalPlayer)
{
actor.Root.transform.position = state.RenderPosition;
return;
}
if (actor.Body is not { } body)
return;
if ((body.position - state.Position).sqrMagnitude > 25f)
body.position = state.Position;
}
private void DestroyPlayerActor(string playerId)
{
if (!_playerActors.TryGetValue(playerId, out var actor))
return;
if (actor.Root)
Destroy(actor.Root);
_playerActors.Remove(playerId);
}
private void DestroyAllPlayerActors()
{
foreach (var actor in _playerActors.Values)
{
if (actor.Root)
Destroy(actor.Root);
}
_playerActors.Clear();
}
private void UpdateCameraFollow()
{
if (!_demoCamera)
return;
Vector2 targetPosition = Vector2.zero;
var hasTarget = false;
if (!string.IsNullOrEmpty(_localPlayerId) &&
_playerActors.TryGetValue(_localPlayerId, out var actor) &&
actor.Body is { } body)
{
targetPosition = body.position;
hasTarget = true;
}
else
{
var snapshot = SnapshotPlayerStates();
if (snapshot.Length > 0)
{
targetPosition = snapshot[0].Position;
hasTarget = true;
}
}
if (!hasTarget)
return;
targetPosition.x = Mathf.Clamp(targetPosition.x, WorldLeft + 6f, WorldRight - 6f);
targetPosition.y = Mathf.Clamp(targetPosition.y + 1.5f, -1.5f, 4.5f);
var cameraTransform = _demoCamera!.transform;
var current = cameraTransform.position;
var next = Vector3.Lerp(current, new Vector3(targetPosition.x, targetPosition.y, -10f),
1f - Mathf.Exp(-CameraFollowSpeed * Time.unscaledDeltaTime));
cameraTransform.position = next;
}
private static Vector2 StabilizeNetworkVelocity(Vector2 velocity, bool isGrounded)
{
if (Mathf.Abs(velocity.x) <= NetworkVelocityThreshold)
velocity.x = 0f;
if (isGrounded && Mathf.Abs(velocity.y) <= GroundedZeroVelocityThreshold)
velocity.y = 0f;
return velocity;
}
private static Vector2 QuantizePosition(Vector2 position)
{
position.x = Mathf.Round(position.x / NetworkPositionQuantize) * NetworkPositionQuantize;
position.y = Mathf.Round(position.y / NetworkPositionQuantize) * NetworkPositionQuantize;
return position;
}
private static Vector2 QuantizeVelocity(Vector2 velocity)
{
velocity.x = Mathf.Round(velocity.x / NetworkVelocityQuantize) * NetworkVelocityQuantize;
velocity.y = Mathf.Round(velocity.y / NetworkVelocityQuantize) * NetworkVelocityQuantize;
return velocity;
}
private static Vector2 ClampToWorld(Vector2 position)
{
position.x = Mathf.Clamp(position.x, WorldLeft, WorldRight);
position.y = Mathf.Clamp(position.y, WorldBottom, WorldTop);
return position;
}
private static Vector2 GetSpawnPosition(long seed)
{
if (seed <= 0)
return new Vector2(-16f, -2.4f);
var index = (int)((seed - 1) % 4);
return new Vector2(-16f + index * 3.2f, -2.4f);
}
private string BuildLanAddressSummary()
{
try
{
var addresses = Dns.GetHostAddresses(Dns.GetHostName())
.Where(addr => addr.AddressFamily == AddressFamily.InterNetwork && !IPAddress.IsLoopback(addr))
.Select(addr => addr.ToString())
.Distinct()
.ToArray();
return addresses.Length == 0 ? "未找到 IPv4 地址" : string.Join(" / ", addresses);
}
catch (Exception ex)
{
return $"获取地址失败:{ex.Message}";
}
}
private void AddLog(string message)
{
lock (_stateLock)
{
_logs.Add($"[{DateTime.Now:HH:mm:ss}] {message}");
if (_logs.Count > 30)
_logs.RemoveAt(0);
}
_logScroll.y = float.MaxValue;
}
private void TickHeartbeat()
{
if (_isHosting && _hostService != null)
{
_hostHeartbeatTimer += Time.unscaledDeltaTime;
if (_hostHeartbeatTimer >= HostHeartbeatInterval)
{
_hostHeartbeatTimer = 0f;
BroadcastHeartbeatAsync().Forget();
}
}
if (_isHosting || _clientService == null || _localClientSession == null)
return;
if (_lastHeartbeatReceivedAt <= 0f)
return;
if (Time.unscaledTime - _lastHeartbeatReceivedAt <= HostHeartbeatTimeout)
return;
AddLog("与房主 Host 的心跳已超时,自动断开连接。");
_statusText = "Host 已断开";
ShutdownNetworking();
}
private static Sprite GetRuntimeSprite()
{
if (s_runtimeSprite)
return s_runtimeSprite!;
var texture = new Texture2D(1, 1, TextureFormat.RGBA32, false)
{
filterMode = FilterMode.Point,
wrapMode = TextureWrapMode.Clamp
};
texture.SetPixel(0, 0, Color.white);
texture.Apply();
s_runtimeSprite = Sprite.Create(texture, new Rect(0f, 0f, 1f, 1f), new Vector2(0.5f, 0.5f), 1f);
return s_runtimeSprite;
}
}
[ShrinkNetworkMessage(4101, "lan/join_room")]
[ShrinkNetworkStateSync("lan", ShrinkNetworkStateSyncRole.JoinRequest)]
public sealed class LanJoinRoomRequest : IShrinkNetworkRequest
{
public string PlayerName { get; set; } = string.Empty;
}
[ShrinkNetworkMessage(1201, "server/auth/login")]
public sealed class ServerAuthLoginRequest : IShrinkNetworkRequest
{
public string ClientName { get; set; } = string.Empty;
public string Token { get; set; } = string.Empty;
}
[ShrinkNetworkMessage(1202, "server/auth/login_response")]
public sealed class ServerAuthLoginResponse : ShrinkRpcResponseBase
{
public bool IsAuthenticated { get; set; }
public string[] GrantedPermissions { get; set; } = Array.Empty<string>();
public string ServerMessage { get; set; } = string.Empty;
}
[ShrinkNetworkMessage(4102, "lan/join_room_response")]
[ShrinkNetworkStateSync("lan", ShrinkNetworkStateSyncRole.JoinResponse)]
public sealed class LanJoinRoomResponse : ShrinkRpcResponseBase
{
public string PlayerId { get; set; } = string.Empty;
public LanPlayerStateDto[] Players { get; set; } = Array.Empty<LanPlayerStateDto>();
}
[ShrinkNetworkMessage(4110, "lan/move_command")]
[ShrinkNetworkStateSync("lan", ShrinkNetworkStateSyncRole.Command)]
public sealed class LanMoveCommand : IShrinkNetworkMessage
{
public string PlayerId { get; set; } = string.Empty;
public float X { get; set; }
public float Y { get; set; }
public float VX { get; set; }
public float VY { get; set; }
public bool IsGrounded { get; set; }
public long Version { get; set; }
}
[ShrinkNetworkMessage(4111, "lan/player_state_delta")]
[ShrinkNetworkStateSync("lan", ShrinkNetworkStateSyncRole.StateDelta)]
public sealed class LanPlayerStateDelta : IShrinkNetworkMessage
{
public string PlayerId { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public float X { get; set; }
public float Y { get; set; }
public float VX { get; set; }
public float VY { get; set; }
public bool IsGrounded { get; set; }
public long Version { get; set; }
}
[ShrinkNetworkMessage(4112, "lan/player_left")]
[ShrinkNetworkStateSync("lan", ShrinkNetworkStateSyncRole.LeaveNotice)]
public sealed class LanPlayerLeftNotice : IShrinkNetworkMessage
{
public string PlayerId { get; set; } = string.Empty;
}
[ShrinkNetworkMessage(4113, "lan/heartbeat")]
[ShrinkNetworkStateSync("lan", ShrinkNetworkStateSyncRole.Heartbeat)]
public sealed class LanHeartbeatNotice : IShrinkNetworkMessage
{
public long ServerUnixMs { get; set; }
}
public sealed class LanPlayerStateDto
{
public string PlayerId { get; set; } = string.Empty;
public string DisplayName { get; set; } = string.Empty;
public float X { get; set; }
public float Y { get; set; }
public float VX { get; set; }
public float VY { get; set; }
public bool IsGrounded { get; set; }
public long Version { get; set; }
}