迭代新存档流程
This commit is contained in:
parent
c26c4d008a
commit
4e66b61e3c
144
.codex/skills/th1-game-archive/SKILL.md
Normal file
144
.codex/skills/th1-game-archive/SKILL.md
Normal file
@ -0,0 +1,144 @@
|
||||
---
|
||||
name: th1-game-archive
|
||||
description: TH1 project-specific guide for the new local save/archive system: GameRecord, GameRecordKind Ended/Manual/Quick, GameArchiveManager, begin/quick_continue/continue/end files, single-player and multiplayer resume from records, manual saves, quick saves, end records, archive validation, spectator/OSS/bug-report archive packaging, and removal of legacy map_archive/PlayerPrefs continue flows. Use whenever Codex works on TH1 continue game, save/load, game records, local archives, surrender/end save behavior, bug report save attachments, spectator archive loading, or any change that might affect single/multiplayer resume reliability.
|
||||
---
|
||||
|
||||
# TH1 Game Archive
|
||||
|
||||
## Core Rule
|
||||
|
||||
The current save system is `GameRecord`-indexed. Continue/resume must start from a `GameRecord`, not by scanning files or using `MapID`.
|
||||
|
||||
`GameRecord` is only an index and display row. The real `MapData` bytes live under `Config/GameArchives`.
|
||||
|
||||
## First Files To Read
|
||||
|
||||
- `Unity/Assets/Scripts/TH1_Logic/GameArchive/GameArchiveManager.cs`
|
||||
- `Unity/Assets/Scripts/TH1_Logic/GameRecord/GameRecordManager.cs`
|
||||
- `Unity/Assets/Scripts/TH1_Logic/Core/Main.cs`
|
||||
- `Unity/Assets/Scripts/TH1_Data/MapData.cs`
|
||||
- `Unity/Assets/Scripts/TH1_Logic/Core/GameLogic.cs`
|
||||
- `Unity/Assets/Scripts/TH1_UI/View/Outside/UIOutsideMenuView.cs`
|
||||
- `Unity/Assets/Scripts/TH1_UI/View/Outside/UIOutsideMultiplayView.cs`
|
||||
- `Unity/Assets/Scripts/TH1_Logic/Editor/SpectatorEditorWindow.cs`
|
||||
- `Unity/Assets/Scripts/TH1_Logic/Oss/PlayerBugReportService.cs`
|
||||
- `Tools/PlayerBugViewer/player_bug_viewer.py` when changing player bug report archive restore.
|
||||
|
||||
If the change touches multiplayer host resume or `GameStart`, also use `th1-network-sync`.
|
||||
If the change touches surrender or other action execution side effects, also use `th1-action-logic`.
|
||||
|
||||
## Data Model
|
||||
|
||||
`GameRecordKind` has three business categories:
|
||||
|
||||
- `Ended`: complete finished match record. It indexes `BeginArchiveId` and `EndArchiveId`; it is not resumable.
|
||||
- `Manual`: player-created general save. It indexes `BeginArchiveId` and `ContinueArchiveId`; it is resumable and can have many records.
|
||||
- `Quick`: automatic quick save. It indexes `BeginArchiveId` and `ContinueArchiveId`; it is resumable, but only one exists globally.
|
||||
|
||||
Archive files are grouped by folder:
|
||||
|
||||
- `Config/GameArchives/begin/{beginArchiveId}.dat`
|
||||
- `Config/GameArchives/quick_continue/quick.dat`
|
||||
- `Config/GameArchives/continue/{continueArchiveId}.dat`
|
||||
- `Config/GameArchives/end/{endArchiveId}.dat`
|
||||
|
||||
`Quick` uses fixed record id and file id `quick`. Manual/end/begin ids are GUID-like strings from `GameArchiveManager`.
|
||||
|
||||
## Write Flow
|
||||
|
||||
- New single-player game: write begin after map generation, before first turn refresh.
|
||||
- New multiplayer host game: write begin only after `GameStart` broadcast succeeds.
|
||||
- Ended game: `FinishState.Enter` calls `GameArchiveManager.SaveEndRecord(Main.MapData)`.
|
||||
- Per-turn quick save: `MapData.RefreshTurn` calls `SaveQuickContinueRecord`.
|
||||
- Manual save: UI or upper layer should call `GameArchiveManager.SaveManualGameRecord(recordName)`.
|
||||
- Manual delete: call `GameArchiveManager.DeleteManualGameRecord(record)`; do not delete quick/end records through this path.
|
||||
|
||||
Do not save archives directly from surrender action. Surrender should mutate game state through the action flow; `AfterExecute` refreshes settlement, `GameLogic.Update` enters `Finished`, and `FinishState.Enter` writes end and clears quick.
|
||||
|
||||
`SaveEndRecord` writes end, creates an `Ended` record except for Tutor, and deletes the current begin's quick record/file. Manual saves survive game end until the player deletes them.
|
||||
|
||||
## Resume Flow
|
||||
|
||||
Public resume should go through:
|
||||
|
||||
```csharp
|
||||
Main.Instance.ResumeMatch(GameRecord record, MapData prereadMap = null)
|
||||
```
|
||||
|
||||
That method:
|
||||
|
||||
- validates/loads the `Quick` or `Manual` continue archive via `GameArchiveManager.TryLoadContinueArchive`;
|
||||
- rejects null records, bad archive files, wrong `NetMode`, and surrendered continue saves;
|
||||
- calls `GameArchiveManager.SetActiveRecord(record)` so future quick/manual/end saves keep using `record.BeginArchiveId`;
|
||||
- dispatches by `record.NetMode` to single-player resume or host multiplayer resume.
|
||||
|
||||
Resume methods must not create a new begin. New games create begin; record resume reuses the record's begin.
|
||||
|
||||
For UI availability:
|
||||
|
||||
- single-player quick resume button: `GameArchiveManager.HasQuickResumeArchive(NetMode.Single)`
|
||||
- multiplayer host resume toggle: `GameArchiveManager.HasQuickResumeArchive(NetMode.Multi)`
|
||||
- per-record validation: `GameArchiveManager.HasUsableResumeArchive(record)`
|
||||
|
||||
These checks validate only the relevant record/file, not all local archives.
|
||||
|
||||
## Read/Export Flows
|
||||
|
||||
Use `GameArchiveManager` helpers:
|
||||
|
||||
- Continue: `TryLoadContinueArchive(record, out map)`
|
||||
- Begin: `TryLoadBeginArchive(record, out map)`
|
||||
- End: `TryLoadEndArchive(record, out map)`
|
||||
- Current begin for OSS end upload: `TryLoadCurrentBeginArchive(expectedMode, out map)`
|
||||
- File path for packaging: `TryGetArchivePath(kind, archiveId, out path)`
|
||||
|
||||
Spectator tools, OSS collect upload, and player bug reports should build archive pairs from `GameRecordData.Records`, then use the helper APIs above. Do not parse archive filenames.
|
||||
|
||||
Player bug report zip manifests include `archiveFolder`, and the viewer restores files under `Config/GameArchives/{archiveFolder}`. The Python viewer cannot safely synthesize `game_record.dat` because it is MemoryPack data.
|
||||
|
||||
## Compatibility
|
||||
|
||||
`GameRecord` and `GameRecordData` are MemoryPack types. Do not reorder existing fields. New fields must be appended at the end, and compatibility-sensitive MemoryPack changes require explicit user confirmation.
|
||||
|
||||
`MapID` is legacy. Keep it for old record display/compatibility if needed, but do not use it to locate current archives or resume games.
|
||||
|
||||
The legacy flow is removed:
|
||||
|
||||
- no `MapData.SaveMapData`
|
||||
- no `MapData.GetMapData`
|
||||
- no `MapData.HasMapArchive`
|
||||
- no `PlayerPrefs` keys `Archive` / `MultiArchive`
|
||||
- no `map_archive_*.dat` scanning or filename parsing
|
||||
- no resume by `mapId`
|
||||
|
||||
## Checks Before Finishing
|
||||
|
||||
Run:
|
||||
|
||||
```powershell
|
||||
dotnet build Unity/Assembly-CSharp.csproj --no-restore
|
||||
```
|
||||
|
||||
Also run editor build if editor windows/tools changed:
|
||||
|
||||
```powershell
|
||||
dotnet build Unity/Assembly-CSharp-Editor.csproj --no-restore
|
||||
```
|
||||
|
||||
Search for legacy regressions:
|
||||
|
||||
```powershell
|
||||
rg "SaveMapData|GetMapData\\(|HasMapArchive|HasArchive\\(|HasMultiArchive\\(" Unity/Assets/Scripts -n
|
||||
rg "map_archive|PlayerPrefs\\.(SetInt|GetInt)\\(\"(Archive|MultiArchive)\"" Unity/Assets/Scripts Tools MD -n
|
||||
rg "useCurrentArchiveSession|ResumeMatch\\(uint|MainMemberResumeMatch\\(" Unity/Assets/Scripts -n
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- Do not scan all local saves to build a continue list; use GameRecord lists and validate one selected record.
|
||||
- Do not show a record as resumable until `TryLoadContinueArchive` or `HasUsableResumeArchive` passes.
|
||||
- Do not let single-player and multiplayer quick resumes share UI state; always filter by `NetMode`.
|
||||
- Do not close multiplayer room UI or advance local host state until `GameStart` succeeds.
|
||||
- Do not let a failed resume leave `GameArchiveManager.CurrentBeginArchiveId` bound to the attempted record.
|
||||
- Do not delete manual saves on game end.
|
||||
- Do not write end from action execution before settlement refresh; let `FinishState.Enter` own end saving.
|
||||
4
.codex/skills/th1-game-archive/agents/openai.yaml
Normal file
4
.codex/skills/th1-game-archive/agents/openai.yaml
Normal file
@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "TH1 Game Archive"
|
||||
short_description: "TH1 GameRecord-indexed saves, resume, quick/manual/end archives"
|
||||
default_prompt: "Use $th1-game-archive when working on TH1 GameRecord, GameArchiveManager, continue/resume, quick/manual/end saves, archive validation, or bug-report/spectator save packaging."
|
||||
@ -357,7 +357,7 @@ Unity 菜单:`Tools/玩家 Bug 汇报`
|
||||
|
||||
- 玩家自述文本输入。
|
||||
- 版本号默认读取当前 `VersionConfig`,可手动修改。
|
||||
- 默认附带最近一局存档,根据本地 `map_archive_begin + map_archive_continue/end` 自动识别。
|
||||
- 默认附带最近一局存档,根据 `GameRecord` 索引到本地 `GameArchives/begin + quick_continue/continue/end` 自动识别。
|
||||
- 可勾选单机存档、联机存档;打包为一个 zip 后走 `type=bugreport` 上传。
|
||||
|
||||
Zip 内容:
|
||||
@ -365,10 +365,10 @@ Zip 内容:
|
||||
```
|
||||
manifest.json
|
||||
description.txt
|
||||
saves/single/map_archive_begin_{mapId}.dat
|
||||
saves/single/map_archive_continue_{mapId}.dat 或 map_archive_end_{mapId}.dat
|
||||
saves/multi/map_archive_begin_multi_{mapId}.dat
|
||||
saves/multi/map_archive_continue_multi_{mapId}.dat 或 map_archive_end_multi_{mapId}.dat
|
||||
saves/single/begin/{beginArchiveId}.dat
|
||||
saves/single/quick_continue/quick.dat 或 saves/single/continue/{continueArchiveId}.dat 或 saves/single/end/{endArchiveId}.dat
|
||||
saves/multi/begin/{beginArchiveId}.dat
|
||||
saves/multi/quick_continue/quick.dat 或 saves/multi/continue/{continueArchiveId}.dat 或 saves/multi/end/{endArchiveId}.dat
|
||||
```
|
||||
|
||||
`manifest.json` 目前包含:
|
||||
@ -377,7 +377,7 @@ saves/multi/map_archive_continue_multi_{mapId}.dat 或 map_archive_end_multi_{ma
|
||||
- 玩家与版本:`steamId`、`version`、`unityVersion`、`platform`。
|
||||
- CrashSight 对齐字段:`crashSightDeviceId`。项目初始化 CrashSight 时也会显式设置同一个设备 ID,便于后台检索对应玩家。来源优先使用 `SystemInfo.deviceUniqueIdentifier`;如果 Unity 返回空值或 unsupported,则生成并持久化一个 `th1-{guid}` 作为本机安装级 fallback;极端情况下 PlayerPrefs 不可用时,使用进程内 `th1-device-id-unavailable-{guid}` fallback。
|
||||
- 设备信息:`deviceName`、`deviceModel`、`operatingSystem`、`processorType`、`processorCount`、`systemMemorySizeMb`、`graphicsDeviceName`、`graphicsMemorySizeMb`。
|
||||
- 玩家自述与存档:`description`、`archiveCount`、`archives[]`。每个存档条目包含模式、MapID、begin/continue/end 类型、源文件名、zip 路径、文件大小和最后写入时间。
|
||||
- 玩家自述与存档:`description`、`archiveCount`、`archives[]`。每个存档条目包含模式、MapID、begin/continue/end 类型、`archiveFolder` 子目录、源文件名、zip 路径、文件大小和最后写入时间。
|
||||
|
||||
### 12.2 开发者查看器
|
||||
|
||||
@ -388,7 +388,7 @@ saves/multi/map_archive_continue_multi_{mapId}.dat 或 map_archive_end_multi_{ma
|
||||
- 使用 OSS AccessKey 更新 `bugreport/` 内容到本地缓存。
|
||||
- 按版本和 SteamID 筛选条目。
|
||||
- 预览玩家自述、上传时间、版本、SteamID、CrashSight 设备 ID、设备信息、附带存档。
|
||||
- 一键清理本地 `map_archive_*.dat/.bak` 并替换为该汇报内的存档。
|
||||
- 一键清理本地 `Config/GameArchives` 下的新存档文件,并按汇报内的 `archiveFolder` 子目录写回。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -8,6 +8,6 @@
|
||||
2. 工具会自动读取 Unity OSS 编辑器保存的 `AccessKey ID` / `AccessKey Secret`,确认 `Endpoint` 和 `Bucket`。
|
||||
3. 点击「更新内容」拉取 `bugreport/` 下的 zip。
|
||||
4. 选择条目查看玩家自述、版本、SteamID、CrashSight 设备 ID、设备信息、附带存档。
|
||||
5. 点击「一键替换到本地存档」会删除目标目录下所有 `map_archive_*.dat` / `.bak`,再写入该汇报中的 `start + continue/end` 存档。
|
||||
5. 点击「一键替换到本地存档」会删除目标目录下 `GameArchives/begin`、`quick_continue`、`continue`、`end` 内的存档文件,再按汇报中的子目录写回 `begin + continue/end` 存档。
|
||||
|
||||
也可以用 `config.local.json` 或环境变量覆盖 OSS 配置,该文件应只保留在本机。
|
||||
|
||||
@ -27,6 +27,8 @@ from oss_viewer_config import merge_default_oss_config, merge_local_oss_config
|
||||
DATA_DIR = APP_DIR / "Data"
|
||||
CONFIG_PATH = APP_DIR / "config.local.json"
|
||||
OSS_PREFIX = "bugreport/"
|
||||
ARCHIVE_ROOT_FOLDER = "GameArchives"
|
||||
ARCHIVE_FOLDERS = {"begin", "quick_continue", "continue", "end"}
|
||||
|
||||
|
||||
def default_save_config_dir() -> str:
|
||||
@ -45,6 +47,21 @@ def default_config() -> dict:
|
||||
}
|
||||
|
||||
|
||||
def archive_folder_for_manifest(archive: dict, source_file_name: str) -> str | None:
|
||||
folder = Path(archive.get("archiveFolder") or "").name
|
||||
if folder in ARCHIVE_FOLDERS:
|
||||
return folder
|
||||
|
||||
kind = archive.get("kind")
|
||||
if kind == "begin":
|
||||
return "begin"
|
||||
if kind == "end":
|
||||
return "end"
|
||||
if kind == "continue":
|
||||
return "quick_continue" if source_file_name == "quick.dat" else "continue"
|
||||
return None
|
||||
|
||||
|
||||
def load_config() -> dict:
|
||||
cfg = merge_default_oss_config(default_config())
|
||||
if CONFIG_PATH.exists():
|
||||
@ -222,14 +239,19 @@ def download_reports(cfg: dict) -> tuple[int, int, int]:
|
||||
|
||||
def replace_local_saves(report: ReportEntry, save_config_dir: str) -> tuple[int, int]:
|
||||
target_dir = Path(os.path.expandvars(save_config_dir)).expanduser().resolve()
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
archive_root = target_dir / ARCHIVE_ROOT_FOLDER
|
||||
archive_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
deleted = 0
|
||||
for pattern in ("map_archive_*.dat", "map_archive_*.dat.bak"):
|
||||
for path in target_dir.glob(pattern):
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
deleted += 1
|
||||
for folder in ARCHIVE_FOLDERS:
|
||||
folder_path = archive_root / folder
|
||||
if not folder_path.exists():
|
||||
continue
|
||||
for pattern in ("*.dat", "*.dat.bak"):
|
||||
for path in folder_path.glob(pattern):
|
||||
if path.is_file():
|
||||
path.unlink()
|
||||
deleted += 1
|
||||
|
||||
copied = 0
|
||||
archives = report.manifest.get("archives") or []
|
||||
@ -239,7 +261,12 @@ def replace_local_saves(report: ReportEntry, save_config_dir: str) -> tuple[int,
|
||||
source_file_name = Path(archive.get("sourceFileName") or "").name
|
||||
if not entry_name or not source_file_name:
|
||||
continue
|
||||
destination = target_dir / source_file_name
|
||||
archive_folder = archive_folder_for_manifest(archive, source_file_name)
|
||||
if not archive_folder:
|
||||
continue
|
||||
destination_dir = archive_root / archive_folder
|
||||
destination_dir.mkdir(parents=True, exist_ok=True)
|
||||
destination = destination_dir / source_file_name
|
||||
with zf.open(entry_name, "r") as source, destination.open("wb") as target:
|
||||
shutil.copyfileobj(source, target)
|
||||
copied += 1
|
||||
@ -480,7 +507,7 @@ class PlayerBugViewer(Tk):
|
||||
for archive in report.manifest.get("archives") or []:
|
||||
rows.append((
|
||||
"存档",
|
||||
f"{archive.get('mode')} map={archive.get('mapId')} {archive.get('kind')} {archive.get('sourceFileName')} {human_bytes(int(archive.get('fileSize') or 0))}",
|
||||
f"{archive.get('mode')} map={archive.get('mapId')} {archive.get('kind')} {archive.get('archiveFolder') or ''}/{archive.get('sourceFileName')} {human_bytes(int(archive.get('fileSize') or 0))}",
|
||||
))
|
||||
for field, value in rows:
|
||||
self.preview.insert("", END, values=(field, value))
|
||||
@ -505,7 +532,7 @@ class PlayerBugViewer(Tk):
|
||||
|
||||
confirmed = messagebox.askyesno(
|
||||
"确认替换",
|
||||
f"将清理本地所有 map_archive 存档,并替换为当前汇报内的存档。\n\n目标目录:\n{target_dir}",
|
||||
f"将清理本地 GameArchives 存档,并替换为当前汇报内的存档文件。\n\n目标目录:\n{target_dir}",
|
||||
)
|
||||
if not confirmed:
|
||||
return
|
||||
|
||||
@ -2228,371 +2228,8 @@ namespace RuntimeData
|
||||
return null;
|
||||
}
|
||||
|
||||
public static bool SaveMapData(MapData map, bool isBegin=false, bool isEnd=false)
|
||||
{
|
||||
if (map == null) return false;
|
||||
if (ShouldSkipMapArchive(map)) return false;
|
||||
|
||||
// 改为二进制文件扩展名
|
||||
string path = Application.persistentDataPath + "/../Config/map_archive";
|
||||
if (isBegin) path += "_begin";
|
||||
else if (isEnd) path += "_end";
|
||||
else path += "_continue";
|
||||
if (map.Net.Mode == NetMode.Multi) path += "_multi";
|
||||
path += $"_{map.MapID}.dat";
|
||||
int retryCount = 3;
|
||||
|
||||
while (retryCount > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] bytes = SerializeMapArchive(map);
|
||||
if (FileTools.SafeWriteFile(path, bytes))
|
||||
{
|
||||
InvalidateMapArchiveAvailabilityCache();
|
||||
return true;
|
||||
}
|
||||
retryCount--;
|
||||
if (retryCount <= 0)
|
||||
{
|
||||
LogSystem.LogError($"保存地图数据失败: 安全写入失败");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
retryCount--;
|
||||
if (retryCount <= 0)
|
||||
{
|
||||
LogSystem.LogError($"保存地图数据失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private enum MapArchiveKind
|
||||
{
|
||||
Begin,
|
||||
Continue,
|
||||
End
|
||||
}
|
||||
|
||||
private static readonly TimeSpan MapArchiveAvailabilityCacheRefreshInterval = TimeSpan.FromSeconds(1);
|
||||
private static readonly Dictionary<bool, MapArchiveAvailabilityCache> MapArchiveAvailabilityCaches =
|
||||
new Dictionary<bool, MapArchiveAvailabilityCache>();
|
||||
|
||||
private sealed class MapArchiveAvailabilityCache
|
||||
{
|
||||
public DateTime CheckedAtUtc;
|
||||
public string Fingerprint;
|
||||
public bool HasArchive;
|
||||
}
|
||||
|
||||
public static bool HasMapArchive(bool isMulti = false)
|
||||
{
|
||||
var now = DateTime.UtcNow;
|
||||
if (MapArchiveAvailabilityCaches.TryGetValue(isMulti, out var cache)
|
||||
&& now - cache.CheckedAtUtc < MapArchiveAvailabilityCacheRefreshInterval)
|
||||
{
|
||||
return cache.HasArchive;
|
||||
}
|
||||
|
||||
var fingerprint = GetMapArchiveAvailabilityFingerprint(isMulti);
|
||||
if (cache != null && cache.Fingerprint == fingerprint)
|
||||
{
|
||||
cache.CheckedAtUtc = now;
|
||||
return cache.HasArchive;
|
||||
}
|
||||
|
||||
var hasArchive = GetLatestReadableMapArchive(isMulti, MapArchiveKind.Continue, 0) != null;
|
||||
MapArchiveAvailabilityCaches[isMulti] = new MapArchiveAvailabilityCache
|
||||
{
|
||||
CheckedAtUtc = now,
|
||||
Fingerprint = fingerprint,
|
||||
HasArchive = hasArchive
|
||||
};
|
||||
return hasArchive;
|
||||
}
|
||||
|
||||
public static MapData GetMapData(bool isMulti = false, bool isBegin = false, bool isEnd = false, uint mapId = 0)
|
||||
{
|
||||
var kind = GetMapArchiveKind(isBegin, isEnd);
|
||||
return GetLatestReadableMapArchive(isMulti, kind, mapId);
|
||||
}
|
||||
|
||||
private static MapArchiveKind GetMapArchiveKind(bool isBegin, bool isEnd)
|
||||
{
|
||||
if (isBegin) return MapArchiveKind.Begin;
|
||||
if (isEnd) return MapArchiveKind.End;
|
||||
return MapArchiveKind.Continue;
|
||||
}
|
||||
|
||||
private static MapData GetLatestReadableMapArchive(bool isMulti, MapArchiveKind kind, uint mapId)
|
||||
{
|
||||
var files = GetMapArchiveCandidates(isMulti, kind, mapId);
|
||||
var targetFile = files.FirstOrDefault();
|
||||
if (targetFile == null) return null;
|
||||
|
||||
if (!TryParseMapArchiveFileName(targetFile, out _, out _, out var archiveMapId)) return null;
|
||||
var endArchiveTimes = kind == MapArchiveKind.Continue
|
||||
? GetLatestEndMapArchiveTimes(isMulti)
|
||||
: null;
|
||||
if (endArchiveTimes != null
|
||||
&& endArchiveTimes.TryGetValue(archiveMapId, out var endWriteTime)
|
||||
&& endWriteTime >= GetMapArchiveLastWriteTime(targetFile))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var mapData = ReadMapDataWithBackup(targetFile);
|
||||
if (mapData == null) return null;
|
||||
if (ShouldSkipMapArchive(mapData)) return null;
|
||||
|
||||
var expectedMode = isMulti ? NetMode.Multi : NetMode.Single;
|
||||
if (mapData.Net.Mode != expectedMode)
|
||||
{
|
||||
LogSystem.LogWarning($"存档模式与文件名不一致,跳过: {targetFile}");
|
||||
return null;
|
||||
}
|
||||
|
||||
if (kind == MapArchiveKind.Continue && mapData.PlayerMap.SelfPlayerData?.IsSurrender == true)
|
||||
return null;
|
||||
|
||||
return mapData;
|
||||
}
|
||||
|
||||
private static bool ShouldSkipMapArchive(MapData map)
|
||||
{
|
||||
return map?.Net?.Mode == NetMode.Spectator
|
||||
|| map?.MapConfig?.MatchSettlement == MatchSettlementType.Story;
|
||||
}
|
||||
|
||||
private static void InvalidateMapArchiveAvailabilityCache()
|
||||
{
|
||||
MapArchiveAvailabilityCaches.Clear();
|
||||
}
|
||||
|
||||
private static string GetMapArchiveAvailabilityFingerprint(bool isMulti)
|
||||
{
|
||||
string directory = Application.persistentDataPath + "/../Config/";
|
||||
if (!Directory.Exists(directory)) return string.Empty;
|
||||
|
||||
unchecked
|
||||
{
|
||||
long hash = 17;
|
||||
int count = 0;
|
||||
AddMapArchiveFingerprint(directory, "map_archive_continue*.dat", isMulti, ref hash, ref count);
|
||||
AddMapArchiveFingerprint(directory, "map_archive_continue*.dat.bak", isMulti, ref hash, ref count);
|
||||
AddMapArchiveFingerprint(directory, "map_archive_end*.dat", isMulti, ref hash, ref count);
|
||||
AddMapArchiveFingerprint(directory, "map_archive_end*.dat.bak", isMulti, ref hash, ref count);
|
||||
return $"{count}:{hash}";
|
||||
}
|
||||
}
|
||||
|
||||
private static void AddMapArchiveFingerprint(string directory, string pattern, bool isMulti,
|
||||
ref long hash, ref int count)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var path in Directory.EnumerateFiles(directory, pattern))
|
||||
{
|
||||
if (!TryParseMapArchiveFileName(path, out var kind, out var fileIsMulti, out _)) continue;
|
||||
if (fileIsMulti != isMulti) continue;
|
||||
if (kind != MapArchiveKind.Continue && kind != MapArchiveKind.End) continue;
|
||||
|
||||
count++;
|
||||
hash = hash * 31 + Path.GetFileName(path).GetHashCode();
|
||||
hash = hash * 31 + GetMapArchiveLastWriteTime(path).Ticks;
|
||||
hash = hash * 31 + GetMapArchiveFileLength(path);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogSystem.LogError($"读取存档目录失败: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
private static long GetMapArchiveFileLength(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return new FileInfo(path).Length;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> GetMapArchiveCandidates(bool isMulti, MapArchiveKind kind, uint mapId)
|
||||
{
|
||||
string directory = Application.persistentDataPath + "/../Config/";
|
||||
if (!Directory.Exists(directory)) return new List<string>();
|
||||
|
||||
try
|
||||
{
|
||||
return Directory.GetFiles(directory, "map_archive_*.dat")
|
||||
.Concat(Directory.GetFiles(directory, "map_archive_*.dat.bak"))
|
||||
.Where(path => IsMatchingMapArchive(path, isMulti, kind, mapId))
|
||||
.OrderByDescending(GetMapArchiveLastWriteTime)
|
||||
.ToList();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogSystem.LogError($"读取存档目录失败: {ex.Message}");
|
||||
return new List<string>();
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsMatchingMapArchive(string path, bool isMulti, MapArchiveKind kind, uint mapId)
|
||||
{
|
||||
if (!TryParseMapArchiveFileName(path, out var fileKind, out var fileIsMulti, out var fileMapId)) return false;
|
||||
if (fileKind != kind || fileIsMulti != isMulti) return false;
|
||||
return mapId == 0 || fileMapId == mapId;
|
||||
}
|
||||
|
||||
private static bool TryParseMapArchiveFileName(string path, out MapArchiveKind kind, out bool isMulti, out uint mapId)
|
||||
{
|
||||
kind = MapArchiveKind.Continue;
|
||||
isMulti = false;
|
||||
mapId = 0;
|
||||
|
||||
var fileName = Path.GetFileName(path);
|
||||
if (string.IsNullOrEmpty(fileName)) return false;
|
||||
if (fileName.EndsWith(".bak", StringComparison.OrdinalIgnoreCase))
|
||||
fileName = fileName.Substring(0, fileName.Length - ".bak".Length);
|
||||
if (!fileName.EndsWith(".dat", StringComparison.OrdinalIgnoreCase)) return false;
|
||||
|
||||
var stem = fileName.Substring(0, fileName.Length - ".dat".Length);
|
||||
const string prefix = "map_archive_";
|
||||
if (!stem.StartsWith(prefix, StringComparison.Ordinal)) return false;
|
||||
|
||||
var rest = stem.Substring(prefix.Length);
|
||||
if (rest.StartsWith("begin_", StringComparison.Ordinal))
|
||||
{
|
||||
kind = MapArchiveKind.Begin;
|
||||
rest = rest.Substring("begin_".Length);
|
||||
}
|
||||
else if (rest.StartsWith("continue_", StringComparison.Ordinal))
|
||||
{
|
||||
kind = MapArchiveKind.Continue;
|
||||
rest = rest.Substring("continue_".Length);
|
||||
}
|
||||
else if (rest.StartsWith("end_", StringComparison.Ordinal))
|
||||
{
|
||||
kind = MapArchiveKind.End;
|
||||
rest = rest.Substring("end_".Length);
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (rest.StartsWith("multi_", StringComparison.Ordinal))
|
||||
{
|
||||
isMulti = true;
|
||||
rest = rest.Substring("multi_".Length);
|
||||
}
|
||||
|
||||
return uint.TryParse(rest, out mapId);
|
||||
}
|
||||
|
||||
private static Dictionary<uint, DateTime> GetLatestEndMapArchiveTimes(bool isMulti)
|
||||
{
|
||||
var latestTimes = new Dictionary<uint, DateTime>();
|
||||
var endFiles = GetMapArchiveCandidates(isMulti, MapArchiveKind.End, 0);
|
||||
foreach (var endFile in endFiles)
|
||||
{
|
||||
if (!TryParseMapArchiveFileName(endFile, out _, out _, out var mapId)) continue;
|
||||
|
||||
var writeTime = GetMapArchiveLastWriteTime(endFile);
|
||||
if (!latestTimes.TryGetValue(mapId, out var existingTime) || writeTime > existingTime)
|
||||
latestTimes[mapId] = writeTime;
|
||||
}
|
||||
|
||||
return latestTimes;
|
||||
}
|
||||
|
||||
private static DateTime GetMapArchiveLastWriteTime(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
return File.GetLastWriteTime(path);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return DateTime.MinValue;
|
||||
}
|
||||
}
|
||||
|
||||
private static MapData ReadMapDataWithBackup(string targetFile)
|
||||
{
|
||||
if (targetFile.EndsWith(".bak", StringComparison.OrdinalIgnoreCase))
|
||||
return ReadMapDataFile(targetFile);
|
||||
|
||||
var mapData = ReadMapDataFile(targetFile);
|
||||
if (mapData != null) return mapData;
|
||||
|
||||
var backupPath = targetFile + ".bak";
|
||||
if (!File.Exists(backupPath)) return null;
|
||||
|
||||
LogSystem.LogWarning($"读取地图数据失败,尝试读取备份: {backupPath}");
|
||||
return ReadMapDataFile(backupPath);
|
||||
}
|
||||
|
||||
private static MapData ReadMapDataFile(string targetFile)
|
||||
{
|
||||
if (!File.Exists(targetFile)) return null;
|
||||
|
||||
int retryCount = 3;
|
||||
while (retryCount > 0)
|
||||
{
|
||||
try
|
||||
{
|
||||
byte[] bytes = File.ReadAllBytes(targetFile);
|
||||
var mapData = DeserializeMapArchive(bytes);
|
||||
|
||||
// 版本校验:检查反序列化后的数据是否有效
|
||||
if (mapData == null
|
||||
|| mapData.DeserializedMissingCriticalData
|
||||
|| mapData.MapConfig == null
|
||||
|| mapData.GridMap == null
|
||||
|| mapData.PlayerMap == null
|
||||
|| mapData.CityMap == null
|
||||
|| mapData.UnitMap == null
|
||||
|| mapData.Net == null)
|
||||
{
|
||||
LogSystem.LogError($"反序列化后的地图数据不完整,可能是版本不兼容");
|
||||
return null;
|
||||
}
|
||||
|
||||
return mapData;
|
||||
}
|
||||
catch (IOException ex)
|
||||
{
|
||||
retryCount--;
|
||||
if (retryCount <= 0)
|
||||
{
|
||||
LogSystem.LogError($"读取地图数据失败: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
catch (MemoryPackSerializationException ex)
|
||||
{
|
||||
LogSystem.LogError($"地图数据反序列化失败,可能是版本不兼容: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogSystem.LogError($"读取地图数据时发生未知错误: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// 旧版按 MapID 扫描/读写散落存档文件的流程已经移除。
|
||||
// 新版所有 begin / quick_continue / continue / end 都通过 GameArchiveManager 管理。
|
||||
private static byte[] SerializeMapArchive(MapData map)
|
||||
{
|
||||
var rawBytes = MemoryPackSerializer.Serialize(map);
|
||||
@ -2738,20 +2375,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)
|
||||
{
|
||||
if (Main.MapData.Net.Mode == NetMode.Single) PlayerPrefs.SetInt("Archive", 1);
|
||||
if (Main.MapData.Net.Mode == NetMode.Multi) PlayerPrefs.SetInt("MultiArchive", 1);
|
||||
PlayerPrefs.Save();
|
||||
}
|
||||
// 设置当前玩家
|
||||
Main.PlayerLogic.StartPlayerTurn(this, nextPlayer.Id);
|
||||
}
|
||||
|
||||
@ -2458,23 +2458,9 @@ namespace Logic.Action
|
||||
protected override bool Execute(CommonActionParams actionParams)
|
||||
{
|
||||
actionParams.PlayerData.Surrender(actionParams.MapData);
|
||||
SaveLocalSurrenderEndArchive(actionParams);
|
||||
return true;
|
||||
}
|
||||
|
||||
private void SaveLocalSurrenderEndArchive(CommonActionParams actionParams)
|
||||
{
|
||||
var map = actionParams.MapData;
|
||||
if (map == null || map != Main.MapData) return;
|
||||
if (actionParams.PlayerData == null || actionParams.PlayerData != map.PlayerMap.SelfPlayerData) return;
|
||||
if (map.MapConfig?.MatchSettlement == MatchSettlementType.Story) return;
|
||||
|
||||
if (map.Net.Mode == NetMode.Multi) PlayerPrefs.SetInt("MultiArchive", 0);
|
||||
if (map.Net.Mode == NetMode.Single) PlayerPrefs.SetInt("Archive", 0);
|
||||
PlayerPrefs.Save();
|
||||
MapData.SaveMapData(map, false, true);
|
||||
}
|
||||
|
||||
public override bool CheckCan(CommonActionParams actionParams)
|
||||
{
|
||||
if (actionParams.PlayerData == null) return false;
|
||||
|
||||
@ -549,14 +549,6 @@ namespace Logic
|
||||
// 教程仍不进历史记录,过滤由 GameArchiveManager 内部处理。
|
||||
GameArchiveManager.Instance.SaveEndRecord(Main.MapData);
|
||||
|
||||
// 保存存档
|
||||
if (Main.MapData.MapConfig.MatchSettlement != MatchSettlementType.Story)
|
||||
{
|
||||
if (Main.MapData.Net.Mode == NetMode.Multi) PlayerPrefs.SetInt("MultiArchive", 0);
|
||||
if (Main.MapData.Net.Mode == NetMode.Single) PlayerPrefs.SetInt("Archive", 0);
|
||||
MapData.SaveMapData(Main.MapData, false, true);
|
||||
}
|
||||
|
||||
// 上传到 oss 服务器
|
||||
var id = LobbyManager.Instance.Lobby.GetSelfMemberId();
|
||||
if (id != 0) OssManager.Instance.UploadCollectData(id.ToString(), Main.MapData);
|
||||
|
||||
@ -272,7 +272,6 @@ namespace TH1_Logic.Core
|
||||
}, 1.5f, "Main_CenterMessage_Anim");
|
||||
|
||||
MapData.SaveMatchConfig(MapConfig);
|
||||
MapData.SaveMapData(MapData, true);
|
||||
// 新版存档:新开局一定先写 begin。
|
||||
// 后续每回合 quick、玩家手动 manual、最终 end 都会通过这个 begin 串起来。
|
||||
GameArchiveManager.Instance.SaveBeginArchive(MapData);
|
||||
@ -284,12 +283,17 @@ namespace TH1_Logic.Core
|
||||
MapData.RefreshTurn();
|
||||
}
|
||||
|
||||
public bool ResumeMatch(GameRecord record)
|
||||
public bool ResumeMatch(GameRecord record, MapData prereadMap = null)
|
||||
{
|
||||
// 新版 record 继续入口。
|
||||
// 传入 Quick/Manual record 后,先通过 record.ContinueArchiveId 读取 MapData。
|
||||
// 只有读到完整、NetMode 匹配的 continue 文件,才会进入真正的单机/联机开始流程。
|
||||
if (!GameArchiveManager.Instance.TryLoadContinueArchive(record, out var resumeMap)) return false;
|
||||
if (record == null) return false;
|
||||
|
||||
var resumeMap = prereadMap;
|
||||
if (resumeMap == null && !GameArchiveManager.Instance.TryLoadContinueArchive(record, out resumeMap))
|
||||
return false;
|
||||
if (resumeMap?.Net == null || resumeMap.Net.Mode != record.NetMode) return false;
|
||||
|
||||
// record 继续要沿用原 record.BeginArchiveId。
|
||||
// 如果开始流程失败,需要恢复之前的 begin 会话,避免后续保存挂错局。
|
||||
@ -298,37 +302,21 @@ namespace TH1_Logic.Core
|
||||
var result = record.NetMode switch
|
||||
{
|
||||
// 单机 record:直接复用单机读档开始流程。
|
||||
NetMode.Single => ResumeMatch(resumeMap, true),
|
||||
NetMode.Single => ResumeMatch(resumeMap),
|
||||
// 联机 record:仍走房主联机继续流程,里面会做 Net.RefreshPlayerNet 和 GameStart 广播。
|
||||
NetMode.Multi => MainMemberResumeMatchWithMap(resumeMap, true),
|
||||
NetMode.Multi => MainMemberResumeMatchWithMap(resumeMap),
|
||||
_ => false
|
||||
};
|
||||
if (!result) GameArchiveManager.Instance.SetCurrentBeginArchiveId(previousBeginArchiveId);
|
||||
return result;
|
||||
}
|
||||
|
||||
public bool ResumeMatch(uint mapId)
|
||||
{
|
||||
if (mapId == 0) return false;
|
||||
var resumeMap = MapData.GetMapData(mapId: mapId);
|
||||
return resumeMap != null && ResumeMatch(resumeMap);
|
||||
}
|
||||
|
||||
// 继续单机游戏。
|
||||
//
|
||||
// prereadMap:
|
||||
// - 旧 UI 可以预读 MapData 传进来,避免 Loading 图和正式进入时重复反序列化。
|
||||
//
|
||||
// useCurrentArchiveSession:
|
||||
// - false:旧继续入口,没有 record 索引,需要给这次会话新建 begin。
|
||||
// - true:新版 record 继续入口,已经在 ResumeMatch(GameRecord) 里绑定了 BeginArchiveId,不能再新建 begin。
|
||||
public bool ResumeMatch(MapData prereadMap = null, bool useCurrentArchiveSession = false)
|
||||
// 这里不再提供无 record 的旧 continue 入口;调用方必须先通过 GameRecord 找到 continue 文件。
|
||||
// ResumeMatch(GameRecord) 已经绑定 record.BeginArchiveId,这里只负责把传入的 MapData 启动起来。
|
||||
private bool ResumeMatch(MapData resumeMap)
|
||||
{
|
||||
//如果没有存档,退出(外部已预读传入时跳过存档磁盘检查,map 自身即证据)
|
||||
if (prereadMap == null && !HasArchive()) return false;
|
||||
|
||||
//step #2 读取存档的map(外部已预读则复用,避免重复反序列化)
|
||||
var resumeMap = prereadMap ?? MapData.GetMapData();
|
||||
if (resumeMap == null) return false;
|
||||
MapData = resumeMap;
|
||||
|
||||
@ -356,9 +344,7 @@ 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);
|
||||
// 继续游戏不会新建 begin;后续 quick/end 会继续挂到 record.BeginArchiveId。
|
||||
MapData.RefreshTurn();
|
||||
return true;
|
||||
}
|
||||
@ -442,7 +428,6 @@ namespace TH1_Logic.Core
|
||||
//UIManager.Instance.CenterMessageUI.SetCenterMessageShow(UICenterMessageID.StartGame,MapData.PlayerMap.SelfPlayerData);
|
||||
},1.5f,"Main_CenterMessage_Anim");
|
||||
|
||||
MapData.SaveMapData(MapData, true);
|
||||
// 联机新开局也写 begin,但必须放在 GameStart 广播成功之后。
|
||||
// 否则房主本地开始失败时可能留下一个没有真正开局的 begin。
|
||||
GameArchiveManager.Instance.SaveBeginArchive(MapData);
|
||||
@ -463,38 +448,14 @@ namespace TH1_Logic.Core
|
||||
PlayerLogic?.UpdateAllTeammateCapitalSight(mapData);
|
||||
}
|
||||
|
||||
public bool MainMemberResumeMatch(uint mapId)
|
||||
{
|
||||
if (mapId == 0) return false;
|
||||
var resumeMap = MapData.GetMapData(isMulti: true, mapId: mapId);
|
||||
if (resumeMap == null)
|
||||
{
|
||||
NetworkPlayerTipManager.Instance.Request(NetworkPlayerTipType.GameStartFailed);
|
||||
return false;
|
||||
}
|
||||
|
||||
return MainMemberResumeMatchWithMap(resumeMap);
|
||||
}
|
||||
|
||||
// 房主继续多人游戏
|
||||
public bool MainMemberResumeMatch()
|
||||
{
|
||||
//如果没有存档,退出
|
||||
if (!HasMultiArchive()) return false;
|
||||
|
||||
return MainMemberResumeMatchWithMap(MapData.GetMapData(isMulti: true));
|
||||
}
|
||||
|
||||
// 房主联机继续的实际实现。
|
||||
//
|
||||
// useCurrentArchiveSession 与单机 ResumeMatch 相同:
|
||||
// - false:旧联机继续入口,需要补 begin。
|
||||
// - true:新版 record 继续入口,沿用 record.BeginArchiveId。
|
||||
// ResumeMatch(GameRecord) 已经绑定 record.BeginArchiveId,这里只负责用传入 MapData 启动房主联机继续。
|
||||
//
|
||||
// 网络安全点:
|
||||
// - 先 RefreshPlayerNet 校验当前房间成员映射。
|
||||
// - GameStart 广播成功后才算真正进入游戏,失败会回滚。
|
||||
private bool MainMemberResumeMatchWithMap(MapData resumeMap, bool useCurrentArchiveSession = false)
|
||||
private bool MainMemberResumeMatchWithMap(MapData resumeMap)
|
||||
{
|
||||
var previousMap = MapData;
|
||||
var previousInput = InputLogic;
|
||||
@ -553,9 +514,7 @@ namespace TH1_Logic.Core
|
||||
return false;
|
||||
}
|
||||
|
||||
// 旧联机继续没有 record.BeginArchiveId,广播成功后补一条 begin。
|
||||
// 新版 record 继续必须沿用原 begin,保证后续 quick/end 仍挂在同一局链路下。
|
||||
if (!useCurrentArchiveSession) GameArchiveManager.Instance.SaveBeginArchive(MapData);
|
||||
// 继续游戏不会新建 begin;后续 quick/end 会继续挂到 record.BeginArchiveId。
|
||||
MapData.RefreshTurn();
|
||||
LogSystem.LogInfo($"MainMemberResumeMatch : {NetData.GetMapDataHash(MapData)}");
|
||||
return true;
|
||||
@ -849,7 +808,8 @@ namespace TH1_Logic.Core
|
||||
Debug.Log("Main : Trgger Force Change To NetGame");
|
||||
//UIManager.Instance.GameUI.CloseAllGameUI();
|
||||
UIManager.Instance.UIOutsideManager.CloseAll();
|
||||
MainMemberResumeMatch();
|
||||
var record = GameArchiveManager.Instance.GetQuickResumeRecord(NetMode.Multi);
|
||||
if (record != null) ResumeMatch(record);
|
||||
|
||||
}
|
||||
}
|
||||
@ -875,18 +835,6 @@ namespace TH1_Logic.Core
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
public bool HasArchive()
|
||||
{
|
||||
return MapData.HasMapArchive();
|
||||
}
|
||||
|
||||
public bool HasMultiArchive()
|
||||
{
|
||||
return MapData.HasMapArchive(true);
|
||||
}
|
||||
|
||||
private void OnApplicationQuit()
|
||||
{
|
||||
LobbyManager.Instance.Lobby.Cleanup();
|
||||
|
||||
@ -12,6 +12,7 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using RuntimeData;
|
||||
using TH1_Logic.Core;
|
||||
using TH1_Logic.GameArchive;
|
||||
using UnityEditor;
|
||||
using UnityEngine;
|
||||
|
||||
@ -76,6 +77,7 @@ namespace Logic.Editor
|
||||
EditorGUILayout.BeginVertical("box");
|
||||
|
||||
EditorGUILayout.LabelField($"Map ID: {pair.MapId}", EditorStyles.boldLabel);
|
||||
EditorGUILayout.LabelField($"记录: {pair.RecordName}");
|
||||
EditorGUILayout.LabelField($"类型: {(pair.IsMulti ? "多人" : "单人")}");
|
||||
EditorGUILayout.LabelField($"开始存档: {Path.GetFileName(pair.BeginPath)}");
|
||||
EditorGUILayout.LabelField($"{GetEndKindLabel(pair.EndKind)}: {Path.GetFileName(pair.EndPath)}");
|
||||
@ -99,54 +101,12 @@ namespace Logic.Editor
|
||||
private void RefreshMapList()
|
||||
{
|
||||
_mapPairs.Clear();
|
||||
|
||||
string directory = Application.persistentDataPath + "/../Config/";
|
||||
if (!Directory.Exists(directory))
|
||||
|
||||
var records = GameRecordManager.Instance.GameRecordData?.Records ?? new List<GameRecord>();
|
||||
foreach (var record in records)
|
||||
{
|
||||
Debug.LogWarning($"存档目录不存在: {directory}");
|
||||
return;
|
||||
}
|
||||
|
||||
// 查找所有 begin 文件
|
||||
var beginFiles = Directory.GetFiles(directory, "map_archive_begin*.dat")
|
||||
.Concat(Directory.GetFiles(directory, "map_archive_begin*.dat.bak"))
|
||||
.OrderByDescending(File.GetLastWriteTime);
|
||||
var visitedMapKeys = new HashSet<string>();
|
||||
|
||||
foreach (var beginFile in beginFiles)
|
||||
{
|
||||
var fileName = Path.GetFileName(beginFile);
|
||||
|
||||
// 解析文件名获取 MapId 和类型
|
||||
if (!TryParseFileName(fileName, out uint mapId, out bool isMulti))
|
||||
continue;
|
||||
|
||||
var mapKey = $"{isMulti}:{mapId}";
|
||||
if (!visitedMapKeys.Add(mapKey))
|
||||
continue;
|
||||
|
||||
// 优先查找对应的 end 文件;没有 end 时使用 continue 作为回放终点。
|
||||
var endKind = ReplayEndKind.End;
|
||||
var endPath = FindArchivePath(directory, "end", isMulti, mapId);
|
||||
if (endPath == null)
|
||||
{
|
||||
endKind = ReplayEndKind.Continue;
|
||||
endPath = FindArchivePath(directory, "continue", isMulti, mapId);
|
||||
}
|
||||
|
||||
if (endPath == null)
|
||||
continue;
|
||||
|
||||
// 添加到列表
|
||||
_mapPairs.Add(new MapArchivePair
|
||||
{
|
||||
MapId = mapId,
|
||||
IsMulti = isMulti,
|
||||
BeginPath = beginFile,
|
||||
EndPath = endPath,
|
||||
EndKind = endKind,
|
||||
LastModifiedTime = Max(File.GetLastWriteTime(beginFile), File.GetLastWriteTime(endPath))
|
||||
});
|
||||
if (TryBuildMapArchivePair(record, out var pair))
|
||||
_mapPairs.Add(pair);
|
||||
}
|
||||
|
||||
// 按修改时间降序排序
|
||||
@ -155,45 +115,61 @@ namespace Logic.Editor
|
||||
Debug.Log($"找到 {_mapPairs.Count} 对可观战的地图存档");
|
||||
}
|
||||
|
||||
private bool TryParseFileName(string fileName, out uint mapId, out bool isMulti)
|
||||
private bool TryBuildMapArchivePair(GameRecord record, out MapArchivePair pair)
|
||||
{
|
||||
mapId = 0;
|
||||
isMulti = false;
|
||||
|
||||
// 文件名格式: map_archive_begin[_multi]_{mapId}.dat
|
||||
var stem = StripArchiveExtensions(fileName);
|
||||
if (string.IsNullOrEmpty(stem))
|
||||
return false;
|
||||
|
||||
const string singlePrefix = "map_archive_begin_";
|
||||
const string multiPrefix = "map_archive_begin_multi_";
|
||||
string idPart;
|
||||
if (stem.StartsWith(multiPrefix, StringComparison.Ordinal))
|
||||
{
|
||||
isMulti = true;
|
||||
idPart = stem.Substring(multiPrefix.Length);
|
||||
}
|
||||
else if (stem.StartsWith(singlePrefix, StringComparison.Ordinal))
|
||||
{
|
||||
idPart = stem.Substring(singlePrefix.Length);
|
||||
}
|
||||
else
|
||||
pair = null;
|
||||
if (record == null || string.IsNullOrEmpty(record.BeginArchiveId)) return false;
|
||||
if (!GameArchiveManager.Instance.TryGetArchivePath(
|
||||
GameArchiveFileKind.Begin,
|
||||
record.BeginArchiveId,
|
||||
out var beginPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return uint.TryParse(idPart, out mapId);
|
||||
var endKind = ReplayEndKind.End;
|
||||
var companionArchiveKind = GameArchiveFileKind.End;
|
||||
var companionArchiveId = record.EndArchiveId;
|
||||
if (string.IsNullOrEmpty(companionArchiveId))
|
||||
{
|
||||
endKind = ReplayEndKind.Continue;
|
||||
companionArchiveKind = record.RecordKind == GameRecordKind.Quick
|
||||
? GameArchiveFileKind.QuickContinue
|
||||
: GameArchiveFileKind.Continue;
|
||||
companionArchiveId = record.ContinueArchiveId;
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(companionArchiveId)) return false;
|
||||
if (!GameArchiveManager.Instance.TryGetArchivePath(companionArchiveKind, companionArchiveId, out var endPath))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
pair = new MapArchivePair
|
||||
{
|
||||
Record = record,
|
||||
RecordName = GetRecordDisplayName(record),
|
||||
MapId = record.MapID,
|
||||
IsMulti = record.NetMode == NetMode.Multi,
|
||||
BeginPath = beginPath,
|
||||
EndPath = endPath,
|
||||
EndKind = endKind,
|
||||
LastModifiedTime = Max(File.GetLastWriteTime(beginPath), File.GetLastWriteTime(endPath))
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
private void LoadMapForSpectator(MapArchivePair pair)
|
||||
{
|
||||
// 这里添加加载地图的逻辑
|
||||
// 例如: 调用游戏中的地图加载方法
|
||||
// Main.Instance.LoadSpectatorMap(mapId, isMulti);
|
||||
var startMap = MapData.GetMapData(pair.IsMulti, true, false, pair.MapId);
|
||||
var endMap = pair.EndKind == ReplayEndKind.End
|
||||
? MapData.GetMapData(pair.IsMulti, false, true, pair.MapId)
|
||||
: MapData.GetMapData(pair.IsMulti, false, false, pair.MapId);
|
||||
if (!GameArchiveManager.Instance.TryLoadBeginArchive(pair.Record, out var startMap))
|
||||
startMap = null;
|
||||
|
||||
MapData endMap = null;
|
||||
if (pair.EndKind == ReplayEndKind.End)
|
||||
GameArchiveManager.Instance.TryLoadEndArchive(pair.Record, out endMap);
|
||||
else
|
||||
GameArchiveManager.Instance.TryLoadContinueArchive(pair.Record, out endMap);
|
||||
|
||||
if (startMap == null || endMap == null)
|
||||
{
|
||||
Debug.LogWarning($"加载回放存档失败: mapId={pair.MapId}, isMulti={pair.IsMulti}, endKind={pair.EndKind}");
|
||||
@ -203,24 +179,10 @@ namespace Logic.Editor
|
||||
Main.Instance.StartSpectate(startMap, endMap);
|
||||
}
|
||||
|
||||
private static string FindArchivePath(string directory, string kind, bool isMulti, uint mapId)
|
||||
private static string GetRecordDisplayName(GameRecord record)
|
||||
{
|
||||
var fileName = $"map_archive_{kind}{(isMulti ? "_multi" : "")}_{mapId}.dat";
|
||||
var path = Path.Combine(directory, fileName);
|
||||
if (File.Exists(path)) return path;
|
||||
|
||||
var backupPath = path + ".bak";
|
||||
return File.Exists(backupPath) ? backupPath : null;
|
||||
}
|
||||
|
||||
private static string StripArchiveExtensions(string fileName)
|
||||
{
|
||||
if (string.IsNullOrEmpty(fileName)) return string.Empty;
|
||||
if (fileName.EndsWith(".bak", StringComparison.OrdinalIgnoreCase))
|
||||
fileName = fileName.Substring(0, fileName.Length - ".bak".Length);
|
||||
if (!fileName.EndsWith(".dat", StringComparison.OrdinalIgnoreCase))
|
||||
return string.Empty;
|
||||
return fileName.Substring(0, fileName.Length - ".dat".Length);
|
||||
if (!string.IsNullOrEmpty(record.RecordName)) return record.RecordName;
|
||||
return $"{record.RecordKind} {record.Time}";
|
||||
}
|
||||
|
||||
private static DateTime Max(DateTime a, DateTime b)
|
||||
@ -241,6 +203,8 @@ namespace Logic.Editor
|
||||
|
||||
private class MapArchivePair
|
||||
{
|
||||
public GameRecord Record;
|
||||
public string RecordName;
|
||||
public uint MapId;
|
||||
public bool IsMulti;
|
||||
public string BeginPath;
|
||||
|
||||
@ -61,7 +61,7 @@ namespace TH1_Logic.GameArchive
|
||||
// 调用时机:
|
||||
// - 单机新开局:地图生成完成后。
|
||||
// - 联机房主新开局:GameStart 广播成功后,避免网络开始失败却留下 begin。
|
||||
// - 旧的非 record 继续入口:为了让之后的 quick/manual/end 也能挂到一个 begin。
|
||||
// - 存档兜底:如果当前局缺少 begin,会先补一条 begin,避免 quick/manual/end 没有索引。
|
||||
public bool SaveBeginArchive(MapData map)
|
||||
{
|
||||
// 先清空旧会话 id,避免本次 begin 写入失败时误把后续存档挂到上一局。
|
||||
@ -167,6 +167,21 @@ namespace TH1_Logic.GameArchive
|
||||
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 的含义:
|
||||
@ -186,6 +201,56 @@ namespace TH1_Logic.GameArchive
|
||||
return mapData != null;
|
||||
}
|
||||
|
||||
// 读取 record 对应的 begin 快照,供回放、OSS 上传等需要“开局状态”的系统使用。
|
||||
public bool TryLoadBeginArchive(GameRecord record, out MapData mapData)
|
||||
{
|
||||
mapData = null;
|
||||
if (record == null) 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;
|
||||
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)
|
||||
@ -245,7 +310,7 @@ namespace TH1_Logic.GameArchive
|
||||
}
|
||||
|
||||
// 确保当前局已经有 begin。
|
||||
// 正常新开局会先 SaveBeginArchive;这里主要兜底旧继续入口或异常调用顺序。
|
||||
// 正常新开局或 record 继续都会先绑定 begin;这里主要兜底异常调用顺序。
|
||||
private bool EnsureCurrentBeginArchive(MapData map)
|
||||
{
|
||||
return !string.IsNullOrEmpty(_currentBeginArchiveId) || SaveBeginArchive(map);
|
||||
@ -409,7 +474,7 @@ namespace TH1_Logic.GameArchive
|
||||
return Path.Combine(GetArchiveDirectory(archiveKind), archiveId + ".dat");
|
||||
}
|
||||
|
||||
// 新系统全部放在 Config/GameArchives 下,和旧 map_archive_* 文件分开。
|
||||
// 新系统全部放在 Config/GameArchives 下,和旧的散落存档文件路径隔离。
|
||||
private string GetArchiveDirectory(GameArchiveFileKind archiveKind)
|
||||
{
|
||||
var root = Path.GetFullPath(Path.Combine(Application.persistentDataPath, "../Config", ArchiveRootFolderName));
|
||||
|
||||
@ -7,6 +7,7 @@ using Steamworks;
|
||||
using TH1_Logic.Collect;
|
||||
using TH1_Logic.Config;
|
||||
using TH1_Logic.Core;
|
||||
using TH1_Logic.GameArchive;
|
||||
using TH1_Logic.Net;
|
||||
using UnityEngine;
|
||||
|
||||
@ -43,8 +44,7 @@ namespace TH1_Logic.Oss
|
||||
public void UploadMapData(string steamId, MapData endMap)
|
||||
{
|
||||
if (endMap.Net.Mode == NetMode.Multi && !LobbyManager.Instance.Lobby.IsLobbyOwner()) return;
|
||||
var beginMap = MapData.GetMapData(endMap.Net.Mode == NetMode.Multi, true, false, endMap.MapID);
|
||||
if (beginMap == null)
|
||||
if (!GameArchiveManager.Instance.TryLoadCurrentBeginArchive(endMap.Net.Mode, out var beginMap))
|
||||
{
|
||||
LogSystem.LogError($"UploadMapData Error beginMap is null : {endMap.Net.Mode} {endMap.MapID}");
|
||||
return;
|
||||
|
||||
@ -13,7 +13,9 @@ using System.Text;
|
||||
using Logic.Config;
|
||||
using Logic.CrashSight;
|
||||
using Logic.Multilingual;
|
||||
using RuntimeData;
|
||||
using TH1_Logic.Config;
|
||||
using TH1_Logic.GameArchive;
|
||||
using UnityEngine;
|
||||
|
||||
|
||||
@ -32,6 +34,7 @@ namespace TH1_Logic.Oss
|
||||
public string mode;
|
||||
public uint mapId;
|
||||
public string kind;
|
||||
public string archiveFolder;
|
||||
public string sourceFileName;
|
||||
public string zipEntry;
|
||||
public long fileSize;
|
||||
@ -370,102 +373,57 @@ namespace TH1_Logic.Oss
|
||||
private static bool TryGetLatestArchiveSession(bool isMulti, out PlayerBugReportArchiveSession session)
|
||||
{
|
||||
session = null;
|
||||
var files = GetArchiveFiles(isMulti);
|
||||
if (files.Count == 0) return false;
|
||||
|
||||
var groups = new Dictionary<uint, ArchiveGroup>();
|
||||
foreach (var file in files)
|
||||
{
|
||||
if (!groups.TryGetValue(file.MapId, out var group))
|
||||
{
|
||||
group = new ArchiveGroup { MapId = file.MapId, IsMulti = isMulti };
|
||||
groups[file.MapId] = group;
|
||||
}
|
||||
|
||||
group.Add(file);
|
||||
}
|
||||
|
||||
session = groups.Values
|
||||
.Select(group => group.TryBuildSession())
|
||||
var expectedMode = isMulti ? NetMode.Multi : NetMode.Single;
|
||||
var records = GameRecordManager.Instance.GameRecordData?.Records ?? new List<GameRecord>();
|
||||
session = records
|
||||
.Where(record => record != null && record.NetMode == expectedMode)
|
||||
.Select(TryBuildArchiveSession)
|
||||
.Where(value => value != null)
|
||||
.OrderByDescending(value => value.LatestWriteTimeUtc)
|
||||
.FirstOrDefault();
|
||||
return session != null;
|
||||
}
|
||||
|
||||
private static List<ArchiveFile> GetArchiveFiles(bool isMulti)
|
||||
private static PlayerBugReportArchiveSession TryBuildArchiveSession(GameRecord record)
|
||||
{
|
||||
var result = new List<ArchiveFile>();
|
||||
var directory = ConfigDirectory;
|
||||
if (!Directory.Exists(directory)) return result;
|
||||
|
||||
foreach (var pattern in new[] { "map_archive_*.dat", "map_archive_*.dat.bak" })
|
||||
if (record == null || string.IsNullOrEmpty(record.BeginArchiveId)) return null;
|
||||
if (!GameArchiveManager.Instance.TryGetArchivePath(
|
||||
GameArchiveFileKind.Begin,
|
||||
record.BeginArchiveId,
|
||||
out var beginPath))
|
||||
{
|
||||
foreach (var path in Directory.GetFiles(directory, pattern))
|
||||
{
|
||||
if (TryParseArchiveFile(path, out var file) && file.IsMulti == isMulti)
|
||||
result.Add(file);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private static bool TryParseArchiveFile(string path, out ArchiveFile file)
|
||||
{
|
||||
file = null;
|
||||
|
||||
var fileName = Path.GetFileName(path);
|
||||
if (string.IsNullOrEmpty(fileName)) return false;
|
||||
|
||||
if (fileName.EndsWith(".bak", StringComparison.OrdinalIgnoreCase))
|
||||
fileName = fileName.Substring(0, fileName.Length - ".bak".Length);
|
||||
if (!fileName.EndsWith(".dat", StringComparison.OrdinalIgnoreCase)) return false;
|
||||
|
||||
var stem = fileName.Substring(0, fileName.Length - ".dat".Length);
|
||||
const string prefix = "map_archive_";
|
||||
if (!stem.StartsWith(prefix, StringComparison.Ordinal)) return false;
|
||||
|
||||
var rest = stem.Substring(prefix.Length);
|
||||
var kind = PlayerBugArchiveKind.Continue;
|
||||
if (rest.StartsWith("begin_", StringComparison.Ordinal))
|
||||
var companionKind = PlayerBugArchiveKind.End;
|
||||
var companionArchiveKind = GameArchiveFileKind.End;
|
||||
var companionArchiveId = record.EndArchiveId;
|
||||
if (string.IsNullOrEmpty(companionArchiveId))
|
||||
{
|
||||
kind = PlayerBugArchiveKind.Begin;
|
||||
rest = rest.Substring("begin_".Length);
|
||||
}
|
||||
else if (rest.StartsWith("continue_", StringComparison.Ordinal))
|
||||
{
|
||||
kind = PlayerBugArchiveKind.Continue;
|
||||
rest = rest.Substring("continue_".Length);
|
||||
}
|
||||
else if (rest.StartsWith("end_", StringComparison.Ordinal))
|
||||
{
|
||||
kind = PlayerBugArchiveKind.End;
|
||||
rest = rest.Substring("end_".Length);
|
||||
}
|
||||
else
|
||||
{
|
||||
return false;
|
||||
companionKind = PlayerBugArchiveKind.Continue;
|
||||
companionArchiveKind = record.RecordKind == GameRecordKind.Quick
|
||||
? GameArchiveFileKind.QuickContinue
|
||||
: GameArchiveFileKind.Continue;
|
||||
companionArchiveId = record.ContinueArchiveId;
|
||||
}
|
||||
if (string.IsNullOrEmpty(companionArchiveId)) return null;
|
||||
if (!GameArchiveManager.Instance.TryGetArchivePath(
|
||||
companionArchiveKind,
|
||||
companionArchiveId,
|
||||
out var companionPath))
|
||||
return null;
|
||||
|
||||
var isMulti = false;
|
||||
if (rest.StartsWith("multi_", StringComparison.Ordinal))
|
||||
return new PlayerBugReportArchiveSession
|
||||
{
|
||||
isMulti = true;
|
||||
rest = rest.Substring("multi_".Length);
|
||||
}
|
||||
|
||||
if (!uint.TryParse(rest, out var mapId)) return false;
|
||||
|
||||
file = new ArchiveFile
|
||||
{
|
||||
Path = path,
|
||||
Kind = kind,
|
||||
IsMulti = isMulti,
|
||||
MapId = mapId,
|
||||
LastWriteTimeUtc = File.GetLastWriteTimeUtc(path)
|
||||
IsMulti = record.NetMode == NetMode.Multi,
|
||||
MapId = record.MapID,
|
||||
BeginPath = beginPath,
|
||||
CompanionPath = companionPath,
|
||||
CompanionKind = companionKind,
|
||||
LatestWriteTimeUtc = File.GetLastWriteTimeUtc(beginPath) > File.GetLastWriteTimeUtc(companionPath)
|
||||
? File.GetLastWriteTimeUtc(beginPath)
|
||||
: File.GetLastWriteTimeUtc(companionPath)
|
||||
};
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void AddArchiveFile(ZipArchive zip, PlayerBugReportArchiveSession session,
|
||||
@ -475,7 +433,10 @@ namespace TH1_Logic.Oss
|
||||
|
||||
var kindLabel = GetKindLabel(kind);
|
||||
var sourceFileName = Path.GetFileName(sourcePath);
|
||||
var entryPath = $"saves/{session.ModeLabel}/{sourceFileName}";
|
||||
var archiveFolder = Path.GetFileName(Path.GetDirectoryName(sourcePath)) ?? "";
|
||||
var entryPath = string.IsNullOrEmpty(archiveFolder)
|
||||
? $"saves/{session.ModeLabel}/{sourceFileName}"
|
||||
: $"saves/{session.ModeLabel}/{archiveFolder}/{sourceFileName}";
|
||||
var entry = zip.CreateEntry(entryPath, System.IO.Compression.CompressionLevel.Optimal);
|
||||
using (var entryStream = entry.Open())
|
||||
using (var fileStream = File.OpenRead(sourcePath))
|
||||
@ -489,6 +450,7 @@ namespace TH1_Logic.Oss
|
||||
mode = session.ModeLabel,
|
||||
mapId = session.MapId,
|
||||
kind = kindLabel,
|
||||
archiveFolder = archiveFolder,
|
||||
sourceFileName = sourceFileName,
|
||||
zipEntry = entryPath,
|
||||
fileSize = fileInfo.Length,
|
||||
@ -514,66 +476,6 @@ namespace TH1_Logic.Oss
|
||||
};
|
||||
}
|
||||
|
||||
private class ArchiveFile
|
||||
{
|
||||
public string Path;
|
||||
public PlayerBugArchiveKind Kind;
|
||||
public bool IsMulti;
|
||||
public uint MapId;
|
||||
public DateTime LastWriteTimeUtc;
|
||||
}
|
||||
|
||||
private class ArchiveGroup
|
||||
{
|
||||
public uint MapId;
|
||||
public bool IsMulti;
|
||||
private ArchiveFile _begin;
|
||||
private ArchiveFile _continue;
|
||||
private ArchiveFile _end;
|
||||
|
||||
public void Add(ArchiveFile file)
|
||||
{
|
||||
switch (file.Kind)
|
||||
{
|
||||
case PlayerBugArchiveKind.Begin:
|
||||
if (_begin == null || file.LastWriteTimeUtc > _begin.LastWriteTimeUtc) _begin = file;
|
||||
break;
|
||||
case PlayerBugArchiveKind.End:
|
||||
if (_end == null || file.LastWriteTimeUtc > _end.LastWriteTimeUtc) _end = file;
|
||||
break;
|
||||
default:
|
||||
if (_continue == null || file.LastWriteTimeUtc > _continue.LastWriteTimeUtc) _continue = file;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public PlayerBugReportArchiveSession TryBuildSession()
|
||||
{
|
||||
if (_begin == null) return null;
|
||||
var companion = PickCompanion();
|
||||
if (companion == null) return null;
|
||||
|
||||
return new PlayerBugReportArchiveSession
|
||||
{
|
||||
IsMulti = IsMulti,
|
||||
MapId = MapId,
|
||||
BeginPath = _begin.Path,
|
||||
CompanionPath = companion.Path,
|
||||
CompanionKind = companion.Kind,
|
||||
LatestWriteTimeUtc = _begin.LastWriteTimeUtc > companion.LastWriteTimeUtc
|
||||
? _begin.LastWriteTimeUtc
|
||||
: companion.LastWriteTimeUtc
|
||||
};
|
||||
}
|
||||
|
||||
private ArchiveFile PickCompanion()
|
||||
{
|
||||
if (_end == null) return _continue;
|
||||
if (_continue == null) return _end;
|
||||
return _end.LastWriteTimeUtc >= _continue.LastWriteTimeUtc ? _end : _continue;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetLocalTimezone()
|
||||
{
|
||||
try
|
||||
|
||||
@ -11,6 +11,7 @@ using TH1_Core.Events;
|
||||
using TH1_Core.Managers;
|
||||
using TH1_Logic.Core;
|
||||
using TH1_Logic.Config;
|
||||
using TH1_Logic.GameArchive;
|
||||
using TH1_Logic.Net;
|
||||
using TH1_Logic.Steam;
|
||||
using TH1_UI.Components;
|
||||
@ -241,6 +242,8 @@ namespace TH1_UI.View.Bottom
|
||||
if (Main.MapData == null) return;
|
||||
if (!Main.MapData.CurPlayer.IsSelfPlayer()) return;
|
||||
if (Main.MapData.Net.Mode != NetMode.Single) return;
|
||||
var resumeRecord = GameArchiveManager.Instance.GetQuickResumeRecord(NetMode.Single);
|
||||
if (resumeRecord == null) return;
|
||||
|
||||
// 切到 Menu 状态前先抓 Empire:MenuState.Enter 会调 Main.Instance.Clear() 清空 MapData
|
||||
var loadingEvt = new ShowUIOutsideLoading();
|
||||
@ -253,7 +256,7 @@ namespace TH1_UI.View.Bottom
|
||||
//播放loading
|
||||
EventManager.Publish(loadingEvt);
|
||||
//重置游戏
|
||||
Timer.Instance.TimerRegister(this, () => { Main.Instance.ResumeMatch(); }, 0.3f,"MainUI_ShowLoadingAndRusume");
|
||||
Timer.Instance.TimerRegister(this, () => { Main.Instance.ResumeMatch(resumeRecord); }, 0.3f,"MainUI_ShowLoadingAndRusume");
|
||||
//关闭loading
|
||||
Timer.Instance.TimerRegister(this,()=> { EventManager.Publish(new HideUIOutsideLoading()); },1f,"MainUI_ShowLoadingAndRusume2");
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ using TH1_Core.Managers;
|
||||
using TH1_Logic.Action;
|
||||
using TH1_Logic.Config;
|
||||
using TH1_Logic.Core;
|
||||
using TH1_Logic.GameArchive;
|
||||
using TH1_Logic.Net;
|
||||
using TH1_Logic.Steam;
|
||||
using TH1_UI.View.Announce;
|
||||
@ -123,7 +124,7 @@ namespace TH1_UI.View.Outside
|
||||
MultiplayButton.onClick.AddListener(OnMultiplayClicked);
|
||||
ResumeButton.onClick.RemoveAllListeners();
|
||||
ResumeButton.gameObject.SetActive(false);
|
||||
if (Main.Instance.HasArchive())
|
||||
if (GameArchiveManager.Instance.HasQuickResumeArchive(NetMode.Single))
|
||||
{
|
||||
ResumeButton.gameObject.SetActive(true);
|
||||
ResumeButton.onClick.AddListener(OnResumeClicked);
|
||||
@ -170,10 +171,11 @@ namespace TH1_UI.View.Outside
|
||||
|
||||
public void OnResumeClicked()
|
||||
{
|
||||
if (!Main.Instance.HasArchive()) return;
|
||||
var record = GameArchiveManager.Instance.GetQuickResumeRecord(NetMode.Single);
|
||||
if (record == null) return;
|
||||
|
||||
// 预读存档拿 Empire 用于切换 Loading 图;ResumeMatch 会复用该实例,不会重复反序列化
|
||||
var preread = MapData.GetMapData();
|
||||
// 预读新 quick 存档拿 Empire 用于切换 Loading 图;ResumeMatch 会复用该实例,不会重复反序列化。
|
||||
if (!GameArchiveManager.Instance.TryLoadContinueArchive(record, out var preread)) return;
|
||||
if (preread == null) return;
|
||||
// 单机存档 SelfPlayer 与 ResumeMatch 内一致:PlayerDataList[0]
|
||||
var dataList = preread.PlayerMap?.PlayerDataList;
|
||||
@ -184,7 +186,7 @@ namespace TH1_UI.View.Outside
|
||||
EventManager.Publish(loadingEvt);
|
||||
var fadeinTime = ResourceCache.Instance.AnimCache.UICommonPanelFadeIn.length;
|
||||
var prepareTime = 1f;
|
||||
Timer.Instance.TimerRegister(this,()=>{Main.Instance.ResumeMatch(preread);EventManager.Publish(new HideUIOutsideMenu());},fadeinTime,"MenuResumeClicked1");
|
||||
Timer.Instance.TimerRegister(this,()=>{Main.Instance.ResumeMatch(record, preread);EventManager.Publish(new HideUIOutsideMenu());},fadeinTime,"MenuResumeClicked1");
|
||||
Timer.Instance.TimerRegister(this,()=>{EventManager.Publish(new HideUIOutsideAll());},fadeinTime+prepareTime,"MenuResumeClicked2");
|
||||
}
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ using TH1_Core.Managers;
|
||||
using TH1_Logic.Action;
|
||||
using TH1_Logic.Chat;
|
||||
using TH1_Logic.Core;
|
||||
using TH1_Logic.GameArchive;
|
||||
using TH1_Logic.MatchConfig;
|
||||
using TH1_Logic.Net;
|
||||
using TH1_Logic.Steam;
|
||||
@ -678,7 +679,7 @@ namespace TH1_UI.View.Outside
|
||||
if (ResumeToggle == null) return;
|
||||
var mapConfig = Main.Instance.MapConfig;
|
||||
var isOwner = _lobby.IsLobbyOwner();
|
||||
var canOwnerResume = isOwner && Main.Instance.HasMultiArchive();
|
||||
var canOwnerResume = isOwner && GameArchiveManager.Instance.HasQuickResumeArchive(NetMode.Multi);
|
||||
var selected = mapConfig != null && mapConfig.IsResumeArchiveSelected;
|
||||
if (isOwner && selected && !canOwnerResume)
|
||||
selected = false;
|
||||
@ -698,7 +699,7 @@ namespace TH1_UI.View.Outside
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Main.Instance.HasMultiArchive()) selected = false;
|
||||
if (!GameArchiveManager.Instance.HasQuickResumeArchive(NetMode.Multi)) selected = false;
|
||||
if (Main.Instance.MapConfig.SetResumeArchiveSelected(selected))
|
||||
Main.Instance.MapConfig.CheckMapConfigChanged();
|
||||
RefreshResumeBoard(selected);
|
||||
@ -1538,7 +1539,7 @@ namespace TH1_UI.View.Outside
|
||||
SetMapConfig();
|
||||
ReconcileRoomMembers();
|
||||
|
||||
if(Main.Instance.HasMultiArchive() && Main.Instance.MapConfig.IsResumeArchiveSelected){
|
||||
if(GameArchiveManager.Instance.HasQuickResumeArchive(NetMode.Multi) && Main.Instance.MapConfig.IsResumeArchiveSelected){
|
||||
if (!AreCurrentLobbyMembersReady())
|
||||
{
|
||||
Debug.Log("Cannot resume multiplayer game: not all current lobby members are ready");
|
||||
@ -1672,7 +1673,10 @@ namespace TH1_UI.View.Outside
|
||||
|
||||
private bool TryStartHostGame(bool resume)
|
||||
{
|
||||
return resume ? Main.Instance.MainMemberResumeMatch() : Main.Instance.MainMemberStartMatch();
|
||||
if (!resume) return Main.Instance.MainMemberStartMatch();
|
||||
|
||||
var record = GameArchiveManager.Instance.GetQuickResumeRecord(NetMode.Multi);
|
||||
return record != null && Main.Instance.ResumeMatch(record);
|
||||
}
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user