422 lines
14 KiB
C#
422 lines
14 KiB
C#
using System;
|
|
using System.Collections.Generic;
|
|
using System.IO;
|
|
using System.Linq;
|
|
using System.Threading;
|
|
using BriarQueen.Data.Identifiers;
|
|
using BriarQueen.Data.IO;
|
|
using BriarQueen.Data.IO.Saves;
|
|
using BriarQueen.Framework.Coordinators.Events;
|
|
using BriarQueen.Framework.Events.Save;
|
|
using BriarQueen.Framework.Extensions;
|
|
using Cysharp.Threading.Tasks;
|
|
using MemoryPack;
|
|
using UnityEngine;
|
|
using VContainer;
|
|
using SaveGame = BriarQueen.Data.IO.Saves.SaveGame;
|
|
|
|
namespace BriarQueen.Framework.Managers.IO
|
|
{
|
|
public class SaveManager : IDisposable, IManager
|
|
{
|
|
private const int MAX_RETRY_COUNT = 3;
|
|
private const int RETRY_DELAY_MS = 100;
|
|
private readonly EventCoordinator _eventCoordinator;
|
|
private readonly object _saveLock = new();
|
|
|
|
private CancellationTokenSource _currentSaveCts;
|
|
private DateTime _lastSaveTime;
|
|
|
|
[Inject]
|
|
public SaveManager(EventCoordinator eventCoordinator)
|
|
{
|
|
_eventCoordinator = eventCoordinator;
|
|
}
|
|
|
|
public bool IsGameLoaded { get; private set; }
|
|
public SaveGame CurrentSave { get; set; }
|
|
|
|
public void Dispose()
|
|
{
|
|
_eventCoordinator.Unsubscribe<RequestGameSaveEvent>(OnRequestedSave);
|
|
_currentSaveCts?.Cancel();
|
|
}
|
|
|
|
public event Action<SaveGame> OnSaveGameLoaded;
|
|
public event Action OnSaveGameSaved;
|
|
|
|
// Existing synchronous hook
|
|
public event Action<SaveGame> OnSaveRequested;
|
|
|
|
// New async hook that runs BEFORE cloning/serialization
|
|
public event Func<UniTask> OnBeforeSaveRequestedAsync;
|
|
|
|
public bool Initialized { get; private set; }
|
|
|
|
public void Initialize()
|
|
{
|
|
_eventCoordinator.Subscribe<RequestGameSaveEvent>(OnRequestedSave);
|
|
Initialized = true;
|
|
}
|
|
|
|
private void OnRequestedSave(RequestGameSaveEvent evt)
|
|
{
|
|
SaveGameDataLatest().Forget();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Queues the latest save and cancels any in-progress save.
|
|
/// </summary>
|
|
public async UniTask SaveGameDataLatest()
|
|
{
|
|
CancellationTokenSource oldCts = null;
|
|
CancellationTokenSource myCts;
|
|
|
|
lock (_saveLock)
|
|
{
|
|
if (_currentSaveCts != null)
|
|
{
|
|
oldCts = _currentSaveCts;
|
|
_currentSaveCts.Cancel();
|
|
}
|
|
|
|
myCts = new CancellationTokenSource();
|
|
_currentSaveCts = myCts;
|
|
}
|
|
|
|
oldCts?.Dispose();
|
|
|
|
try
|
|
{
|
|
await SaveGameDataInternal(myCts.Token);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
// A newer save was requested; ignore.
|
|
}
|
|
finally
|
|
{
|
|
lock (_saveLock)
|
|
{
|
|
if (ReferenceEquals(_currentSaveCts, myCts))
|
|
{
|
|
_currentSaveCts.Dispose();
|
|
_currentSaveCts = null;
|
|
}
|
|
}
|
|
|
|
myCts.Dispose();
|
|
}
|
|
}
|
|
|
|
private async UniTask SaveGameDataInternal(CancellationToken ct)
|
|
{
|
|
if ((DateTime.UtcNow - _lastSaveTime).TotalMilliseconds < 250)
|
|
{
|
|
Debug.Log("[SaveManager] Last save within 250ms, skipping.");
|
|
return;
|
|
}
|
|
|
|
if (CurrentSave == null)
|
|
CurrentSave = new SaveGame { SaveFileName = "NewGame" };
|
|
|
|
// NEW: let systems write into CurrentSave before the clone is made
|
|
await InvokeBeforeSaveRequestedAsync();
|
|
|
|
var saveClone = CurrentSave.DeepClone();
|
|
OnSaveRequested?.Invoke(saveClone);
|
|
Debug.Log($"[SaveManager] After OnSaveRequested, clone inventory count = {saveClone.InventoryData?.Count ?? 0}");
|
|
|
|
byte[] binaryData = null;
|
|
await UniTask.SwitchToThreadPool();
|
|
try
|
|
{
|
|
binaryData = MemoryPackSerializer.Serialize(saveClone);
|
|
}
|
|
finally
|
|
{
|
|
await UniTask.SwitchToMainThread(ct);
|
|
}
|
|
|
|
if (binaryData == null || binaryData.Length == 0)
|
|
{
|
|
Debug.LogWarning("[SaveManager] Empty serialized data, skipping save.");
|
|
return;
|
|
}
|
|
|
|
var saveDir = FilePaths.SaveDataFolder;
|
|
var backupDir = FilePaths.SaveBackupDataFolder;
|
|
Directory.CreateDirectory(saveDir);
|
|
Directory.CreateDirectory(backupDir);
|
|
|
|
var saveFileName = CurrentSave.SaveFileName + ".sav";
|
|
var saveFilePath = Path.Combine(saveDir, saveFileName);
|
|
var backupFilePath = Path.Combine(backupDir, saveFileName);
|
|
var tempSavePath = saveFilePath + ".tmp";
|
|
|
|
var attempt = 0;
|
|
var success = false;
|
|
while (attempt < MAX_RETRY_COUNT && !success)
|
|
{
|
|
attempt++;
|
|
try
|
|
{
|
|
await File.WriteAllBytesAsync(tempSavePath, binaryData, ct);
|
|
|
|
if (File.Exists(saveFilePath))
|
|
File.Replace(tempSavePath, saveFilePath, backupFilePath);
|
|
else
|
|
File.Move(tempSavePath, saveFilePath);
|
|
|
|
success = true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.LogError($"[SaveManager] Save attempt {attempt} failed: {ex}");
|
|
await UniTask.Delay(RETRY_DELAY_MS, cancellationToken: ct);
|
|
}
|
|
}
|
|
|
|
if (!success)
|
|
{
|
|
Debug.LogError("[SaveManager] All save attempts failed, save aborted.");
|
|
return;
|
|
}
|
|
|
|
CurrentSave = saveClone;
|
|
IsGameLoaded = true;
|
|
_lastSaveTime = DateTime.UtcNow;
|
|
|
|
OnSaveGameSaved?.Invoke();
|
|
Debug.Log($"[SaveManager] Save complete: {CurrentSave.SaveFileName}");
|
|
}
|
|
|
|
private async UniTask InvokeBeforeSaveRequestedAsync()
|
|
{
|
|
var handlers = OnBeforeSaveRequestedAsync;
|
|
if (handlers == null)
|
|
return;
|
|
|
|
foreach (Func<UniTask> handler in handlers.GetInvocationList())
|
|
{
|
|
try
|
|
{
|
|
await handler();
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.LogError($"[SaveManager] OnBeforeSaveRequestedAsync handler failed: {ex}");
|
|
}
|
|
}
|
|
}
|
|
|
|
public async UniTask CreateNewSaveGame(string saveFileName)
|
|
{
|
|
CurrentSave = new SaveGame
|
|
{
|
|
SaveFileName = saveFileName,
|
|
SaveVersion = "0.0.1a",
|
|
OpeningCinematicPlayed = false
|
|
};
|
|
IsGameLoaded = true;
|
|
OnSaveGameLoaded?.Invoke(CurrentSave);
|
|
await SaveGameDataLatest();
|
|
}
|
|
|
|
public List<(string FileName, DateTime LastModified)> GetAvailableSaves()
|
|
{
|
|
var saveDir = FilePaths.SaveDataFolder;
|
|
if (!Directory.Exists(saveDir)) return new List<(string, DateTime)>();
|
|
|
|
try
|
|
{
|
|
return Directory.GetFiles(saveDir, "*.sav")
|
|
.Select(file => (Path.GetFileNameWithoutExtension(file),
|
|
File.GetLastWriteTimeUtc(file))
|
|
)
|
|
.OrderByDescending(x => x.Item2)
|
|
.ToList();
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Debug.LogError($"[SaveManager] Failed to enumerate saves: {e.Message}");
|
|
return new List<(string, DateTime)>();
|
|
}
|
|
}
|
|
|
|
public async UniTask LoadLatestSave()
|
|
{
|
|
var available = GetAvailableSaves();
|
|
if (available.Count > 0)
|
|
{
|
|
await LoadGameData(available[0].FileName);
|
|
}
|
|
else
|
|
{
|
|
Debug.LogWarning("[SaveManager] No save files found. Creating new game.");
|
|
await CreateNewSaveGame("NewGame");
|
|
}
|
|
}
|
|
|
|
public async UniTask LoadGameData(string saveFileName)
|
|
{
|
|
var mainPath = Path.Combine(FilePaths.SaveDataFolder, saveFileName + ".sav");
|
|
var backupPath = Path.Combine(FilePaths.SaveBackupDataFolder, saveFileName + ".sav");
|
|
|
|
var loadedSave = await LoadFromFileAsync(mainPath);
|
|
if (loadedSave == null)
|
|
{
|
|
Debug.LogWarning($"[SaveManager] Main save load failed, trying backup: {saveFileName}");
|
|
loadedSave = await LoadFromFileAsync(backupPath);
|
|
|
|
if (loadedSave != null)
|
|
{
|
|
CurrentSave = loadedSave;
|
|
await SaveGameDataLatest();
|
|
Debug.Log("[SaveManager] Restored save from backup.");
|
|
}
|
|
}
|
|
|
|
CurrentSave = loadedSave ?? new SaveGame { SaveFileName = saveFileName, SaveVersion = "0.0.1-Pre-Alpha" };
|
|
|
|
Debug.Log($"[SaveManager] Loading save {loadedSave?.SaveFileName}");
|
|
IsGameLoaded = true;
|
|
OnSaveGameLoaded?.Invoke(CurrentSave);
|
|
}
|
|
|
|
private async UniTask<SaveGame> LoadFromFileAsync(string path)
|
|
{
|
|
if (string.IsNullOrEmpty(path) || !File.Exists(path)) return null;
|
|
|
|
try
|
|
{
|
|
var bytes = await File.ReadAllBytesAsync(path);
|
|
if (bytes == null || bytes.Length == 0) return null;
|
|
|
|
await UniTask.SwitchToThreadPool();
|
|
SaveGame result = null;
|
|
try
|
|
{
|
|
result = MemoryPackSerializer.Deserialize<SaveGame>(bytes);
|
|
}
|
|
finally
|
|
{
|
|
await UniTask.SwitchToMainThread();
|
|
}
|
|
|
|
return result;
|
|
}
|
|
catch (Exception e)
|
|
{
|
|
Debug.LogError($"[SaveManager] Failed to load or deserialize '{path}': {e}");
|
|
return null;
|
|
}
|
|
}
|
|
|
|
public bool Delete(string saveFileName)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(saveFileName))
|
|
return false;
|
|
|
|
var normalizedName = Path.GetFileNameWithoutExtension(saveFileName);
|
|
|
|
var mainPath = Path.Combine(FilePaths.SaveDataFolder, normalizedName + ".sav");
|
|
var backupPath = Path.Combine(FilePaths.SaveBackupDataFolder, normalizedName + ".sav");
|
|
|
|
var deletedAny = false;
|
|
|
|
try
|
|
{
|
|
if (File.Exists(mainPath))
|
|
{
|
|
File.Delete(mainPath);
|
|
deletedAny = true;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.LogError($"[SaveManager] Failed to delete main save '{mainPath}': {ex}");
|
|
}
|
|
|
|
try
|
|
{
|
|
if (File.Exists(backupPath))
|
|
{
|
|
File.Delete(backupPath);
|
|
deletedAny = true;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.LogError($"[SaveManager] Failed to delete backup save '{backupPath}': {ex}");
|
|
}
|
|
|
|
if (CurrentSave != null &&
|
|
string.Equals(CurrentSave.SaveFileName, normalizedName, StringComparison.Ordinal))
|
|
{
|
|
CurrentSave = null;
|
|
IsGameLoaded = false;
|
|
}
|
|
|
|
return deletedAny;
|
|
}
|
|
|
|
public bool DoesSaveExist(string saveInfoFileName)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(saveInfoFileName))
|
|
return false;
|
|
|
|
var mainPath = Path.Combine(FilePaths.SaveDataFolder, saveInfoFileName + ".sav");
|
|
var backupPath = Path.Combine(FilePaths.SaveBackupDataFolder, saveInfoFileName + ".sav");
|
|
|
|
try
|
|
{
|
|
return File.Exists(mainPath) || File.Exists(backupPath);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Debug.LogError($"[SaveManager] Error checking save existence for '{saveInfoFileName}': {ex}");
|
|
return false;
|
|
}
|
|
}
|
|
|
|
public void SetLevelFlag(LevelFlag levelFlag, bool value, bool requestSave = true)
|
|
{
|
|
if (CurrentSave?.PersistentVariables?.Game == null)
|
|
{
|
|
Debug.LogWarning($"[SaveManager] Could not set level flag '{levelFlag}' because CurrentSave or PersistentVariables.Game is null.");
|
|
return;
|
|
}
|
|
|
|
CurrentSave.PersistentVariables.Game.SetLevelFlag(levelFlag, value);
|
|
|
|
if (requestSave)
|
|
_eventCoordinator.PublishImmediate(new RequestGameSaveEvent());
|
|
}
|
|
|
|
public void SetPuzzleCompleted(PuzzleKey puzzleKey, bool value, bool requestSave = true)
|
|
{
|
|
if (CurrentSave?.PersistentVariables?.Game == null)
|
|
{
|
|
Debug.LogWarning($"[SaveManager] Could not set level flag '{puzzleKey}' because CurrentSave or PersistentVariables.Game is null.");
|
|
return;
|
|
}
|
|
|
|
CurrentSave.PersistentVariables.Game.SetPuzzleCompleted(puzzleKey, value);
|
|
|
|
if (requestSave)
|
|
_eventCoordinator.PublishImmediate(new RequestGameSaveEvent());
|
|
}
|
|
|
|
public bool GetLevelFlag(LevelFlag levelFlag)
|
|
{
|
|
if (CurrentSave?.PersistentVariables?.Game == null)
|
|
{
|
|
Debug.LogWarning($"[SaveManager] Could not get level flag '{levelFlag}' because CurrentSave or PersistentVariables.Game is null.");
|
|
return false;
|
|
}
|
|
|
|
return CurrentSave.PersistentVariables.Game.GetLevelFlag(levelFlag);
|
|
}
|
|
}
|
|
} |