MPV of save/load system

This commit is contained in:
Michal Pikulski
2025-10-27 14:00:37 +01:00
parent 690e8b4507
commit f5c1ae51cd
15 changed files with 1234 additions and 17 deletions

View File

@@ -26,3 +26,4 @@ MonoBehaviour:
- {fileID: 3528960956969533010, guid: 53eea3840d3cde34a9768b8773a3a7e8, type: 3} - {fileID: 3528960956969533010, guid: 53eea3840d3cde34a9768b8773a3a7e8, type: 3}
- {fileID: 6895404274863911569, guid: 840f3d8a936b39a41b5896328a692005, type: 3} - {fileID: 6895404274863911569, guid: 840f3d8a936b39a41b5896328a692005, type: 3}
- {fileID: 3863019143023165617, guid: 774e30e3f0b1d0d49bad0c2abf11038a, type: 3} - {fileID: 3863019143023165617, guid: 774e30e3f0b1d0d49bad0c2abf11038a, type: 3}
- {fileID: 5034240524438268576, guid: b15ba9d3d508ef244b0eeb76404dc9de, type: 3}

View File

@@ -0,0 +1,63 @@
using System.IO;
using UnityEditor;
using UnityEngine;
namespace Editor.Tools
{
/// <summary>
/// Editor utility to clear all save data from the SaveLoadManager's save folder
/// </summary>
public static class ClearSavesMenuItem
{
[MenuItem("AppleHills/Clear Saves")]
private static void ClearSaves()
{
// Construct the save folder path (matches SaveLoadManager.DefaultSaveFolder)
string saveFolder = Path.Combine(Application.persistentDataPath, "GameSaves");
if (!Directory.Exists(saveFolder))
{
Debug.Log($"[ClearSaves] Save folder does not exist: {saveFolder}");
EditorUtility.DisplayDialog("Clear Saves", "No save data found to clear.", "OK");
return;
}
// Confirm with the user
bool confirmed = EditorUtility.DisplayDialog(
"Clear Saves",
$"Are you sure you want to delete all save data?\n\nFolder: {saveFolder}",
"Delete All",
"Cancel"
);
if (!confirmed)
{
Debug.Log("[ClearSaves] User cancelled save deletion");
return;
}
try
{
// Delete all files in the save folder
string[] files = Directory.GetFiles(saveFolder);
int deletedCount = 0;
foreach (string file in files)
{
File.Delete(file);
deletedCount++;
Debug.Log($"[ClearSaves] Deleted: {file}");
}
Debug.Log($"[ClearSaves] Successfully deleted {deletedCount} save file(s)");
EditorUtility.DisplayDialog("Clear Saves", $"Successfully deleted {deletedCount} save file(s).", "OK");
}
catch (System.Exception ex)
{
Debug.LogError($"[ClearSaves] Failed to clear saves: {ex}");
EditorUtility.DisplayDialog("Clear Saves", $"Error clearing saves:\n{ex.Message}", "OK");
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 747ab9e66b014771bc1486f9ab073f90
timeCreated: 1761569785

View File

@@ -0,0 +1,48 @@
%YAML 1.1
%TAG !u! tag:unity3d.com,2011:
--- !u!1 &5034240524438268576
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 536416456044738252}
- component: {fileID: 6631817730171642502}
m_Layer: 0
m_Name: SaveLoadManager
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &536416456044738252
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5034240524438268576}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!114 &6631817730171642502
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 5034240524438268576}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: a1731f4790be4ec3bab71506427768d7, type: 3}
m_Name:
m_EditorClassIdentifier: AppleHillsScripts::Core.SaveLoad.SaveLoadManager
currentSaveData:
playedDivingTutorial: 0

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: b15ba9d3d508ef244b0eeb76404dc9de
PrefabImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 0541e0e1c3dd41f7a61773e4bc2c64ed
timeCreated: 1761553949

View File

@@ -0,0 +1,29 @@
using System.Collections.Generic;
namespace Core.SaveLoad
{
[System.Serializable]
public class SaveLoadData
{
public bool playedDivingTutorial = false;
// Snapshot of the player's card collection (MVP)
public CardCollectionState cardCollection;
}
// Minimal DTOs for card persistence
[System.Serializable]
public class CardCollectionState
{
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;
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 1704f1fc7e6b4a398f39fb86cec265f8
timeCreated: 1761553972

View File

@@ -0,0 +1,238 @@
using System;
using System.Collections;
using System.IO;
using Bootstrap;
using UnityEngine;
namespace Core.SaveLoad
{
/// <summary>
/// Simple 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)
/// - Fires events on completion
/// This is intended as boilerplate to be expanded with a real serialization backend.
/// </summary>
public class SaveLoadManager : MonoBehaviour
{
private static SaveLoadManager _instance;
public static SaveLoadManager Instance => _instance;
// Path
private static string DefaultSaveFolder => Path.Combine(Application.persistentDataPath, "GameSaves");
public SaveLoadData currentSaveData;
// State
public bool IsSaving { get; private set; }
public bool IsLoading { get; private set; }
public bool IsSaveDataLoaded { get; private set; }
// Events
public event Action<string> OnSaveCompleted;
public event Action<string> OnLoadCompleted;
void Awake()
{
_instance = this;
IsSaveDataLoaded = false;
BootCompletionService.RegisterInitAction(InitializePostBoot);
}
private void Start()
{
Load();
}
private void OnApplicationQuit()
{
Save();
}
private void InitializePostBoot()
{
Logging.Debug("[SaveLoadManager] Post-boot initialization complete");
}
void OnDestroy()
{
if (_instance == this)
_instance = null;
}
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.
/// </summary>
public void Save(string slot = "default")
{
if (IsSaving)
{
Logging.Warning("[SaveLoadManager] Save called while another save is in progress");
return;
}
StartCoroutine(SaveAsync(slot));
}
/// <summary>
/// Entry point to load from a named slot. Starts an async coroutine that reads from PlayerPrefs
/// (placeholder behavior). Fires OnLoadCompleted when finished.
/// </summary>
public void Load(string slot = "default")
{
if (IsLoading)
{
Logging.Warning("[SaveLoadManager] Load called while another load is in progress");
return;
}
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}'");
IsSaving = true;
string path = GetFilePath(slot);
string tempPath = path + ".tmp";
string json = null;
bool prepFailed = false;
// Prep phase: ensure folder exists and serialize (no yields allowed inside try/catch)
try
{
Directory.CreateDirectory(DefaultSaveFolder);
if (currentSaveData == null)
{
Logging.Debug("[SaveLoadManager] currentSaveData is null, creating default instance before saving");
currentSaveData = new SaveLoadData();
}
// Pull latest card collection snapshot from CardSystem before serializing (don't overwrite with null)
if (Data.CardSystem.CardSystemManager.Instance != null)
{
currentSaveData.cardCollection = Data.CardSystem.CardSystemManager.Instance.ExportCardCollectionState();
}
json = JsonUtility.ToJson(currentSaveData, true);
}
catch (Exception ex)
{
Logging.Warning($"[SaveLoadManager] Exception during save prep for slot '{slot}': {ex}");
prepFailed = true;
}
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");
yield break;
}
// Write phase: perform IO and atomic move with fallback
try
{
File.WriteAllText(tempPath, json);
try
{
if (File.Exists(path))
File.Delete(path);
File.Move(tempPath, path);
}
catch (Exception moveEx)
{
Logging.Warning($"[SaveLoadManager] Atomic move failed for '{path}', attempting overwrite: {moveEx}");
File.Copy(tempPath, path, true);
File.Delete(tempPath);
}
Logging.Debug($"[SaveLoadManager] Save data written to '{path}'");
}
catch (Exception ex)
{
Logging.Warning($"[SaveLoadManager] Exception while saving slot '{slot}': {ex}");
}
finally
{
IsSaving = false;
OnSaveCompleted?.Invoke(slot);
Debug.Log($"[SaveLoadManager] Save completed for slot '{slot}'");
}
}
// 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}'");
IsLoading = true;
string path = GetFilePath(slot);
// Fast-path: no save file
if (!File.Exists(path))
{
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;
OnLoadCompleted?.Invoke(slot);
yield break;
}
// Simulate async operation (optional)
yield return null;
try
{
string json = File.ReadAllText(path);
if (string.IsNullOrEmpty(json))
{
Logging.Warning($"[SaveLoadManager] Save file at '{path}' is empty. Creating defaults.");
currentSaveData = new SaveLoadData();
}
else
{
// Attempt to deserialize; if it fails or returns null, fall back to defaults
var loaded = JsonUtility.FromJson<SaveLoadData>(json);
if (loaded == null)
{
Logging.Warning($"[SaveLoadManager] Deserialized save data was null for slot '{slot}'. Creating defaults.");
currentSaveData = new SaveLoadData();
}
else
{
currentSaveData = loaded;
}
}
}
catch (Exception ex)
{
Logging.Warning($"[SaveLoadManager] Exception while reading/deserializing save file at '{path}': {ex}");
currentSaveData = new SaveLoadData();
}
finally
{
IsLoading = false;
IsSaveDataLoaded = true;
OnLoadCompleted?.Invoke(slot);
Logging.Debug($"[SaveLoadManager] Load completed for slot '{slot}'");
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: a1731f4790be4ec3bab71506427768d7
timeCreated: 1761553854

View File

@@ -4,6 +4,7 @@ using System.Linq;
using AppleHills.Data.CardSystem; using AppleHills.Data.CardSystem;
using Bootstrap; using Bootstrap;
using Core; using Core;
using Core.SaveLoad;
using UnityEngine; using UnityEngine;
#if UNITY_EDITOR #if UNITY_EDITOR
using UnityEditor; using UnityEditor;
@@ -36,6 +37,9 @@ namespace Data.CardSystem
public event Action<CardData> OnCardRarityUpgraded; public event Action<CardData> OnCardRarityUpgraded;
public event Action<int> OnBoosterCountChanged; public event Action<int> OnBoosterCountChanged;
// Keep a reference to unsubscribe safely
private Action<string> _onSaveDataLoadedHandler;
private void Awake() private void Awake()
{ {
_instance = this; _instance = this;
@@ -49,9 +53,6 @@ namespace Data.CardSystem
// Load card definitions from Addressables // Load card definitions from Addressables
LoadCardDefinitionsFromAddressables(); LoadCardDefinitionsFromAddressables();
// Build lookup dictionary
BuildDefinitionLookup();
Logging.Debug("[CardSystemManager] Post-boot initialization complete"); Logging.Debug("[CardSystemManager] Post-boot initialization complete");
} }
@@ -87,9 +88,54 @@ namespace Data.CardSystem
} }
} }
} }
// Build lookup now that cards are loaded
BuildDefinitionLookup();
// Apply saved state when save data is available
if (SaveLoadManager.Instance != null)
{
if (SaveLoadManager.Instance.IsSaveDataLoaded)
{
ApplySavedCardStateIfAvailable();
}
else
{
SaveLoadManager.Instance.OnLoadCompleted += OnSaveDataLoadedHandler;
}
}
} }
} }
private void OnDestroy()
{
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;
}
}
/// <summary> /// <summary>
/// Builds a lookup dictionary for quick access to card definitions by ID /// Builds a lookup dictionary for quick access to card definitions by ID
/// </summary> /// </summary>
@@ -383,5 +429,58 @@ namespace Data.CardSystem
return (float)collectedOfRarity / totalOfRarity * 100f; return (float)collectedOfRarity / totalOfRarity * 100f;
} }
/// <summary>
/// Export current card collection to a serializable snapshot
/// </summary>
public CardCollectionState ExportCardCollectionState()
{
var state = new CardCollectionState
{
boosterPackCount = playerInventory.BoosterPackCount,
cards = new List<SavedCardEntry>()
};
foreach (var card in playerInventory.CollectedCards.Values)
{
if (string.IsNullOrEmpty(card.DefinitionId)) continue;
state.cards.Add(new SavedCardEntry
{
definitionId = card.DefinitionId,
rarity = card.Rarity,
copiesOwned = card.CopiesOwned
});
}
return state;
}
/// <summary>
/// Apply a previously saved snapshot to the runtime inventory
/// </summary>
public void ApplyCardCollectionState(CardCollectionState state)
{
if (state == null) return;
playerInventory.ClearAllCards();
playerInventory.BoosterPackCount = state.boosterPackCount;
OnBoosterCountChanged?.Invoke(playerInventory.BoosterPackCount);
foreach (var entry in state.cards)
{
if (string.IsNullOrEmpty(entry.definitionId)) continue;
if (_definitionLookup.TryGetValue(entry.definitionId, out var def))
{
// Create from definition to ensure links, then overwrite runtime fields
var cd = def.CreateCardData();
cd.Rarity = entry.rarity;
cd.CopiesOwned = entry.copiesOwned;
playerInventory.AddCard(cd);
}
else
{
Logging.Warning($"[CardSystemManager] Saved card definition not found: {entry.definitionId}");
}
}
}
} }
} }

View File

@@ -177,7 +177,7 @@ namespace AppleHills.Tests
#if UNITY_EDITOR #if UNITY_EDITOR
[CustomEditor(typeof(CardSystemTester))] [CustomEditor(typeof(CardSystemTester))]
public class CardSystemTesterEditor : Editor public class CardSystemTesterEditor : UnityEditor.Editor
{ {
public override void OnInspectorGUI() public override void OnInspectorGUI()
{ {

View File

@@ -233,7 +233,7 @@ namespace UI.CardSystem
#if UNITY_EDITOR #if UNITY_EDITOR
[CustomEditor(typeof(CardUIElement))] [CustomEditor(typeof(CardUIElement))]
public class CardUIElementEditor : Editor public class CardUIElementEditor : UnityEditor.Editor
{ {
public override void OnInspectorGUI() public override void OnInspectorGUI()
{ {

View File

@@ -1,6 +1,7 @@
using System.Collections; using System.Collections;
using Bootstrap; using Bootstrap;
using Core; using Core;
using Core.SaveLoad;
using Input; using Input;
using Pixelplacement; using Pixelplacement;
using UI.Core; using UI.Core;
@@ -31,8 +32,10 @@ namespace UI.Tutorial
void InitializeTutorial() void InitializeTutorial()
{ {
if (playTutorial) if (playTutorial && !SaveLoadManager.Instance.currentSaveData.playedDivingTutorial)
{ {
// TODO: Possibly do it better, but for now just mark tutorial as played immediately
SaveLoadManager.Instance.currentSaveData.playedDivingTutorial = true;
// pause the game, hide UI, and register for input overrides // pause the game, hide UI, and register for input overrides
GameManager.Instance.RequestPause(this); GameManager.Instance.RequestPause(this);
UIPageController.Instance.HideAllUI(); UIPageController.Instance.HideAllUI();