#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 _logs = new(); private readonly Dictionary _players = new(); private readonly Dictionary _hostSessionToPlayerId = new(); private readonly Dictionary _playerActors = new(); private readonly List _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(HandleHostJoinRoomAsync); service.RegisterHandler(HandleHostMoveCommandAsync); service.OnSessionDisconnected += OnHostSessionDisconnected; } private void RegisterClientHandlers(ShrinkNetworkService service) { service.AutoRegisterAttributedMessages(); service.RegisterHandler(HandleClientPlayerStateDeltaAsync); service.RegisterHandler(HandleClientPlayerLeftAsync); service.RegisterHandler(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( 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(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 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 GetHostSessionsSnapshot() { if (_hostService == null) return Array.Empty(); 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(); 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(); renderer.sprite = GetRuntimeSprite(); renderer.color = color; renderer.sortingOrder = sortingOrder; if (!withCollider) return; var collider = platform.AddComponent(); collider.size = Vector2.one; var body = platform.AddComponent(); 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(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(); renderer.sprite = GetRuntimeSprite(); renderer.sortingOrder = 5; Rigidbody2D? body = null; BoxCollider2D? collider = null; if (state.IsLocalPlayer) { collider = root.AddComponent(); collider.size = Vector2.one; body = root.AddComponent(); 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(); 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(); } [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; } }