First commit for private source control. Older commits available on Github.
This commit is contained in:
422
Assets/Scripts/Framework/Managers/IO/SaveManager.cs
Normal file
422
Assets/Scripts/Framework/Managers/IO/SaveManager.cs
Normal file
@@ -0,0 +1,422 @@
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user