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,29 @@
using System;
using fNbt;
namespace PCL.Core.Minecraft.Saves.Parsing;
/// <summary>
/// 存档解析器接口 —— 负责将 level.dat 中的 NBT 数据转换为 <see cref="SaveInfo"/>。
/// 每种格式版本对应一个实现类。
/// </summary>
public interface ISaveParser
{
/// <summary>此解析器对应的存档格式版本。</summary>
SaveFormatVersion FormatVersion { get; }
/// <summary>返回此解析器能否处理给定的 NBT 数据。</summary>
/// <param name="data">level.dat 中的 Data 复合标签。</param>
/// <param name="dataVersion">Data 中的 DataVersion 字段值,如果不存在则为 null。</param>
bool CanHandle(NbtCompound data, int? dataVersion);
/// <summary>
/// 解析 NBT 数据并返回 <see cref="SaveInfo"/>。
/// 文件系统元数据(创建时间、修改时间)由调用方传入。
/// </summary>
/// <param name="folderPath">存档文件夹的绝对路径。</param>
/// <param name="data">level.dat 中的 Data 复合标签。</param>
/// <param name="createdAt">文件夹创建时间(UTC)。</param>
/// <param name="modifiedAt">level.dat 最后修改时间(UTC)。</param>
SaveInfo Parse(string folderPath, NbtCompound data, DateTime createdAt, DateTime modifiedAt);
}
@@ -0,0 +1,84 @@
using System;
using fNbt;
using System.Numerics;
namespace PCL.Core.Minecraft.Saves.Parsing.Internal;
/// <summary>
/// NBT 读取工具方法,多个版本解析器共用。
/// </summary>
internal static class NbtReadHelper
{
/// <summary>尝试从 NBT 复合标签中读取 long 值。</summary>
public static long? TryGetLong(NbtCompound data, string key) =>
data.TryGet<NbtLong>(key, out var tag) ? tag!.Value : null;
/// <summary>读取最后游玩时间并转为 UTC DateTime。</summary>
public static DateTime ReadLastPlayed(NbtCompound data) =>
EpochMsToUtc(TryGetLong(data, "LastPlayed") ?? 0);
/// <summary>将 Unix 毫秒时间戳转为 UTC DateTime。</summary>
public static DateTime EpochMsToUtc(long ms) =>
DateTime.UnixEpoch.AddMilliseconds(ms);
/// <summary>读取累计游戏时间。Minecraft 以 tick 为单位(20 tick = 1 秒)。</summary>
public static TimeSpan ReadPlayTime(NbtCompound data)
{
var ticks = TryGetLong(data, "Time");
return TimeSpan.FromSeconds((ticks ?? 0) / 20.0d);
}
/// <summary>读取游戏模式。hardcore 不是独立的 GameType,而是 Survival + hardcore=1。</summary>
public static GameMode ReadGameMode(NbtCompound data, out bool isHardcore)
{
isHardcore = data.TryGet<NbtByte>("hardcore", out var hc) && hc!.Value == 1;
if (isHardcore) return GameMode.Hardcore;
var gt = data.TryGet<NbtInt>("GameType", out var gameType) ? gameType!.Value : 0;
return gt switch
{
1 => GameMode.Creative,
2 => GameMode.Adventure,
3 => GameMode.Spectator,
_ => GameMode.Survival,
};
}
/// <summary>读取出生点坐标 —— 旧版格式(SpawnX/Y/Z 三个独立 int 字段)。</summary>
public static Vector3? TryReadSpawnFromFields(NbtCompound data)
{
if (data.TryGet<NbtInt>("SpawnX", out var sx) &&
data.TryGet<NbtInt>("SpawnY", out var sy) &&
data.TryGet<NbtInt>("SpawnZ", out var sz))
return new Vector3(sx!.Value, sy!.Value, sz!.Value);
return null;
}
/// <summary>读取出生点坐标 —— 新版格式(spawn.pos int[] 数组)。</summary>
public static Vector3? TryReadSpawnFromPos(NbtCompound data)
{
if (data.TryGet<NbtCompound>("spawn", out var spawn) &&
spawn!.TryGet<NbtIntArray>("pos", out var pos) && pos!.Value.Length == 3)
return new Vector3(pos[0], pos[1], pos[2]);
return null;
}
/// <summary>读取旧版字节型难度(0=和平, 1=简单, 2=普通, 3=困难)。</summary>
public static Difficulty? ReadDifficultyByte(NbtCompound data)
{
if (data.TryGet<NbtByte>("Difficulty", out var diff))
return (Difficulty)diff!.Value;
return null;
}
/// <summary>读取 Data.Version 复合标签中的版本信息。</summary>
public static (string? name, int? id) ReadVersion(NbtCompound data)
{
if (data.TryGet<NbtCompound>("Version", out var version))
{
var name = version!.TryGet<NbtString>("Name", out var n) ? n!.Value : null;
var id = version.TryGet<NbtInt>("Id", out var i) ? i!.Value : (int?)null;
return (name, id);
}
return (null, null);
}
}
@@ -0,0 +1,38 @@
using System;
using fNbt;
namespace PCL.Core.Minecraft.Saves.Parsing.Internal;
/// <summary>
/// Alpha ~ 1.2.5 的存档格式。
/// 特征:没有 DataVersion、没有 allowCommands、没有 Difficulty。
/// </summary>
internal sealed class Pre113SaveParser : ISaveParser
{
public SaveFormatVersion FormatVersion => SaveFormatVersion.Pre113;
public bool CanHandle(NbtCompound data, int? dataVersion)
=> dataVersion is null && !data.Contains("allowCommands");
public SaveInfo Parse(string folderPath, NbtCompound data, DateTime createdAt, DateTime modifiedAt)
{
return new SaveInfo
{
LevelName = data.TryGet<NbtString>("LevelName", out var ln) ? ln!.Value : "unknown",
VersionName = null,
VersionId = null,
Seed = NbtReadHelper.TryGetLong(data, "RandomSeed"),
LastPlayedUtc = NbtReadHelper.ReadLastPlayed(data),
Spawn = NbtReadHelper.TryReadSpawnFromFields(data),
GameMode = NbtReadHelper.ReadGameMode(data, out var isHardcore),
Difficulty = null,
IsDifficultyLocked = false,
IsHardcore = isHardcore,
AllowCommands = false,
PlayTime = NbtReadHelper.ReadPlayTime(data),
FolderPath = folderPath,
CreatedAt = createdAt,
ModifiedAt = modifiedAt,
};
}
}
@@ -0,0 +1,26 @@
using System;
using fNbt;
namespace PCL.Core.Minecraft.Saves.Parsing.Internal;
/// <summary>
/// 17w47a(1.13) ~ 1.15.2 的存档格式。
/// 特征:DataVersion 在 [1443, 2536) 之间,新增 DataPacks 字段。
/// </summary>
internal sealed class Version113To1152SaveParser : ISaveParser
{
private readonly ISaveParser _baseParser;
public Version113To1152SaveParser() : this(new Version19To1122SaveParser()) { }
public Version113To1152SaveParser(ISaveParser baseParser) => _baseParser = baseParser;
public SaveFormatVersion FormatVersion => SaveFormatVersion.Version113To1152;
public bool CanHandle(NbtCompound data, int? dataVersion)
=> dataVersion.HasValue
&& dataVersion.Value >= DataVersionBoundaries._17w47a
&& dataVersion.Value < DataVersionBoundaries._20w20a;
public SaveInfo Parse(string folderPath, NbtCompound data, DateTime createdAt, DateTime modifiedAt)
=> _baseParser.Parse(folderPath, data, createdAt, modifiedAt);
}
@@ -0,0 +1,43 @@
using System;
using fNbt;
namespace PCL.Core.Minecraft.Saves.Parsing.Internal;
/// <summary>
/// 20w20a(1.16) ~ 1.21.11 的存档格式。
/// 特征:DataVersion 在 [2536, 4774) 之间。
/// 变更:种子从 Data.RandomSeed 迁移到 Data.WorldGenSettings.seed。
/// </summary>
internal sealed class Version116To1211SaveParser : ISaveParser
{
private readonly ISaveParser _baseParser;
public Version116To1211SaveParser() : this(new Version19To1122SaveParser()) { }
public Version116To1211SaveParser(ISaveParser baseParser) => _baseParser = baseParser;
public SaveFormatVersion FormatVersion => SaveFormatVersion.Version116To1211;
public bool CanHandle(NbtCompound data, int? dataVersion)
=> dataVersion.HasValue
&& dataVersion.Value >= DataVersionBoundaries._20w20a
&& dataVersion.Value < DataVersionBoundaries._261snapshot6;
public SaveInfo Parse(string folderPath, NbtCompound data, DateTime createdAt, DateTime modifiedAt)
{
var baseInfo = _baseParser.Parse(folderPath, data, createdAt, modifiedAt);
return baseInfo with
{
Seed = ReadWorldGenSeed(data),
Spawn = NbtReadHelper.TryReadSpawnFromPos(data)
?? NbtReadHelper.TryReadSpawnFromFields(data),
};
}
internal static long? ReadWorldGenSeed(NbtCompound data)
{
if (data.TryGet<NbtCompound>("WorldGenSettings", out var wgs) &&
wgs!.TryGet<NbtLong>("seed", out var seed))
return seed!.Value;
return NbtReadHelper.TryGetLong(data, "RandomSeed");
}
}
@@ -0,0 +1,38 @@
using System;
using fNbt;
namespace PCL.Core.Minecraft.Saves.Parsing.Internal;
/// <summary>
/// 1.3.1 ~ 1.8.9 的存档格式。
/// 特征:没有 DataVersion,有 allowCommands。
/// </summary>
internal sealed class Version131To189SaveParser : ISaveParser
{
public SaveFormatVersion FormatVersion => SaveFormatVersion.Version131To189;
public bool CanHandle(NbtCompound data, int? dataVersion)
=> dataVersion is null && data.Contains("allowCommands");
public SaveInfo Parse(string folderPath, NbtCompound data, DateTime createdAt, DateTime modifiedAt)
{
return new SaveInfo
{
LevelName = data.TryGet<NbtString>("LevelName", out var ln) ? ln!.Value : "unknown",
VersionName = null,
VersionId = null,
Seed = NbtReadHelper.TryGetLong(data, "RandomSeed"),
LastPlayedUtc = NbtReadHelper.ReadLastPlayed(data),
Spawn = NbtReadHelper.TryReadSpawnFromFields(data),
GameMode = NbtReadHelper.ReadGameMode(data, out _),
Difficulty = NbtReadHelper.ReadDifficultyByte(data),
IsDifficultyLocked = data.TryGet<NbtByte>("DifficultyLocked", out var dl) && dl!.Value == 1,
IsHardcore = data.TryGet<NbtByte>("hardcore", out var hc) && hc!.Value == 1,
AllowCommands = data.TryGet<NbtByte>("allowCommands", out var ac) && ac!.Value == 1,
PlayTime = NbtReadHelper.ReadPlayTime(data),
FolderPath = folderPath,
CreatedAt = createdAt,
ModifiedAt = modifiedAt,
};
}
}
@@ -0,0 +1,30 @@
using System;
using fNbt;
namespace PCL.Core.Minecraft.Saves.Parsing.Internal;
/// <summary>
/// 15w32a(1.9) ~ 1.12.2 的存档格式。
/// 特征:DataVersion >= 100 且 &lt; 1443,新增 DataVersion 和 Version 复合标签。
/// </summary>
internal sealed class Version19To1122SaveParser : ISaveParser
{
private readonly ISaveParser _baseParser;
public Version19To1122SaveParser() : this(new Version131To189SaveParser()) { }
public Version19To1122SaveParser(ISaveParser baseParser) => _baseParser = baseParser;
public SaveFormatVersion FormatVersion => SaveFormatVersion.Version19To1122;
public bool CanHandle(NbtCompound data, int? dataVersion)
=> dataVersion.HasValue
&& dataVersion.Value >= DataVersionBoundaries._15w32a
&& dataVersion.Value < DataVersionBoundaries._17w47a;
public SaveInfo Parse(string folderPath, NbtCompound data, DateTime createdAt, DateTime modifiedAt)
{
var baseInfo = _baseParser.Parse(folderPath, data, createdAt, modifiedAt);
(var versionName, var versionId) = NbtReadHelper.ReadVersion(data);
return baseInfo with { VersionName = versionName, VersionId = versionId };
}
}
@@ -0,0 +1,101 @@
using System;
using System.IO;
using fNbt;
namespace PCL.Core.Minecraft.Saves.Parsing.Internal;
/// <summary>
/// 26.1-snapshot-6 及之后的存档格式(2026 新版本号体系)。
/// 特征:DataVersion >= 4774 或存在 difficulty_settings 复合标签。
/// 变更:
/// - 出生点迁移到 spawn.pos int[3]
/// - 难度迁移到 difficulty_settings 复合标签(字符串型)
/// - 种子可能在外部文件 data/minecraft/world_gen_settings.dat 中
/// </summary>
internal sealed class Version261PlusSaveParser : ISaveParser
{
private readonly ISaveParser _baseParser;
public Version261PlusSaveParser() : this(new Version19To1122SaveParser()) { }
public Version261PlusSaveParser(ISaveParser baseParser) => _baseParser = baseParser;
public SaveFormatVersion FormatVersion => SaveFormatVersion.Version261Plus;
public bool CanHandle(NbtCompound data, int? dataVersion)
=> dataVersion >= DataVersionBoundaries._261snapshot6
|| data.Contains("difficulty_settings");
public SaveInfo Parse(string folderPath, NbtCompound data, DateTime createdAt, DateTime modifiedAt)
{
var baseInfo = _baseParser.Parse(folderPath, data, createdAt, modifiedAt);
var seed = Version116To1211SaveParser.ReadWorldGenSeed(data)
?? ReadSeedFromExternalFile(folderPath);
var spawn = NbtReadHelper.TryReadSpawnFromPos(data)
?? NbtReadHelper.TryReadSpawnFromFields(data);
var difficulty = ReadDifficultySettings(data);
var isHardcore = ReadHardcore(data);
var isLocked = ReadLocked(data);
return baseInfo with
{
Seed = seed,
Spawn = spawn,
Difficulty = difficulty,
IsHardcore = isHardcore,
IsDifficultyLocked = isLocked,
GameMode = isHardcore ? GameMode.Hardcore : baseInfo.GameMode,
};
}
// ── difficulty_settings 复合标签解析 ──
internal static Difficulty? ReadDifficultySettings(NbtCompound data)
{
if (data.TryGet<NbtCompound>("difficulty_settings", out var ds) &&
ds!.TryGet<NbtString>("difficulty", out var diffStr))
{
return diffStr!.Value switch
{
"peaceful" => Difficulty.Peaceful,
"easy" => Difficulty.Easy,
"normal" => Difficulty.Normal,
"hard" => Difficulty.Hard,
_ => null,
};
}
return NbtReadHelper.ReadDifficultyByte(data);
}
internal static bool ReadHardcore(NbtCompound data)
{
if (data.TryGet<NbtCompound>("difficulty_settings", out var ds) &&
ds!.TryGet<NbtByte>("hardcore", out var hc))
return hc!.Value == 1;
return data.TryGet<NbtByte>("hardcore", out var legacyHc) && legacyHc!.Value == 1;
}
internal static bool ReadLocked(NbtCompound data)
{
if (data.TryGet<NbtCompound>("difficulty_settings", out var ds) &&
ds!.TryGet<NbtByte>("locked", out var locked))
return locked!.Value == 1;
return data.TryGet<NbtByte>("DifficultyLocked", out var dl) && dl!.Value == 1;
}
internal static long? ReadSeedFromExternalFile(string folderPath)
{
var externalPath = Path.Combine(folderPath, "data", "minecraft", "world_gen_settings.dat");
if (!File.Exists(externalPath))
return null;
try
{
var nbtFile = new NbtFile(externalPath);
var rootData = nbtFile.RootTag.Get<NbtCompound>("data");
return rootData?.TryGet<NbtLong>("seed", out var seed) == true ? seed!.Value : null;
}
catch { return null; }
}
}
@@ -0,0 +1,52 @@
using System.Collections.Generic;
using System.Linq;
using fNbt;
using PCL.Core.Minecraft.Saves.Parsing.Internal;
namespace PCL.Core.Minecraft.Saves.Parsing;
/// <summary>
/// 解析器工厂 —— 按优先级遍历已注册的解析器,返回第一个能处理给定数据的解析器。
/// 默认注册顺序从高版本到低版本,确保最特化的解析器优先匹配。
/// 可通过构造函数注入自定义解析器列表。
/// </summary>
public sealed class SaveParserFactory
{
private readonly IReadOnlyList<ISaveParser> _parsers;
/// <summary>使用内置的默认解析器列表初始化(从高版本到低版本)。</summary>
public SaveParserFactory()
{
_parsers =
[
new Version261PlusSaveParser(), // >= 26.1-snapshot-6
new Version116To1211SaveParser(), // 1.16 ~ 1.21.11
new Version113To1152SaveParser(), // 1.13 ~ 1.15.2
new Version19To1122SaveParser(), // 1.9 ~ 1.12.2
new Version131To189SaveParser(), // 1.3.1 ~ 1.8.9
new Pre113SaveParser(), // Alpha ~ 1.2.5
];
}
/// <summary>使用自定义解析器列表初始化(支持 DI 注入)。解析器按传入顺序求值。</summary>
public SaveParserFactory(IEnumerable<ISaveParser> customParsers)
{
_parsers = customParsers?.ToArray() ?? [];
}
/// <summary>
/// 查找第一个能处理给定 NBT 数据的解析器。
/// </summary>
/// <param name="data">level.dat 中的 Data 复合标签。</param>
/// <param name="dataVersion">DataVersion 字段值,如果不存在则为 null。</param>
/// <returns>匹配的解析器,未找到时返回 null。</returns>
public ISaveParser? Resolve(NbtCompound data, int? dataVersion)
{
foreach (var parser in _parsers)
{
if (parser.CanHandle(data, dataVersion))
return parser;
}
return null;
}
}