初始化 monorepo: Go后端(7微服务) + Unity客户端(9模块) + 启动器 HTML5原型: Three.js 3D体素世界, Perlin噪声地形, 原版材质, 22种方块 Minecraft创造模式背包: 双栏布局, 拖拽移动物品, 方向性元件引脚 AI助搭策划文档 + 客户端/服务端骨架 + Docker Compose + CI
This commit is contained in:
@@ -0,0 +1,318 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.CompilerServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using fNbt;
|
||||
using PCL.Core.Logging;
|
||||
using PCL.Core.Minecraft.Saves.Editing;
|
||||
using PCL.Core.Minecraft.Saves.Editing.Internal;
|
||||
using PCL.Core.Minecraft.Saves.Exceptions;
|
||||
using PCL.Core.Minecraft.Saves.Parsing;
|
||||
|
||||
namespace PCL.Core.Minecraft.Saves;
|
||||
|
||||
/// <summary>
|
||||
/// 存档管理器 —— 存档系统的统一入口。
|
||||
/// 提供扫描、读取、批量读取和修改存档的功能。
|
||||
/// 可通过构造函数注入自定义的解析器工厂和编辑器列表。
|
||||
/// </summary>
|
||||
public class SaveManager
|
||||
{
|
||||
private readonly SaveParserFactory _parserFactory;
|
||||
private readonly IReadOnlyList<ISaveEditor> _editors;
|
||||
|
||||
/// <summary>
|
||||
/// 创建新的存档管理器。
|
||||
/// </summary>
|
||||
/// <param name="parserFactory">自定义解析器工厂,为 null 时使用默认工厂。</param>
|
||||
/// <param name="customEditors">自定义编辑器列表,为 null 时使用默认编辑器。</param>
|
||||
public SaveManager(
|
||||
SaveParserFactory? parserFactory = null,
|
||||
IEnumerable<ISaveEditor>? customEditors = null)
|
||||
{
|
||||
_parserFactory = parserFactory ?? new SaveParserFactory();
|
||||
_editors = customEditors?.ToArray() ?? [new Pre261SaveEditor(), new Version261PlusSaveEditor()];
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 扫描指定目录下的所有有效存档文件夹,返回按最后游玩时间降序排列的列表。
|
||||
/// 不含 level.dat 的文件夹会被静默跳过。
|
||||
/// </summary>
|
||||
/// <param name="savesPath">存档根目录(通常为 .minecraft/saves)。</param>
|
||||
/// <param name="ct">取消令牌。</param>
|
||||
public async Task<IReadOnlyList<SaveInfo>> ScanSaveFoldersAsync(
|
||||
string savesPath, CancellationToken ct = default)
|
||||
{
|
||||
if (!Directory.Exists(savesPath))
|
||||
return [];
|
||||
|
||||
var folderPaths = Directory.GetDirectories(savesPath);
|
||||
var results = new List<SaveInfo>(folderPaths.Length);
|
||||
|
||||
foreach (var folder in folderPaths)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
try
|
||||
{
|
||||
var info = await LoadSaveAsync(folder, ct).ConfigureAwait(false);
|
||||
if (info is not null)
|
||||
results.Add(info);
|
||||
}
|
||||
catch (SaveNotFoundException)
|
||||
{
|
||||
// 非存档文件夹,静默跳过
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Warn(ex, "Saves", $"扫描存档文件夹失败:{folder}");
|
||||
}
|
||||
}
|
||||
|
||||
return results.OrderByDescending(s => s.LastPlayedUtc).ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 加载指定文件夹中的单个存档。
|
||||
/// </summary>
|
||||
/// <param name="folderPath">存档文件夹的绝对路径。</param>
|
||||
/// <param name="ct">取消令牌。</param>
|
||||
/// <exception cref="SaveNotFoundException">level.dat 缺失。</exception>
|
||||
/// <exception cref="SaveCorruptedException">level.dat 存在但无法解析。</exception>
|
||||
public Task<SaveInfo> LoadSaveAsync(string folderPath, CancellationToken ct = default)
|
||||
{
|
||||
var levelDatPath = ResolveLevelDatPath(folderPath);
|
||||
if (levelDatPath is null)
|
||||
throw new SaveNotFoundException(folderPath);
|
||||
|
||||
return LoadFromPathAsync(folderPath, levelDatPath, ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 批量异步加载存档目录下的所有存档,每解析完一个即通过 IAsyncEnumerable 向外产出。
|
||||
/// 无法加载的存档会被记录日志并跳过,不会中断整个枚举。
|
||||
/// </summary>
|
||||
/// <param name="savesPath">存档根目录。</param>
|
||||
/// <param name="ct">取消令牌。</param>
|
||||
public async IAsyncEnumerable<SaveInfo> LoadSavesAsync(
|
||||
string savesPath,
|
||||
[EnumeratorCancellation] CancellationToken ct = default)
|
||||
{
|
||||
if (!Directory.Exists(savesPath))
|
||||
yield break;
|
||||
|
||||
var folderPaths = Directory.GetDirectories(savesPath);
|
||||
foreach (var folder in folderPaths)
|
||||
{
|
||||
ct.ThrowIfCancellationRequested();
|
||||
|
||||
SaveInfo? info = null;
|
||||
try
|
||||
{
|
||||
info = await LoadSaveAsync(folder, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (SaveNotFoundException)
|
||||
{
|
||||
// 非存档文件夹,跳过
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogWrapper.Warn(ex, "Saves", $"加载存档失败:{folder}");
|
||||
}
|
||||
|
||||
if (info is not null)
|
||||
yield return info;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 将指定的修改应用到某个存档。
|
||||
/// </summary>
|
||||
/// <param name="folderPath">存档文件夹的绝对路径。</param>
|
||||
/// <param name="changes">要应用的修改集合。</param>
|
||||
/// <param name="ct">取消令牌。</param>
|
||||
/// <returns>至少有一项修改成功写入时返回 true。</returns>
|
||||
/// <exception cref="SaveNotFoundException">level.dat 缺失。</exception>
|
||||
/// <exception cref="SaveCorruptedException">level.dat 解析或写入失败。</exception>
|
||||
public async Task<bool> ApplyChangesAsync(
|
||||
string folderPath, SaveChanges changes, CancellationToken ct = default)
|
||||
{
|
||||
// 无修改时直接返回,避免不必要的文件 IO
|
||||
if (changes.IsEmpty)
|
||||
return false;
|
||||
|
||||
var levelDatPath = ResolveLevelDatPath(folderPath)
|
||||
?? throw new SaveNotFoundException(folderPath);
|
||||
|
||||
// 一次解析 level.dat,提取 Data 复合标签和 DataVersion
|
||||
NbtFile nbtFile;
|
||||
NbtCompound data;
|
||||
try
|
||||
{
|
||||
nbtFile = new NbtFile();
|
||||
await Task.Run(() =>
|
||||
{
|
||||
using var fs = new FileStream(levelDatPath, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true);
|
||||
nbtFile.LoadFromStream(fs, NbtCompression.AutoDetect);
|
||||
}, ct).ConfigureAwait(false);
|
||||
|
||||
data = nbtFile.RootTag.Get<NbtCompound>("Data")
|
||||
?? throw new InvalidDataException("level.dat 中缺少 Data 复合标签");
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
throw new SaveCorruptedException(folderPath, $"解析 level.dat 失败:'{levelDatPath}'", ex);
|
||||
}
|
||||
|
||||
var dataVersion = ReadDataVersionFromCompound(data);
|
||||
|
||||
// 匹配编辑器,执行内存修改
|
||||
foreach (var editor in _editors)
|
||||
{
|
||||
if (editor.CanHandle(dataVersion))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!editor.ApplyChanges(data, changes))
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex) when (ex is not OperationCanceledException)
|
||||
{
|
||||
throw new SaveCorruptedException(folderPath,
|
||||
$"应用修改失败:'{folderPath}'", ex);
|
||||
}
|
||||
|
||||
// 原子写入:temp → 备份 → 重命名
|
||||
await WriteLevelDatAtomicallyAsync(levelDatPath, nbtFile, ct).ConfigureAwait(false);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// 找不到匹配编辑器时抛出异常,与"无修改"(返回 false)区分
|
||||
throw new SaveCorruptedException(folderPath,
|
||||
$"找不到匹配的存档编辑器(DataVersion: {dataVersion})");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 原子写入 level.dat:先将 NBT 写入临时文件,再通过重命名完成原子替换。
|
||||
/// 始终写入 level.dat(即使从 level.dat_old 回退读取)。
|
||||
/// </summary>
|
||||
private static async Task WriteLevelDatAtomicallyAsync(
|
||||
string sourcePath, NbtFile nbtFile, CancellationToken ct)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(sourcePath)!;
|
||||
var tempPath = Path.Combine(dir, $"level{Guid.NewGuid():N}.dat");
|
||||
var backupPath = Path.Combine(dir, "level.dat_old");
|
||||
var targetPath = Path.Combine(dir, "level.dat");
|
||||
|
||||
try
|
||||
{
|
||||
// 1. 写入临时文件
|
||||
await Task.Run(() =>
|
||||
{
|
||||
using var fs = new FileStream(tempPath, FileMode.CreateNew, FileAccess.Write,
|
||||
FileShare.None, 4096, true);
|
||||
nbtFile.SaveToStream(fs, NbtCompression.GZip);
|
||||
}, ct).ConfigureAwait(false);
|
||||
|
||||
// 2. 仅当从 level.dat 读取时,才备份当前 level.dat → level.dat_old;
|
||||
// 若从 level.dat_old 回退读取,说明 level.dat 已损坏/不存在,跳过备份。
|
||||
if (sourcePath == targetPath && File.Exists(targetPath))
|
||||
{
|
||||
File.Move(targetPath, backupPath, overwrite: true);
|
||||
}
|
||||
|
||||
// 3. 重命名临时文件 → level.dat
|
||||
File.Move(tempPath, targetPath);
|
||||
}
|
||||
catch
|
||||
{
|
||||
TryDelete(tempPath);
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
private static void TryDelete(string path)
|
||||
{
|
||||
try { if (File.Exists(path)) File.Delete(path); } catch { /* best-effort */ }
|
||||
}
|
||||
|
||||
/// <summary>核心加载逻辑:读取 level.dat → 解析 DataVersion → 匹配解析器 → 构建 SaveInfo。</summary>
|
||||
private async Task<SaveInfo> LoadFromPathAsync(
|
||||
string folderPath, string levelDatPath, CancellationToken ct)
|
||||
{
|
||||
NbtFile nbtFile;
|
||||
try
|
||||
{
|
||||
nbtFile = await LoadNbtFileAsync(levelDatPath, ct).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
throw new SaveCorruptedException(folderPath,
|
||||
$"解析 level.dat 失败:'{levelDatPath}'", ex);
|
||||
}
|
||||
|
||||
// level.dat 根标签下必须有 Data 复合标签
|
||||
var data = nbtFile.RootTag.Get<NbtCompound>("Data")
|
||||
?? throw new SaveCorruptedException(folderPath,
|
||||
$"level.dat 中缺少 Data 复合标签:{levelDatPath}");
|
||||
|
||||
var dataVersion = ReadDataVersionFromCompound(data);
|
||||
var createdAt = Directory.GetCreationTimeUtc(folderPath);
|
||||
var modifiedAt = File.GetLastWriteTimeUtc(levelDatPath);
|
||||
|
||||
var parser = _parserFactory.Resolve(data, dataVersion)
|
||||
?? throw new SaveCorruptedException(folderPath,
|
||||
$"找不到与存档 '{folderPath}' 匹配的解析器(DataVersion: {dataVersion})");
|
||||
|
||||
return parser.Parse(folderPath, data, createdAt, modifiedAt);
|
||||
}
|
||||
|
||||
/// <summary>以异步方式加载 NBT 文件,自动检测压缩格式。</summary>
|
||||
private static async Task<NbtFile> LoadNbtFileAsync(string path, CancellationToken ct)
|
||||
{
|
||||
var nbtFile = new NbtFile();
|
||||
await Task.Run(() =>
|
||||
{
|
||||
using var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.Read, 4096, true);
|
||||
nbtFile.LoadFromStream(fs, NbtCompression.AutoDetect);
|
||||
}, ct).ConfigureAwait(false);
|
||||
return nbtFile;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 确定 level.dat 的路径。
|
||||
/// 优先查找 level.dat,如果不存在则查找 level.dat_old 作为备份。
|
||||
/// </summary>
|
||||
private static string? ResolveLevelDatPath(string folderPath)
|
||||
{
|
||||
var primary = Path.Combine(folderPath, "level.dat");
|
||||
if (File.Exists(primary))
|
||||
return primary;
|
||||
var backup = Path.Combine(folderPath, "level.dat_old");
|
||||
return File.Exists(backup) ? backup : null;
|
||||
}
|
||||
|
||||
/// <summary>从 Data 复合标签中读取 DataVersion 字段。</summary>
|
||||
private static int? ReadDataVersionFromCompound(NbtCompound data)
|
||||
{
|
||||
if (data.TryGet<NbtInt>("DataVersion", out var dv))
|
||||
return dv!.Value;
|
||||
return null;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user