重构新版本存档
This commit is contained in:
parent
e8e9f2a243
commit
c26c4d008a
@ -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<MapData>(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)
|
||||
{
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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;
|
||||
|
||||
8
Unity/Assets/Scripts/TH1_Logic/GameArchive.meta
Normal file
8
Unity/Assets/Scripts/TH1_Logic/GameArchive.meta
Normal file
@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a84fb21d65824516b27a8ba29f2cd847
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
448
Unity/Assets/Scripts/TH1_Logic/GameArchive/GameArchiveManager.cs
Normal file
448
Unity/Assets/Scripts/TH1_Logic/GameArchive/GameArchiveManager.cs
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f8ab77ac40b64c6daa3c9bbe4de18869
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@ -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<GameRecord>();
|
||||
}
|
||||
|
||||
// 步骤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<GameRecord> GetGameRecordListByKind(GameRecordKind recordKind)
|
||||
{
|
||||
RefreshGameRecord();
|
||||
return _gameRecord?.Records?.FindAll(record => record.RecordKind == recordKind) ?? new List<GameRecord>();
|
||||
}
|
||||
|
||||
// 追加一条普通 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<GameRecord>();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user