Initial commit: Client Doc docs Server Tools

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
ud18010
2026-07-10 10:24:29 +08:00
co-authored by Cursor
commit 7e35d8da31
3374 changed files with 680813 additions and 0 deletions
+81
View File
@@ -0,0 +1,81 @@
using System;
using System.IO;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using ChannelsNS = System.Threading.Channels;
using XWorld.Framework.Protocol;
namespace XWorld.Server.Gateway
{
// 一个 WebSocket 连接:收循环解码帧→Submit;发循环排空出站队列→SendAsync(每 socket 仅一个发循环,发送串行)
public sealed class Connection : IClientConnection
{
public int PlayerId { get; }
private readonly WebSocket _socket;
private readonly ServerLoop _loop;
private readonly ChannelsNS.Channel<byte[]> _outbound = ChannelsNS.Channel.CreateUnbounded<byte[]>();
public Connection(int playerId, WebSocket socket, ServerLoop loop)
{
PlayerId = playerId; _socket = socket; _loop = loop;
}
public void Send(byte[] frame) => _outbound.Writer.TryWrite(frame);
public async Task RunAsync(CancellationToken ct)
{
var sendTask = SendLoopAsync(ct);
try { await ReceiveLoopAsync(ct).ConfigureAwait(false); }
finally
{
_outbound.Writer.TryComplete();
await sendTask.ConfigureAwait(false);
}
}
private async Task ReceiveLoopAsync(CancellationToken ct)
{
var buf = new byte[8192];
using var ms = new MemoryStream();
try
{
while (_socket.State == WebSocketState.Open && !ct.IsCancellationRequested)
{
ms.SetLength(0);
WebSocketReceiveResult res;
do
{
res = await _socket.ReceiveAsync(new ArraySegment<byte>(buf), ct).ConfigureAwait(false);
if (res.MessageType == WebSocketMessageType.Close) return;
ms.Write(buf, 0, res.Count);
} while (!res.EndOfMessage);
Frame frame;
try { frame = FrameCodec.Decode(ms.ToArray()); }
catch (FormatException) { continue; } // 畸形帧:丢弃,不拖垮连接
_loop.Submit(new FrameCommand { PlayerId = PlayerId, Frame = frame });
}
}
catch (OperationCanceledException) { } // 主机关停/请求中止
catch (WebSocketException) { } // 对端突然断开(TCP 掉线)
}
private async Task SendLoopAsync(CancellationToken ct)
{
try
{
await foreach (var frame in _outbound.Reader.ReadAllAsync(ct).ConfigureAwait(false))
{
if (_socket.State != WebSocketState.Open) break;
await _socket.SendAsync(new ArraySegment<byte>(frame),
WebSocketMessageType.Binary, true, ct).ConfigureAwait(false);
}
}
catch (OperationCanceledException) { }
catch (WebSocketException) { } // 对端已断,停止发送
}
}
}
+82
View File
@@ -0,0 +1,82 @@
using System;
using System.Net;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
using XWorld.Framework.Protocol;
namespace XWorld.Server.Gateway
{
public sealed class DevDiscoveryResponder
{
private readonly DevDiscoveryReply _template;
private readonly string _token;
private readonly UdpClient _udp;
private CancellationTokenSource _cts;
private Task _loop;
public int Port { get; }
public DevDiscoveryResponder(string serverName, string gatewayUrl, string resourceBaseUrl,
string token, int listenPort = DevDiscovery.Port)
{
if (string.IsNullOrEmpty(token)) throw new ArgumentException("token cannot be empty", nameof(token));
_token = token;
_template = new DevDiscoveryReply
{
ProtoVersion = DevDiscovery.ProtoVersion,
ServerName = serverName ?? "dev-server",
GatewayUrl = gatewayUrl ?? "",
ResourceBaseUrl = resourceBaseUrl ?? "",
Token = token,
};
_udp = new UdpClient(AddressFamily.InterNetwork);
_udp.Client.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true);
_udp.Client.Bind(new IPEndPoint(IPAddress.Any, listenPort));
Port = ((IPEndPoint)_udp.Client.LocalEndPoint).Port;
}
public void Start()
{
if (_loop != null) return;
_cts = new CancellationTokenSource();
_loop = ReceiveLoopAsync(_cts.Token);
}
private async Task ReceiveLoopAsync(CancellationToken ct)
{
using (ct.Register(() => { try { _udp.Close(); } catch { } }))
{
while (!ct.IsCancellationRequested)
{
UdpReceiveResult res;
try { res = await _udp.ReceiveAsync().ConfigureAwait(false); }
catch (ObjectDisposedException) { break; }
catch (SocketException) { break; }
if (!DevDiscoveryCodec.TryDecodeQuery(res.Buffer, out var q)) continue;
if (q.ProtoVersion != DevDiscovery.ProtoVersion) continue;
if (!string.Equals(q.Token, _token, StringComparison.Ordinal)) continue;
DevDiscoveryReply reply = _template;
reply.Nonce = q.Nonce;
byte[] bytes = DevDiscoveryCodec.EncodeReply(reply);
try { await _udp.SendAsync(bytes, bytes.Length, res.RemoteEndPoint).ConfigureAwait(false); }
catch (ObjectDisposedException) { break; }
catch (SocketException) { }
}
}
}
public async Task StopAsync()
{
if (_cts == null) return;
_cts.Cancel();
try { await _loop.ConfigureAwait(false); }
catch { }
_cts.Dispose();
_cts = null;
_loop = null;
}
}
}
+146
View File
@@ -0,0 +1,146 @@
using System;
using System.Linq;
using System.Net;
using System.Net.WebSockets;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using XWorld.Framework.Protocol;
using XWorld.Server.Host;
using XWorld.Server.Auth;
namespace XWorld.Server.Gateway
{
// 建 Kestrel + /ws 端点,把 socket 接入 ServerLoop。Start 返回实际监听地址(端口由系统分配)。
public sealed class GameServerHost
{
private WebApplication _app;
private ServerLoop _loop;
private CancellationTokenSource _cts;
private Task _loopTask;
private DevDiscoveryResponder _discovery;
private AccountStore _accounts;
public string WsBaseUrl { get; private set; } // 例如 ws://127.0.0.1:54321
public int DevDiscoveryPort => _discovery?.Port ?? 0;
public async Task StartAsync(string gamesRoot, int tickIntervalMs, long reconnectWindowTicks = 100,
long matchTimeoutTicks = 150, float? logicalDt = null, string publicKeyPem = null, int listenPort = 0,
bool bindAllInterfaces = false,
string devDiscoveryToken = null, string advertiseGatewayUrl = null, string advertiseResourceBaseUrl = null,
int devDiscoveryPort = DevDiscovery.Port,
byte[] authSecret = null, string authDbPath = null)
{
_loop = new ServerLoop(gamesRoot, new ConsoleLogger("[loop]"), reconnectWindowTicks, matchTimeoutTicks, publicKeyPem);
_cts = new CancellationTokenSource();
float dt = logicalDt ?? tickIntervalMs / 1000f;
var builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders();
builder.WebHost.ConfigureKestrel(o =>
o.Listen(bindAllInterfaces ? IPAddress.Any : IPAddress.Loopback, listenPort)); // 0 = 临时端口
TokenService tokens = null;
if (authSecret != null)
{
tokens = new TokenService(authSecret);
_accounts = new AccountStore(string.IsNullOrEmpty(authDbPath)
? "Data Source=accounts.db" : authDbPath);
}
var app = builder.Build();
app.UseWebSockets();
if (tokens != null)
AuthEndpoints.Map(app, _accounts, tokens, TimeSpan.FromDays(7));
app.Use(async (ctx, next) =>
{
if (ctx.Request.Path == "/ws" && ctx.WebSockets.IsWebSocketRequest)
{
int pid;
if (tokens != null)
{
string token = ctx.Request.Query["token"];
if (!tokens.Verify(token, out pid))
{
ctx.Response.StatusCode = 401;
return;
}
}
else if (!int.TryParse(ctx.Request.Query["pid"], out pid))
{
ctx.Response.StatusCode = 400;
return;
}
using var ws = await ctx.WebSockets.AcceptWebSocketAsync();
var conn = new Connection(pid, ws, _loop);
_loop.Submit(new ConnectCommand { PlayerId = pid, Connection = conn });
try { await conn.RunAsync(ctx.RequestAborted); }
catch (OperationCanceledException) { } // 关停/中止:正常收束
finally
{
if (ws.State == WebSocketState.Open)
{
try { await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "server stopping", CancellationToken.None); }
catch { /* 关闭握手尽力而为 */ }
}
_loop.Submit(new DisconnectCommand { PlayerId = pid, Connection = conn });
}
}
else
{
await next();
}
});
await app.StartAsync();
_app = app;
var addresses = app.Services.GetRequiredService<IServer>()
.Features.Get<IServerAddressesFeature>().Addresses;
string http = addresses.FirstOrDefault()
?? throw new InvalidOperationException("Kestrel 已启动但未注册任何监听地址");
WsBaseUrl = http.Replace("http://", "ws://");
if (!string.IsNullOrEmpty(devDiscoveryToken))
{
string adGw = advertiseGatewayUrl;
if (string.IsNullOrEmpty(adGw))
{
// 绑所有网卡时 WsBaseUrl 形如 ws://0.0.0.0:port,对内网其它设备无意义 ——
// 用本机真实 LAN IP 广而告之(仿 C++ XBroadcastServer.GetLocalIPAddress)。
if (bindAllInterfaces)
adGw = $"ws://{LanIp.Resolve()}:{new Uri(WsBaseUrl).Port}/ws";
else
adGw = WsBaseUrl + "/ws";
}
_discovery = new DevDiscoveryResponder(
Environment.MachineName, adGw, advertiseResourceBaseUrl ?? "", devDiscoveryToken, devDiscoveryPort);
_discovery.Start();
}
_loopTask = _loop.RunAsync(tickIntervalMs, dt, _cts.Token);
}
public async Task StopAsync()
{
if (_discovery != null)
{
await _discovery.StopAsync();
_discovery = null;
}
_cts.Cancel();
try { await _loopTask; } catch { /* 忽略取消 */ }
await _app.StopAsync();
await _app.DisposeAsync();
_accounts?.Dispose();
_accounts = null;
}
}
}
+17
View File
@@ -0,0 +1,17 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>disable</Nullable>
<ImplicitUsings>disable</ImplicitUsings>
<AssemblyName>XWorld.Server.Gateway</AssemblyName>
<RootNamespace>XWorld.Server.Gateway</RootNamespace>
</PropertyGroup>
<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="..\Server.Host\Server.Host.csproj" />
<ProjectReference Include="..\Auth\Auth.csproj" />
</ItemGroup>
</Project>
+10
View File
@@ -0,0 +1,10 @@
namespace XWorld.Server.Gateway
{
// actor 把出站帧交给它(非阻塞入队);生产实现写 WebSocket 出站队列,测试用假实现记录
public interface IClientConnection
{
int PlayerId { get; }
// frame 在调用后不会被修改:实现可保留该引用(广播会把同一数组发给多个连接)。
void Send(byte[] frame);
}
}
+55
View File
@@ -0,0 +1,55 @@
using System.Linq;
using System.Net;
using System.Net.NetworkInformation;
using System.Net.Sockets;
namespace XWorld.Server.Gateway
{
// 取本机内网主 IPv4,用于广播 advertise(仿 C++ XBroadcastServer.GetLocalIPAddress)。
// 优先选 Up、非 loopback、非隧道、带网关的网卡上的 IPv4;都没有则回退 127.0.0.1。
public static class LanIp
{
public static string Resolve()
{
// 1) 优先:有默认网关的活动网卡(最可能是真实内网口)
string withGateway = PickFromNics(requireGateway: true);
if (withGateway != null) return withGateway;
// 2) 退一步:任意 Up 的非 loopback/隧道网卡上的私有 IPv4
string anyUp = PickFromNics(requireGateway: false);
if (anyUp != null) return anyUp;
// 3) 兜底:DNS 解析本机名里的第一个 IPv4
foreach (IPAddress addr in Dns.GetHostAddresses(Dns.GetHostName()))
{
if (addr.AddressFamily == AddressFamily.InterNetwork && !IPAddress.IsLoopback(addr))
return addr.ToString();
}
return "127.0.0.1";
}
private static string PickFromNics(bool requireGateway)
{
foreach (NetworkInterface nic in NetworkInterface.GetAllNetworkInterfaces())
{
if (nic.OperationalStatus != OperationalStatus.Up) continue;
if (nic.NetworkInterfaceType == NetworkInterfaceType.Loopback) continue;
if (nic.NetworkInterfaceType == NetworkInterfaceType.Tunnel) continue;
IPInterfaceProperties props = nic.GetIPProperties();
if (requireGateway && !props.GatewayAddresses.Any(g => g.Address.AddressFamily == AddressFamily.InterNetwork))
continue;
foreach (UnicastIPAddressInformation ua in props.UnicastAddresses)
{
IPAddress ip = ua.Address;
if (ip.AddressFamily != AddressFamily.InterNetwork) continue;
if (IPAddress.IsLoopback(ip)) continue;
return ip.ToString();
}
}
return null;
}
}
}
+64
View File
@@ -0,0 +1,64 @@
using System.Collections.Generic;
namespace XWorld.Server.Gateway
{
public sealed class Matchmaker
{
public sealed class Seat { public int PlayerId; public bool IsAi; }
public sealed class Match { public string GameId; public int Version; public List<Seat> Seats = new List<Seat>(); }
private sealed class Waiter { public int Pid; public long EnqueuedTick; }
private sealed class Bucket { public int PlayerCount; public List<Waiter> Waiters = new List<Waiter>(); }
private readonly long _timeoutTicks;
private int _aiSeq;
private readonly Dictionary<string, Bucket> _buckets = new Dictionary<string, Bucket>();
public Matchmaker(long timeoutTicks) { _timeoutTicks = timeoutTicks; }
private static string Key(string g, int v) => $"{g}@{v}";
public void Enqueue(int pid, string gameId, int version, int playerCount, long tick)
{
string k = Key(gameId, version);
if (!_buckets.TryGetValue(k, out var b)) { b = new Bucket { PlayerCount = playerCount }; _buckets[k] = b; }
b.Waiters.Add(new Waiter { Pid = pid, EnqueuedTick = tick });
}
// 从等待队列移除某玩家(断线时调用);返回是否移除
public bool Remove(int pid)
{
foreach (var kv in _buckets)
{
var ws = kv.Value.Waiters;
for (int i = 0; i < ws.Count; i++)
if (ws[i].Pid == pid) { ws.RemoveAt(i); return true; }
}
return false;
}
// 返回本次可成形的对局(凑齐或超时 AI 补足)
public IReadOnlyList<Match> Poll(long currentTick)
{
List<Match> formed = null;
foreach (var kv in _buckets)
{
var b = kv.Value;
string[] gv = kv.Key.Split('@');
while (b.Waiters.Count > 0 &&
(b.Waiters.Count >= b.PlayerCount ||
currentTick - b.Waiters[0].EnqueuedTick >= _timeoutTicks))
{
var m = new Match { GameId = gv[0], Version = int.Parse(gv[1]) };
int take = b.Waiters.Count >= b.PlayerCount ? b.PlayerCount : b.Waiters.Count;
for (int i = 0; i < take; i++)
m.Seats.Add(new Seat { PlayerId = b.Waiters[i].Pid, IsAi = false });
b.Waiters.RemoveRange(0, take);
while (m.Seats.Count < b.PlayerCount)
m.Seats.Add(new Seat { PlayerId = -(++_aiSeq), IsAi = true }); // AI 用负 pid
(formed ??= new List<Match>()).Add(m);
}
}
return formed ?? (IReadOnlyList<Match>)System.Array.Empty<Match>();
}
}
}
+34
View File
@@ -0,0 +1,34 @@
using System.Collections.Generic;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using XWorld.Server.Host;
namespace XWorld.Server.Gateway
{
// 房间广播 → 解析各玩家当前连接(重连后自动指向新连接)→ 包成 Game 通道帧入对应出站队列
public sealed class RoomOutputSink : IRoomOutput
{
private readonly string _roomId; // 保留作诊断/日志标识
private readonly IReadOnlyList<int> _players;
private readonly SessionManager _sessions;
public RoomOutputSink(string roomId, IReadOnlyList<int> players, SessionManager sessions)
{
_roomId = roomId; _players = players; _sessions = sessions;
}
public void Broadcast(NetMessage message)
{
byte[] frame = FrameCodec.Encode(new Frame(Channel.Game, message.Opcode, message.Payload));
foreach (var pid in _players)
_sessions.GetConnection(pid)?.Send(frame);
}
public void SendTo(int playerId, NetMessage message)
{
var conn = _sessions.GetConnection(playerId);
if (conn != null)
conn.Send(FrameCodec.Encode(new Frame(Channel.Game, message.Opcode, message.Payload)));
}
}
}
+12
View File
@@ -0,0 +1,12 @@
using XWorld.Framework.Protocol;
namespace XWorld.Server.Gateway
{
public abstract class ServerCommand { public int PlayerId; }
public sealed class ConnectCommand : ServerCommand { public IClientConnection Connection; }
public sealed class DisconnectCommand : ServerCommand { public IClientConnection Connection; }
public sealed class FrameCommand : ServerCommand { public Frame Frame; }
}
+433
View File
@@ -0,0 +1,433 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using ChannelsNS = System.Threading.Channels;
using XWorld.Framework;
using XWorld.Framework.Protocol;
using XWorld.Server.Host;
namespace XWorld.Server.Gateway
{
// 单线程 actor:独占 RoomHost 与 SessionManager;命令入队、周期性排空+推进。
public sealed class ServerLoop
{
private readonly RoomHost _host;
private readonly SessionManager _sessions = new SessionManager();
private readonly ILogger _logger;
private readonly long _reconnectWindowTicks;
private readonly string _gamesRoot;
private readonly Matchmaker _matchmaker;
private readonly ChannelsNS.Channel<ServerCommand> _cmds =
ChannelsNS.Channel.CreateUnbounded<ServerCommand>();
private readonly Dictionary<string, List<int>> _roomPlayers = new Dictionary<string, List<int>>();
private readonly Dictionary<int, LobbyPlayerMsg> _lobbyPlayers = new Dictionary<int, LobbyPlayerMsg>();
private readonly Dictionary<string, int> _playerCountCache = new Dictionary<string, int>(); // "gameId@version" -> playerCount
private readonly HashSet<int> _queuedPids = new HashSet<int>(); // pids currently in matchmaker queue
private readonly Random _random = new Random(1);
private long _tick;
private int _roomSeq;
public ServerLoop(string gamesRoot, ILogger logger, long reconnectWindowTicks = 100,
long matchTimeoutTicks = 150, string publicKeyPem = null)
{
_logger = logger;
_reconnectWindowTicks = reconnectWindowTicks;
_gamesRoot = gamesRoot;
_host = new RoomHost(new GameModuleRegistry(new GameModuleLoader(publicKeyPem), gamesRoot), logger);
_matchmaker = new Matchmaker(matchTimeoutTicks);
}
// 线程安全:网络线程调用,仅入队
public void Submit(ServerCommand cmd) => _cmds.Writer.TryWrite(cmd);
// 仅 actor 线程调用(_sessions 无锁);测试在单线程下使用
public bool HasSession(int pid) => _sessions.Get(pid) != null;
// 测试直调;真实循环周期性调它。
// 两步排空:先处理所有 Connect/Disconnect/Framework(含 MatchRequest 入队),
// 再 Poll 匹配器建房,再处理 Game 帧(此时 RoomId 已就位)。
public void DrainAndTick(float dt)
{
// 第一遍:收集所有命令,立刻处理非游戏帧(connect/disconnect/framework
var deferred = new List<FrameCommand>(); // 本批游戏帧,延后到建房后处理
while (_cmds.Reader.TryRead(out var cmd))
{
if (cmd is FrameCommand fc && fc.Frame.Channel == Channel.Game)
deferred.Add(fc);
else
Process(cmd);
}
// Poll 匹配器 → 对本 tick 内已凑齐或超时的队列建房
var matches = _matchmaker.Poll(_tick);
for (int i = 0; i < matches.Count; i++)
{
_logger.Info($"match formed {matches[i].GameId}@{matches[i].Version} seats={matches[i].Seats.Count} tick={_tick}");
CreateRoomForSeats(matches[i]);
}
// 第二遍:处理本批游戏帧(此时 RoomId 已就位)
for (int i = 0; i < deferred.Count; i++)
{
var fc = deferred[i];
Route(fc.PlayerId, fc.Frame);
}
_tick++;
var ended = _host.TickAll(dt);
for (int i = 0; i < ended.Count; i++) OnRoomEnded(ended[i].RoomId, ended[i].Result);
var expired = _sessions.SweepExpired(_tick, _reconnectWindowTicks);
for (int i = 0; i < expired.Count; i++)
_logger.Info($"session {expired[i].PlayerId} 重连窗口过期,清除");
}
public async Task RunAsync(int tickIntervalMs, CancellationToken ct)
=> await RunAsync(tickIntervalMs, tickIntervalMs / 1000f, ct);
public async Task RunAsync(int tickIntervalMs, float logicalDt, CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
DrainAndTick(logicalDt);
try { await Task.Delay(tickIntervalMs, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
}
}
private void Process(ServerCommand cmd)
{
switch (cmd)
{
case ConnectCommand cc:
_sessions.OnConnect(cc.PlayerId, cc.Connection);
_logger.Info($"session connect pid={cc.PlayerId}");
break;
case DisconnectCommand dc:
_sessions.OnDisconnect(dc.PlayerId, dc.Connection, _tick);
_logger.Info($"session disconnect pid={dc.PlayerId}");
if (_queuedPids.Remove(dc.PlayerId)) _matchmaker.Remove(dc.PlayerId);
if (_lobbyPlayers.Remove(dc.PlayerId)) BroadcastLobbyState();
break;
case FrameCommand fc: Route(fc.PlayerId, fc.Frame); break;
}
}
private void Route(int pid, Frame frame)
{
if (frame.Channel == Channel.Framework) HandleFramework(pid, frame);
else
{
var s = _sessions.Get(pid);
if (s?.RoomId != null)
_host.DeliverTo(s.RoomId, pid, new NetMessage(frame.Opcode, frame.Payload));
}
}
private void HandleFramework(int pid, Frame frame)
{
FrameworkOpcode op = (FrameworkOpcode)frame.Opcode;
if (op != FrameworkOpcode.LobbyMove)
{
_logger.Info($"framework pid={pid} op={op} bytes={frame.Payload?.Length ?? 0}");
}
switch (op)
{
case FrameworkOpcode.Heartbeat:
SendFramework(pid, FrameworkOpcode.Heartbeat, frame.Payload); // 原样回显
break;
case FrameworkOpcode.MatchRequest:
var req = MatchRequestMsg.Decode(frame.Payload);
EnqueueMatch(pid, req.GameId, req.Version);
break;
case FrameworkOpcode.GameListRequest:
SendFramework(pid, FrameworkOpcode.GameListResponse, BuildGameList().Encode());
break;
case FrameworkOpcode.RandomMatchRequest:
EnqueueRandomMatch(pid);
break;
case FrameworkOpcode.LobbyJoin:
HandleLobbyJoin(pid, frame.Payload);
break;
case FrameworkOpcode.LobbyMove:
HandleLobbyMove(pid, frame.Payload);
break;
case FrameworkOpcode.LobbyLeave:
if (_lobbyPlayers.Remove(pid)) BroadcastLobbyState();
break;
}
}
private void HandleLobbyJoin(int pid, byte[] payload)
{
var req = LobbyJoinMsg.Decode(payload);
string name = string.IsNullOrWhiteSpace(req.Name) ? $"P{pid}" : req.Name;
int figure = req.Figure <= 0 ? 1 : req.Figure;
if (!_lobbyPlayers.TryGetValue(pid, out var player))
{
float lane = (_lobbyPlayers.Count % 6) - 2.5f;
player = new LobbyPlayerMsg
{
PlayerId = pid,
Name = name,
Figure = figure,
X = lane * 1.5f,
Y = 0f,
Z = 0f,
DirX = 0f,
DirZ = 1f,
Moving = false
};
_lobbyPlayers[pid] = player;
}
else
{
player.Name = name;
player.Figure = figure;
}
_logger.Info($"lobby join pid={pid} name={name} figure={figure} players={_lobbyPlayers.Count} pids={string.Join(",", _lobbyPlayers.Keys)}");
BroadcastLobbyState();
}
private void HandleLobbyMove(int pid, byte[] payload)
{
if (!_lobbyPlayers.TryGetValue(pid, out var player))
{
_logger.Warn($"lobby move ignored pid={pid} reason=not_joined players={_lobbyPlayers.Count} pids={string.Join(",", _lobbyPlayers.Keys)}");
return;
}
var move = LobbyMoveMsg.Decode(payload);
player.X = move.X;
player.Y = move.Y;
player.Z = move.Z;
player.DirX = move.DirX;
player.DirZ = move.DirZ;
player.Moving = move.Moving;
move.PlayerId = pid;
byte[] encoded = move.Encode();
foreach (int targetPid in _lobbyPlayers.Keys)
{
SendFramework(targetPid, FrameworkOpcode.LobbyMove, encoded);
}
}
private void BroadcastLobbyState()
{
if (_lobbyPlayers.Count == 0) return;
var state = new LobbyStateMsg();
foreach (var player in _lobbyPlayers.Values)
{
state.Players.Add(player);
}
byte[] payload = state.Encode();
_logger.Info($"lobby state players={_lobbyPlayers.Count} pids={string.Join(",", _lobbyPlayers.Keys)}");
foreach (int pid in _lobbyPlayers.Keys)
{
SendFramework(pid, FrameworkOpcode.LobbyState, payload);
}
}
private void EnqueueRandomMatch(int pid)
{
var games = BuildGameList().Games;
if (games.Count == 0)
{
SendFramework(pid, FrameworkOpcode.Error,
new ErrorMsg { Code = 1, Message = "no minigame available" }.Encode());
return;
}
var selected = games[_random.Next(games.Count)];
_logger.Info($"random match pid={pid} assigned {selected.GameId}@{selected.Version}");
SendFramework(pid, FrameworkOpcode.MatchAssigned,
new MatchAssignedMsg { GameId = selected.GameId, Version = selected.Version }.Encode());
EnqueueMatch(pid, selected.GameId, selected.Version);
}
private GameListResponseMsg BuildGameList()
{
var latest = new Dictionary<string, GameInfoMsg>(StringComparer.OrdinalIgnoreCase);
if (!Directory.Exists(_gamesRoot)) return new GameListResponseMsg();
foreach (var gameDir in Directory.GetDirectories(_gamesRoot))
{
foreach (var versionDir in Directory.GetDirectories(gameDir))
{
string manifestPath = Path.Combine(versionDir, "game.json");
if (!File.Exists(manifestPath)) continue;
try
{
var manifest = GameManifest.Load(manifestPath);
if (string.Equals(manifest.GameId, "lobby", StringComparison.OrdinalIgnoreCase)) continue;
if (manifest.PlayerCount < 2 || manifest.PlayerCount > 8) continue;
if (!latest.TryGetValue(manifest.GameId, out var old) || manifest.Version > old.Version)
{
latest[manifest.GameId] = new GameInfoMsg
{
GameId = manifest.GameId,
Version = manifest.Version,
DisplayName = manifest.GameId,
MinPlayers = 2,
MaxPlayers = Math.Max(2, Math.Min(8, manifest.PlayerCount)),
};
}
}
catch (Exception ex)
{
_logger.Warn($"skip invalid game manifest {manifestPath}: {ex.Message}");
}
}
}
var msg = new GameListResponseMsg();
foreach (var game in latest.Values) msg.Games.Add(game);
msg.Games.Sort((a, b) => string.Compare(a.GameId, b.GameId, StringComparison.OrdinalIgnoreCase));
return msg;
}
private void EnqueueMatch(int pid, string gameId, int version)
{
var s = _sessions.Get(pid);
if (s == null) return; // 未建立会话,忽略
if (s.RoomId != null || _queuedPids.Contains(pid))
{
// 已在房间内或已在队列中:拒绝
SendFramework(pid, FrameworkOpcode.Error,
new ErrorMsg { Code = 2, Message = "already in room or queue" }.Encode());
return;
}
// 读取游戏的 playerCount(缓存)
string cacheKey = $"{gameId}@{version}";
if (!_playerCountCache.TryGetValue(cacheKey, out int playerCount))
{
string manifestPath = Path.Combine(_gamesRoot, gameId, version.ToString(), "game.json");
try
{
var manifest = GameManifest.Load(manifestPath);
playerCount = manifest.PlayerCount;
_playerCountCache[cacheKey] = playerCount;
}
catch (Exception ex)
{
_logger.Error($"读取 game.json 失败 {gameId}@{version}: {ex.Message}");
SendFramework(pid, FrameworkOpcode.Error,
new ErrorMsg { Code = 1, Message = "game not found" }.Encode());
return;
}
}
_queuedPids.Add(pid);
_matchmaker.Enqueue(pid, gameId, version, playerCount, _tick);
_logger.Info($"enqueue match pid={pid} game={gameId}@{version} playerCount={playerCount} tick={_tick}");
}
private void CreateRoomForSeats(Matchmaker.Match match)
{
string roomId = $"r{++_roomSeq}";
var seats = match.Seats;
int aiCount = 0;
for (int i = 0; i < seats.Count; i++)
{
if (seats[i].IsAi) aiCount++;
}
_logger.Info($"create room {roomId} game={match.GameId}@{match.Version} real={seats.Count - aiCount} ai={aiCount}");
// Build PlayerInfo[] for all seats
var playerInfos = new PlayerInfo[seats.Count];
var realPids = new List<int>();
for (int i = 0; i < seats.Count; i++)
{
var seat = seats[i];
playerInfos[i] = new PlayerInfo
{
PlayerId = seat.PlayerId,
Name = seat.IsAi ? "AI" : $"P{seat.PlayerId}",
IsAI = seat.IsAi
};
if (!seat.IsAi)
{
realPids.Add(seat.PlayerId);
_queuedPids.Remove(seat.PlayerId);
}
}
var sink = new RoomOutputSink(roomId, realPids, _sessions);
var cfg = new RoomConfig
{
GameId = match.GameId,
Version = match.Version,
Seed = (ulong)_roomSeq,
PlayerCount = seats.Count
};
Room room;
try
{
room = _host.CreateRoom(roomId, match.GameId, match.Version, cfg, playerInfos, sink);
}
catch (Exception ex)
{
_logger.Error($"建房失败 {match.GameId}@{match.Version}: {ex.Message}");
// Notify real players of error
var errPayload = new ErrorMsg { Code = 1, Message = "create room failed" }.Encode();
foreach (int rpid in realPids)
SendFramework(rpid, FrameworkOpcode.Error, errPayload);
return;
}
// Build MatchFound message with all players info
var allPlayers = new List<PlayerInfo>(playerInfos);
// For each real player: set session.RoomId + send MatchFound
foreach (int rpid in realPids)
{
var s = _sessions.Get(rpid);
if (s != null) s.RoomId = roomId;
var found = new MatchFoundMsg
{
RoomId = roomId,
Version = match.Version,
SelfPlayerId = rpid
};
foreach (var pi in allPlayers) found.Players.Add(pi);
SendFramework(rpid, FrameworkOpcode.MatchFound, found.Encode());
}
_roomPlayers[roomId] = realPids;
if (!room.IsActive) OnRoomEnded(roomId, room.TrustedEndResult); // 极端:Start 即结束
}
private void OnRoomEnded(string roomId, RoomEndResult result)
{
if (!_roomPlayers.TryGetValue(roomId, out var players)) return;
_roomPlayers.Remove(roomId);
var msg = new RoomEndMsg
{
WinnerPlayerId = result?.WinnerPlayerId ?? 0,
ResultBlob = result?.ResultBlob ?? Array.Empty<byte>()
};
byte[] payload = msg.Encode();
for (int i = 0; i < players.Count; i++)
{
int pid = players[i];
SendFramework(pid, FrameworkOpcode.RoomEnd, payload);
var s = _sessions.Get(pid);
if (s != null) s.RoomId = null;
}
}
private void SendFramework(int pid, FrameworkOpcode op, byte[] payload)
{
_sessions.GetConnection(pid)?.Send(
FrameCodec.Encode(new Frame(Channel.Framework, (ushort)op, payload)));
}
}
}
+11
View File
@@ -0,0 +1,11 @@
namespace XWorld.Server.Gateway
{
public sealed class Session
{
public int PlayerId;
public IClientConnection Connection; // 当前连接;断线中仍保留会话但 Connected=false
public bool Connected;
public string RoomId; // 所在房间,可空
public long DisconnectedAtTick; // 断线时的逻辑 tick,用于窗口过期
}
}
+56
View File
@@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
namespace XWorld.Server.Gateway
{
// pid -> Session。只在 actor 线程被改,故无锁。
public sealed class SessionManager
{
private readonly Dictionary<int, Session> _byPid = new Dictionary<int, Session>();
public Session OnConnect(int pid, IClientConnection conn)
{
if (_byPid.TryGetValue(pid, out var s))
{
s.Connection = conn; // 重连:重绑新连接,保留 RoomId
s.Connected = true;
return s;
}
s = new Session { PlayerId = pid, Connection = conn, Connected = true };
_byPid[pid] = s;
return s;
}
public void OnDisconnect(int pid, IClientConnection conn, long currentTick)
{
// 仅当断的是"当前连接"才标离线,避免旧连接的迟到断线清掉已重连的新连接
if (_byPid.TryGetValue(pid, out var s) && ReferenceEquals(s.Connection, conn))
{
s.Connected = false;
s.DisconnectedAtTick = currentTick;
}
}
public IClientConnection GetConnection(int pid)
=> _byPid.TryGetValue(pid, out var s) && s.Connected ? s.Connection : null;
public bool IsConnected(int pid) => _byPid.TryGetValue(pid, out var s) && s.Connected;
public Session Get(int pid) => _byPid.TryGetValue(pid, out var s) ? s : null;
/// <summary>移除断线且"严格越过"重连窗口的会话(currentTick - DisconnectedAtTick &gt; windowTicks)。返回被移除的会话;无则空数组。</summary>
public IReadOnlyList<Session> SweepExpired(long currentTick, long windowTicks)
{
List<Session> removed = null;
foreach (var kv in _byPid)
{
var s = kv.Value;
if (!s.Connected && currentTick - s.DisconnectedAtTick > windowTicks)
(removed ??= new List<Session>()).Add(s);
}
if (removed != null)
foreach (var s in removed) _byPid.Remove(s.PlayerId);
return removed ?? (IReadOnlyList<Session>)Array.Empty<Session>();
}
}
}
+71
View File
@@ -0,0 +1,71 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Hosting.Server;
using Microsoft.AspNetCore.Hosting.Server.Features;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.FileProviders;
using Microsoft.Extensions.Logging;
namespace XWorld.Server.Gateway
{
// 独立内网 CDNKestrel 静态文件服务,HTTP 根 = root(发布产物 client/ 目录)。
// 仿 GameServerHost 的 Start/Stop 形态;缺失文件返回 404,无目录浏览。
public sealed class StaticFileServer
{
private readonly string _root;
private readonly int _port;
private readonly bool _bindAll;
private WebApplication _app;
public string HttpBaseUrl { get; private set; } // 例如 http://127.0.0.1:15081
public StaticFileServer(string root, int port = 15081, bool bindAllInterfaces = false)
{
if (string.IsNullOrWhiteSpace(root)) throw new ArgumentException("root 不能为空", nameof(root));
_root = Path.GetFullPath(root);
_port = port;
_bindAll = bindAllInterfaces;
}
public async Task StartAsync()
{
Directory.CreateDirectory(_root); // 根目录不存在则建空目录(GET 全 404,而非启动崩溃)
var builder = WebApplication.CreateBuilder();
builder.Logging.ClearProviders();
builder.WebHost.ConfigureKestrel(o =>
o.Listen(_bindAll ? System.Net.IPAddress.Any : System.Net.IPAddress.Loopback, _port)); // 0 = 临时端口
var app = builder.Build();
var provider = new PhysicalFileProvider(_root);
var opts = new StaticFileOptions
{
FileProvider = provider,
ServeUnknownFileTypes = true, // .bytes/.unity3d/.sig 等无标准 MIME 也要发
DefaultContentType = "application/octet-stream",
};
app.UseStaticFiles(opts); // 仅静态文件,不开目录浏览;未命中落到下面的 404
await app.StartAsync();
_app = app;
string http = app.Services.GetRequiredService<IServer>()
.Features.Get<IServerAddressesFeature>().Addresses.FirstOrDefault()
?? throw new InvalidOperationException("Kestrel 已启动但未注册任何监听地址");
HttpBaseUrl = http;
}
public async Task StopAsync()
{
if (_app == null) return;
await _app.StopAsync();
await _app.DisposeAsync();
_app = null;
}
}
}