1545 lines
53 KiB
C#
1545 lines
53 KiB
C#
#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; }
|
||
}
|