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,35 @@
using PCL.Core.Link.McPing.Model;
using System;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
namespace PCL.Core.Link.McPing;
/// <summary>
/// Minecraft服务器探测服务接口
/// </summary>
public interface IMcPingService : IDisposable
{
/// <summary>
/// 异步探测Minecraft服务器信息
/// </summary>
/// <param name="cancellationToken">取消令牌</param>
/// <returns>服务器探测结果,如果探测失败则返回null</returns>
Task<McPingResult?> PingAsync(CancellationToken cancellationToken = default);
/// <summary>
/// 获取服务端点信息
/// </summary>
IPEndPoint Endpoint { get; }
/// <summary>
/// 获取主机地址
/// </summary>
string Host { get; }
/// <summary>
/// 获取超时时间(毫秒)
/// </summary>
int Timeout { get; }
}
@@ -0,0 +1,113 @@
using PCL.Core.Link.McPing.Model;
using PCL.Core.Logging;
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace PCL.Core.Link.McPing;
/// <summary>
/// 旧版Minecraft协议服务器探测服务实现
/// 支持1.6及以下版本的服务器信息查询协议
/// </summary>
public class LegacyMcPingService : IMcPingService
{
private readonly IPEndPoint _endpoint;
private readonly string _host;
private const int DefaultTimeout = 10000;
private readonly int _timeout;
private bool _disposed;
public IPEndPoint Endpoint => _endpoint;
public string Host => _host;
public int Timeout => _timeout;
public LegacyMcPingService(IPEndPoint endpoint, int timeout = DefaultTimeout)
{
_endpoint = endpoint;
_host = _endpoint.Address.ToString();
_timeout = timeout;
}
public LegacyMcPingService(string ip, int port = 25565, int timeout = DefaultTimeout)
{
_endpoint = IPAddress.TryParse(ip, out var ipAddress)
? new IPEndPoint(ipAddress, port)
: new IPEndPoint(Dns.GetHostAddresses(ip).First(), port);
_host = ip;
_timeout = timeout;
}
/// <summary>
/// 执行旧版Minecraft协议的服务器探测
/// </summary>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task<McPingResult?> PingAsync(CancellationToken cancellationToken = default)
{
// TODO: 实现旧版协议的探测逻辑
// 这里需要迁移原来McPing类中的PingOldAsync方法逻辑
using var so = new Socket(SocketType.Stream, ProtocolType.Tcp);
using var timeoutCts = new CancellationTokenSource(_timeout);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
linkedCts.Token.Register(() =>
{
try
{
if (so.Connected) so.Close();
}
catch (ObjectDisposedException)
{
/* Ignore */
}
});
await so.ConnectAsync(_endpoint, linkedCts.Token);
LogWrapper.Debug("LegacyMcPing", $"Connected to {_endpoint}");
await using var stream = new NetworkStream(so, false);
var queryPack = new byte[] { 0xfe, 0x01 };
await stream.WriteAsync(queryPack.AsMemory(0, queryPack.Length), linkedCts.Token);
var ms = new MemoryStream();
await stream.CopyToAsync(ms, linkedCts.Token);
so.Close();
var retData = ms.ToArray();
if (retData.Length < 21 || (retData.Length >= 21 && retData[0] != 0xff))
{
LogWrapper.Info("McPing", $"Unknown response from {_endpoint}, ignore");
return null;
}
var retRep = Encoding.UTF8.GetString(retData);
try
{
var retPart = retRep.Split(["\0\0\0"], StringSplitOptions.None);
retPart = retPart
.Select(s => new string([.. s.Where((_, index) => index % 2 == 0)]))
.ToArray();
if (retPart.Length < 6)
return null;
return new McPingResult(new McPingVersionResult(retPart[2], int.Parse(retPart[1])),
new McPingPlayerResult(int.Parse(retPart[5]), int.Parse(retPart[4]), []), retPart[3], string.Empty, 0,
new McPingModInfoResult(string.Empty, []), null);
}
catch (Exception e)
{
LogWrapper.Error(e, "McPing", $"Unable to serialize response from {_endpoint}");
return null;
}
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
GC.SuppressFinalize(this);
}
}
@@ -0,0 +1,382 @@
using System.Buffers.Binary;
using PCL.Core.App.Localization;
using PCL.Core.Link.McPing.Model;
using PCL.Core.Logging;
using PCL.Core.Utils;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Sockets;
using System.Text;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Threading;
using System.Threading.Tasks;
namespace PCL.Core.Link.McPing;
/// <summary>
/// 现代Minecraft协议服务器探测服务实现
/// 支持1.7+版本的服务器信息查询协议
/// </summary>
public class McPingService : IMcPingService
{
private readonly IPEndPoint _endpoint;
private readonly string _host;
private const int DefaultTimeout = 10000;
private readonly int _timeout;
private bool _disposed;
private const string ModuleName = "McPing";
public IPEndPoint Endpoint => _endpoint;
public string Host => _host;
public int Timeout => _timeout;
public McPingService(IPEndPoint endpoint, int timeout = DefaultTimeout)
{
_endpoint = endpoint;
_host = _endpoint.Address.ToString();
_timeout = timeout;
}
public McPingService(string ip, int port = 25565, int timeout = DefaultTimeout)
{
_endpoint = IPAddress.TryParse(ip, out var ipAddress)
? new IPEndPoint(ipAddress, port)
: new IPEndPoint(Dns.GetHostAddresses(ip).First(), port);
_host = ip;
_timeout = timeout;
}
public McPingService(string host, IPEndPoint endpoint, int timeout = DefaultTimeout)
{
_endpoint = endpoint;
_host = host;
_timeout = timeout;
}
/// <summary>
/// 执行现代Minecraft协议的服务器探测
/// </summary>
/// <param name="cancellationToken"></param>
/// <returns></returns>
public async Task<McPingResult?> PingAsync(CancellationToken cancellationToken = default)
{
using var so = new Socket(SocketType.Stream, ProtocolType.Tcp);
using var timeoutCts = new CancellationTokenSource(_timeout);
using var linkedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, timeoutCts.Token);
try
{
LogWrapper.Debug(ModuleName, $"Connecting to {_endpoint}");
await so.ConnectAsync(_endpoint.Address, _endpoint.Port, linkedCts.Token);
}
catch (OperationCanceledException)
{
LogWrapper.Error(new TimeoutException(Lang.Text("Tools.ServerQuery.Error.Timeout.Connect")), ModuleName, $"Failed to connect to the {_endpoint}");
return null;
}
catch (Exception e)
{
LogWrapper.Error(e, ModuleName, $"Failed to connect to the {_endpoint}");
return null;
}
LogWrapper.Debug(ModuleName, $"Connection established: {_endpoint}");
await using var stream = new NetworkStream(so, false);
var handshakePacket = _BuildHandshakePacket(_host, _endpoint.Port);
var statusPacket = _BuildStatusRequestPacket();
byte[]? statusPayload;
long latency = 0;
try
{
await stream.WriteAsync(handshakePacket, linkedCts.Token);
LogWrapper.Debug(ModuleName, $"Handshake sent, packet length: {handshakePacket.Length}");
await stream.WriteAsync(statusPacket, linkedCts.Token);
LogWrapper.Debug(ModuleName, $"Status sent, packet length: {statusPacket.Length}");
var pingTimestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
var pingPacket = _BuildPingRequestPacket(pingTimestamp);
await stream.WriteAsync(pingPacket, linkedCts.Token);
LogWrapper.Debug(ModuleName, $"Ping sent, packet length: {pingPacket.Length}");
(statusPayload, latency) = await _ReadStatusPayloadAsync(stream, linkedCts.Token);
}
catch (OperationCanceledException)
{
LogWrapper.Error(new TimeoutException(Lang.Text("Tools.ServerQuery.Error.Timeout.ReadWrite")), "McPing", $"Operation timed out on {_endpoint}");
return null;
}
catch (Exception e)
{
LogWrapper.Error(e, ModuleName, $"Failed to communicate with {_endpoint}: {e.Message}");
return null;
}
finally
{
if (so.Connected) so.Shutdown(SocketShutdown.Both);
}
so.Close();
if (statusPayload is null || statusPayload.Length == 0) throw new InvalidDataException(Lang.Text("Tools.ServerQuery.State.NoInfo"));
var retCtx = Encoding.UTF8.GetString(statusPayload);
var retJson = JsonCompat.ParseNode(retCtx) ?? throw new NullReferenceException(Lang.Text("Tools.ServerQuery.Error.InvalidResponse"));
#if DEBUG
var resJsonDebug = retJson.DeepClone();
if (resJsonDebug is JsonObject jsonObject && jsonObject.ContainsKey("favicon"))
{
jsonObject["favicon"] = "...";
}
LogWrapper.Debug(ModuleName, resJsonDebug.ToJsonString());
#endif
// 先处理Description字段,将其转换为字符串形式
if (retJson["description"] is JsonObject descObj)
{
retJson["description"] = _ConvertJNodeToMcString(descObj);
}
var response = JsonSerializer.Deserialize<McPingResult>(retJson, JsonCompat.SerializerOptions);
if (response?.Version is null)
throw new NullReferenceException(Lang.Text("Tools.ServerQuery.Error.InvalidResponse"));
response = response with
{
Latency = latency
};
return response;
}
public void Dispose()
{
if (_disposed) return;
_disposed = true;
GC.SuppressFinalize(this);
}
/// <summary>
/// 构建握手包
/// </summary>
/// <param name="serverIp">服务器的地址</param>
/// <param name="serverPort">服务器的端口</param>
/// <returns>返回握手包的字节数组</returns>
private byte[] _BuildHandshakePacket(string serverIp, int serverPort)
{
List<byte> handshake = [];
handshake.AddRange(VarIntHelper.Encode(0)); //状态头 表明这是一个握手包
handshake.AddRange(VarIntHelper.Encode(772)); //协议头 表明请求客户端的版本
var binaryIp = Encoding.UTF8.GetBytes(serverIp);
if (binaryIp.Length > 255) throw new Exception(Lang.Text("Tools.ServerQuery.Error.AddressTooLong"));
handshake.AddRange(VarIntHelper.Encode((uint)binaryIp.Length)); //服务器地址长度
handshake.AddRange(binaryIp); //服务器地址
handshake.AddRange(BitConverter.GetBytes((ushort)serverPort).AsEnumerable().Reverse()); //服务器端口
handshake.AddRange(VarIntHelper.Encode(1)); //1 表明当前状态为 ping 2 表明当前的状态为连接
handshake.InsertRange(0, VarIntHelper.Encode((uint)handshake.Count)); //包长度
return handshake.ToArray();
}
private byte[] _BuildStatusRequestPacket()
{
List<byte> statusRequest = [];
statusRequest.AddRange(VarIntHelper.Encode(1)); //包长度
statusRequest.AddRange(VarIntHelper.Encode(0)); //包 ID
return statusRequest.ToArray();
}
private byte[] _BuildPingRequestPacket(long timestamp)
{
List<byte> pingRequest = [];
// Packet ID 使用值为 1 的 VarInt 编码和 8 字节的 long 时间戳
pingRequest.AddRange(VarIntHelper.Encode(9));
pingRequest.AddRange(VarIntHelper.Encode(1));
pingRequest.AddRange(BitConverter.GetBytes(timestamp).AsEnumerable().Reverse());
return pingRequest.ToArray();
}
private async Task<(byte[] StatusPayload, long Latency)> _ReadStatusPayloadAsync(Stream stream, CancellationToken cancellationToken)
{
byte[]? statusPayload = null;
long? latency = null;
try
{
while (statusPayload is null || latency is null)
{
var packetLength = checked((int)await VarIntHelper.ReadFromStreamAsync(stream, cancellationToken));
LogWrapper.Debug(ModuleName, $"Packet length: {packetLength}");
if (packetLength <= 0) throw new InvalidDataException(Lang.Text("Tools.ServerQuery.Error.EmptyPacket"));
var packetData = await _ReadExactAsync(stream, packetLength, cancellationToken);
using var packetStream = new MemoryStream(packetData, writable: false);
var packetId = checked((int)await VarIntHelper.ReadFromStreamAsync(packetStream, cancellationToken));
LogWrapper.Debug(ModuleName, $"Packet id: {packetId}");
switch (packetId)
{
case 0:
var jsonLength = checked((int)await VarIntHelper.ReadFromStreamAsync(packetStream, cancellationToken));
statusPayload = await _ReadExactAsync(packetStream, jsonLength, cancellationToken);
if (packetStream.Position != packetStream.Length)
LogWrapper.Warn(ModuleName, $"Status packet contains {packetStream.Length - packetStream.Position} trailing bytes.");
break;
case 1:
var pongData = await _ReadExactAsync(packetStream, 8, cancellationToken);
if (packetStream.Position != packetStream.Length)
LogWrapper.Warn(ModuleName, $"Pong packet contains {packetStream.Length - packetStream.Position} trailing bytes.");
latency = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - _ReadInt64BigEndian(pongData);
break;
default:
LogWrapper.Warn(ModuleName, $"Ignore unexpected packet type: {packetId}");
break;
}
}
}
catch (EndOfStreamException ex)
{
if (statusPayload is not null && latency is null)
throw new EndOfStreamException(Lang.Text("Tools.ServerQuery.Error.StaleConnection"), ex);
if (statusPayload is null)
throw new EndOfStreamException(Lang.Text("Tools.ServerQuery.Error.IncompleteConnection"), ex);
throw;
}
return (statusPayload, latency.Value);
}
private static long _ReadInt64BigEndian(byte[] data)
{
return data.Length != 8
? throw new ArgumentException(Lang.Text("Tools.ServerQuery.Error.PongDataLength"), nameof(data))
: BinaryPrimitives.ReadInt64BigEndian(data);
}
private static async Task<byte[]> _ReadExactAsync(Stream stream, int length, CancellationToken cancellationToken)
{
var buffer = new byte[length];
await stream.ReadExactlyAsync(buffer, cancellationToken);
return buffer;
}
private static string _ConvertJNodeToMcString(JsonNode? jsonNode)
{
if (jsonNode is null) return string.Empty;
StringBuilder result = new();
Stack<JsonNode> stack = new();
stack.Push(jsonNode);
while (stack.Count > 0)
{
var current = stack.Pop();
switch (current.GetValueKind())
{
// 处理对象
case JsonValueKind.Object:
{
var obj = current.AsObject();
// LogWrapper.Debug("McPing",$"Treat {obj} as JObject");
// 检查并处理 extra 数组
if (obj.TryGetPropertyValue("extra", out var extraNode) && extraNode is JsonArray extraArray)
// 逆序压栈保证原始顺序
for (var i = extraArray.Count - 1; i >= 0; i--)
if (extraArray[i] is not null)
stack.Push(extraArray[i]!);
// 检查并处理 text 属性
if (obj.TryGetPropertyValue("text", out _))
{
var formatCode = _GetTextStyleString(
obj["color"]?.ToString() ?? string.Empty,
Convert.ToBoolean(obj["bold"]?.ToString() ?? "false"),
Convert.ToBoolean(obj["obfuscated"]?.ToString() ?? "false"),
Convert.ToBoolean(obj["strikethrough"]?.ToString() ?? "false"),
Convert.ToBoolean(obj["underline"]?.ToString() ?? "false"),
Convert.ToBoolean(obj["italic"]?.ToString() ?? "false")
);
result.Append($"{formatCode}{obj["text"] ?? string.Empty}");
}
break;
}
// 处理字符串值
case JsonValueKind.String:
{
// LogWrapper.Debug("McPing",$"Treat {value} as JValue");
result.Append(current);
break;
}
// 处理数组
// 逆序压栈保证原始顺序
case JsonValueKind.Array:
{
var jArr = current.AsArray();
// LogWrapper.Debug("McPing",$"Treat {array} as JArray");
for (var i = jArr.Count - 1; i >= 0; i--)
if (jArr[i] is not null)
stack.Push(jArr[i]!);
break;
}
default:
{
LogWrapper.Warn(ModuleName, $"解析到无法处理的 Motd 内容({current.GetValueKind()}){current}");
break;
}
}
}
LogWrapper.Debug(ModuleName, $"处理 Motd 内容完成,结果:{result}");
return result.ToString();
}
private static readonly Dictionary<string, string> _ColorMap = new()
{
["black"] = "0",
["dark_blue"] = "1",
["dark_green"] = "2",
["dark_aqua"] = "3",
["dark_red"] = "4",
["dark_purple"] = "5",
["gold"] = "6",
["gray"] = "7",
["dark_gray"] = "8",
["blue"] = "9",
["green"] = "a",
["aqua"] = "b",
["red"] = "c",
["light_purple"] = "d",
["yellow"] = "e",
["white"] = "f"
};
private static string _GetTextStyleString(
string color,
bool bold = false,
bool obfuscated = false,
bool strikethrough = false,
bool underline = false,
bool italic = false)
{
var sb = new StringBuilder();
if (_ColorMap.TryGetValue(color, out var colorCode)) sb.Append($"§{colorCode}");
if (bold) sb.Append("§l");
if (italic) sb.Append("§o");
// if (obfuscated) sb.Append("§k"); // 暂时别用
if (underline) sb.Append("§n");
if (strikethrough) sb.Append("§m");
if (color.StartsWith('#')) sb.Append(color);
return sb.ToString();
}
}
@@ -0,0 +1,68 @@
using System.Net;
namespace PCL.Core.Link.McPing;
/// <summary>
/// Minecraft服务器探测服务工厂
/// 提供统一的服务创建接口
/// </summary>
public static class McPingServiceFactory
{
/// <summary>
/// 创建现代协议探测服务
/// </summary>
/// <param name="endpoint">服务器端点</param>
/// <param name="timeout">超时时间(毫秒)</param>
/// <returns>IMcPingService实例</returns>
public static IMcPingService CreateService(IPEndPoint endpoint, int timeout = 10000)
{
return new McPingService(endpoint, timeout);
}
/// <summary>
/// 创建现代协议探测服务
/// </summary>
/// <param name="ip">服务器IP地址</param>
/// <param name="port">服务器端口</param>
/// <param name="timeout">超时时间(毫秒)</param>
/// <returns>IMcPingService实例</returns>
public static IMcPingService CreateService(string ip, int port = 25565, int timeout = 10000)
{
return new McPingService(ip, port, timeout);
}
public static IMcPingService CreateService(string host, string? ip, int port = 25565)
{
return CreateService(host, ip, port, 10000);
}
public static IMcPingService CreateService(string host, string? ip, int port, int timeout)
{
return !string.IsNullOrWhiteSpace(ip) && IPAddress.TryParse(ip, out var ipAddress)
? new McPingService(host, new IPEndPoint(ipAddress, port), timeout)
: new McPingService(host, port, timeout);
}
/// <summary>
/// 创建旧版协议探测服务
/// </summary>
/// <param name="endpoint">服务器端点</param>
/// <param name="timeout">超时时间(毫秒)</param>
/// <returns>IMcPingService实例</returns>
public static IMcPingService CreateLegacyService(IPEndPoint endpoint, int timeout = 10000)
{
return new LegacyMcPingService(endpoint, timeout);
}
/// <summary>
/// 创建旧版协议探测服务
/// </summary>
/// <param name="ip">服务器IP地址</param>
/// <param name="port">服务器端口</param>
/// <param name="timeout">超时时间(毫秒)</param>
/// <returns>IMcPingService实例</returns>
public static IMcPingService CreateLegacyService(string ip, int port = 25565, int timeout = 10000)
{
return new LegacyMcPingService(ip, port, timeout);
}
}
@@ -0,0 +1,7 @@
using System.Text.Json.Serialization;
namespace PCL.Core.Link.McPing.Model;
public record McPingModInfoModResult(
[property: JsonPropertyName("modid")] string Id,
[property: JsonPropertyName("version")] string Version);
@@ -0,0 +1,8 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace PCL.Core.Link.McPing.Model;
public record McPingModInfoResult(
[property: JsonPropertyName("type")] string Type,
[property: JsonPropertyName("modList")] List<McPingModInfoModResult> ModList);
@@ -0,0 +1,9 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
namespace PCL.Core.Link.McPing.Model;
public record McPingPlayerResult(
[property: JsonPropertyName("max")] int Max,
[property: JsonPropertyName("online")] int Online,
[property: JsonPropertyName("sample")] List<McPingPlayerSampleResult>? Samples);
@@ -0,0 +1,7 @@
using System.Text.Json.Serialization;
namespace PCL.Core.Link.McPing.Model;
public record McPingPlayerSampleResult(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("id")] string Id);
@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
namespace PCL.Core.Link.McPing.Model;
public record McPingResult(
[property: JsonPropertyName("version")] McPingVersionResult Version,
[property: JsonPropertyName("players")] McPingPlayerResult Players,
[property: JsonPropertyName("description")] string Description,
[property: JsonPropertyName("favicon")] string? Favicon,
[property: JsonPropertyName("latency")] long Latency,
[property: JsonPropertyName("modinfo")] McPingModInfoResult? ModInfo,
[property: JsonPropertyName("preventsChatReports")] bool? PreventsChatReports);
@@ -0,0 +1,7 @@
using System.Text.Json.Serialization;
namespace PCL.Core.Link.McPing.Model;
public record McPingVersionResult(
[property: JsonPropertyName("name")] string Name,
[property: JsonPropertyName("protocol")] int Protocol);