From c26c4d008a0fa35765a3225aec81c7cd0b959286 Mon Sep 17 00:00:00 2001 From: wuwenbo Date: Tue, 2 Jun 2026 17:57:27 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E6=96=B0=E7=89=88=E6=9C=AC?= =?UTF-8?q?=E5=AD=98=E6=A1=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Unity/Assets/Scripts/TH1_Data/MapData.cs | 22 +- .../Scripts/TH1_Logic/Core/GameLogic.cs | 19 +- Unity/Assets/Scripts/TH1_Logic/Core/Main.cs | 55 ++- .../Assets/Scripts/TH1_Logic/GameArchive.meta | 8 + .../GameArchive/GameArchiveManager.cs | 448 ++++++++++++++++++ .../GameArchive/GameArchiveManager.cs.meta | 11 + .../TH1_Logic/GameRecord/GameRecordManager.cs | 154 +++++- 7 files changed, 675 insertions(+), 42 deletions(-) create mode 100644 Unity/Assets/Scripts/TH1_Logic/GameArchive.meta create mode 100644 Unity/Assets/Scripts/TH1_Logic/GameArchive/GameArchiveManager.cs create mode 100644 Unity/Assets/Scripts/TH1_Logic/GameArchive/GameArchiveManager.cs.meta diff --git a/Unity/Assets/Scripts/TH1_Data/MapData.cs b/Unity/Assets/Scripts/TH1_Data/MapData.cs index 1fdf5bbbe..d5bf6ad01 100644 --- a/Unity/Assets/Scripts/TH1_Data/MapData.cs +++ b/Unity/Assets/Scripts/TH1_Data/MapData.cs @@ -24,6 +24,7 @@ using TH1_Core.Managers; using TH1_Logic.Collect; using TH1_Logic.Config; using TH1_Logic.Core; +using TH1_Logic.GameArchive; using TH1_Logic.MatchConfig; using TH1_Logic.Net; using TH1_Logic.Steam; @@ -2604,6 +2605,20 @@ namespace RuntimeData return MemoryPackSerializer.Deserialize(rawBytes); } + // 给新版 GameArchiveManager 使用的 MapData 存档序列化入口。 + // 新系统仍复用旧系统的 MemoryPack + NetworkPayloadCodec 格式,避免两套存档格式分裂。 + public static byte[] SerializeArchiveBytes(MapData map) + { + return SerializeMapArchive(map); + } + + // 给新版 GameArchiveManager 使用的 MapData 存档反序列化入口。 + // 外部读新 begin/continue/end 文件时统一走这里,保证压缩/兼容解码逻辑一致。 + public static MapData DeserializeArchiveBytes(byte[] bytes) + { + return DeserializeMapArchive(bytes); + } + public GameRecord ExportGameRecord() { var gameRecord = new GameRecord(); @@ -2723,8 +2738,13 @@ namespace RuntimeData return; } - // 存档 + // 存档。 + // 这里已经过了“只允许房主更新联机回合”的判断,并且 Net.CurPlayerId == 0, + // 所以这是每轮轮转到下一位玩家前的统一自动保存点。 var saveSucceeded = SaveMapData(Main.MapData); + // 新版快速存档:每次回合轮转覆盖 quick_continue/quick.dat,并更新唯一 Quick record。 + // 不扫描本地所有存档,只维护当前这一局的快速继续入口。 + GameArchiveManager.Instance.SaveQuickContinueRecord(Main.MapData); AchievementDataManager.Instance.SaveAchievementData(); if (saveSucceeded) { diff --git a/Unity/Assets/Scripts/TH1_Logic/Core/GameLogic.cs b/Unity/Assets/Scripts/TH1_Logic/Core/GameLogic.cs index 8e57c59da..67b5152cb 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Core/GameLogic.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Core/GameLogic.cs @@ -18,6 +18,7 @@ using TH1_Core.Managers; using TH1_Logic.AITrain; using TH1_Logic.Collect; using TH1_Logic.Core; +using TH1_Logic.GameArchive; using TH1_Logic.MatchConfig; using TH1_Logic.Net; using TH1_Logic.Oss; @@ -537,14 +538,16 @@ namespace Logic CollectManager.Instance.MatchGameEndCollect(Main.MapData); } - // 保存游戏记录 - // 教程关卡不进历史存档:UIOutsideHistory 只按 GameMode 过滤(GameMode 没有 Tutor 这一项), - // 教程的 record.Mode 实际是 DOMINATION,会污染 DOMINATION 列表。Story 仍保留。 - if (Main.MapData.MapConfig.MatchSettlement != MatchSettlementType.Tutor) - { - var record = Main.MapData.ExportGameRecord(); - GameRecordManager.Instance.AddRecord(record); - } + // 保存新版结束存档和结束记录。 + // + // GameArchiveManager 会做三件事: + // 1. 写 end 文件到 Config/GameArchives/end。 + // 2. 创建 Ended record,让完整战绩索引 begin/end。 + // 3. 删除当前 begin 对应的 Quick record 和 quick.dat,避免已结束的局还能快速继续。 + // + // 手动通用存档不会在这里删除,只能由玩家主动删除。 + // 教程仍不进历史记录,过滤由 GameArchiveManager 内部处理。 + GameArchiveManager.Instance.SaveEndRecord(Main.MapData); // 保存存档 if (Main.MapData.MapConfig.MatchSettlement != MatchSettlementType.Story) diff --git a/Unity/Assets/Scripts/TH1_Logic/Core/Main.cs b/Unity/Assets/Scripts/TH1_Logic/Core/Main.cs index bdd7c1d12..9fbe452ae 100644 --- a/Unity/Assets/Scripts/TH1_Logic/Core/Main.cs +++ b/Unity/Assets/Scripts/TH1_Logic/Core/Main.cs @@ -20,6 +20,7 @@ using TH1_Logic.AITrain; using TH1_Logic.Collect; using TH1_Logic.Comic; using TH1_Logic.Config; +using TH1_Logic.GameArchive; using TH1_Logic.HeroTask; using TH1_Logic.MatchConfig; using TH1_Logic.Net; @@ -272,6 +273,9 @@ namespace TH1_Logic.Core MapData.SaveMatchConfig(MapConfig); MapData.SaveMapData(MapData, true); + // 新版存档:新开局一定先写 begin。 + // 后续每回合 quick、玩家手动 manual、最终 end 都会通过这个 begin 串起来。 + GameArchiveManager.Instance.SaveBeginArchive(MapData); #if CHECK_ACTIONDEFFERENCE byte[] bt = MemoryPack.MemoryPackSerializer.Serialize(Main.MapData); @@ -282,13 +286,25 @@ namespace TH1_Logic.Core public bool ResumeMatch(GameRecord record) { - if (!GameRecordManager.Instance.HasUsableResumeArchive(record)) return false; - return record.NetMode switch + // 新版 record 继续入口。 + // 传入 Quick/Manual record 后,先通过 record.ContinueArchiveId 读取 MapData。 + // 只有读到完整、NetMode 匹配的 continue 文件,才会进入真正的单机/联机开始流程。 + if (!GameArchiveManager.Instance.TryLoadContinueArchive(record, out var resumeMap)) return false; + + // record 继续要沿用原 record.BeginArchiveId。 + // 如果开始流程失败,需要恢复之前的 begin 会话,避免后续保存挂错局。 + var previousBeginArchiveId = GameArchiveManager.Instance.CurrentBeginArchiveId; + GameArchiveManager.Instance.SetActiveRecord(record); + var result = record.NetMode switch { - NetMode.Single => ResumeMatch(record.MapID), - NetMode.Multi => MainMemberResumeMatch(record.MapID), + // 单机 record:直接复用单机读档开始流程。 + NetMode.Single => ResumeMatch(resumeMap, true), + // 联机 record:仍走房主联机继续流程,里面会做 Net.RefreshPlayerNet 和 GameStart 广播。 + NetMode.Multi => MainMemberResumeMatchWithMap(resumeMap, true), _ => false }; + if (!result) GameArchiveManager.Instance.SetCurrentBeginArchiveId(previousBeginArchiveId); + return result; } public bool ResumeMatch(uint mapId) @@ -298,8 +314,15 @@ namespace TH1_Logic.Core return resumeMap != null && ResumeMatch(resumeMap); } - // 继续单机游戏。prereadMap 可由调用方预读传入,避免对 1MB+ 存档重复反序列化(用于 Loading 图提前拿 Empire) - public bool ResumeMatch(MapData prereadMap = null) + // 继续单机游戏。 + // + // prereadMap: + // - 旧 UI 可以预读 MapData 传进来,避免 Loading 图和正式进入时重复反序列化。 + // + // useCurrentArchiveSession: + // - false:旧继续入口,没有 record 索引,需要给这次会话新建 begin。 + // - true:新版 record 继续入口,已经在 ResumeMatch(GameRecord) 里绑定了 BeginArchiveId,不能再新建 begin。 + public bool ResumeMatch(MapData prereadMap = null, bool useCurrentArchiveSession = false) { //如果没有存档,退出(外部已预读传入时跳过存档磁盘检查,map 自身即证据) if (prereadMap == null && !HasArchive()) return false; @@ -333,6 +356,9 @@ namespace TH1_Logic.Core && MapData.GetGridDataByCityId(cap.Id, out var grid)) camera.CameraFocusOnGrid(grid,true); + // 旧单机继续没有 record.BeginArchiveId,补一条 begin 让后续 quick/manual/end 可以索引。 + // 新版 record 继续则沿用原 begin。 + if (!useCurrentArchiveSession) GameArchiveManager.Instance.SaveBeginArchive(MapData); MapData.RefreshTurn(); return true; } @@ -417,6 +443,9 @@ namespace TH1_Logic.Core },1.5f,"Main_CenterMessage_Anim"); MapData.SaveMapData(MapData, true); + // 联机新开局也写 begin,但必须放在 GameStart 广播成功之后。 + // 否则房主本地开始失败时可能留下一个没有真正开局的 begin。 + GameArchiveManager.Instance.SaveBeginArchive(MapData); MapData.RefreshTurn(); return true; } @@ -456,7 +485,16 @@ namespace TH1_Logic.Core return MainMemberResumeMatchWithMap(MapData.GetMapData(isMulti: true)); } - private bool MainMemberResumeMatchWithMap(MapData resumeMap) + // 房主联机继续的实际实现。 + // + // useCurrentArchiveSession 与单机 ResumeMatch 相同: + // - false:旧联机继续入口,需要补 begin。 + // - true:新版 record 继续入口,沿用 record.BeginArchiveId。 + // + // 网络安全点: + // - 先 RefreshPlayerNet 校验当前房间成员映射。 + // - GameStart 广播成功后才算真正进入游戏,失败会回滚。 + private bool MainMemberResumeMatchWithMap(MapData resumeMap, bool useCurrentArchiveSession = false) { var previousMap = MapData; var previousInput = InputLogic; @@ -515,6 +553,9 @@ namespace TH1_Logic.Core return false; } + // 旧联机继续没有 record.BeginArchiveId,广播成功后补一条 begin。 + // 新版 record 继续必须沿用原 begin,保证后续 quick/end 仍挂在同一局链路下。 + if (!useCurrentArchiveSession) GameArchiveManager.Instance.SaveBeginArchive(MapData); MapData.RefreshTurn(); LogSystem.LogInfo($"MainMemberResumeMatch : {NetData.GetMapDataHash(MapData)}"); return true; diff --git a/Unity/Assets/Scripts/TH1_Logic/GameArchive.meta b/Unity/Assets/Scripts/TH1_Logic/GameArchive.meta new file mode 100644 index 000000000..a24188fd8 --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/GameArchive.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: a84fb21d65824516b27a8ba29f2cd847 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/Scripts/TH1_Logic/GameArchive/GameArchiveManager.cs b/Unity/Assets/Scripts/TH1_Logic/GameArchive/GameArchiveManager.cs new file mode 100644 index 000000000..f6021d332 --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/GameArchive/GameArchiveManager.cs @@ -0,0 +1,448 @@ +/* +* @Author: Codex +* @Description: 新版游戏存档索引与读写 +* @Date: 2026年06月02日 星期二 00:00:00 +* @Modify: +*/ + +using System; +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。 + // - 旧的非 record 继续入口:为了让之后的 quick/manual/end 也能挂到一个 begin。 + 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 是玩家自定义名字;空名字会归一成“手动存档”。 + // - 这里只创建,不自动删除;删除必须走 DeleteManualContinueRecord。 + 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 _); + } + + // 通过一条 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。 + // 后续回合 quick、玩家 manual、最终 end 都会继续挂到原 begin 下。 + public void SetActiveRecord(GameRecord record) + { + if (record == null) return; + _currentBeginArchiveId = record.BeginArchiveId; + } + + // 用于继续流程失败时恢复旧会话 id。 + // 正常业务一般不要直接调用。 + public void SetCurrentBeginArchiveId(string beginArchiveId) + { + _currentBeginArchiveId = beginArchiveId; + } + + // 玩家主动删除通用存档的上层接口。 + // 这里只允许删除 Manual record,Quick 和 Ended 不通过这个接口删除。 + public bool DeleteManualGameRecord(GameRecord record) + { + return DeleteManualContinueRecord(record); + } + + // 删除玩家手动通用存档。 + // + // 删除顺序刻意是先删 record,再删文件: + // - 如果传进来的 record 不在 GameRecordData 里,不会误删磁盘文件。 + // - 如果文件删除失败,record 已经移除,之后 UI 不会继续显示一条坏记录。 + public bool DeleteManualContinueRecord(GameRecord record) + { + if (record == null || record.RecordKind != GameRecordKind.Manual) return false; + if (!GameRecordManager.Instance.RemoveRecord(record)) return false; + + if (!string.IsNullOrEmpty(record.ContinueArchiveId)) + { + DeleteArchive(GameArchiveFileKind.Continue, record.ContinueArchiveId); + } + + return true; + } + + // 删除快速存档 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); + } + + // 确保当前局已经有 begin。 + // 正常新开局会先 SaveBeginArchive;这里主要兜底旧继续入口或异常调用顺序。 + 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 void DeleteFileIfExists(string path) + { + try + { + if (File.Exists(path)) File.Delete(path); + } + catch (Exception ex) + { + LogSystem.LogError($"[GameArchive] 删除存档失败: {path} | {ex.Message}"); + } + } + + // archiveId 来自 record,所以拼路径前先校验,避免 .. 或路径分隔符造成越界访问。 + private string GetArchivePath(GameArchiveFileKind archiveKind, string archiveId) + { + if (!IsValidArchiveId(archiveId)) return null; + return Path.Combine(GetArchiveDirectory(archiveKind), archiveId + ".dat"); + } + + // 新系统全部放在 Config/GameArchives 下,和旧 map_archive_* 文件分开。 + private string GetArchiveDirectory(GameArchiveFileKind archiveKind) + { + var root = Path.GetFullPath(Path.Combine(Application.persistentDataPath, "../Config", ArchiveRootFolderName)); + var subFolder = archiveKind switch + { + GameArchiveFileKind.Begin => "begin", + GameArchiveFileKind.QuickContinue => "quick_continue", + GameArchiveFileKind.Continue => "continue", + GameArchiveFileKind.End => "end", + _ => "unknown" + }; + return Path.Combine(root, subFolder); + } + + // 只允许简单文件名作为 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(); + } + } +} diff --git a/Unity/Assets/Scripts/TH1_Logic/GameArchive/GameArchiveManager.cs.meta b/Unity/Assets/Scripts/TH1_Logic/GameArchive/GameArchiveManager.cs.meta new file mode 100644 index 000000000..109830b53 --- /dev/null +++ b/Unity/Assets/Scripts/TH1_Logic/GameArchive/GameArchiveManager.cs.meta @@ -0,0 +1,11 @@ +fileFormatVersion: 2 +guid: f8ab77ac40b64c6daa3c9bbe4de18869 +MonoImporter: + externalObjects: {} + serializedVersion: 2 + defaultReferences: [] + executionOrder: 0 + icon: {instanceID: 0} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Unity/Assets/Scripts/TH1_Logic/GameRecord/GameRecordManager.cs b/Unity/Assets/Scripts/TH1_Logic/GameRecord/GameRecordManager.cs index 4b9275fbf..d64bedca6 100644 --- a/Unity/Assets/Scripts/TH1_Logic/GameRecord/GameRecordManager.cs +++ b/Unity/Assets/Scripts/TH1_Logic/GameRecord/GameRecordManager.cs @@ -13,6 +13,7 @@ using Logic.AI; using Logic.CrashSight; using MemoryPack; using TH1_Logic.Config; +using TH1_Logic.GameArchive; using TH1_Logic.MatchConfig; using TH1_Logic.Tools; using UnityEngine; @@ -46,7 +47,8 @@ namespace RuntimeData bool isScore = false, bool isTurn = false, bool isTime = false, - bool order = true) + bool order = true, + GameRecordKind recordKind = GameRecordKind.Ended) { RefreshGameRecord(); @@ -55,9 +57,13 @@ namespace RuntimeData return new List(); } - // 步骤1: 筛选 + // 步骤1: 筛选。 + // 默认只返回 Ended,保持旧历史战绩 UI 不会混入快速存档/手动存档。 + // 如果外部要展示“手动存档列表”或“快速存档”,传入对应 recordKind 即可。 var filteredRecords = _gameRecord.Records.FindAll(record => { + if (record.RecordKind != recordKind) return false; + // 必筛选: 游戏模式 if (record.Mode != mode) return false; @@ -106,42 +112,112 @@ namespace RuntimeData return filteredRecords; } - - public void AddRecord(GameRecord record) + + // 按新版三分类直接取 GameRecord。 + // 适合未来 UI 分页展示: + // - Ended:完整结束记录 + // - Manual:玩家手动通用存档 + // - Quick:唯一快速存档 + public List GetGameRecordListByKind(GameRecordKind recordKind) { RefreshGameRecord(); + return _gameRecord?.Records?.FindAll(record => record.RecordKind == recordKind) ?? new List(); + } + + // 追加一条普通 record。 + // Ended 和 Manual 都走这里;Quick 不走这里,因为 Quick 要保证全局只有一条。 + public void AddRecord(GameRecord record) + { + if (record == null) return; + RefreshGameRecord(); + EnsureRecordId(record); _gameRecord.Records.Add(record); - byte[] bytes = MemoryPackSerializer.Serialize(_gameRecord); - FileTools.SafeWriteFile(Application.persistentDataPath + "/../Config/game_record.dat", bytes); + SaveGameRecordData(); } - public bool HasUsableResumeArchive(GameRecord record) + // 写入快速存档记录。 + // “Upsert”表示先删旧 Quick,再写新 Quick,从数据层保证快速存档只有一条。 + public void UpsertQuickRecord(GameRecord record) + { + if (record == null) return; + RefreshGameRecord(); + record.RecordKind = GameRecordKind.Quick; + record.RecordId = GameArchiveManager.QuickArchiveId; + _gameRecord.Records.RemoveAll(existing => existing.RecordKind == GameRecordKind.Quick); + _gameRecord.Records.Add(record); + SaveGameRecordData(); + } + + // 取得当前唯一的快速存档 record。 + // 没有快速存档或快速存档被游戏结束清理后返回 null。 + public GameRecord GetQuickRecord() + { + RefreshGameRecord(); + return _gameRecord?.Records?.Find(record => record.RecordKind == GameRecordKind.Quick); + } + + // 删除快速存档 record。 + // beginArchiveId 为空表示无条件删除;非空表示只删除同一局 begin 下的 quick。 + public bool RemoveQuickRecord(string beginArchiveId = null) + { + RefreshGameRecord(); + var removed = _gameRecord.Records.RemoveAll(record => + record.RecordKind == GameRecordKind.Quick && + (string.IsNullOrEmpty(beginArchiveId) || record.BeginArchiveId == beginArchiveId)); + if (removed <= 0) return false; + + SaveGameRecordData(); + return true; + } + + // 删除某条 record。主要用于删除玩家手动通用存档。 + // UI 传回来的 record 可能不是同一个对象引用,所以 IsSameRecord 会用 RecordId 和索引字段兜底匹配。 + public bool RemoveRecord(GameRecord record) { if (record == null) return false; - if (record.MapID == 0) return false; - if (record.NetMode != NetMode.Single && record.NetMode != NetMode.Multi) return false; + RefreshGameRecord(); + var removed = _gameRecord.Records.RemoveAll(existing => IsSameRecord(existing, record)); + if (removed <= 0) return false; - var isMulti = record.NetMode == NetMode.Multi; - var mapData = MapData.GetMapData(isMulti: isMulti, mapId: record.MapID); - return IsRecordResumeArchiveUsable(record, mapData); + SaveGameRecordData(); + return true; } - private bool IsRecordResumeArchiveUsable(GameRecord record, MapData mapData) + // 检查这条 record 是否真的能继续。 + // 具体校验交给 GameArchiveManager:只验证这一条 record 指向的 continue 文件,不扫描全量存档。 + public bool HasUsableResumeArchive(GameRecord record) { - if (mapData?.Net == null - || mapData.DeserializedMissingCriticalData - || mapData.MapConfig == null - || mapData.GridMap == null - || mapData.PlayerMap == null - || mapData.CityMap == null - || mapData.UnitMap == null) - { - return false; - } + return GameArchiveManager.Instance.HasUsableResumeArchive(record); + } - if (mapData.MapID != record.MapID) return false; - if (mapData.Net.Mode != record.NetMode) return false; - return true; + // 老数据没有 RecordId,新写入前补一个。 + // Quick record 使用固定 quick id,会在 UpsertQuickRecord 里覆盖。 + private void EnsureRecordId(GameRecord record) + { + if (!string.IsNullOrEmpty(record.RecordId)) return; + record.RecordId = Guid.NewGuid().ToString("N"); + } + + // 判断两条 record 是否指向同一条记录。 + // 优先用 RecordId;如果老数据没有 RecordId,再用 begin/continue/end/time 组合做兼容匹配。 + private bool IsSameRecord(GameRecord left, GameRecord right) + { + if (ReferenceEquals(left, right)) return true; + if (left == null || right == null) return false; + if (!string.IsNullOrEmpty(left.RecordId) && left.RecordId == right.RecordId) return true; + + return left.RecordKind == right.RecordKind + && left.BeginArchiveId == right.BeginArchiveId + && left.ContinueArchiveId == right.ContinueArchiveId + && left.EndArchiveId == right.EndArchiveId + && left.Time == right.Time; + } + + // 统一保存 GameRecordData,避免 Add/Remove/Upsert 到处重复序列化逻辑。 + private void SaveGameRecordData() + { + byte[] bytes = MemoryPackSerializer.Serialize(_gameRecord); + FileTools.SafeWriteFile(Application.persistentDataPath + "/../Config/game_record.dat", bytes); } public void RefreshGameRecord() @@ -180,6 +256,7 @@ namespace RuntimeData } _gameRecord ??= new GameRecordData(); + _gameRecord.Records ??= new List(); } } @@ -196,6 +273,16 @@ namespace RuntimeData } } + public enum GameRecordKind + { + // 已结束的完整游戏记录。旧 GameRecord 反序列化后默认也是 0,即 Ended。 + Ended, + // 玩家主动创建的通用继续存档记录,可以有多条。 + Manual, + // 自动快速继续存档记录,全局只维护一条。 + Quick + } + [MemoryPackable] public partial class GameRecord { @@ -213,9 +300,24 @@ namespace RuntimeData // 新增字段必须追加在末尾,否则会破坏老玩家存档。 // 用于区分教程/剧情等特殊关卡,UI 历史记录会过滤 Tutor。 public MatchSettlementType MatchSettlement; + // 旧系统的 mapid 仍保留,兼容旧继续流程和旧数据;新版继续不再依赖它。 public uint MapID; + // 单机/联机模式。新版按 record.NetMode 选择对应的开始游戏接口。 public NetMode NetMode; + // 写入 record 时的游戏版本信息,用于未来做版本兼容提示。 public string GameVersion; public uint GameVersionId; + // 新版 GameRecord 三分类:Ended / Manual / Quick。 + public GameRecordKind RecordKind; + // 记录自身 id。Quick 固定为 quick,其它记录使用 guid。 + public string RecordId; + // 玩家可见名称,目前主要给 Manual record 使用。 + public string RecordName; + // 本 record 归属的开局快照。新版 record 一定要能索引到 begin。 + public string BeginArchiveId; + // 可继续存档的文件 id。只有 Manual/Quick 需要它,Ended 通常为空。 + public string ContinueArchiveId; + // 结束快照文件 id。只有 Ended 需要它,Manual/Quick 通常为空。 + public string EndArchiveId; } }