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,25 @@
using System;
using System.Buffers;
namespace PCL.Core.Link.Scaffolding.Client.Abstractions;
public interface IRequest<out TResponse>
{
/// <summary>
/// Gets the request type string, e.g., "c:ping".
/// </summary>
string RequestType { get; }
/// <summary>
/// Writes the request body to the provided buffer writer.
/// </summary>
/// <param name="writer">The buffer to write to.</param>
void WriteRequestBody(IBufferWriter<byte> writer);
/// <summary>
/// Parses the response body from the given memory span.
/// </summary>
/// <param name="responseBody">The raw response body.</param>
/// <returns>The parsed response object.</returns>
TResponse ParseResponseBody(ReadOnlyMemory<byte> responseBody);
}
@@ -0,0 +1,5 @@
using System;
namespace PCL.Core.Link.Scaffolding.Client.Abstractions;
public record ScaffoldingResponse(byte Status, ReadOnlyMemory<byte> Body);
@@ -0,0 +1,89 @@
using System;
using System.Buffers;
using System.Buffers.Binary;
using System.IO.Pipelines;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using PCL.Core.Link.Scaffolding.Client.Abstractions;
using PCL.Core.Link.Scaffolding.Exceptions;
namespace PCL.Core.Link.Scaffolding.Client.Framing;
internal static class ProtocolReader
{
/// <summary>
/// Read a response from the pipe.
/// </summary>
/// <param name="reader">The PipeReader to read from.</param>
/// <param name="ct">The CancellationToken.</param>
/// <returns>A task representing the asynchronous operation, with a ScaffoldingResponse as the result.</returns>
/// <exception cref="ScaffoldingRequestException">Thrown if the request fails due to an invalid status.</exception>
/// <exception cref="InvalidOperationException">Thrown if the connection is closed unexpectedly.</exception>
public static async ValueTask<ScaffoldingResponse> ReadResponseAsync(
PipeReader reader,
CancellationToken ct = default)
{
while (true)
{
var result = await reader.ReadAsync(ct).ConfigureAwait(false);
var buffer = result.Buffer;
if (_TryParseResponse(ref buffer, out var response))
{
reader.AdvanceTo(buffer.Start);
return response;
}
reader.AdvanceTo(result.Buffer.Start, result.Buffer.End);
if (result.IsCompleted)
{
throw new InvalidOperationException("Connection closed unexpectedly.");
}
}
}
/// <summary>
/// Tries to parse a response from the given buffer.
/// </summary>
/// <param name="buffer">The buffer containing the response data.</param>
/// <param name="response">The parsed ScaffoldingResponse.</param>
/// <returns>True if the response was successfully parsed; otherwise, false.</returns>
/// <exception cref="ScaffoldingRequestException">Thrown if the request fails due to an invalid status.</exception>
private static bool _TryParseResponse(ref ReadOnlySequence<byte> buffer, out ScaffoldingResponse response)
{
response = null!;
if (buffer.Length < 5)
{
return false;
}
Span<byte> header = stackalloc byte[5];
buffer.Slice(0, 5).CopyTo(header);
var status = header[0];
var bodyLength = BinaryPrimitives.ReadUInt32BigEndian(header[1..]);
var fullPacketLength = 5 + bodyLength;
if (buffer.Length < fullPacketLength)
{
return false;
}
var bodyBuffer = buffer.Slice(5, bodyLength);
var body = bodyBuffer.ToArray();
buffer = buffer.Slice(fullPacketLength);
response = new ScaffoldingResponse(status, body);
if (response.Status != 0)
{
var serverMessage = response.Status == 255 ? Encoding.UTF8.GetString(response.Body.Span) : null;
throw new ScaffoldingRequestException(response.Status, serverMessage);
}
return true;
}
}
@@ -0,0 +1,55 @@
using System;
using System.Buffers;
using System.Buffers.Binary;
using System.IO.Pipelines;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using PCL.Core.Link.Scaffolding.Client.Abstractions;
namespace PCL.Core.Link.Scaffolding.Client.Framing;
internal static class ProtocolWriter
{
/// <summary>
/// Write message to server.
/// </summary>
/// <typeparam name="T">Request body object type.</typeparam>
/// <exception cref="InvalidOperationException">Thrown if request type is too long.</exception>
/// <exception cref="OperationCanceledException">Thrown if operation is canceled.</exception>
public static async ValueTask WriteRequestAsync<T>(
PipeWriter writer,
IRequest<T> request,
CancellationToken ct = default)
{
var bodyWriter = new ArrayBufferWriter<byte>();
request.WriteRequestBody(bodyWriter);
var requestBody = bodyWriter.WrittenMemory;
var requestTypeBytes = Encoding.ASCII.GetBytes(request.RequestType);
if (requestTypeBytes.Length > byte.MaxValue)
{
throw new InvalidOperationException("Request type is too long.");
}
writer.GetSpan(1)[0] = (byte)requestTypeBytes.Length;
writer.Advance(1);
writer.Write(requestTypeBytes);
var lengthSpan = writer.GetSpan(4);
BinaryPrimitives.WriteUInt32BigEndian(lengthSpan, (uint)requestBody.Length);
writer.Advance(4);
if (!requestBody.IsEmpty)
{
writer.Write(requestBody.Span);
}
var result = await writer.FlushAsync(ct).ConfigureAwait(false);
if (result.IsCanceled)
{
throw new OperationCanceledException();
}
}
}
@@ -0,0 +1,3 @@
namespace PCL.Core.Link.Scaffolding.Client.Models;
public record LobbyInfo(string FullCode, string NetworkName, string NetworkSecret);
@@ -0,0 +1,9 @@
namespace PCL.Core.Link.Scaffolding.Client.Models;
public enum LobbyType
{
// ReSharper disable once InconsistentNaming
PCLCE,
Terracotta,
Scaffolding
}
@@ -0,0 +1,12 @@
using System.Text.Json.Serialization;
// ReSharper disable InconsistentNaming
namespace PCL.Core.Link.Scaffolding.Client.Models;
[JsonConverter(typeof(JsonStringEnumConverter))]
public enum PlayerKind
{
HOST,
GUEST
}
@@ -0,0 +1,14 @@
using System.Text.Json.Serialization;
namespace PCL.Core.Link.Scaffolding.Client.Models;
public record PlayerProfile
{
[JsonPropertyName("name")] public required string Name { get; init; }
[JsonPropertyName("machine_id")] public required string MachineId { get; init; }
[JsonPropertyName("vendor")] public required string Vendor { get; init; }
[JsonPropertyName("kind")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public PlayerKind? Kind { get; init; }
}
@@ -0,0 +1,35 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
using PCL.Core.Link.Scaffolding.Client.Abstractions;
using PCL.Core.Link.Scaffolding.Client.Models;
using PCL.Core.Utils;
namespace PCL.Core.Link.Scaffolding.Client.Requests;
public sealed class GetPlayerProfileListRequest : IRequest<IReadOnlyList<PlayerProfile>>
{
private static readonly JsonSerializerOptions _JsonOptions = new(JsonCompat.SerializerOptions)
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
/// <inheritdoc />
public string RequestType { get; } = "c:player_profiles_list";
/// <inheritdoc />
public void WriteRequestBody(IBufferWriter<byte> writer)
{
// empty request body
}
/// <inheritdoc />
public IReadOnlyList<PlayerProfile> ParseResponseBody(ReadOnlyMemory<byte> responseBody)
{
var profiles = JsonSerializer.Deserialize<IReadOnlyList<PlayerProfile>>(responseBody.Span, _JsonOptions);
return profiles ?? ArraySegment<PlayerProfile>.Empty;
}
}
@@ -0,0 +1,37 @@
using System;
using System.Buffers;
using System.Collections.Generic;
using System.Text;
using PCL.Core.Link.Scaffolding.Client.Abstractions;
namespace PCL.Core.Link.Scaffolding.Client.Requests;
public sealed class GetProtocolsRequest(IEnumerable<string> supportProtocols) : IRequest<IReadOnlyList<string>>
{
private static readonly byte[] _Separator = [(byte)'\0'];
private readonly IEnumerable<string> _supportProtocols = supportProtocols;
/// <inheritdoc />
public string RequestType { get; } = "c:protocols";
/// <inheritdoc />
public void WriteRequestBody(IBufferWriter<byte> writer)
{
var protocolStr = string.Join('\0', _supportProtocols);
var bytes = Encoding.ASCII.GetBytes(protocolStr);
writer.Write(bytes);
}
/// <inheritdoc />
public IReadOnlyList<string> ParseResponseBody(ReadOnlyMemory<byte> responseBody)
{
if (responseBody.IsEmpty)
{
return ArraySegment<string>.Empty;
}
var responseStr = Encoding.ASCII.GetString(responseBody.Span);
return responseStr.Split('\0', StringSplitOptions.RemoveEmptyEntries);
}
}
@@ -0,0 +1,29 @@
using System;
using System.Buffers;
using System.Buffers.Binary;
using PCL.Core.Link.Scaffolding.Client.Abstractions;
namespace PCL.Core.Link.Scaffolding.Client.Requests;
public sealed class GetServerPortRequest : IRequest<ushort>
{
/// <inheritdoc />
public string RequestType { get; } = "c:server_port";
/// <inheritdoc />
public void WriteRequestBody(IBufferWriter<byte> writer)
{
// empty
}
/// <inheritdoc />
public ushort ParseResponseBody(ReadOnlyMemory<byte> responseBody)
{
if (responseBody.Length != 2)
{
throw new InvalidOperationException("Invalid response body for server port.");
}
return BinaryPrimitives.ReadUInt16BigEndian(responseBody.Span);
}
}
@@ -0,0 +1,35 @@
using System;
using System.Buffers;
using PCL.Core.Link.Scaffolding.Client.Abstractions;
namespace PCL.Core.Link.Scaffolding.Client.Requests;
public sealed class PingRequest : IRequest<ReadOnlyMemory<byte>>
{
private readonly ReadOnlyMemory<byte> _payload;
/// <inheritdoc />
public string RequestType { get; } = "c:ping";
public PingRequest(ReadOnlyMemory<byte> payload)
{
if (payload.Length >= 32)
{
throw new ArgumentException("Payload must be less than 32 bytes.", nameof(payload));
}
_payload = payload;
}
/// <inheritdoc />
public void WriteRequestBody(IBufferWriter<byte> writer)
{
writer.Write(_payload.Span);
}
/// <inheritdoc />
public ReadOnlyMemory<byte> ParseResponseBody(ReadOnlyMemory<byte> responseBody)
{
return responseBody;
}
}
@@ -0,0 +1,43 @@
using System;
using System.Buffers;
using System.Text.Json;
using System.Text.Json.Serialization;
using PCL.Core.Link.Scaffolding.Client.Abstractions;
using PCL.Core.Link.Scaffolding.Client.Models;
using PCL.Core.Utils;
namespace PCL.Core.Link.Scaffolding.Client.Requests;
public sealed class PlayerPingRequest(string name, string machineId, string vendor) : IRequest<bool>
{
private readonly PlayerProfile _profile = new()
{
Name = name,
MachineId = machineId,
Vendor = vendor
};
private static readonly JsonSerializerOptions _JsonOptions = new(JsonCompat.SerializerOptions)
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
/// <inheritdoc />
public string RequestType { get; } = "c:player_ping";
/// <inheritdoc />
public void WriteRequestBody(IBufferWriter<byte> writer)
{
using var jsonWriter = new Utf8JsonWriter(writer);
JsonSerializer.Serialize(jsonWriter, _profile, _JsonOptions);
}
/// <inheritdoc />
public bool ParseResponseBody(ReadOnlyMemory<byte> responseBody)
{
// An empty response body indicates success.
return responseBody.IsEmpty;
}
}
@@ -0,0 +1,223 @@
using PCL.Core.Link.Scaffolding.Client.Abstractions;
using PCL.Core.Link.Scaffolding.Client.Framing;
using PCL.Core.Link.Scaffolding.Client.Models;
using PCL.Core.Link.Scaffolding.Client.Requests;
using PCL.Core.Logging;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO.Pipelines;
using System.Net.Sockets;
using System.Threading;
using System.Threading.Tasks;
namespace PCL.Core.Link.Scaffolding.Client;
internal enum ClientState
{
Disconnected,
Connecting,
Handshaking, // 正在进行握手
Connected, // 握手成功,准备就绪
Disposing
}
/// <summary>
/// A client for the Scaffolding data exchange protocol.
/// </summary>
public sealed class ScaffoldingClient(string host, int scfPort, string playerName, string machineId, string vendor)
: IAsyncDisposable
{
private readonly SemaphoreSlim _srLock = new(1, 1);
private TcpClient? _tcpClient;
private PipeReader? _pipeReader;
private PipeWriter? _pipeWriter;
// Heart Beat
private Task? _heartbeatTask;
private readonly PlayerPingRequest _playerPingRequest = new(playerName, machineId, vendor);
private CancellationTokenSource? _heartbeatCts;
private readonly Stopwatch _heartbeatTimer = new();
private ClientState _state = ClientState.Disconnected;
#region Events
/// <summary>
/// Occurs when a heartbeat signal is received, providing the current list of player profiles and the elapsed time
/// since the last heartbeat.
/// </summary>
/// <remarks>
/// Subscribers can use this event to monitor player activity or synchronize state at regular
/// intervals. The event provides a read-only list of player profiles and an integer representing the elapsed time,
/// typically in milliseconds or seconds, depending on the implementation.
/// </remarks>
public event Action<IReadOnlyList<PlayerProfile>, long>? Heartbeat;
/// <summary>
/// Occurs when the server has been shut down or is unreachable.
/// </summary>
public event Action? ServerShuttedDown;
#endregion
public IReadOnlyList<PlayerProfile>? PlayerList;
public bool IsConnected => _state == ClientState.Connected;
/// <summary>
/// Connects to a Scaffolding server.
/// </summary>
/// <exception cref="Exception">Throws if fialed to connect to server.</exception>
public async Task ConnectAsync(CancellationToken ct = default)
{
if (_state is not ClientState.Disconnected)
{
return;
}
_tcpClient = new TcpClient();
try
{
_state = ClientState.Connecting;
LogWrapper.Info("Scaffolding", $"Trying to connect to server: {host}:{scfPort}");
await _tcpClient.ConnectAsync(host, scfPort, ct).ConfigureAwait(false);
var stream = _tcpClient.GetStream();
_pipeReader = PipeReader.Create(stream);
_pipeWriter = PipeWriter.Create(stream);
_state = ClientState.Handshaking;
LogWrapper.Info("Scaffolding", "Connecting established. Performing handshake...");
await SendRequestAsync(_playerPingRequest, ct).ConfigureAwait(false);
_state = ClientState.Connected;
_StartHeartbeats();
}
catch (Exception ex)
{
LogWrapper.Error(ex, "ScaffoldingClient", "Failed to connect to server.");
await DisposeAsync().ConfigureAwait(false);
ServerShuttedDown?.Invoke();
throw;
}
}
private void _StartHeartbeats()
{
_heartbeatCts = new CancellationTokenSource();
_heartbeatTask = _HeartbeatLoopAsync(_heartbeatCts.Token);
}
private async Task _HeartbeatLoopAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
try
{
await Task.Delay(TimeSpan.FromSeconds(5), ct).ConfigureAwait(false);
_heartbeatTimer.Start();
await SendRequestAsync(_playerPingRequest, ct).ConfigureAwait(false);
_heartbeatTimer.Stop();
var letancy = _heartbeatTimer.ElapsedMilliseconds;
_heartbeatTimer.Reset();
PlayerList = await SendRequestAsync(new GetPlayerProfileListRequest(), ct).ConfigureAwait(false);
Heartbeat?.Invoke(PlayerList, letancy);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
LogWrapper.Error(ex, "[ScaffoldingClient]",
"Failed when sending heartbeat message. Maybe the server has been shut down.");
ServerShuttedDown?.Invoke();
break;
}
}
}
/// <summary>
/// Send TCP request to the Scaffolding server.
/// </summary>
/// <param name="request">Thr request type.</param>
/// <param name="ct">Cancellation token.</param>
/// <typeparam name="TResponse">Response type.</typeparam>
/// <exception cref="InvalidOperationException">Throws when server is not ready.</exception>
public async Task<TResponse> SendRequestAsync<TResponse>(
IRequest<TResponse> request,
CancellationToken ct = default)
{
if (_state < ClientState.Handshaking)
{
throw new InvalidOperationException("Client is not connected.");
}
if (_pipeWriter is null || _pipeReader is null)
{
throw new InvalidOperationException("Client is not connected.");
}
await _srLock.WaitAsync(ct).ConfigureAwait(false);
try
{
await ProtocolWriter.WriteRequestAsync(_pipeWriter, request, ct).ConfigureAwait(false);
var response = await ProtocolReader.ReadResponseAsync(_pipeReader, ct).ConfigureAwait(false);
return request.ParseResponseBody(response.Body);
}
finally
{
_srLock.Release();
}
}
/// <inheritdoc />
public async ValueTask DisposeAsync()
{
if (_state is ClientState.Disposing)
{
return;
}
_state = ClientState.Disposing;
await CastAndDispose(_srLock).ConfigureAwait(false);
if (_tcpClient is not null) await CastAndDispose(_tcpClient).ConfigureAwait(false);
if (_heartbeatCts is not null)
{
await _heartbeatCts.CancelAsync().ConfigureAwait(false);
await CastAndDispose(_heartbeatCts).ConfigureAwait(false);
}
if (_heartbeatTask is not null)
{
await _heartbeatTask.ConfigureAwait(false);
await CastAndDispose(_heartbeatTask).ConfigureAwait(false);
}
return;
static async ValueTask CastAndDispose(IDisposable resource)
{
if (resource is IAsyncDisposable resourceAsyncDisposable)
await resourceAsyncDisposable.DisposeAsync().ConfigureAwait(false);
else
resource.Dispose();
}
}
}