First pass on save/load system with participant interface

This commit is contained in:
Michal Pikulski
2025-10-30 14:41:50 +01:00
parent d317fffad7
commit 095f21908b
11 changed files with 1103 additions and 71 deletions

View File

@@ -0,0 +1,28 @@
namespace Core.SaveLoad
{
/// <summary>
/// Interface for objects that participate in the save/load system.
/// Participants must provide a unique ID and handle their own serialization/deserialization.
/// </summary>
public interface ISaveParticipant
{
/// <summary>
/// Returns a globally unique identifier for this participant.
/// Must be consistent across sessions for the same logical object.
/// </summary>
string GetSaveId();
/// <summary>
/// Serializes the current state of this participant to a string.
/// Can use JSON, custom format, or any serialization method.
/// </summary>
string SerializeState();
/// <summary>
/// Restores the state of this participant from previously serialized data.
/// Should handle null/empty data gracefully with default behavior.
/// </summary>
void RestoreState(string serializedData);
}
}

View File

@@ -0,0 +1,12 @@
fileFormatVersion: 2
guid: 7f3a4e8c9d2b1a5f6e8c4d9a2b7e5f3a
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -5,28 +5,22 @@ namespace Core.SaveLoad
[System.Serializable]
public class SaveLoadData
{
public bool playedDivingTutorial = false;
// Snapshot of the player's card collection (MVP)
public CardCollectionState cardCollection;
public bool playedDivingTutorial;
// List of unlocked minigames by name
public List<string> unlockedMinigames = new List<string>();
// List of participant states (directly serializable by JsonUtility)
public List<ParticipantStateEntry> participantStates = new List<ParticipantStateEntry>();
}
// Minimal DTOs for card persistence
/// <summary>
/// Serializable key-value pair for participant state storage.
/// </summary>
[System.Serializable]
public class CardCollectionState
public class ParticipantStateEntry
{
public int boosterPackCount;
public List<SavedCardEntry> cards = new List<SavedCardEntry>();
}
[System.Serializable]
public class SavedCardEntry
{
public string definitionId;
public AppleHills.Data.CardSystem.CardRarity rarity;
public int copiesOwned;
public string saveId;
public string serializedState;
}
}

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using Bootstrap;
using UnityEngine;
@@ -7,12 +8,12 @@ using UnityEngine;
namespace Core.SaveLoad
{
/// <summary>
/// Simple Save/Load manager that follows the project's bootstrap pattern.
/// Save/Load manager that follows the project's bootstrap pattern.
/// - Singleton instance
/// - Registers a post-boot init action with BootCompletionService
/// - Exposes simple async Save/Load methods (PlayerPrefs-backed placeholder)
/// - Manages participant registration for save/load operations
/// - Exposes simple async Save/Load methods
/// - Fires events on completion
/// This is intended as boilerplate to be expanded with a real serialization backend.
/// </summary>
public class SaveLoadManager : MonoBehaviour
{
@@ -23,19 +24,25 @@ namespace Core.SaveLoad
private static string DefaultSaveFolder => Path.Combine(Application.persistentDataPath, "GameSaves");
public SaveLoadData currentSaveData;
// Participant registry
private readonly Dictionary<string, ISaveParticipant> participants = new Dictionary<string, ISaveParticipant>();
// State
public bool IsSaving { get; private set; }
public bool IsLoading { get; private set; }
public bool IsSaveDataLoaded { get; private set; }
public bool IsRestoringState { get; private set; }
// Events
public event Action<string> OnSaveCompleted;
public event Action<string> OnLoadCompleted;
public event Action OnParticipantStatesRestored;
void Awake()
{
_instance = this;
IsSaveDataLoaded = false;
IsRestoringState = false;
BootCompletionService.RegisterInitAction(InitializePostBoot);
}
@@ -52,22 +59,197 @@ namespace Core.SaveLoad
private void InitializePostBoot()
{
Logging.Debug("[SaveLoadManager] Post-boot initialization complete");
// Subscribe to scene lifecycle events if SceneManagerService is available
if (SceneManagerService.Instance != null)
{
SceneManagerService.Instance.SceneLoadCompleted += OnSceneLoadCompleted;
SceneManagerService.Instance.SceneUnloadStarted += OnSceneUnloadStarted;
Logging.Debug("[SaveLoadManager] Subscribed to SceneManagerService events");
}
}
void OnDestroy()
{
if (_instance == this)
{
// Unsubscribe from scene events
if (SceneManagerService.Instance != null)
{
SceneManagerService.Instance.SceneLoadCompleted -= OnSceneLoadCompleted;
SceneManagerService.Instance.SceneUnloadStarted -= OnSceneUnloadStarted;
}
_instance = null;
}
}
#region Participant Registration
/// <summary>
/// Registers a participant with the save/load system.
/// Should be called by participants during their initialization (post-boot).
/// </summary>
public void RegisterParticipant(ISaveParticipant participant)
{
if (participant == null)
{
Logging.Warning("[SaveLoadManager] Attempted to register null participant");
return;
}
string saveId = participant.GetSaveId();
if (string.IsNullOrEmpty(saveId))
{
Logging.Warning("[SaveLoadManager] Participant provided null or empty save ID");
return;
}
if (participants.ContainsKey(saveId))
{
Logging.Warning($"[SaveLoadManager] Participant with ID '{saveId}' is already registered. Overwriting.");
}
participants[saveId] = participant;
Logging.Debug($"[SaveLoadManager] Registered participant: {saveId}");
// If we have save data loaded and we're not currently restoring, restore this participant's state immediately
if (IsSaveDataLoaded && !IsRestoringState && currentSaveData != null)
{
RestoreParticipantState(participant);
}
}
/// <summary>
/// Unregisters a participant from the save/load system.
/// Should be called by participants during their destruction.
/// </summary>
public void UnregisterParticipant(string saveId)
{
if (string.IsNullOrEmpty(saveId))
{
Logging.Warning("[SaveLoadManager] Attempted to unregister with null or empty save ID");
return;
}
if (participants.Remove(saveId))
{
Logging.Debug($"[SaveLoadManager] Unregistered participant: {saveId}");
}
}
/// <summary>
/// Gets a registered participant by their save ID. Returns null if not found.
/// </summary>
public ISaveParticipant GetParticipant(string saveId)
{
participants.TryGetValue(saveId, out var participant);
return participant;
}
#endregion
#region Scene Lifecycle
private void OnSceneLoadCompleted(string sceneName)
{
Logging.Debug($"[SaveLoadManager] Scene '{sceneName}' loaded. Participants can now register and will be restored.");
// Participants register themselves, so we just wait for them
// After registration, they'll be automatically restored if data is available
}
private void OnSceneUnloadStarted(string sceneName)
{
Logging.Debug($"[SaveLoadManager] Scene '{sceneName}' unloading. Note: Participants should unregister themselves.");
// We don't force-clear here because participants should manage their own lifecycle
// This allows for proper cleanup in OnDestroy
}
#endregion
#region State Restoration
/// <summary>
/// Restores state for a single participant from the current save data.
/// </summary>
private void RestoreParticipantState(ISaveParticipant participant)
{
if (currentSaveData == null || currentSaveData.participantStates == null)
return;
string saveId = participant.GetSaveId();
// Find the participant state in the list
var entry = currentSaveData.participantStates.Find(e => e.saveId == saveId);
if (entry != null && !string.IsNullOrEmpty(entry.serializedState))
{
try
{
participant.RestoreState(entry.serializedState);
Logging.Debug($"[SaveLoadManager] Restored state for participant: {saveId}");
}
catch (Exception ex)
{
Logging.Warning($"[SaveLoadManager] Exception while restoring state for '{saveId}': {ex}");
}
}
else
{
Logging.Debug($"[SaveLoadManager] No saved state found for participant: {saveId}");
}
}
/// <summary>
/// Restores state for all currently registered participants.
/// Called after loading save data.
/// </summary>
private void RestoreAllParticipantStates()
{
if (currentSaveData == null || currentSaveData.participantStates == null)
return;
IsRestoringState = true;
int restoredCount = 0;
foreach (var kvp in participants)
{
string saveId = kvp.Key;
ISaveParticipant participant = kvp.Value;
// Find the participant state in the list
var entry = currentSaveData.participantStates.Find(e => e.saveId == saveId);
if (entry != null && !string.IsNullOrEmpty(entry.serializedState))
{
try
{
participant.RestoreState(entry.serializedState);
restoredCount++;
Logging.Debug($"[SaveLoadManager] Restored state for participant: {saveId}");
}
catch (Exception ex)
{
Logging.Warning($"[SaveLoadManager] Exception while restoring state for '{saveId}': {ex}");
}
}
}
IsRestoringState = false;
Logging.Debug($"[SaveLoadManager] Restored state for {restoredCount} participants");
OnParticipantStatesRestored?.Invoke();
}
#endregion
private string GetFilePath(string slot)
{
return Path.Combine(DefaultSaveFolder, $"save_{slot}.json");
}
/// <summary>
/// Entry point to save to a named slot. Starts an async coroutine that writes to PlayerPrefs
/// (placeholder behavior). Fires OnSaveCompleted when finished.
/// Entry point to save to a named slot. Starts an async coroutine that writes to disk.
/// Fires OnSaveCompleted when finished.
/// </summary>
public void Save(string slot = "default")
{
@@ -81,8 +263,8 @@ namespace Core.SaveLoad
}
/// <summary>
/// Entry point to load from a named slot. Starts an async coroutine that reads from PlayerPrefs
/// (placeholder behavior). Fires OnLoadCompleted when finished.
/// Entry point to load from a named slot. Starts an async coroutine that reads from disk.
/// Fires OnLoadCompleted when finished.
/// </summary>
public void Load(string slot = "default")
{
@@ -95,7 +277,6 @@ namespace Core.SaveLoad
StartCoroutine(LoadAsync(slot));
}
// TODO: This is stupid overkill, but over verbose logging is king for now
private IEnumerator SaveAsync(string slot)
{
Logging.Debug($"[SaveLoadManager] Starting save for slot '{slot}'");
@@ -106,7 +287,7 @@ namespace Core.SaveLoad
string json = null;
bool prepFailed = false;
// Prep phase: ensure folder exists and serialize (no yields allowed inside try/catch)
// Prep phase: ensure folder exists and serialize
try
{
Directory.CreateDirectory(DefaultSaveFolder);
@@ -117,11 +298,42 @@ namespace Core.SaveLoad
currentSaveData = new SaveLoadData();
}
// Pull latest card collection snapshot from CardSystem before serializing (don't overwrite with null)
if (Data.CardSystem.CardSystemManager.Instance != null)
// Ensure participantStates list exists
if (currentSaveData.participantStates == null)
{
currentSaveData.cardCollection = Data.CardSystem.CardSystemManager.Instance.ExportCardCollectionState();
currentSaveData.participantStates = new List<ParticipantStateEntry>();
}
else
{
currentSaveData.participantStates.Clear();
}
// Capture state from all registered participants directly into the list
int savedCount = 0;
foreach (var kvp in participants)
{
string saveId = kvp.Key;
ISaveParticipant participant = kvp.Value;
try
{
string serializedState = participant.SerializeState();
currentSaveData.participantStates.Add(new ParticipantStateEntry
{
saveId = saveId,
serializedState = serializedState
});
savedCount++;
Logging.Debug($"[SaveLoadManager] Captured state for participant: {saveId}");
}
catch (Exception ex)
{
Logging.Warning($"[SaveLoadManager] Exception while serializing participant '{saveId}': {ex}");
}
}
Logging.Debug($"[SaveLoadManager] Captured state from {savedCount} participants");
json = JsonUtility.ToJson(currentSaveData, true);
}
@@ -133,7 +345,6 @@ namespace Core.SaveLoad
if (prepFailed || string.IsNullOrEmpty(json))
{
// Ensure we clean up state and notify listeners outside of the try/catch
IsSaving = false;
OnSaveCompleted?.Invoke(slot);
Logging.Warning($"[SaveLoadManager] Aborting save for slot '{slot}' due to prep failure");
@@ -172,7 +383,6 @@ namespace Core.SaveLoad
}
}
// TODO: This is stupid overkill, but over verbose logging is king for now
private IEnumerator LoadAsync(string slot)
{
Logging.Debug($"[SaveLoadManager] Starting load for slot '{slot}'");
@@ -185,16 +395,18 @@ namespace Core.SaveLoad
Logging.Debug($"[SaveLoadManager] No save found at '{path}', creating defaults");
currentSaveData = new SaveLoadData();
// Simulate async operation and finish
yield return null;
IsLoading = false;
IsSaveDataLoaded = true;
// Restore any already-registered participants (e.g., those initialized during boot)
RestoreAllParticipantStates();
OnLoadCompleted?.Invoke(slot);
yield break;
}
// Simulate async operation (optional)
yield return null;
try
@@ -208,7 +420,6 @@ namespace Core.SaveLoad
}
else
{
// Attempt to deserialize; if it fails or returns null, fall back to defaults
var loaded = JsonUtility.FromJson<SaveLoadData>(json);
if (loaded == null)
{
@@ -218,6 +429,12 @@ namespace Core.SaveLoad
else
{
currentSaveData = loaded;
// Ensure participantStates list exists even if not in save file
if (currentSaveData.participantStates == null)
{
currentSaveData.participantStates = new List<ParticipantStateEntry>();
}
}
}
}
@@ -230,6 +447,10 @@ namespace Core.SaveLoad
{
IsLoading = false;
IsSaveDataLoaded = true;
// Restore state for any already-registered participants
RestoreAllParticipantStates();
OnLoadCompleted?.Invoke(slot);
Logging.Debug($"[SaveLoadManager] Load completed for slot '{slot}'");
}

View File

@@ -0,0 +1,26 @@
using System.Collections.Generic;
using AppleHills.Data.CardSystem;
namespace Data.CardSystem
{
/// <summary>
/// Serializable snapshot of the card collection state for save/load operations.
/// </summary>
[System.Serializable]
public class CardCollectionState
{
public int boosterPackCount;
public List<SavedCardEntry> cards = new List<SavedCardEntry>();
}
/// <summary>
/// Serializable representation of a single card's saved data.
/// </summary>
[System.Serializable]
public class SavedCardEntry
{
public string definitionId;
public CardRarity rarity;
public int copiesOwned;
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: e552abbd5bd74192840939e499372ff2
timeCreated: 1761830599

View File

@@ -15,8 +15,9 @@ namespace Data.CardSystem
/// <summary>
/// Manages the player's card collection, booster packs, and related operations.
/// Uses a singleton pattern for global access.
/// Implements ISaveParticipant to integrate with the save/load system.
/// </summary>
public class CardSystemManager : MonoBehaviour
public class CardSystemManager : MonoBehaviour, ISaveParticipant
{
private static CardSystemManager _instance;
public static CardSystemManager Instance => _instance;
@@ -36,9 +37,6 @@ namespace Data.CardSystem
public event Action<CardData> OnCardRarityUpgraded;
public event Action<int> OnBoosterCountChanged;
// Keep a reference to unsubscribe safely
private Action<string> _onSaveDataLoadedHandler;
private void Awake()
{
_instance = this;
@@ -49,7 +47,7 @@ namespace Data.CardSystem
private void InitializePostBoot()
{
// Load card definitions from Addressables
// Load card definitions from Addressables, then register with save system
LoadCardDefinitionsFromAddressables();
Logging.Debug("[CardSystemManager] Post-boot initialization complete");
@@ -85,48 +83,32 @@ namespace Data.CardSystem
// Build lookup now that cards are loaded
BuildDefinitionLookup();
// Apply saved state when save data is available
Logging.Debug($"[CardSystemManager] Loaded {availableCards.Count} card definitions from Addressables");
// NOW register with save/load system (definitions are ready for state restoration)
if (SaveLoadManager.Instance != null)
{
if (SaveLoadManager.Instance.IsSaveDataLoaded)
{
ApplySavedCardStateIfAvailable();
}
else
{
SaveLoadManager.Instance.OnLoadCompleted += OnSaveDataLoadedHandler;
}
SaveLoadManager.Instance.RegisterParticipant(this);
Logging.Debug("[CardSystemManager] Registered with SaveLoadManager after definitions loaded");
}
else
{
Logging.Warning("[CardSystemManager] SaveLoadManager not available for registration");
}
}
else
{
Logging.Warning("[CardSystemManager] Failed to load card definitions from Addressables");
}
}
private void OnDestroy()
{
// Unregister from save/load system
if (SaveLoadManager.Instance != null)
{
SaveLoadManager.Instance.OnLoadCompleted -= OnSaveDataLoadedHandler;
}
}
// Apply saved state if present in the SaveLoadManager
private void ApplySavedCardStateIfAvailable()
{
var data = SaveLoadManager.Instance?.currentSaveData;
if (data?.cardCollection != null)
{
ApplyCardCollectionState(data.cardCollection);
Logging.Debug("[CardSystemManager] Applied saved card collection state after loading definitions");
}
}
// Event handler for when save data load completes
private void OnSaveDataLoadedHandler(string slot)
{
ApplySavedCardStateIfAvailable();
if (SaveLoadManager.Instance != null)
{
SaveLoadManager.Instance.OnLoadCompleted -= OnSaveDataLoadedHandler;
SaveLoadManager.Instance.UnregisterParticipant(GetSaveId());
}
}
@@ -476,5 +458,57 @@ namespace Data.CardSystem
}
}
}
#region ISaveParticipant Implementation
/// <summary>
/// Returns the unique save ID for the CardSystemManager.
/// Since this is a singleton global system, the ID is constant.
/// </summary>
public string GetSaveId()
{
return "CardSystemManager";
}
/// <summary>
/// Serializes the current card collection state to JSON.
/// </summary>
public string SerializeState()
{
var state = ExportCardCollectionState();
return JsonUtility.ToJson(state);
}
/// <summary>
/// Restores the card collection state from serialized JSON data.
/// </summary>
public void RestoreState(string serializedData)
{
if (string.IsNullOrEmpty(serializedData))
{
Logging.Debug("[CardSystemManager] No saved state to restore, using defaults");
return;
}
try
{
var state = JsonUtility.FromJson<CardCollectionState>(serializedData);
if (state != null)
{
ApplyCardCollectionState(state);
Logging.Debug("[CardSystemManager] Successfully restored card collection state");
}
else
{
Logging.Warning("[CardSystemManager] Failed to deserialize card collection state");
}
}
catch (Exception ex)
{
Logging.Warning($"[CardSystemManager] Exception while restoring card collection state: {ex}");
}
}
#endregion
}
}