feat: 项目初始化 + 3D方块世界原型 + AI助搭系统
CI / Go Backend (push) Canceled after 0s

初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器

HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块

Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚

AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
xyou
2026-08-08 14:07:56 +08:00
parent 9500c4c80a
commit f70b061d1a
1972 changed files with 159760 additions and 6 deletions
@@ -0,0 +1,302 @@
using PCL.Core.App;
using PCL.Core.App.Localization;
using PCL.Core.Link.EasyTier;
using PCL.Core.Link.Scaffolding;
using PCL.Core.Link.Scaffolding.Client.Models;
using PCL.Core.Link.Scaffolding.Client.Requests;
using PCL.Core.Logging;
using PCL.Core.Utils.Exts;
using PCL.Core.Utils.OS;
using PCL.Core.Utils.Secret;
using System;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
using PCL.Core.IO.Net;
using static PCL.Core.Link.Lobby.LobbyInfoProvider;
using static PCL.Core.Link.Natayark.NatayarkProfileManager;
using LobbyType = PCL.Core.Link.Scaffolding.Client.Models.LobbyType;
using PCL.Core.Link.McPing;
using PCL.Core.IO.Net.Http;
namespace PCL.Core.Link.Lobby;
/// <summary>
/// The controller of lobby that used for creating Scaffolding entity.
/// </summary>
public sealed class LobbyController
{
/// <summary>
/// Demonstrate the current lobby is host or joiner.
/// </summary>
public bool IsHost = false;
/// <summary>
/// Scaffolding client entity.
/// </summary>
public ScaffoldingClientEntity? ScfClientEntity;
/// <summary>
/// Scaffolding server entity.
/// </summary>
public ScaffoldingServerEntity? ScfServerEntity;
/// <summary>
/// Launch a Scaffolding Client.
/// </summary>
/// <param name="username">Join user name.</param>
/// <param name="code">Lobby share code.</param>
/// <returns>Created <see cref="ScaffoldingClientEntity"/>.</returns>
public async Task<ScaffoldingClientEntity?> LaunchClientAsync(string username, string code, CancellationToken ct = default)
{
if (!await _SendTelemetryAsync(false).ConfigureAwait(false))
{
return null;
}
try
{
var scfEntity = await ScaffoldingFactory
.CreateClientAsync(username, code, LobbyType.Scaffolding, ct).ConfigureAwait(false);
ScfClientEntity = scfEntity;
await scfEntity.Client.ConnectAsync().ConfigureAwait(false);
var port = await scfEntity.Client.SendRequestAsync(new GetServerPortRequest()).ConfigureAwait(false);
var hostname = string.Empty;
while (scfEntity.Client.PlayerList is null)
{
await Task.Delay(800).ConfigureAwait(false);
}
foreach (var profile in scfEntity.Client.PlayerList)
{
if (profile.Kind == PlayerKind.HOST)
{
hostname = profile.Name;
LogWrapper.Debug($"大厅创建者的用户名: {hostname}");
}
}
var localPort = await scfEntity.EasyTier
.AddPortForwardAsync(scfEntity.HostInfo.Ip, port)
.ConfigureAwait(false);
var desc = hostname.IsNullOrWhiteSpace()
? string.Empty
: Lang.Text("Link.Lobby.MotdDesc", hostname);
var tcpPortForForward = NetworkHelper.NewTcpPort();
McForward = new TcpForward(IPAddress.Loopback, tcpPortForForward, IPAddress.Loopback, localPort);
McBroadcast = new BroadcastLocal(Lang.Text("Link.Lobby.MotdFormat", desc), tcpPortForForward);
McForward.Start();
McBroadcast.Start();
return scfEntity;
}
catch (ArgumentNullException e)
{
LogWrapper.Error(e, "大厅创建者的用户名为空");
}
catch (ArgumentException e)
{
if (e.Message.Contains("lobby code"))
{
LogWrapper.Error(e, "大厅编号无效");
}
else if (e.Message.Contains("hostname"))
{
LogWrapper.Error(e, "大厅创建者的用户名无效");
}
else
{
LogWrapper.Error(e, "在加入大厅时出现意外的无效参数");
}
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception e)
{
LogWrapper.Error(e, "在加入大厅时发生意外错误");
}
return null;
}
/// <summary>
/// Launch a Scaffolding Server.
/// </summary>
/// <param name="username">Host user name.</param>
/// <param name="port">Minecraft port.</param>
/// <returns>Created <see cref="ScaffoldingServerEntity"/>.</returns>
/// <remarks>
/// Because of event handling of Scaffolding Server. You SHOULD start the server on your own.
/// </remarks>
public async Task<ScaffoldingServerEntity?> LaunchServerAsync(string username, int port)
{
if (!await _SendTelemetryAsync(true).ConfigureAwait(false))
{
return null;
}
try
{
var scfEntity = ScaffoldingFactory.CreateServer(port, username);
ScfServerEntity = scfEntity;
IsHost = true;
LogWrapper.Info("LobbyController", "Successfully to launch Scaffolding Server.");
return scfEntity;
}
catch (Exception e)
{
LogWrapper.Error(e, "Occurred error when launching Scafolding Server.");
}
return null;
}
/// <summary>
/// 检查主机的 MC 实例是否可用。
/// </summary>
public static async Task<bool> IsHostInstanceAvailableAsync(int port)
{
using var ping = McPingServiceFactory.CreateService("127.0.0.1", port);
var info = await ping.PingAsync().ConfigureAwait(false);
if (info is not null) return true;
LogWrapper.Warn("Link", $"本地 MC 局域网实例 ({port}) 疑似已关闭");
return false;
}
/// <summary>
/// 退出大厅。这将同时关闭 EasyTier 和 MC 端口转发,需要自行清理 UI。
/// </summary>
public async Task<int> CloseAsync()
{
McForward?.Stop();
McBroadcast?.Stop();
try
{
if (ScfClientEntity is not null)
{
await ScfClientEntity.EasyTier.StopAsync().ConfigureAwait(false);
await ScfClientEntity.Client.DisposeAsync().ConfigureAwait(false);
}
else if (ScfServerEntity is not null)
{
await ScfServerEntity.EasyTier.StopAsync().ConfigureAwait(false);
await ScfServerEntity.Server.DisposeAsync().ConfigureAwait(false);
}
}
finally
{
ScfClientEntity = null;
ScfServerEntity = null;
}
return 0;
}
private static async Task<bool> _SendTelemetryAsync(bool isHost)
{
LogWrapper.Info("Link", "开始发送联机数据");
var servers = Config.Link.CustomRelayServer;
var serverType = Config.Link.ServerType;
if (Config.Link.ServerType != 2)
{
servers = (
from relay in ETRelay.RelayList
where (relay.Type == ETRelayType.Selfhosted && serverType != 2) || (relay.Type == ETRelayType.Community && serverType == 1)
select relay
).Aggregate(servers, (current, relay) => current + $"{relay.Url};");
}
JsonObject data = new()
{
["Tag"] = "Link",
["Id"] = Identify.LauncherId,
["NaidId"] = NaidProfile.Id,
["NaidEmail"] = NaidProfile.Email,
["NaidLastIp"] = NaidProfile.LastIp,
["CustomName"] = Config.Link.Username,
["Servers"] = servers,
["IsHost"] = isHost
};
JsonObject sendData = new() { ["data"] = data };
try
{
HttpContent httpContent = new StringContent(sendData.ToJsonString(), Encoding.UTF8, "application/json");
var key = EnvironmentInterop.GetSecret("TelemetryKey");
if (key is null)
{
if (RequiresLogin)
{
LogWrapper.Error("Link", "联机数据发送失败,未设置 TelemetryKey");
return false;
}
LogWrapper.Warn("Link", "联机数据发送失败,未设置 TelemetryKey,跳过发送");
}
else
{
using var response = await HttpRequest
.CreatePost("https://pcl2ce.pysio.online/post")
.WithContent(httpContent)
.WithBearerToken(key)
.SendAsync()
.ConfigureAwait(false);
if (!response.IsSuccess)
{
if (RequiresLogin)
{
LogWrapper.Error("Link", "联机数据发送失败,响应内容为空");
return false;
}
LogWrapper.Warn("Link", "联机数据发送失败,响应内容为空,跳过发送");
}
else
{
var result = await response.AsStringAsync().ConfigureAwait(false);
if (result.Contains("数据已成功保存"))
{
LogWrapper.Info("Link", "联机数据已发送");
}
else
{
if (RequiresLogin)
{
LogWrapper.Error("Link", "联机数据发送失败,响应内容: " + result);
return false;
}
LogWrapper.Warn("Link", "联机数据发送失败,跳过发送,响应内容: " + result);
}
}
}
}
catch (Exception ex)
{
if (RequiresLogin)
{
LogWrapper.Error(ex, "Link",
ex.Message.Contains("429") ? "联机数据发送失败,请求过于频繁" : "联机数据发送失败");
return false;
}
LogWrapper.Warn(ex, "Link", "联机数据发送失败,跳过发送");
}
return true;
}
}
@@ -0,0 +1,131 @@
using System;
using System.Numerics;
using PCL.Core.App;
using PCL.Core.IO.Net;
using PCL.Core.Link.Natayark;
using PCL.Core.Logging;
using PCL.Core.Utils;
using PCL.Core.Utils.Exts;
namespace PCL.Core.Link.Lobby;
public static class LobbyInfoProvider
{
public static bool IsLobbyAvailable { get; set; } = false;
public static bool AllowCustomName { get; set; } = false;
public static bool RequiresLogin { get; set; } = true;
public static bool RequiresRealName { get; set; } = true;
public static int ProtocolVersion { get; set; } = 6;
public static BroadcastLocal? McBroadcast { get; internal set; }
public static TcpForward? McForward { get; internal set; }
public class LobbyInfo
{
public required string OriginalCode { get; init; }
public required LobbyType Type { get; init; }
public required string NetworkName { get; init; }
public required string NetworkSecret { get; init; }
/// <summary>
/// 远程 IP 地址,需要先解析大厅类型再填充
/// </summary>
public string? Ip { get; init; }
/// <summary>
/// 目标游戏端口
/// </summary>
public required int Port { get; init; }
}
public enum LobbyType
{
// ReSharper disable once InconsistentNaming
PCLCE,
Terracotta
}
/// <summary>
/// 目标大厅
/// </summary>
public static LobbyInfo? TargetLobby { get; set; }
public static int JoinerLocalPort { get; set; }
/// <summary>
/// 解析大厅编号,并返回 LobbyInfo 对象。若解析失败则返回 null。
/// </summary>
public static LobbyInfo? ParseCode(string code)
{
code = code.Trim().ToUpper();
if (string.IsNullOrWhiteSpace(code) || code.Length < 9 || !code.IsASCII())
{
LogWrapper.Error("Link", "无效的大厅编号: " + code);
return null;
}
if (code.Split("-".ToCharArray()).Length != 5) // PCL CE 大厅
{
try
{
var info = code.FromB32ToB10();
return new LobbyInfo
{
OriginalCode = code,
NetworkName = info[..8],
NetworkSecret = info[8..10],
Port = int.Parse(info[10..]),
Type = LobbyType.PCLCE,
Ip = "10.114.51.41"
};
}
catch (Exception ex)
{
LogWrapper.Error(ex, "Link", "大厅编号解析失败,可能是无效的 PCL CE 大厅编号: " + code);
}
}
else // 陶瓦
{
var matches = code.RegexSearch(RegexPatterns.TerracottaId);
if (matches.Count == 0)
{
LogWrapper.Error("Link", "大厅编号解析失败,可能是无效的陶瓦大厅编号: " + code);
return null;
}
foreach (var match in matches)
{
var codeString = match.Replace("I", "1").Replace("O", "0").Replace("-", "");
BigInteger value = 0;
var checking = 0;
const string baseChars = "0123456789ABCDEFGHJKLMNPQRSTUVWXYZ";
for (var i = 0; i <= 23; i++)
{
var j = baseChars.IndexOf(codeString[i]);
value += BigInteger.Parse(j.ToString()) * BigInteger.Pow(34, i);
checking = (j + checking) % 34;
}
if (checking != baseChars.IndexOf(codeString[24])) { return null; }
var port = (int)(value % 65536);
if (port < 100) { return null; }
return new LobbyInfo
{
OriginalCode = code,
NetworkName = codeString.Substring(0, 15).ToLower(),
NetworkSecret = codeString.Substring(15, 10).ToLower(),
Port = port,
Type = LobbyType.Terracotta,
Ip = "10.144.144.1"
};
}
}
return null;
}
/// <summary>
/// 获取用于联机显示的用户名
/// </summary>
public static string? GetUsername() => AllowCustomName
? Config.Link.Username.ReplaceNullOrEmpty(NatayarkProfileManager.NaidProfile.Username)
: NatayarkProfileManager.NaidProfile.Username;
}
@@ -0,0 +1,564 @@
using PCL.Core.App;
using PCL.Core.App.Localization;
using PCL.Core.Link.Natayark;
using PCL.Core.Link.Scaffolding;
using PCL.Core.Link.Scaffolding.Client.Models;
using PCL.Core.Link.Scaffolding.EasyTier;
using PCL.Core.Logging;
using PCL.Core.Utils;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
using System.Windows;
using PCL.Core.App.IoC;
using PCL.Core.UI;
using PCL.Core.Link.McPing;
namespace PCL.Core.Link.Lobby;
/// <summary>
/// Lobby server. For auto-management
/// </summary>
[LifecycleService(LifecycleState.Loaded)]
public class LobbyService() : GeneralService("lobby", "LobbyService")
{
private static readonly LobbyController _LobbyController = new();
private static CancellationTokenSource _lobbyCts = new();
private static Task? _discoveringTask;
private static CancellationTokenSource _discoveringCts = new();
private static readonly Timer _ServerGameWatcher =
new(_CheckGameState, null, TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(15));
private static bool _isGameWatcherRunnable = false;
private static int _isLeaving;
/// <summary>
/// Current lobby state.
/// </summary>
public static LobbyState CurrentState { get; private set; } = LobbyState.Idle;
/// <summary>
/// Founded local Minecraft worlds.
/// </summary>
public static ObservableCollection<FoundWorld> DiscoveredWorlds { get; } = [];
/// <summary>
/// Current players in current lobby.
/// </summary>
public static ObservableCollection<PlayerProfile> Players { get; private set; } = [];
/// <summary>
/// Demonstrate whether the current user is the host of the lobby.
/// </summary>
public static bool IsHost => _LobbyController.IsHost;
/// <summary>
/// Current lobby full code.
/// </summary>
public static string? CurrentLobbyCode { get; private set; }
/// <summary>
/// Current lobby username.
/// </summary>
public static string? CurrentUserName { get; private set; }
#region UI Events
/// <summary>
/// Invoked when lobby state changed. (first arg is the old state; second arg is the new state.)
/// </summary>
public static event Action<LobbyState, LobbyState>? StateChanged;
/// <summary>
/// Invoked when need to download EasyTier core files.
/// </summary>
public static event Action? OnNeedDownloadEasyTier;
/// <summary>
/// Invoked when user stop the game in server mode.
/// </summary>
public static event Action? OnUserStopGame;
/// <summary>
/// Invoked when client ping happened.
/// </summary>
public static event Action<long>? OnClientPing;
/// <summary>
/// Invoked when server shut down.
/// </summary>
public static event Action? OnServerShutDown;
/// <summary>
/// Invoked when server started successfully.
/// </summary>
public static event Action? OnServerStarted;
public static event Action<Exception>? OnServerException;
#endregion
/// <inheritdoc />
public override void Stop()
{
_lobbyCts.Cancel();
_LobbyController.CloseAsync().GetAwaiter().GetResult();
_ServerGameWatcher.Dispose();
_lobbyCts.Dispose();
_discoveringCts.Cancel();
if (_discoveringTask is not null)
{
Task.WhenAll(_discoveringTask);
_discoveringTask.Dispose();
}
_discoveringCts.Dispose();
}
private static bool _IsEasyTierCoreFileNotExist() =>
!File.Exists(Path.Combine(EasyTierMetadata.EasyTierFilePath, "easytier-core.exe")) &&
!File.Exists(Path.Combine(EasyTierMetadata.EasyTierFilePath, "Packet.dll")) &&
!File.Exists(Path.Combine(EasyTierMetadata.EasyTierFilePath, "easytier-cli.exe"));
public static async Task InitializeAsync()
{
if (CurrentState is not LobbyState.Idle && CurrentState is not LobbyState.Error)
{
return;
}
_SetState(LobbyState.Initializing);
try
{
if (_IsEasyTierCoreFileNotExist())
{
LogWrapper.Info("LobbyService", "EasyTier not found, starting download.");
OnNeedDownloadEasyTier?.Invoke();
}
else
{
LogWrapper.Info("LobbyService", "EasyTier files check completed.");
}
// refresh naid token
var naidRefreshToken = States.Link.NaidRefreshToken;
if (!string.IsNullOrWhiteSpace(naidRefreshToken))
{
var expTime = States.Link.NaidRefreshExpireTime;
if (!string.IsNullOrWhiteSpace(expTime) &&
Convert.ToDateTime(expTime).CompareTo(DateTime.Now) < 0)
{
States.Link.NaidRefreshToken = string.Empty;
HintWrapper.Show(Lang.Text("Tools.GameLink.Natayark.TokenExpired"), HintTheme.Error);
}
else
{
await NatayarkProfileManager.GetNaidDataAsync(naidRefreshToken, true).ConfigureAwait(false);
}
}
_SetState(LobbyState.Initialized);
LogWrapper.Info("LobbyService", "Lobby service initialized successfully.");
_ = DiscoverWorldAsync();
}
catch (Exception ex)
{
LogWrapper.Error(ex, "LobbyService", "Lobby service initialization failed.");
HintWrapper.Show(Lang.Text("Link.Lobby.InitFailed"), HintTheme.Error);
_SetState(LobbyState.Error);
}
}
/// <summary>
/// Discover minecraft shared world.
/// </summary>
public static async Task DiscoverWorldAsync()
{
if (_discoveringCts.IsCancellationRequested)
{
return;
}
if (CurrentState is not LobbyState.Initialized && CurrentState is not LobbyState.Idle)
{
return;
}
_SetState(LobbyState.Discovering);
await _RunInUiAsync(() => DiscoveredWorlds.Clear()).ConfigureAwait(false);
_discoveringTask = Task.Run(async () =>
{
var recordedPorts = new ConcurrentSet<int>();
using var listener = new BroadcastListener();
var handler = new Action<BroadcastRecord, IPEndPoint>((info, _) => Task.Run(async () =>
{
if (!recordedPorts.TryAdd(info.Address.Port)) return;
using var pinger = McPingServiceFactory.CreateService(new IPEndPoint(IPAddress.Loopback, info.Address.Port));
using var cts = new CancellationTokenSource(2000);
try
{
var pingRes = await pinger.PingAsync(cts.Token).ConfigureAwait(false);
if (pingRes is null)
{
throw new ArgumentNullException(nameof(pingRes), "Failed to ping minecraft entity.");
}
var worldName = Lang.Text("Link.Lobby.WorldNameFormat",
pingRes.Description,
pingRes.Version.Name,
info.Address.Port);
await _RunInUiAsync(() => DiscoveredWorlds.Add(new FoundWorld(worldName, info.Address.Port)))
.ConfigureAwait(false);
}
catch (Exception ex)
{
LogWrapper.Error(ex, "LobbyService", $"Pinging port {info.Address.Port} failed.");
}
}));
listener.OnReceive += handler;
listener.Start();
await Task.Delay(TimeSpan.FromSeconds(3)).ConfigureAwait(false);
listener.OnReceive -= handler;
}, _discoveringCts.Token);
_SetState(LobbyState.Initialized);
}
private static bool _NotHaveNaid() =>
LobbyInfoProvider.RequiresLogin &&
string.IsNullOrWhiteSpace(NatayarkProfileManager.NaidProfile.AccessToken);
/// <summary>
/// Create a new lobby.
/// </summary>
/// <param name="port">Minecraft share port.</param>
/// <param name="username">Player name.</param>
public static async Task<bool> CreateLobbyAsync(int port, string username)
{
if (_NotHaveNaid())
{
HintWrapper.Show(Lang.Text("Link.Lobby.LoginRequired"), HintTheme.Error);
return false;
}
await _discoveringCts.CancelAsync().ConfigureAwait(false);
_SetState(LobbyState.Creating);
try
{
CurrentUserName = username;
var serverEntity = await _LobbyController.LaunchServerAsync(username, port).ConfigureAwait(false);
if (serverEntity is null)
{
HintWrapper.Show(Lang.Text("Link.Lobby.CreateRoomFailed"), HintTheme.Error);
return false;
}
CurrentLobbyCode = serverEntity.EasyTier.Lobby.FullCode;
serverEntity.Server.ServerStopped += () => OnServerShutDown?.Invoke();
serverEntity.Server.PlayerProfilePing += _ServerOnPlayerPing;
serverEntity.Server.ServerStarted += _ServerOnServerStarted;
serverEntity.Server.ServerException += _ServerOnServerException;
//serverEntity.EasyTier.EasyTierProcessExisted += () =>
//{
// OnHint?.Invoke("EasyTierCore异常退出", CoreHintType.Critical);
// OnServerShutDown?.Invoke();
//}; this code will be invoked when EasyTier process successfully exited, not in failed state.
serverEntity.Server.Start();
_SetState(LobbyState.Connected);
_isGameWatcherRunnable = true;
}
catch (Exception ex)
{
LogWrapper.Error(ex, "LobbyService", "Failed to create lobby.");
HintWrapper.Show(Lang.Text("Link.Lobby.CreateLobbyFailed"), HintTheme.Error);
await LeaveLobbyAsync().ConfigureAwait(false);
return false;
}
return true;
}
private static void _ServerOnServerException(Exception? ex)
{
if (ex is null)
{
return;
}
OnServerException?.Invoke(ex);
}
private static void _ServerOnServerStarted(IReadOnlyList<PlayerProfile> profiles)
{
LogWrapper.Debug("LobbyService", "Send server started event.");
OnServerStarted?.Invoke();
_ServerOnPlayerPing(profiles);
}
private static void _ServerOnPlayerPing(IReadOnlyList<PlayerProfile> players)
{
_ = _RunInUiAsync(() =>
{
var currentMachineIds = new HashSet<string>(Players.Select(p => p.MachineId));
var newMachineIds = new HashSet<string>(players.Select(p => p.MachineId));
if (currentMachineIds.SetEquals(newMachineIds))
{
LogWrapper.Debug("Player list has not changed");
return; // nothing was changed
}
LogWrapper.Debug("LobbyService", "Player list membership has changed, updating UI.");
var sortedNewPlayers = PlayerListHandler.Sort(players);
var idsToRemove = currentMachineIds.Except(newMachineIds).ToList();
if (idsToRemove.Any())
{
var playersToRemove = Players.Where(p => idsToRemove.Contains(p.MachineId)).ToList();
foreach (var player in playersToRemove)
{
Players.Remove(player);
}
}
var idsToAdd = newMachineIds.Except(currentMachineIds).ToList();
if (idsToAdd.Any())
{
var playersToAdd = sortedNewPlayers.Where(p => idsToAdd.Contains(p.MachineId)).ToList();
foreach (var player in playersToAdd)
{
Players.Add(player);
}
}
});
}
/// <summary>
/// Join an exist lobby.
/// </summary>
/// <param name="lobbyCode">Lobby share code.</param>
/// <param name="username">Current use name.</param>
public static async Task<bool> JoinLobbyAsync(string lobbyCode, string username)
{
await _discoveringCts.CancelAsync().ConfigureAwait(false);
_SetState(LobbyState.Joining);
LogWrapper.Info("LobbyService", $"Try to join lobby {lobbyCode}");
try
{
CurrentUserName = username;
CurrentLobbyCode = lobbyCode;
var clientEntity = await _LobbyController.LaunchClientAsync(username, lobbyCode, _lobbyCts.Token).ConfigureAwait(false);
if (clientEntity is null)
{
throw new InvalidOperationException(
Lang.Text("Link.Lobby.JoinFailed"));
}
clientEntity.Client.Heartbeat += _ClientOnHeartbeat;
clientEntity.Client.ServerShuttedDown += _ClientOnServerShutDown;
_SetState(LobbyState.Connected);
}
catch (ArgumentException codeEx)
{
LogWrapper.Error(codeEx, "LobbyService", $"Failed to join lobby {lobbyCode}.");
HintWrapper.Show(Lang.Text("Link.Lobby.InvalidCodeFormat"), HintTheme.Error);
await LeaveLobbyAsync().ConfigureAwait(false);
return false;
}
catch (OperationCanceledException)
{
return false;
}
catch (Exception ex)
{
LogWrapper.Error(ex, "LobbyService", $"Failed to join lobby {lobbyCode}.");
HintWrapper.Show(ex.Message, HintTheme.Error);
await LeaveLobbyAsync().ConfigureAwait(false);
return false;
}
return true;
}
private static void _ClientOnServerShutDown()
{
OnServerShutDown?.Invoke();
}
private static void _ClientOnHeartbeat(IReadOnlyList<PlayerProfile> players, long latency)
{
_ = _RunInUiAsync(() =>
{
var currentMachineIds = new HashSet<string>(Players.Select(p => p.MachineId));
var newMachineIds = new HashSet<string>(players.Select(p => p.MachineId));
if (currentMachineIds.SetEquals(newMachineIds))
{
return; // nothing was changed
}
LogWrapper.Debug("LobbyService", "Player list membership has changed, updating UI.");
var sortedNewPlayers = PlayerListHandler.Sort(players);
var idsToRemove = currentMachineIds.Except(newMachineIds).ToList();
if (idsToRemove.Any())
{
var playersToRemove = Players.Where(p => idsToRemove.Contains(p.MachineId)).ToList();
foreach (var player in playersToRemove)
{
Players.Remove(player);
}
}
var idsToAdd = newMachineIds.Except(currentMachineIds).ToList();
if (idsToAdd.Any())
{
var playersToAdd = sortedNewPlayers.Where(p => idsToAdd.Contains(p.MachineId)).ToList();
foreach (var player in playersToAdd)
{
Players.Add(player);
}
}
OnClientPing?.Invoke(latency);
});
}
/// <summary>
/// Leave from lobby.
/// </summary>
public static async Task LeaveLobbyAsync()
{
if (Interlocked.Exchange(ref _isLeaving, 1) == 1)
return;
try
{
_SetState(LobbyState.Leaving);
await _lobbyCts.CancelAsync().ConfigureAwait(false);
Players.Clear();
CurrentLobbyCode = null;
CurrentUserName = null;
if (_LobbyController.ScfClientEntity?.Client is not null)
{
_LobbyController.ScfClientEntity.Client.Heartbeat -= _ClientOnHeartbeat;
}
if (_LobbyController.ScfServerEntity?.Server is not null)
{
_LobbyController.ScfServerEntity.Server.PlayerProfilePing -= _ServerOnPlayerPing;
_LobbyController.ScfServerEntity.Server.ServerStarted -= _ServerOnServerStarted;
}
await _LobbyController.CloseAsync().ConfigureAwait(false);
_lobbyCts = new CancellationTokenSource();
_SetState(LobbyState.Initialized);
LogWrapper.Info("LobbyService", "Left lobby and cleaned up resources.");
_isGameWatcherRunnable = false;
_discoveringCts = new CancellationTokenSource();
_ = DiscoverWorldAsync();
}
catch (Exception ex)
{
LogWrapper.Error(ex, "LobbyService", "Failed when leave lobby.");
}
finally
{
_isLeaving = 0;
}
}
private static void _SetState(LobbyState newState)
{
var oldState = CurrentState;
if (oldState == newState)
{
return;
}
CurrentState = newState;
LogWrapper.Info("LobbyService", $"Lobby state changed from {oldState} to {newState}");
StateChanged?.Invoke(oldState, newState);
}
private static void _CheckGameState(object? state)
{
if (!_isGameWatcherRunnable)
{
return;
}
if (_LobbyController.ScfServerEntity is null)
{
return;
}
LobbyController.IsHostInstanceAvailableAsync(_LobbyController.ScfServerEntity.EasyTier.MinecraftPort)
.ContinueWith(async (task) =>
{
var isExist = await task.ConfigureAwait(false);
if (!isExist)
{
_isGameWatcherRunnable = false;
OnUserStopGame?.Invoke();
}
});
}
private static async Task _RunInUiAsync(Action action)
{
await Application.Current.Dispatcher.InvokeAsync(action);
}
}
/// <summary>
/// Founded minecraft world information.
/// </summary>
/// <param name="Name">World name.</param>
/// <param name="Port">World share port.</param>
public record FoundWorld(string Name, int Port);
@@ -0,0 +1,52 @@
namespace PCL.Core.Link.Lobby;
/// <summary>
/// Lobby server state.
/// </summary>
public enum LobbyState
{
/// <summary>
/// The lobby is idle and not in use.
/// </summary>
Idle,
/// <summary>
/// The lobby is being initialized.
/// </summary>
Initializing,
/// <summary>
/// The lobby has been initialized.
/// </summary>
Initialized,
/// <summary>
/// The lobby is in the process of discovering available minecraft world.
/// </summary>
Discovering,
/// <summary>
/// The lobby is in the process of creating a new lobby.
/// </summary>
Creating,
/// <summary>
/// The lobby is in the process of joining a exist lobby.
/// </summary>
Joining,
/// <summary>
/// The lobby has been joined a exist lobby.
/// </summary>
Connected,
/// <summary>
/// The lobby is leaving a exist lobby.
/// </summary>
Leaving,
/// <summary>
/// Occurred an error in the lobby.
/// </summary>
Error
}
@@ -0,0 +1,51 @@
using System;
using PCL.Core.App.Localization;
using PCL.Core.Link.EasyTier;
namespace PCL.Core.Link.Lobby;
public static class LobbyTextHandler
{
public static string GetNatTypeName(string type)
{
return Lang.Text(type switch
{
_ when type.Contains("Open") || type.Contains("NoP") => "Link.Nat.Type.Open",
_ when type.Contains("FullCone") => "Link.Nat.Type.FullCone",
_ when type.Contains("PortRestricted") => "Link.Nat.Type.PortRestricted",
_ when type.Contains("Restricted") => "Link.Nat.Type.Restricted",
_ when type.Contains("SymmetricEasy") => "Link.Nat.Type.SymmetricEasy",
_ when type.Contains("Symmetric") => "Link.Nat.Type.Symmetric",
_ => "Link.Nat.Type.Unknown"
});
}
public static string GetConnectTypeName(ETConnectionType type)
{
return Lang.Text(type switch
{
ETConnectionType.Local => "Link.Connection.Local",
ETConnectionType.P2P => "Link.Connection.P2P",
ETConnectionType.Relay => "Link.Connection.Relay",
_ => "Link.Connection.Unknown"
});
}
/// <summary>
/// 依据网络质量指数获取大厅连接状况文本。
/// </summary>
public static (string Keyword, string Desc) GetQualityDesc(int quality)
{
var keySuffix = quality switch
{
>= 3 => "Good",
>= 2 => "Normal",
_ => "Poor"
};
return (
Lang.Text($"Link.Quality.{keySuffix}"),
Lang.Text($"Link.Quality.{keySuffix}Description")
);
}
}