初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器 HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块 Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚 AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user