755 lines
32 KiB
C#
755 lines
32 KiB
C#
/*
|
||
* @Author: Codex
|
||
* @Description: 新版游戏存档索引与读写
|
||
* @Date: 2026年06月02日 星期二 00:00:00
|
||
* @Modify:
|
||
*/
|
||
|
||
using System;
|
||
using System.Collections.Generic;
|
||
using System.IO;
|
||
using Logic.CrashSight;
|
||
using RuntimeData;
|
||
using TH1_Logic.Core;
|
||
using TH1_Logic.MatchConfig;
|
||
using TH1_Logic.Tools;
|
||
using UnityEngine;
|
||
|
||
|
||
namespace TH1_Logic.GameArchive
|
||
{
|
||
// 新存档系统的文件类型。
|
||
// 注意:这里说的是“磁盘文件归类”,不是 GameRecord 的记录分类。
|
||
// GameRecordKind 负责 UI/数据层看到的三类记录:结束记录、手动存档、快速存档。
|
||
public enum GameArchiveFileKind
|
||
{
|
||
// 开局快照。每条新版 GameRecord 都会索引一条 begin,用来说明这条记录属于哪一局。
|
||
Begin,
|
||
// 自动快速继续存档。全局只保留 quick.dat,一局一局覆盖。
|
||
QuickContinue,
|
||
// 玩家手动创建的通用继续存档。可以有多条,每条有自己的 archiveId。
|
||
Continue,
|
||
// 结束快照。游戏结束时写入,用于完整战绩记录索引。
|
||
End
|
||
}
|
||
|
||
// 新版存档系统的统一入口。
|
||
//
|
||
// 设计关系:
|
||
// - MapData 文件是真正的大存档内容,存在 Config/GameArchives 下。
|
||
// - GameRecord 只是索引和展示信息,不直接存完整 MapData。
|
||
// - 一条 record 一定指向 BeginArchiveId,可能指向 ContinueArchiveId 或 EndArchiveId。
|
||
// - 继续游戏只允许从 Quick/Manual record 读取 ContinueArchiveId,Ended record 不能继续。
|
||
public class GameArchiveManager
|
||
{
|
||
// 快速存档只有一份,所以 recordId 和文件名都使用固定 id。
|
||
// 对应磁盘文件是 Config/GameArchives/quick_continue/quick.dat。
|
||
public const string QuickArchiveId = "quick";
|
||
public static GameArchiveManager Instance = new GameArchiveManager();
|
||
|
||
private const string ArchiveRootFolderName = "GameArchives";
|
||
|
||
// 当前正在游玩的这一局对应的 begin archive id。
|
||
// 后续每回合 quick、玩家手动 manual、最终 end 都会挂到这个 begin 上。
|
||
// 从旧存档入口继续时没有 record 可用,会自动创建一个新的 begin。
|
||
// 从 record 入口继续时会沿用 record.BeginArchiveId。
|
||
private string _currentBeginArchiveId;
|
||
|
||
public string CurrentBeginArchiveId => _currentBeginArchiveId;
|
||
|
||
// 开始一局游戏时调用:写入 begin 文件,并把当前会话绑定到这个 begin。
|
||
//
|
||
// 调用时机:
|
||
// - 单机新开局:地图生成完成后。
|
||
// - 联机房主新开局:GameStart 广播成功后,避免网络开始失败却留下 begin。
|
||
// - 存档兜底:如果当前局缺少 begin,会先补一条 begin,避免 quick/manual/end 没有索引。
|
||
public bool SaveBeginArchive(MapData map)
|
||
{
|
||
// 先清空旧会话 id,避免本次 begin 写入失败时误把后续存档挂到上一局。
|
||
_currentBeginArchiveId = string.Empty;
|
||
if (!IsMapSaveable(map)) return false;
|
||
|
||
var beginArchiveId = NewArchiveId();
|
||
if (!SaveArchive(map, GameArchiveFileKind.Begin, beginArchiveId)) return false;
|
||
|
||
_currentBeginArchiveId = beginArchiveId;
|
||
return true;
|
||
}
|
||
|
||
// 回合轮转时调用:写入/覆盖 quick_continue/quick.dat,并更新唯一的快速存档 record。
|
||
//
|
||
// 快速存档规则:
|
||
// - 只维护一条记录,UpsertQuickRecord 会先删旧 quick record 再写新 record。
|
||
// - 文件也只维护一份 quick.dat,每次保存覆盖。
|
||
// - record.BeginArchiveId 说明 quick 属于哪一局。
|
||
// - record.ContinueArchiveId 固定为 quick,用于继续时找到 quick.dat。
|
||
public bool SaveQuickContinueRecord(MapData map)
|
||
{
|
||
if (!EnsureCurrentBeginArchive(map)) return false;
|
||
if (!SaveArchive(map, GameArchiveFileKind.QuickContinue, QuickArchiveId)) return false;
|
||
|
||
var record = CreateRecord(
|
||
map,
|
||
GameRecordKind.Quick,
|
||
"快速存档",
|
||
_currentBeginArchiveId,
|
||
QuickArchiveId,
|
||
string.Empty);
|
||
record.RecordId = QuickArchiveId;
|
||
GameRecordManager.Instance.UpsertQuickRecord(record);
|
||
return true;
|
||
}
|
||
|
||
// 玩家主动创建通用存档的上层接口。
|
||
// UI 调这个就够了:当前 MapData、文件 id、record 索引都会由存档系统内部处理。
|
||
public bool SaveManualGameRecord(string recordName)
|
||
{
|
||
return SaveManualContinueRecord(Main.MapData, recordName);
|
||
}
|
||
|
||
// 玩家主动手动保存时调用:创建一条通用 continue 文件和一条 Manual record。
|
||
//
|
||
// 通用存档规则:
|
||
// - 可以有任意多条,所以每条 continue 文件都使用新的 archiveId。
|
||
// - recordName 是玩家自定义名字;空名字会归一成“手动存档”。
|
||
// - 这里只创建,不自动删除;清理必须走 CleanupGameRecord。
|
||
public bool SaveManualContinueRecord(MapData map, string recordName)
|
||
{
|
||
if (!EnsureCurrentBeginArchive(map)) return false;
|
||
|
||
var continueArchiveId = NewArchiveId();
|
||
if (!SaveArchive(map, GameArchiveFileKind.Continue, continueArchiveId)) return false;
|
||
|
||
var record = CreateRecord(
|
||
map,
|
||
GameRecordKind.Manual,
|
||
NormalizeManualRecordName(recordName),
|
||
_currentBeginArchiveId,
|
||
continueArchiveId,
|
||
string.Empty);
|
||
GameRecordManager.Instance.AddRecord(record);
|
||
return true;
|
||
}
|
||
|
||
// 游戏结束时调用:写入 end 文件,并创建一条 Ended record。
|
||
//
|
||
// 结束规则:
|
||
// - begin/end 文件都会保留,Ended record 通过 EndArchiveId 指向 end。
|
||
// - 教程不进历史战绩,所以仍然只写 end 文件,不创建 Ended record。
|
||
// - 当前局结束后,当前 begin 对应的快速存档已经没有继续意义,因此一起清掉。
|
||
// - 手动通用存档不在这里删除,必须由玩家主动删。
|
||
public bool SaveEndRecord(MapData map)
|
||
{
|
||
if (!EnsureCurrentBeginArchive(map)) return false;
|
||
|
||
var endArchiveId = NewArchiveId();
|
||
if (!SaveArchive(map, GameArchiveFileKind.End, endArchiveId)) return false;
|
||
|
||
if (map.MapConfig?.MatchSettlement != MatchSettlementType.Tutor)
|
||
{
|
||
var record = CreateRecord(
|
||
map,
|
||
GameRecordKind.Ended,
|
||
string.Empty,
|
||
_currentBeginArchiveId,
|
||
string.Empty,
|
||
endArchiveId);
|
||
GameRecordManager.Instance.AddRecord(record);
|
||
}
|
||
|
||
DeleteQuickContinueRecord(_currentBeginArchiveId);
|
||
return true;
|
||
}
|
||
|
||
// 给外部 UI/逻辑做“这条 record 现在还能不能继续”的轻量检查。
|
||
// 这里不会扫描所有存档,只验证传入这一条 record 指向的 continue 文件。
|
||
public bool HasUsableResumeArchive(GameRecord record)
|
||
{
|
||
return TryLoadContinueArchive(record, out _);
|
||
}
|
||
|
||
// 当前是否存在可用的快速继续记录。
|
||
// 单机菜单传 NetMode.Single;联机房主继续传 NetMode.Multi。
|
||
public bool HasQuickResumeArchive(NetMode netMode)
|
||
{
|
||
return GetQuickResumeRecord(netMode) != null;
|
||
}
|
||
|
||
// 获取当前唯一 quick record,并确认它属于指定单/联机模式且文件可读。
|
||
public GameRecord GetQuickResumeRecord(NetMode netMode)
|
||
{
|
||
var record = GameRecordManager.Instance.GetQuickRecord();
|
||
if (record == null || record.NetMode != netMode) return null;
|
||
return HasUsableResumeArchive(record) ? record : null;
|
||
}
|
||
|
||
// 通过一条 Quick/Manual record 读取可继续的 MapData。
|
||
//
|
||
// 返回 true 的含义:
|
||
// - record 类型允许继续。
|
||
// - record 有 begin/continue 索引。
|
||
// - continue 文件存在,并且反序列化后核心数据完整。
|
||
// - 存档里的 NetMode 与 record.NetMode 一致,单机/联机不会串。
|
||
public bool TryLoadContinueArchive(GameRecord record, out MapData mapData)
|
||
{
|
||
mapData = null;
|
||
if (!CanRecordResume(record)) return false;
|
||
|
||
var archiveKind = record.RecordKind == GameRecordKind.Quick
|
||
? GameArchiveFileKind.QuickContinue
|
||
: GameArchiveFileKind.Continue;
|
||
mapData = ReadArchive(archiveKind, record.ContinueArchiveId, record.NetMode);
|
||
return mapData != null;
|
||
}
|
||
|
||
// 读取 record 对应的 begin 快照,供回放、OSS 上传等需要“开局状态”的系统使用。
|
||
public bool TryLoadBeginArchive(GameRecord record, out MapData mapData)
|
||
{
|
||
mapData = null;
|
||
if (record == null) return false;
|
||
// Ended 清理后 record 仍可作为历史展示行存在,但不再拥有 begin/end 文件索引。
|
||
if (record.RecordKind == GameRecordKind.Ended && record.DiscardArchiveIndex) return false;
|
||
if (record.NetMode != NetMode.Single && record.NetMode != NetMode.Multi) return false;
|
||
if (string.IsNullOrEmpty(record.BeginArchiveId)) return false;
|
||
|
||
mapData = ReadArchive(GameArchiveFileKind.Begin, record.BeginArchiveId, record.NetMode);
|
||
return mapData != null;
|
||
}
|
||
|
||
// 读取 record 对应的 end 快照。只有 Ended record 通常会有 EndArchiveId。
|
||
public bool TryLoadEndArchive(GameRecord record, out MapData mapData)
|
||
{
|
||
mapData = null;
|
||
if (record == null) return false;
|
||
// 与 map 清理规则保持一致:丢弃索引的 Ended record 不能再读取 end 文件。
|
||
if (record.RecordKind == GameRecordKind.Ended && record.DiscardArchiveIndex) return false;
|
||
if (record.NetMode != NetMode.Single && record.NetMode != NetMode.Multi) return false;
|
||
if (string.IsNullOrEmpty(record.EndArchiveId)) return false;
|
||
|
||
mapData = ReadArchive(GameArchiveFileKind.End, record.EndArchiveId, record.NetMode);
|
||
return mapData != null;
|
||
}
|
||
|
||
// 读取当前会话的 begin。游戏结束上传 OSS 时,endMap 是当前局,begin id 仍保存在这里。
|
||
public bool TryLoadCurrentBeginArchive(NetMode expectedMode, out MapData mapData)
|
||
{
|
||
mapData = null;
|
||
if (string.IsNullOrEmpty(_currentBeginArchiveId)) return false;
|
||
if (expectedMode != NetMode.Single && expectedMode != NetMode.Multi) return false;
|
||
|
||
mapData = ReadArchive(GameArchiveFileKind.Begin, _currentBeginArchiveId, expectedMode);
|
||
return mapData != null;
|
||
}
|
||
|
||
// 根据 archiveId 获取实际文件路径,优先主文件,主文件不存在时返回 .bak。
|
||
// 这个接口用于 BugReport/回放编辑器打包和展示文件名,不负责反序列化。
|
||
public bool TryGetArchivePath(GameArchiveFileKind archiveKind, string archiveId, out string path)
|
||
{
|
||
path = GetArchivePath(archiveKind, archiveId);
|
||
if (path == null) return false;
|
||
if (File.Exists(path)) return true;
|
||
|
||
var backupPath = path + ".bak";
|
||
if (!File.Exists(backupPath)) return false;
|
||
|
||
path = backupPath;
|
||
return true;
|
||
}
|
||
|
||
// record 继续成功前设置当前会话 begin。
|
||
// 后续回合 quick、玩家 manual、最终 end 都会继续挂到原 begin 下。
|
||
public void SetActiveRecord(GameRecord record)
|
||
{
|
||
if (record == null) return;
|
||
_currentBeginArchiveId = record.BeginArchiveId;
|
||
}
|
||
|
||
// 用于继续流程失败时恢复旧会话 id。
|
||
// 正常业务一般不要直接调用。
|
||
public void SetCurrentBeginArchiveId(string beginArchiveId)
|
||
{
|
||
_currentBeginArchiveId = beginArchiveId;
|
||
}
|
||
|
||
// 删除快速存档 record 和 quick.dat。
|
||
//
|
||
// beginArchiveId 不为空时,只允许删除同一局的 quick。
|
||
// 这样某局结束时不会误删另一局刚写下的快速存档。
|
||
public bool DeleteQuickContinueRecord(string beginArchiveId = null)
|
||
{
|
||
var quickRecord = GameRecordManager.Instance.GetQuickRecord();
|
||
if (!string.IsNullOrEmpty(beginArchiveId)
|
||
&& quickRecord != null
|
||
&& quickRecord.BeginArchiveId != beginArchiveId)
|
||
{
|
||
return false;
|
||
}
|
||
|
||
DeleteArchive(GameArchiveFileKind.QuickContinue, QuickArchiveId);
|
||
return GameRecordManager.Instance.RemoveQuickRecord(beginArchiveId);
|
||
}
|
||
|
||
// 用户主动清理指定 record。这里只动 record 状态/索引,不清理 MapData 文件。
|
||
//
|
||
// - Quick:自动快速存档由回合保存和游戏结束流程维护,这个接口不动它。
|
||
// - Manual:直接删除 record;对应 continue/begin 文件等下一阶段无引用清理再回收。
|
||
// - Ended:保留历史展示行,只丢弃 begin/end 索引,等待 map 清理回收文件。
|
||
public bool CleanupGameRecord(GameRecord record)
|
||
{
|
||
if (record == null) return false;
|
||
|
||
switch (record.RecordKind)
|
||
{
|
||
case GameRecordKind.Quick:
|
||
// Quick 是唯一自动存档,用户清理 record 时不删除、不标记。
|
||
return false;
|
||
case GameRecordKind.Manual:
|
||
// Manual 本身就是可继续入口;删除 record 后它自然不再保护任何 map 文件。
|
||
return GameRecordManager.Instance.RemoveRecord(record);
|
||
case GameRecordKind.Ended:
|
||
// Ended 需要继续展示战绩,所以只让它停止保护 begin/end 文件。
|
||
return GameRecordManager.Instance.DiscardEndedRecordArchiveIndex(record);
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 第二阶段 map 清理:删除所有没有有效 record 索引保护的本地 MapData 文件。
|
||
//
|
||
// Manual 清理会先删除 record,所以这里天然不会再看到它;
|
||
// Ended 清理只标记 DiscardArchiveIndex,所以 BuildReferencedArchiveKeySet 会显式跳过它。
|
||
// 覆盖新版 GameArchives,以及旧版 Config/map_archive_* 和早期 persistentDataPath/map_archive_*。
|
||
public int CleanupUnlinkedLocalMapData()
|
||
{
|
||
var referencedArchiveKeys = BuildReferencedArchiveKeySet();
|
||
var deletedCount = 0;
|
||
|
||
foreach (GameArchiveFileKind archiveKind in Enum.GetValues(typeof(GameArchiveFileKind)))
|
||
{
|
||
deletedCount += DeleteUnlinkedArchiveFiles(archiveKind, referencedArchiveKeys);
|
||
}
|
||
|
||
deletedCount += DeleteLegacyLocalMapData();
|
||
return deletedCount;
|
||
}
|
||
|
||
// 确保当前局已经有 begin。
|
||
// 正常新开局或 record 继续都会先绑定 begin;这里主要兜底异常调用顺序。
|
||
private bool EnsureCurrentBeginArchive(MapData map)
|
||
{
|
||
return !string.IsNullOrEmpty(_currentBeginArchiveId) || SaveBeginArchive(map);
|
||
}
|
||
|
||
// 从当前 MapData 导出一条 GameRecord,并填充新版存档索引字段。
|
||
//
|
||
// 重点:record 不是完整存档,它只是:
|
||
// - 展示信息:模式、回合、时间、玩家、分数等。
|
||
// - 索引信息:BeginArchiveId / ContinueArchiveId / EndArchiveId。
|
||
private GameRecord CreateRecord(
|
||
MapData map,
|
||
GameRecordKind recordKind,
|
||
string recordName,
|
||
string beginArchiveId,
|
||
string continueArchiveId,
|
||
string endArchiveId)
|
||
{
|
||
var record = map.ExportGameRecord();
|
||
record.RecordKind = recordKind;
|
||
record.RecordId = recordKind == GameRecordKind.Quick ? QuickArchiveId : NewArchiveId();
|
||
record.RecordName = recordName ?? string.Empty;
|
||
record.BeginArchiveId = beginArchiveId ?? string.Empty;
|
||
record.ContinueArchiveId = continueArchiveId ?? string.Empty;
|
||
record.EndArchiveId = endArchiveId ?? string.Empty;
|
||
return record;
|
||
}
|
||
|
||
// 真正写 MapData 文件的底层方法。
|
||
// 这里复用 MapData.SerializeArchiveBytes,保证新旧存档格式的压缩/兼容编码一致。
|
||
private bool SaveArchive(MapData map, GameArchiveFileKind archiveKind, string archiveId)
|
||
{
|
||
if (!IsMapSaveable(map)) return false;
|
||
var path = GetArchivePath(archiveKind, archiveId);
|
||
if (path == null) return false;
|
||
|
||
try
|
||
{
|
||
byte[] bytes = MapData.SerializeArchiveBytes(map);
|
||
return FileTools.SafeWriteFile(path, bytes);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
LogSystem.LogError($"[GameArchive] 保存存档失败: {archiveKind}/{archiveId} | {ex.Message}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 从磁盘读取某个 archiveId 对应的 MapData,并做基本可用性校验。
|
||
// SafeWriteFile 会保留 .bak,所以主文件坏了时这里会尝试读备份。
|
||
private MapData ReadArchive(GameArchiveFileKind archiveKind, string archiveId, NetMode expectedMode)
|
||
{
|
||
var path = GetArchivePath(archiveKind, archiveId);
|
||
if (path == null) return null;
|
||
|
||
var mapData = ReadArchiveFile(path);
|
||
if (mapData == null && !path.EndsWith(".bak", StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
var backupPath = path + ".bak";
|
||
if (File.Exists(backupPath))
|
||
{
|
||
LogSystem.LogWarning($"[GameArchive] 读取存档失败,尝试读取备份: {backupPath}");
|
||
mapData = ReadArchiveFile(backupPath);
|
||
}
|
||
}
|
||
|
||
if (!IsMapReadable(mapData, expectedMode)) return null;
|
||
// 投降后的 continue 不允许继续;end 文件不走继续入口,所以不受这个限制。
|
||
if (archiveKind != GameArchiveFileKind.End && mapData.PlayerMap.SelfPlayerData?.IsSurrender == true)
|
||
return null;
|
||
|
||
return mapData;
|
||
}
|
||
|
||
// 只负责读文件和反序列化,不做业务校验。
|
||
// 业务校验统一在 ReadArchive / IsMapReadable 里做。
|
||
private MapData ReadArchiveFile(string path)
|
||
{
|
||
if (!File.Exists(path)) return null;
|
||
|
||
try
|
||
{
|
||
byte[] bytes = File.ReadAllBytes(path);
|
||
return MapData.DeserializeArchiveBytes(bytes);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
LogSystem.LogError($"[GameArchive] 读取存档失败: {path} | {ex.Message}");
|
||
return null;
|
||
}
|
||
}
|
||
|
||
// 判断一条 record 是否“理论上可以继续”。
|
||
// 这里只检查 record 自身字段;文件是否存在、内容是否完整,由 TryLoadContinueArchive 继续验证。
|
||
private bool CanRecordResume(GameRecord record)
|
||
{
|
||
if (record == null) return false;
|
||
if (record.RecordKind != GameRecordKind.Quick && record.RecordKind != GameRecordKind.Manual)
|
||
return false;
|
||
if (record.NetMode != NetMode.Single && record.NetMode != NetMode.Multi) return false;
|
||
if (string.IsNullOrEmpty(record.BeginArchiveId)) return false;
|
||
if (string.IsNullOrEmpty(record.ContinueArchiveId)) return false;
|
||
return true;
|
||
}
|
||
|
||
// 判断 MapData 是否适合写入新版存档。
|
||
// 这里排除旁观者和反序列化缺核心数据的坏档,避免把不可用内容写进新系统。
|
||
private bool IsMapSaveable(MapData map)
|
||
{
|
||
return map != null
|
||
&& map.Net != null
|
||
&& map.Net.Mode != NetMode.Spectator
|
||
&& !map.DeserializedMissingCriticalData
|
||
&& map.MapConfig != null
|
||
&& map.GridMap != null
|
||
&& map.PlayerMap != null
|
||
&& map.CityMap != null
|
||
&& map.UnitMap != null;
|
||
}
|
||
|
||
// 读档后校验:除完整性外,还要确认单机/联机模式没有串档。
|
||
private bool IsMapReadable(MapData map, NetMode expectedMode)
|
||
{
|
||
if (!IsMapSaveable(map)) return false;
|
||
if (map.Net.Mode != expectedMode)
|
||
{
|
||
LogSystem.LogWarning($"[GameArchive] 存档模式不匹配: expect={expectedMode}, actual={map.Net.Mode}");
|
||
return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
// 删除一个 archive 文件及 SafeWriteFile 可能留下的备份/临时文件。
|
||
private void DeleteArchive(GameArchiveFileKind archiveKind, string archiveId)
|
||
{
|
||
var path = GetArchivePath(archiveKind, archiveId);
|
||
if (path == null) return;
|
||
|
||
DeleteFileIfExists(path);
|
||
DeleteFileIfExists(path + ".bak");
|
||
DeleteFileIfExists(path + ".tmp");
|
||
}
|
||
|
||
private bool DeleteFileIfExists(string path)
|
||
{
|
||
try
|
||
{
|
||
if (!File.Exists(path)) return false;
|
||
|
||
File.Delete(path);
|
||
return true;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
LogSystem.LogError($"[GameArchive] 删除存档失败: {path} | {ex.Message}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
// 建立“仍被 record 保护”的 archive 集合。
|
||
//
|
||
// 只有进入这个集合的 begin/quick_continue/continue/end 文件才会被保留;
|
||
// 被清理过的 Ended record 虽然还在 Records 里,但已经主动放弃索引保护,必须跳过。
|
||
private HashSet<string> BuildReferencedArchiveKeySet()
|
||
{
|
||
var archiveKeys = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
var records = GameRecordManager.Instance.GameRecordData?.Records;
|
||
if (records != null)
|
||
{
|
||
foreach (var record in records)
|
||
{
|
||
if (record == null) continue;
|
||
if (record.RecordKind == GameRecordKind.Ended && record.DiscardArchiveIndex) continue;
|
||
|
||
AddArchiveKey(archiveKeys, GameArchiveFileKind.Begin, record.BeginArchiveId);
|
||
// Quick 的 continue 文件固定在 quick_continue/quick.dat;Manual 的 continue 文件在 continue/{id}.dat。
|
||
if (record.RecordKind == GameRecordKind.Quick)
|
||
AddArchiveKey(archiveKeys, GameArchiveFileKind.QuickContinue, record.ContinueArchiveId);
|
||
else
|
||
AddArchiveKey(archiveKeys, GameArchiveFileKind.Continue, record.ContinueArchiveId);
|
||
AddArchiveKey(archiveKeys, GameArchiveFileKind.End, record.EndArchiveId);
|
||
}
|
||
}
|
||
|
||
AddArchiveKey(archiveKeys, GameArchiveFileKind.Begin, _currentBeginArchiveId);
|
||
return archiveKeys;
|
||
}
|
||
|
||
private void AddArchiveKey(HashSet<string> archiveKeys, GameArchiveFileKind archiveKind, string archiveId)
|
||
{
|
||
if (!IsValidArchiveId(archiveId)) return;
|
||
archiveKeys.Add(GetArchiveKey(archiveKind, archiveId));
|
||
}
|
||
|
||
private string GetArchiveKey(GameArchiveFileKind archiveKind, string archiveId)
|
||
{
|
||
return $"{archiveKind}:{archiveId}";
|
||
}
|
||
|
||
private int DeleteUnlinkedArchiveFiles(GameArchiveFileKind archiveKind, HashSet<string> referencedArchiveKeys)
|
||
{
|
||
var directory = GetArchiveDirectory(archiveKind);
|
||
if (!Directory.Exists(directory)) return 0;
|
||
|
||
var deletedCount = 0;
|
||
try
|
||
{
|
||
foreach (var path in Directory.EnumerateFiles(directory, "*", SearchOption.TopDirectoryOnly))
|
||
{
|
||
if (!TryGetArchiveIdFromFileName(path, out var archiveId)) continue;
|
||
if (referencedArchiveKeys.Contains(GetArchiveKey(archiveKind, archiveId))) continue;
|
||
if (DeleteFileIfExists(path)) deletedCount++;
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
LogSystem.LogError($"[GameArchive] 清理存档目录失败: {directory} | {ex.Message}");
|
||
}
|
||
|
||
return deletedCount;
|
||
}
|
||
|
||
private bool TryGetArchiveIdFromFileName(string path, out string archiveId)
|
||
{
|
||
archiveId = string.Empty;
|
||
var fileName = Path.GetFileName(path);
|
||
if (string.IsNullOrEmpty(fileName)) return false;
|
||
|
||
fileName = StripArchiveSidecarSuffix(fileName);
|
||
if (!fileName.EndsWith(".dat", StringComparison.OrdinalIgnoreCase)) return false;
|
||
|
||
archiveId = fileName.Substring(0, fileName.Length - ".dat".Length);
|
||
return IsValidArchiveId(archiveId);
|
||
}
|
||
|
||
private string StripArchiveSidecarSuffix(string fileName)
|
||
{
|
||
if (fileName.EndsWith(".tmp", StringComparison.OrdinalIgnoreCase))
|
||
fileName = fileName.Substring(0, fileName.Length - ".tmp".Length);
|
||
if (fileName.EndsWith(".bak", StringComparison.OrdinalIgnoreCase))
|
||
fileName = fileName.Substring(0, fileName.Length - ".bak".Length);
|
||
return fileName;
|
||
}
|
||
|
||
private int DeleteLegacyLocalMapData()
|
||
{
|
||
var deletedCount = 0;
|
||
var configRoot = GetConfigRootDirectory();
|
||
var persistentRoot = Path.GetFullPath(Application.persistentDataPath);
|
||
|
||
deletedCount += DeleteLegacyMapArchiveFilesInDirectory(configRoot, true);
|
||
if (!string.Equals(configRoot, persistentRoot, StringComparison.OrdinalIgnoreCase))
|
||
deletedCount += DeleteLegacyMapArchiveFilesInDirectory(persistentRoot, false);
|
||
deletedCount += DeleteLegacyArchiveSubDirectoryFiles(configRoot);
|
||
return deletedCount;
|
||
}
|
||
|
||
private int DeleteLegacyMapArchiveFilesInDirectory(string directory, bool includeLooseArchiveNames)
|
||
{
|
||
if (!Directory.Exists(directory)) return 0;
|
||
|
||
var deletedCount = 0;
|
||
try
|
||
{
|
||
foreach (var path in Directory.EnumerateFiles(directory, "*", SearchOption.TopDirectoryOnly))
|
||
{
|
||
if (!IsLegacyMapArchiveFile(path, includeLooseArchiveNames)) continue;
|
||
if (DeleteFileIfExists(path)) deletedCount++;
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
LogSystem.LogError($"[GameArchive] 清理旧存档目录失败: {directory} | {ex.Message}");
|
||
}
|
||
|
||
return deletedCount;
|
||
}
|
||
|
||
private int DeleteLegacyArchiveSubDirectoryFiles(string configRoot)
|
||
{
|
||
var deletedCount = 0;
|
||
var legacyFolders = new[]
|
||
{
|
||
"begin",
|
||
"continue",
|
||
"end",
|
||
"begincontinue",
|
||
"begin_continue",
|
||
"quick_continue"
|
||
};
|
||
|
||
foreach (var folder in legacyFolders)
|
||
{
|
||
var directory = Path.Combine(configRoot, folder);
|
||
if (!Directory.Exists(directory)) continue;
|
||
|
||
try
|
||
{
|
||
foreach (var path in Directory.EnumerateFiles(directory, "*", SearchOption.AllDirectories))
|
||
{
|
||
if (!IsArchivePayloadFile(path)) continue;
|
||
if (DeleteFileIfExists(path)) deletedCount++;
|
||
}
|
||
|
||
TryDeleteDirectoryIfEmpty(directory);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
LogSystem.LogError($"[GameArchive] 清理旧存档子目录失败: {directory} | {ex.Message}");
|
||
}
|
||
}
|
||
|
||
return deletedCount;
|
||
}
|
||
|
||
private bool IsLegacyMapArchiveFile(string path, bool includeLooseArchiveNames)
|
||
{
|
||
var stem = GetArchivePayloadStem(path);
|
||
if (string.IsNullOrEmpty(stem)) return false;
|
||
if (stem.StartsWith("map_archive_", StringComparison.OrdinalIgnoreCase)) return true;
|
||
return includeLooseArchiveNames && IsLegacyLooseArchiveStem(stem);
|
||
}
|
||
|
||
private bool IsArchivePayloadFile(string path)
|
||
{
|
||
return !string.IsNullOrEmpty(GetArchivePayloadStem(path));
|
||
}
|
||
|
||
private string GetArchivePayloadStem(string path)
|
||
{
|
||
var fileName = Path.GetFileName(path);
|
||
if (string.IsNullOrEmpty(fileName)) return string.Empty;
|
||
|
||
fileName = StripArchiveSidecarSuffix(fileName);
|
||
if (!fileName.EndsWith(".dat", StringComparison.OrdinalIgnoreCase)) return string.Empty;
|
||
return fileName.Substring(0, fileName.Length - ".dat".Length);
|
||
}
|
||
|
||
private bool IsLegacyLooseArchiveStem(string stem)
|
||
{
|
||
return IsLegacyStem(stem, "begin")
|
||
|| IsLegacyStem(stem, "continue")
|
||
|| IsLegacyStem(stem, "end")
|
||
|| IsLegacyStem(stem, "begincontinue")
|
||
|| IsLegacyStem(stem, "begin_continue")
|
||
|| IsLegacyStem(stem, "quick_continue");
|
||
}
|
||
|
||
private bool IsLegacyStem(string stem, string prefix)
|
||
{
|
||
return string.Equals(stem, prefix, StringComparison.OrdinalIgnoreCase)
|
||
|| stem.StartsWith(prefix + "_", StringComparison.OrdinalIgnoreCase);
|
||
}
|
||
|
||
private void TryDeleteDirectoryIfEmpty(string directory)
|
||
{
|
||
try
|
||
{
|
||
if (Directory.Exists(directory) && Directory.GetFileSystemEntries(directory).Length == 0)
|
||
Directory.Delete(directory);
|
||
}
|
||
catch
|
||
{
|
||
// ignored
|
||
}
|
||
}
|
||
|
||
// archiveId 来自 record,所以拼路径前先校验,避免 .. 或路径分隔符造成越界访问。
|
||
private string GetArchivePath(GameArchiveFileKind archiveKind, string archiveId)
|
||
{
|
||
if (!IsValidArchiveId(archiveId)) return null;
|
||
return Path.Combine(GetArchiveDirectory(archiveKind), archiveId + ".dat");
|
||
}
|
||
|
||
// 新系统全部放在 Config/GameArchives 下,和旧的散落存档文件路径隔离。
|
||
private string GetArchiveDirectory(GameArchiveFileKind archiveKind)
|
||
{
|
||
var root = Path.Combine(GetConfigRootDirectory(), ArchiveRootFolderName);
|
||
var subFolder = archiveKind switch
|
||
{
|
||
GameArchiveFileKind.Begin => "begin",
|
||
GameArchiveFileKind.QuickContinue => "quick_continue",
|
||
GameArchiveFileKind.Continue => "continue",
|
||
GameArchiveFileKind.End => "end",
|
||
_ => "unknown"
|
||
};
|
||
return Path.Combine(root, subFolder);
|
||
}
|
||
|
||
private string GetConfigRootDirectory()
|
||
{
|
||
return Path.GetFullPath(Path.Combine(Application.persistentDataPath, "../Config"));
|
||
}
|
||
|
||
// 只允许简单文件名作为 archiveId。
|
||
// 正常 id 是 Guid.NewGuid().ToString("N"),快速存档固定是 quick。
|
||
private bool IsValidArchiveId(string archiveId)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(archiveId)) return false;
|
||
if (archiveId.Contains("..")) return false;
|
||
if (archiveId.Contains("/") || archiveId.Contains("\\")) return false;
|
||
return archiveId.IndexOfAny(Path.GetInvalidFileNameChars()) < 0;
|
||
}
|
||
|
||
private string NewArchiveId()
|
||
{
|
||
return Guid.NewGuid().ToString("N");
|
||
}
|
||
|
||
// 手动存档名只影响 record 展示,不参与文件路径。
|
||
private string NormalizeManualRecordName(string recordName)
|
||
{
|
||
return string.IsNullOrWhiteSpace(recordName) ? "手动存档" : recordName.Trim();
|
||
}
|
||
}
|
||
}
|