First go around with save load system

This commit is contained in:
Michal Pikulski
2025-11-02 12:48:48 +01:00
parent 5d6d4c8ba1
commit ebca297d28
13 changed files with 1511 additions and 122 deletions

View File

@@ -244,5 +244,36 @@ namespace Core
public IEnumerable<Pickup> Pickups => _pickups;
public IEnumerable<ItemSlot> ItemSlots => _itemSlots;
/// <summary>
/// Gets all registered pickups. Used by save/load system to find items by save ID.
/// </summary>
public IEnumerable<Pickup> GetAllPickups() => _pickups;
/// <summary>
/// Gets all registered item slots. Used by save/load system.
/// </summary>
public IEnumerable<ItemSlot> GetAllItemSlots() => _itemSlots;
/// <summary>
/// Finds a pickup by its save ID. Used by save/load system to restore item references.
/// </summary>
/// <param name="saveId">The save ID to search for</param>
/// <returns>The pickup's GameObject if found, null otherwise</returns>
public GameObject FindPickupBySaveId(string saveId)
{
if (string.IsNullOrEmpty(saveId)) return null;
// Search through all registered pickups
foreach (var pickup in _pickups)
{
if (pickup is SaveableInteractable saveable && saveable.GetSaveId() == saveId)
{
return pickup.gameObject;
}
}
return null;
}
}
}

View File

@@ -23,6 +23,12 @@
/// Should handle null/empty data gracefully with default behavior.
/// </summary>
void RestoreState(string serializedData);
/// <summary>
/// Returns true if this participant has already had its state restored.
/// Used to prevent double-restoration when inactive objects become active.
/// </summary>
bool HasBeenRestored { get; }
}
}

View File

@@ -48,7 +48,7 @@ namespace Core.SaveLoad
private void Start()
{
Load();
}
private void OnApplicationQuit()
@@ -67,6 +67,12 @@ namespace Core.SaveLoad
SceneManagerService.Instance.SceneUnloadStarted += OnSceneUnloadStarted;
Logging.Debug("[SaveLoadManager] Subscribed to SceneManagerService events");
}
#if UNITY_EDITOR
OnSceneLoadCompleted("RestoreInEditor");
#endif
Load();
}
void OnDestroy()
@@ -114,7 +120,8 @@ namespace Core.SaveLoad
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)
// BUT only if the participant hasn't already been restored (prevents double-restoration when inactive objects become active)
if (IsSaveDataLoaded && !IsRestoringState && currentSaveData != null && !participant.HasBeenRestored)
{
RestoreParticipantState(participant);
}
@@ -153,10 +160,28 @@ namespace Core.SaveLoad
private void OnSceneLoadCompleted(string sceneName)
{
Logging.Debug($"[SaveLoadManager] Scene '{sceneName}' loaded. Participants can now register and will be restored.");
Logging.Debug($"[SaveLoadManager] Scene '{sceneName}' loaded. Discovering inactive SaveableInteractables...");
// Participants register themselves, so we just wait for them
// After registration, they'll be automatically restored if data is available
// Find ONLY INACTIVE SaveableInteractables (active ones will register themselves via Start())
var inactiveSaveables = FindObjectsByType(
typeof(Interactions.SaveableInteractable),
FindObjectsInactive.Include,
FindObjectsSortMode.None
);
int registeredCount = 0;
foreach (var obj in inactiveSaveables)
{
var saveable = obj as Interactions.SaveableInteractable;
if (saveable != null && !saveable.gameObject.activeInHierarchy)
{
// Only register if it's actually inactive
RegisterParticipant(saveable);
registeredCount++;
}
}
Logging.Debug($"[SaveLoadManager] Discovered and registered {registeredCount} inactive SaveableInteractables");
}
private void OnSceneUnloadStarted(string sceneName)

View File

@@ -461,6 +461,13 @@ namespace Data.CardSystem
#region ISaveParticipant Implementation
private bool hasBeenRestored;
/// <summary>
/// Returns true if this participant has already had its state restored.
/// </summary>
public bool HasBeenRestored => hasBeenRestored;
/// <summary>
/// Returns the unique save ID for the CardSystemManager.
/// Since this is a singleton global system, the ID is constant.
@@ -487,6 +494,7 @@ namespace Data.CardSystem
if (string.IsNullOrEmpty(serializedData))
{
Logging.Debug("[CardSystemManager] No saved state to restore, using defaults");
hasBeenRestored = true;
return;
}
@@ -496,6 +504,7 @@ namespace Data.CardSystem
if (state != null)
{
ApplyCardCollectionState(state);
hasBeenRestored = true;
Logging.Debug("[CardSystemManager] Successfully restored card collection state");
}
else

View File

@@ -664,7 +664,7 @@ namespace Dialogue
// Check all pickups for the given ID
foreach (var pickup in ItemManager.Instance.Pickups)
{
if (pickup.isPickedUp && pickup.itemData != null &&
if (pickup.IsPickedUp && pickup.itemData != null &&
pickup.itemData.itemId == itemID)
{
return true;

View File

@@ -16,6 +16,19 @@ namespace Interactions
Forbidden
}
/// <summary>
/// Saveable data for ItemSlot state
/// </summary>
[System.Serializable]
public class ItemSlotSaveData
{
public PickupSaveData pickupData; // Base pickup state
public ItemSlotState slotState; // Current slot validation state
public string slottedItemSaveId; // Save ID of slotted item (if any)
public string slottedItemDataAssetPath; // Asset path to PickupItemData
}
// TODO: Remove this ridiculous inheritance from Pickup if possible
/// <summary>
/// Interaction requirement that allows slotting, swapping, or picking up items in a slot.
/// </summary>
@@ -178,7 +191,130 @@ namespace Interactions
}
}
public void SlotItem(GameObject itemToSlot, PickupItemData itemToSlotData, bool clearFollowerHeldItem = true)
// Register with ItemManager when enabled
protected override void Start()
{
base.Start(); // This calls Pickup.Start() which registers with save system
// Additionally register as ItemSlot
ItemManager.Instance?.RegisterItemSlot(this);
}
protected override void OnDestroy()
{
base.OnDestroy(); // Unregister from save system and pickup manager
// Unregister from slot manager
ItemManager.Instance?.UnregisterItemSlot(this);
}
#region Save/Load Implementation
protected override object GetSerializableState()
{
// Get base pickup state
PickupSaveData baseData = base.GetSerializableState() as PickupSaveData;
// Get slotted item save ID if there's a slotted item
string slottedSaveId = "";
string slottedAssetPath = "";
if (_currentlySlottedItemObject != null)
{
var slottedPickup = _currentlySlottedItemObject.GetComponent<Pickup>();
if (slottedPickup is SaveableInteractable saveablePickup)
{
slottedSaveId = saveablePickup.GetSaveId();
}
if (_currentlySlottedItemData != null)
{
#if UNITY_EDITOR
slottedAssetPath = UnityEditor.AssetDatabase.GetAssetPath(_currentlySlottedItemData);
#endif
}
}
return new ItemSlotSaveData
{
pickupData = baseData,
slotState = _currentState,
slottedItemSaveId = slottedSaveId,
slottedItemDataAssetPath = slottedAssetPath
};
}
protected override void ApplySerializableState(string serializedData)
{
ItemSlotSaveData data = JsonUtility.FromJson<ItemSlotSaveData>(serializedData);
if (data == null)
{
Debug.LogWarning($"[ItemSlot] Failed to deserialize save data for {gameObject.name}");
return;
}
// First restore base pickup state
if (data.pickupData != null)
{
string pickupJson = JsonUtility.ToJson(data.pickupData);
base.ApplySerializableState(pickupJson);
}
// Restore slot state
_currentState = data.slotState;
// Restore slotted item if there was one
if (!string.IsNullOrEmpty(data.slottedItemSaveId))
{
RestoreSlottedItem(data.slottedItemSaveId, data.slottedItemDataAssetPath);
}
}
/// <summary>
/// Restore a slotted item from save data.
/// This is called during load restoration and should NOT trigger events.
/// </summary>
private void RestoreSlottedItem(string slottedItemSaveId, string slottedItemDataAssetPath)
{
// Try to find the item in the scene by its save ID via ItemManager
GameObject slottedObject = ItemManager.Instance?.FindPickupBySaveId(slottedItemSaveId);
if (slottedObject == null)
{
Debug.LogWarning($"[ItemSlot] Could not find slotted item with save ID: {slottedItemSaveId}");
return;
}
// Get the item data
PickupItemData slottedData = null;
#if UNITY_EDITOR
if (!string.IsNullOrEmpty(slottedItemDataAssetPath))
{
slottedData = UnityEditor.AssetDatabase.LoadAssetAtPath<PickupItemData>(slottedItemDataAssetPath);
}
#endif
if (slottedData == null)
{
var pickup = slottedObject.GetComponent<Pickup>();
if (pickup != null)
{
slottedData = pickup.itemData;
}
}
// Silently slot the item (no events, no interaction completion)
ApplySlottedItemState(slottedObject, slottedData, triggerEvents: false);
}
/// <summary>
/// Core logic for slotting an item. Can be used both for normal slotting and silent restoration.
/// </summary>
/// <param name="itemToSlot">The item GameObject to slot (or null to clear)</param>
/// <param name="itemToSlotData">The PickupItemData for the item</param>
/// <param name="triggerEvents">Whether to fire events and complete interaction</param>
/// <param name="clearFollowerHeldItem">Whether to clear the follower's held item</param>
private void ApplySlottedItemState(GameObject itemToSlot, PickupItemData itemToSlotData, bool triggerEvents, bool clearFollowerHeldItem = true)
{
// Cache the previous item data before clearing, needed for OnItemSlotRemoved event
var previousItemData = _currentlySlottedItemData;
@@ -188,11 +324,10 @@ namespace Interactions
{
_currentlySlottedItemObject = null;
_currentlySlottedItemData = null;
// Clear state when no item is slotted
_currentState = ItemSlotState.None;
// Fire native event for slot clearing
if (wasSlotCleared)
// Fire native event for slot clearing (only if triggering events)
if (wasSlotCleared && triggerEvents)
{
OnItemSlotRemoved?.Invoke(previousItemData);
}
@@ -205,55 +340,53 @@ namespace Interactions
_currentlySlottedItemData = itemToSlotData;
}
if (clearFollowerHeldItem)
if (clearFollowerHeldItem && _followerController != null)
{
_followerController.ClearHeldItem();
}
UpdateSlottedSprite();
// Once an item is slotted, we know it is not forbidden, so we can skip that check, but now check if it was
// the correct item we're looking for
var config = _interactionSettings?.GetSlotItemConfig(itemData);
var allowed = config?.allowedItems ?? new List<PickupItemData>();
if (itemToSlotData != null && PickupItemData.ListContainsEquivalent(allowed, itemToSlotData))
// Only validate and trigger events if requested
if (triggerEvents)
{
if (itemToSlot != null)
// Once an item is slotted, we know it is not forbidden, so we can skip that check, but now check if it was
// the correct item we're looking for
var config = _interactionSettings?.GetSlotItemConfig(itemData);
var allowed = config?.allowedItems ?? new List<PickupItemData>();
if (itemToSlotData != null && PickupItemData.ListContainsEquivalent(allowed, itemToSlotData))
{
DebugUIMessage.Show("You correctly slotted " + itemToSlotData.itemName + " into: " + itemData.itemName, Color.green);
onCorrectItemSlotted?.Invoke();
OnCorrectItemSlotted?.Invoke(itemData, _currentlySlottedItemData);
_currentState = ItemSlotState.Correct;
if (itemToSlot != null)
{
DebugUIMessage.Show("You correctly slotted " + itemToSlotData.itemName + " into: " + itemData.itemName, Color.green);
onCorrectItemSlotted?.Invoke();
OnCorrectItemSlotted?.Invoke(itemData, _currentlySlottedItemData);
_currentState = ItemSlotState.Correct;
}
CompleteInteraction(true);
}
CompleteInteraction(true);
}
else
{
if (itemToSlot != null)
else
{
DebugUIMessage.Show("I'm not sure this works.", Color.yellow);
onIncorrectItemSlotted?.Invoke();
OnIncorrectItemSlotted?.Invoke(itemData, _currentlySlottedItemData);
_currentState = ItemSlotState.Incorrect;
if (itemToSlot != null)
{
DebugUIMessage.Show("I'm not sure this works.", Color.yellow);
onIncorrectItemSlotted?.Invoke();
OnIncorrectItemSlotted?.Invoke(itemData, _currentlySlottedItemData);
_currentState = ItemSlotState.Incorrect;
}
CompleteInteraction(false);
}
CompleteInteraction(false);
}
}
// Register with ItemManager when enabled
void Start()
/// <summary>
/// Public API for slotting items during gameplay.
/// </summary>
public void SlotItem(GameObject itemToSlot, PickupItemData itemToSlotData, bool clearFollowerHeldItem = true)
{
// Note: Base Pickup class also calls RegisterPickup in its Start
// This additionally registers as ItemSlot
ItemManager.Instance?.RegisterPickup(this);
ItemManager.Instance?.RegisterItemSlot(this);
ApplySlottedItemState(itemToSlot, itemToSlotData, triggerEvents: true, clearFollowerHeldItem);
}
void OnDestroy()
{
// Unregister from both pickup and slot managers
ItemManager.Instance?.UnregisterPickup(this);
ItemManager.Instance?.UnregisterItemSlot(this);
}
#endregion
}
}

View File

@@ -1,17 +1,30 @@
using Input;
using UnityEngine;
using System; // added for Action<T>
using System;
using System.Linq; // added for Action<T>
using Core; // register with ItemManager
namespace Interactions
{
public class Pickup : InteractableBase
/// <summary>
/// Saveable data for Pickup state
/// </summary>
[System.Serializable]
public class PickupSaveData
{
public bool isPickedUp;
public Vector3 worldPosition;
public Quaternion worldRotation;
public bool isActive;
}
public class Pickup : SaveableInteractable
{
public PickupItemData itemData;
public SpriteRenderer iconRenderer;
// Track if the item has been picked up
public bool isPickedUp { get; private set; }
public bool IsPickedUp { get; internal set; }
// Event: invoked when the item was picked up successfully
public event Action<PickupItemData> OnItemPickedUp;
@@ -22,8 +35,10 @@ namespace Interactions
/// <summary>
/// Unity Awake callback. Sets up icon and applies item data.
/// </summary>
protected virtual void Awake()
protected override void Awake()
{
base.Awake(); // Register with save system
if (iconRenderer == null)
iconRenderer = GetComponent<SpriteRenderer>();
@@ -33,16 +48,24 @@ namespace Interactions
/// <summary>
/// Register with ItemManager on Start
/// </summary>
void Start()
protected override void Start()
{
ItemManager.Instance?.RegisterPickup(this);
base.Start(); // Register with save system
// Don't register with ItemManager if already picked up (restored from save)
if (!IsPickedUp)
{
ItemManager.Instance?.RegisterPickup(this);
}
}
/// <summary>
/// Unity OnDestroy callback. Unregisters from ItemManager.
/// </summary>
void OnDestroy()
protected override void OnDestroy()
{
base.OnDestroy(); // Unregister from save system
// Unregister from ItemManager
ItemManager.Instance?.UnregisterPickup(this);
}
@@ -130,9 +153,69 @@ namespace Interactions
// Update pickup state and invoke event when the item was picked up successfully
if (wasPickedUp)
{
isPickedUp = true;
IsPickedUp = true;
OnItemPickedUp?.Invoke(itemData);
}
}
#region Save/Load Implementation
protected override object GetSerializableState()
{
return new PickupSaveData
{
isPickedUp = this.IsPickedUp,
worldPosition = transform.position,
worldRotation = transform.rotation,
isActive = gameObject.activeSelf
};
}
protected override void ApplySerializableState(string serializedData)
{
PickupSaveData data = JsonUtility.FromJson<PickupSaveData>(serializedData);
if (data == null)
{
Debug.LogWarning($"[Pickup] Failed to deserialize save data for {gameObject.name}");
return;
}
// Restore picked up state
IsPickedUp = data.isPickedUp;
if (IsPickedUp)
{
// Hide the pickup if it was already picked up
gameObject.SetActive(false);
}
else
{
// Restore position for items that haven't been picked up (they may have moved)
transform.position = data.worldPosition;
transform.rotation = data.worldRotation;
gameObject.SetActive(data.isActive);
}
// Note: We do NOT fire OnItemPickedUp event during restoration
// This prevents duplicate logic execution
}
/// <summary>
/// Resets the pickup state when the item is dropped back into the world.
/// Called by FollowerController when swapping items.
/// </summary>
public void ResetPickupState()
{
IsPickedUp = false;
gameObject.SetActive(true);
// Re-register with ItemManager if not already registered
if (ItemManager.Instance != null && !ItemManager.Instance.GetAllPickups().Contains(this))
{
ItemManager.Instance.RegisterPickup(this);
}
}
#endregion
}
}

View File

@@ -0,0 +1,261 @@
using Core.SaveLoad;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace Interactions
{
/// <summary>
/// Base class for interactables that participate in the save/load system.
/// Provides common save ID generation and serialization infrastructure.
/// </summary>
public abstract class SaveableInteractable : InteractableBase, ISaveParticipant
{
[Header("Save System")]
[SerializeField]
[Tooltip("Optional custom save ID. If empty, will auto-generate from hierarchy path.")]
private string customSaveId = "";
/// <summary>
/// Flag to indicate we're currently restoring from save data.
/// Child classes can check this to skip initialization logic during load.
/// </summary>
protected bool IsRestoringFromSave { get; private set; }
private bool hasRegistered;
private bool hasRestoredState;
/// <summary>
/// Returns true if this participant has already had its state restored.
/// </summary>
public bool HasBeenRestored => hasRestoredState;
protected virtual void Awake()
{
// Register early in Awake so even disabled objects are tracked
RegisterWithSaveSystem();
}
protected virtual void Start()
{
// If we didn't register in Awake (shouldn't happen), register now
if (!hasRegistered)
{
RegisterWithSaveSystem();
}
}
protected virtual void OnDestroy()
{
UnregisterFromSaveSystem();
}
private void RegisterWithSaveSystem()
{
if (hasRegistered) return;
if (SaveLoadManager.Instance != null)
{
SaveLoadManager.Instance.RegisterParticipant(this);
hasRegistered = true;
// Check if save data was already loaded before we registered
// If so, we need to subscribe to the next load event
if (!SaveLoadManager.Instance.IsSaveDataLoaded && !hasRestoredState)
{
SaveLoadManager.Instance.OnLoadCompleted += OnSaveDataLoadedHandler;
}
}
else
{
Debug.LogWarning($"[SaveableInteractable] SaveLoadManager not found for {gameObject.name}");
}
}
private void UnregisterFromSaveSystem()
{
if (!hasRegistered) return;
if (SaveLoadManager.Instance != null)
{
SaveLoadManager.Instance.UnregisterParticipant(GetSaveId());
SaveLoadManager.Instance.OnLoadCompleted -= OnSaveDataLoadedHandler;
hasRegistered = false;
}
}
/// <summary>
/// Event handler for when save data finishes loading.
/// Called if the object registered before save data was loaded.
/// </summary>
private void OnSaveDataLoadedHandler(string slot)
{
// The SaveLoadManager will automatically call RestoreState on us
// We just need to unsubscribe from the event
if (SaveLoadManager.Instance != null)
{
SaveLoadManager.Instance.OnLoadCompleted -= OnSaveDataLoadedHandler;
}
}
#region ISaveParticipant Implementation
public string GetSaveId()
{
string sceneName = GetSceneName();
if (!string.IsNullOrEmpty(customSaveId))
{
return $"{sceneName}/{customSaveId}";
}
// Auto-generate from hierarchy path
string hierarchyPath = GetHierarchyPath();
return $"{sceneName}/{hierarchyPath}";
}
public string SerializeState()
{
object stateData = GetSerializableState();
if (stateData == null)
{
return "{}";
}
return JsonUtility.ToJson(stateData);
}
public void RestoreState(string serializedData)
{
if (string.IsNullOrEmpty(serializedData))
{
Debug.LogWarning($"[SaveableInteractable] Empty save data for {GetSaveId()}");
return;
}
// CRITICAL: Only restore state if we're actually in a restoration context
// This prevents state machines from teleporting objects when they enable them mid-gameplay
if (SaveLoadManager.Instance != null && !SaveLoadManager.Instance.IsRestoringState)
{
// If we're not in an active restoration cycle, this is probably a late registration
// (object was disabled during initial load and just got enabled)
// Skip restoration to avoid mid-gameplay teleportation
Debug.Log($"[SaveableInteractable] Skipping late restoration for {GetSaveId()} - object enabled after initial load");
hasRestoredState = true; // Mark as restored to prevent future attempts
return;
}
IsRestoringFromSave = true;
hasRestoredState = true;
try
{
ApplySerializableState(serializedData);
}
catch (System.Exception e)
{
Debug.LogError($"[SaveableInteractable] Failed to restore state for {GetSaveId()}: {e.Message}");
}
finally
{
IsRestoringFromSave = false;
}
}
#endregion
#region Virtual Methods for Child Classes
/// <summary>
/// Child classes override this to return their serializable state data.
/// Return an object that can be serialized with JsonUtility.
/// </summary>
protected abstract object GetSerializableState();
/// <summary>
/// Child classes override this to apply restored state data.
/// Should NOT trigger events or re-initialize logic that already happened.
/// </summary>
/// <param name="serializedData">JSON string containing the saved state</param>
protected abstract void ApplySerializableState(string serializedData);
#endregion
#region Helper Methods
private string GetSceneName()
{
Scene scene = gameObject.scene;
if (!scene.IsValid())
{
Debug.LogWarning($"[SaveableInteractable] GameObject {gameObject.name} has invalid scene");
return "UnknownScene";
}
return scene.name;
}
private string GetHierarchyPath()
{
// Build path from scene root to this object
// Format: ParentName/ChildName/ObjectName_SiblingIndex
string path = gameObject.name;
Transform current = transform.parent;
while (current != null)
{
path = $"{current.name}/{path}";
current = current.parent;
}
// Add sibling index for uniqueness among same-named objects
int siblingIndex = transform.GetSiblingIndex();
if (siblingIndex > 0)
{
path = $"{path}_{siblingIndex}";
}
return path;
}
#endregion
#region Editor Helpers
#if UNITY_EDITOR
[ContextMenu("Log Save ID")]
private void LogSaveId()
{
Debug.Log($"Save ID: {GetSaveId()}");
}
[ContextMenu("Test Serialize/Deserialize")]
private void TestSerializeDeserialize()
{
string serialized = SerializeState();
Debug.Log($"Serialized state: {serialized}");
RestoreState(serialized);
Debug.Log("Deserialization test complete");
}
#endif
#endregion
}
#region Common Save Data Structures
/// <summary>
/// Base save data for all interactables.
/// Can be extended by child classes.
/// </summary>
[System.Serializable]
public class InteractableBaseSaveData
{
public bool isActive;
public Vector3 worldPosition;
public Quaternion worldRotation;
}
#endregion
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: a0d7c8f7344746ce9dc863985cc3f543
timeCreated: 1762079555

View File

@@ -24,14 +24,14 @@ namespace Levels
// Settings reference
private IInteractionSettings _interactionSettings;
private bool _isActive = true;
private bool switchActive = true;
/// <summary>
/// Unity Awake callback. Sets up icon, interactable, and event handlers.
/// </summary>
void Awake()
{
_isActive = true;
switchActive = true;
if (_iconRenderer == null)
_iconRenderer = GetComponent<SpriteRenderer>();
@@ -72,7 +72,7 @@ namespace Levels
/// </summary>
protected override void OnCharacterArrived()
{
if (switchData == null || string.IsNullOrEmpty(switchData.targetLevelSceneName) || !_isActive)
if (switchData == null || string.IsNullOrEmpty(switchData.targetLevelSceneName) || !switchActive)
return;
var menuPrefab = _interactionSettings?.LevelSwitchMenuPrefab;
@@ -92,7 +92,7 @@ namespace Levels
}
// Setup menu with data and callbacks
menu.Setup(switchData, OnLevelSelectedWrapper, OnMinigameSelected, OnMenuCancel, OnRestartSelected);
_isActive = false; // Prevent re-triggering until menu is closed
switchActive = false; // Prevent re-triggering until menu is closed
// Switch input mode to UI only
InputManager.Instance.SetInputMode(InputMode.UI);
@@ -123,7 +123,7 @@ namespace Levels
private void OnMenuCancel()
{
_isActive = true; // Allow interaction again if cancelled
switchActive = true; // Allow interaction again if cancelled
InputManager.Instance.SetInputMode(InputMode.GameAndUI);
}
}

View File

@@ -13,94 +13,73 @@ using Core.SaveLoad;
namespace Levels
{
/// <summary>
/// Saveable data for MinigameSwitch state
/// </summary>
[System.Serializable]
public class MinigameSwitchSaveData
{
public bool isUnlocked;
}
/// <summary>
/// Handles switching into minigame levels when interacted with. Applies switch data and triggers scene transitions.
/// </summary>
public class MinigameSwitch : InteractableBase
public class MinigameSwitch : SaveableInteractable
{
/// <summary>
/// Data for this level switch (target scene, icon, etc).
/// </summary>
public LevelSwitchData switchData;
private SpriteRenderer _iconRenderer;
private SpriteRenderer iconRenderer;
// Settings reference
private IInteractionSettings _interactionSettings;
private IInteractionSettings interactionSettings;
private bool _isActive = true;
private bool switchActive = true;
private bool isUnlocked;
/// <summary>
/// Unity Awake callback. Sets up icon, interactable, and event handlers.
/// </summary>
void Awake()
protected override void Awake()
{
gameObject.SetActive(false); // Start inactive
base.Awake(); // Register with save system
BootCompletionService.RegisterInitAction(InitializePostBoot);
_isActive = true;
if (_iconRenderer == null)
_iconRenderer = GetComponent<SpriteRenderer>();
switchActive = true;
if (iconRenderer == null)
iconRenderer = GetComponent<SpriteRenderer>();
// Initialize settings reference
_interactionSettings = GameManager.GetSettingsObject<IInteractionSettings>();
interactionSettings = GameManager.GetSettingsObject<IInteractionSettings>();
ApplySwitchData();
}
// --- Save state loading logic ---
if (SaveLoadManager.Instance != null)
protected override void Start()
{
base.Start(); // Register with save system
// If not restoring from save, start inactive
if (!IsRestoringFromSave && !isUnlocked)
{
if (SaveLoadManager.Instance.IsSaveDataLoaded)
{
ApplySavedMinigameStateIfAvailable();
}
else
{
SaveLoadManager.Instance.OnLoadCompleted += OnSaveDataLoadedHandler;
}
gameObject.SetActive(false);
}
}
private void OnDestroy()
protected override void OnDestroy()
{
if (SaveLoadManager.Instance != null)
{
SaveLoadManager.Instance.OnLoadCompleted -= OnSaveDataLoadedHandler;
}
}
// Apply saved state if present in the SaveLoadManager
private void ApplySavedMinigameStateIfAvailable()
{
var data = SaveLoadManager.Instance?.currentSaveData;
if (data?.unlockedMinigames != null && switchData != null &&
data.unlockedMinigames.Contains(switchData.targetLevelSceneName))
{
gameObject.SetActive(true);
}
}
// Event handler for when save data load completes
private void OnSaveDataLoadedHandler(string slot)
{
ApplySavedMinigameStateIfAvailable();
if (SaveLoadManager.Instance != null)
{
SaveLoadManager.Instance.OnLoadCompleted -= OnSaveDataLoadedHandler;
}
base.OnDestroy(); // Unregister from save system
}
private void HandleAllPuzzlesComplete(PuzzleS.PuzzleLevelDataSO _)
{
// Unlock and save
if (switchData != null)
{
var unlocked = SaveLoadManager.Instance.currentSaveData.unlockedMinigames;
string minigameName = switchData.targetLevelSceneName;
if (!unlocked.Contains(minigameName))
{
unlocked.Add(minigameName);
}
}
// Unlock the minigame
isUnlocked = true;
gameObject.SetActive(true);
// Save will happen automatically on next save cycle via ISaveParticipant
}
#if UNITY_EDITOR
@@ -109,8 +88,8 @@ namespace Levels
/// </summary>
void OnValidate()
{
if (_iconRenderer == null)
_iconRenderer = GetComponent<SpriteRenderer>();
if (iconRenderer == null)
iconRenderer = GetComponent<SpriteRenderer>();
ApplySwitchData();
}
#endif
@@ -122,8 +101,8 @@ namespace Levels
{
if (switchData != null)
{
if (_iconRenderer != null)
_iconRenderer.sprite = switchData.mapSprite;
if (iconRenderer != null)
iconRenderer.sprite = switchData.mapSprite;
gameObject.name = switchData.targetLevelSceneName;
// Optionally update other fields, e.g. description
}
@@ -134,10 +113,10 @@ namespace Levels
/// </summary>
protected override void OnCharacterArrived()
{
if (switchData == null || string.IsNullOrEmpty(switchData.targetLevelSceneName) || !_isActive)
if (switchData == null || string.IsNullOrEmpty(switchData.targetLevelSceneName) || !switchActive)
return;
var menuPrefab = _interactionSettings?.MinigameSwitchMenuPrefab;
var menuPrefab = interactionSettings?.MinigameSwitchMenuPrefab;
if (menuPrefab == null)
{
Debug.LogError("MinigameSwitchMenu prefab not assigned in InteractionSettings!");
@@ -154,7 +133,7 @@ namespace Levels
}
// Setup menu with data and callbacks
menu.Setup(switchData, OnLevelSelectedWrapper, OnMenuCancel);
_isActive = false; // Prevent re-triggering until menu is closed
switchActive = false; // Prevent re-triggering until menu is closed
// Switch input mode to UI only
InputManager.Instance.SetInputMode(InputMode.UI);
@@ -173,7 +152,7 @@ namespace Levels
private void OnMenuCancel()
{
_isActive = true; // Allow interaction again if cancelled
switchActive = true; // Allow interaction again if cancelled
InputManager.Instance.SetInputMode(InputMode.GameAndUI);
}
@@ -181,5 +160,32 @@ namespace Levels
{
PuzzleManager.Instance.OnAllPuzzlesComplete += HandleAllPuzzlesComplete;
}
#region Save/Load Implementation
protected override object GetSerializableState()
{
return new MinigameSwitchSaveData
{
isUnlocked = isUnlocked
};
}
protected override void ApplySerializableState(string serializedData)
{
MinigameSwitchSaveData data = JsonUtility.FromJson<MinigameSwitchSaveData>(serializedData);
if (data == null)
{
Debug.LogWarning($"[MinigameSwitch] Failed to deserialize save data for {gameObject.name}");
return;
}
isUnlocked = data.isUnlocked;
// Show/hide based on unlock state
gameObject.SetActive(isUnlocked);
}
#endregion
}
}

View File

@@ -584,7 +584,14 @@ public class FollowerController: MonoBehaviour
if (matchingRule != null && matchingRule.resultPrefab != null)
{
newItem = Instantiate(matchingRule.resultPrefab, spawnPos, Quaternion.identity);
PickupItemData itemData = newItem.GetComponent<Pickup>().itemData;
var resultPickup = newItem.GetComponent<Pickup>();
PickupItemData itemData = resultPickup.itemData;
// Mark the base items as picked up before destroying them
// (This ensures they save correctly if the game is saved during the combination animation)
pickupA.IsPickedUp = true;
pickupB.IsPickedUp = true;
Destroy(pickupA.gameObject);
Destroy(pickupB.gameObject);
TryPickupItem(newItem, itemData);
@@ -662,6 +669,14 @@ public class FollowerController: MonoBehaviour
item.transform.position = position;
item.transform.SetParent(null);
item.SetActive(true);
// Reset the pickup state so it can be picked up again and saves correctly
var pickup = item.GetComponent<Pickup>();
if (pickup != null)
{
pickup.ResetPickupState();
}
follower.ClearHeldItem();
_animator.SetBool("IsCarrying", false);
// Optionally: fire event, update UI, etc.

View File

@@ -0,0 +1,817 @@
# Interactables Save/Load Integration Plan
## Overview
This document outlines the complete implementation plan for integrating the Interactable system (InteractableBase and all child classes) into the existing save/load system using the ISaveParticipant pattern.
---
## Current State Analysis
### Interactable Hierarchy
```
InteractableBase (abstract)
├── Pickup
│ └── ItemSlot (extends Pickup)
├── OneClickInteraction
├── LevelSwitch
└── MinigameSwitch
```
### Key Observations
1. **InteractableBase** - Abstract base class with common interaction flow, events, cooldown system
2. **Pickup** - Tracks `isPickedUp` state, handles item pickup/combination logic
3. **ItemSlot** - Extends Pickup, tracks slotted item state (`ItemSlotState`, `_currentlySlottedItemData`, `_currentlySlottedItemObject`)
4. **OneClickInteraction** - Stateless, completes immediately (likely no save state needed)
5. **LevelSwitch** - Appears to be stateless switch behavior (no persistent state)
6. **MinigameSwitch** - **Already partially integrated** with save system (tracks unlock state in `SaveLoadData.unlockedMinigames`)
### State Machine Challenge
- State machines (Pixelplacement's StateMachine) are used in scenes
- Calling `ChangeState()` triggers `OnEnable()` in target state, which can:
- Start animations
- Move player characters
- Enable child GameObjects with Start() calls
- **Problem**: Restoring a state directly would replay all initialization logic
- **Solution**: Need a way to restore state without triggering initialization side effects
---
## Implementation Strategy
### ~~Phase 1: Create Base SaveableInteractable Class~~ ✅ **COMPLETED**
**Goal**: Establish the foundation for saving/loading interactable state
**Phase 1 Completion Summary:**
- ✅ Created `SaveableInteractable` abstract class inheriting from `InteractableBase`
- ✅ Implemented `ISaveParticipant` interface with GetSaveId(), SerializeState(), RestoreState()
- ✅ Added hybrid save ID generation: custom IDs or auto-generated from hierarchy path
- ✅ Implemented registration/unregistration in Start()/OnDestroy()
- ✅ Added `IsRestoringFromSave` protected flag for child classes
- ✅ Created abstract methods `GetSerializableState()` and `ApplySerializableState()` for children
- ✅ Added `InteractableBaseSaveData` structure with position, rotation, and active state
- ✅ Included editor helper methods (Log Save ID, Test Serialize/Deserialize)
- ✅ No compilation errors, only minor style warnings
**Files Created**:
- `Assets/Scripts/Interactions/SaveableInteractable.cs`
---
### ~~Phase 2: Migrate Pickup to SaveableInteractable~~ ✅ **COMPLETED**
**Goal**: Make Pickup save/restore its picked-up state and world position
**Phase 2 Completion Summary:**
- ✅ Changed `Pickup` to extend `SaveableInteractable` instead of `InteractableBase`
- ✅ Created `PickupSaveData` structure with: isPickedUp, worldPosition, worldRotation, isActive
- ✅ Updated Start() to call base.Start() and skip ItemManager registration if already picked up
- ✅ Updated OnDestroy() to call base.OnDestroy() for save system unregistration
- ✅ Implemented `GetSerializableState()` to capture current pickup state
- ✅ Implemented `ApplySerializableState()` to restore state without triggering events
-**Added world position/rotation saving** for items that haven't been picked up (user requirement)
- ✅ Hide GameObject if picked up, restore position/rotation if not picked up
- ✅ No OnItemPickedUp events fired during restoration (prevents duplicate logic)
- ✅ No compilation errors, only style warnings
**Files Modified**:
- `Assets/Scripts/Interactions/Pickup.cs`
---
### ~~Phase 3: Migrate ItemSlot to SaveableInteractable~~ ✅ **COMPLETED**
**Goal**: Make ItemSlot save/restore slotted item state
**State to Save**:
- `_currentState` (ItemSlotState enum) - Current slot validation state
- `_currentlySlottedItemData` (PickupItemData reference) - Which item is slotted
- Base Pickup state (isPickedUp) - Inherited from Pickup
**Serialization Strategy**:
```csharp
[System.Serializable]
public class ItemSlotSaveData
{
public bool isPickedUp; // From Pickup base
public ItemSlotState slotState;
public string slottedItemDataGuid; // Reference to PickupItemData asset
}
```
**Restoration Logic**:
1. Restore base Pickup state (isPickedUp)
**Phase 3 Completion Summary:**
- ✅ Created `ItemSlotSaveData` structure with: pickupData, slotState, slottedItemSaveId, slottedItemDataAssetPath
- ✅ Updated Start() to call base.Start() and additionally register as ItemSlot
- ✅ Updated OnDestroy() to call base.OnDestroy() and unregister from ItemSlot manager
- ✅ Implemented `GetSerializableState()` to capture slot state and slotted item references
- ✅ Implemented `ApplySerializableState()` to restore base pickup state + slot state
-**Created `ApplySlottedItemState()` method** - unified slotting logic with triggerEvents parameter (post-refactor)
-**Created `RestoreSlottedItem()` method** - finds slotted item by save ID and restores it
-**SlotItem() refactored** - now a clean wrapper around ApplySlottedItemState (post-refactor)
- ✅ Reuses base Pickup serialization through inheritance (code reuse achieved!)
- ✅ No compilation errors, only style warnings
**Code Reuse Pattern Identified:**
- ItemSlot calls `base.GetSerializableState()` to get PickupSaveData
- ItemSlot calls `base.ApplySerializableState()` to restore pickup state first
- ApplySlottedItemState() handles both gameplay and restoration with a single code path
- This pattern can be used by future child classes (inheritance-based state composition)
**Files Modified**:
- `Assets/Scripts/Interactions/ItemSlot.cs`
- `Assets/Scripts/Core/ItemManager.cs` (added GetAllPickups/GetAllItemSlots/FindPickupBySaveId methods)
---
### ~~Phase 4: Handle Stateless Interactables~~ ✅ **COMPLETED**
**Goal**: Determine which interactables need saving and migrate MinigameSwitch to participant pattern
**Phase 4 Completion Summary:**
-**OneClickInteraction**: Confirmed stateless, kept inheriting from InteractableBase
-**LevelSwitch**: Confirmed stateless, kept inheriting from InteractableBase
-**MinigameSwitch**: Successfully migrated to SaveableInteractable
- Created `MinigameSwitchSaveData` structure with isUnlocked flag
- Changed inheritance from InteractableBase to SaveableInteractable
- Removed old direct SaveLoadManager.currentSaveData access pattern
- Removed manual event subscription to OnLoadCompleted
- Added `_isUnlocked` private field to track state
- Implemented `GetSerializableState()` and `ApplySerializableState()`
- Updated Start() to check IsRestoringFromSave flag and set initial active state
- Updated HandleAllPuzzlesComplete() to set unlock flag (save happens automatically)
- GameObject.activeSelf now managed by save/load system
- ✅ No compilation errors, only style warnings
**Migration Benefit:**
- MinigameSwitch now uses the same pattern as all other saveable interactables
- No special case code needed in SaveLoadManager
- Cleaner, more maintainable architecture
**Files Modified**:
- `Assets/Scripts/Levels/MinigameSwitch.cs`
---
## Code Quality Improvements (Post Phase 1-4)
### Refactoring Round 1 ✅ **COMPLETED**
**Issues Identified:**
1. **ItemSlot Code Duplication** - SlotItem() and SlotItemSilent() had significant duplicate logic
2. **Poor Method Placement** - FindPickupBySaveId() was in ItemSlot but should be in ItemManager
3. **Bootstrap Timing Issue** - SaveableInteractable didn't handle save data loading before/after registration
**Improvements Implemented:**
#### 1. ItemSlot Refactoring
-**Extracted common logic** into `ApplySlottedItemState(itemToSlot, itemToSlotData, triggerEvents, clearFollowerHeldItem)`
-**Eliminated code duplication** - SlotItem() and restoration both use the same core logic
-**Added triggerEvents parameter** - single method handles both interactive and silent slotting
-**Simplified public API** - `SlotItem()` is now a thin wrapper around `ApplySlottedItemState()`
**Before:**
```csharp
SlotItem() // 60 lines of logic
SlotItemSilent() // 15 lines of duplicated logic
```
**After:**
```csharp
ApplySlottedItemState(triggerEvents) // 85 lines - single source of truth
SlotItem() // 3 lines - wrapper for gameplay
RestoreSlottedItem() // uses ApplySlottedItemState with triggerEvents=false
```
#### 2. ItemManager Enhancement
-**Moved FindPickupBySaveId()** from ItemSlot to ItemManager (proper separation of concerns)
-**Centralized item lookup** - ItemManager is now the single source for finding items by save ID
-**Cleaner architecture** - ItemSlot no longer needs to know about scene searching
**New ItemManager API:**
```csharp
public GameObject FindPickupBySaveId(string saveId)
```
#### 3. SaveableInteractable Bootstrap Fix
-**Added hasRestoredState flag** - tracks whether state has been restored
-**Subscribes to OnLoadCompleted** - handles save data loading after registration
-**Unsubscribes properly** - prevents memory leaks
-**Mirrors MinigameSwitch pattern** - uses same pattern that was working before
**Bootstrap Flow:**
1. SaveableInteractable registers in Start()
2. Checks if save data is already loaded
3. If not loaded yet, subscribes to OnLoadCompleted event
4. When load completes, SaveLoadManager calls RestoreState() automatically
5. Unsubscribes from event to prevent duplicate restoration
**Files Modified:**
- `Assets/Scripts/Interactions/ItemSlot.cs` (refactored slot logic)
- `Assets/Scripts/Core/ItemManager.cs` (added FindPickupBySaveId)
- `Assets/Scripts/Interactions/SaveableInteractable.cs` (fixed bootstrap timing)
---
### Refactoring Round 2 - Critical Fixes ✅ **COMPLETED**
#### Issue 1: Item Swap Not Resetting Pickup State
**Problem:** When picking up Item B while holding Item A, Item A was dropped but remained marked as `isPickedUp = true`, causing it to be hidden on load.
**Solution:**
- ✅ Added `ResetPickupState()` method to Pickup class
- ✅ Changed `isPickedUp` setter from `private` to `internal`
- ✅ Updated `FollowerController.DropItem()` to call `ResetPickupState()`
- ✅ Fixed combination edge case: base items marked as picked up before destruction
**Files Modified:**
- `Assets/Scripts/Interactions/Pickup.cs`
- `Assets/Scripts/Movement/FollowerController.cs`
#### Issue 2: Serialization Field Name Conflict
**Problem:** Unity error - `_isActive` field name serialized multiple times in class hierarchy (InteractableBase → MinigameSwitch/LevelSwitch).
**Solution:**
- ✅ Renamed child class fields to be more specific: `_isActive``switchActive`
- ✅ Updated all references in MinigameSwitch (4 occurrences)
- ✅ Updated all references in LevelSwitch (5 occurrences)
**Files Modified:**
- `Assets/Scripts/Levels/MinigameSwitch.cs`
- `Assets/Scripts/Levels/LevelSwitch.cs`
#### Issue 3: State Machine Initialization Order - CRITICAL 🔥 **FIXED**
**Problem:** Objects controlled by state machines start disabled. When they're enabled mid-gameplay:
1. Object becomes active
2. `Start()` runs → registers with SaveLoadManager
3. SaveLoadManager immediately calls `RestoreState()`
4. Object teleports to saved position **during gameplay**
**Root Cause:** Disabled GameObjects don't run `Awake()`/`Start()` until enabled. Late registration triggers immediate restoration.
**Solution - SaveLoadManager Discovery + Participant Self-Tracking:**
**Key Principles:**
- ✅ SaveableInteractable keeps normal lifecycle (Start/Awake/OnDestroy) unchanged
- ✅ SaveLoadManager actively discovers INACTIVE SaveableInteractables on scene load
- ✅ Each participant tracks if it's been restored via `HasBeenRestored` property
- ✅ Prevent double-restoration using participant's own state (no redundant tracking)
**Implementation:**
1. **ISaveParticipant Interface** - Added HasBeenRestored property:
```csharp
bool HasBeenRestored { get; }
```
2. **SaveableInteractable** - Exposed existing flag:
```csharp
private bool hasRestoredState;
public bool HasBeenRestored => hasRestoredState;
```
3. **SaveLoadManager.OnSceneLoadCompleted()** - Active Discovery:
```csharp
var inactiveSaveables = FindObjectsByType(typeof(SaveableInteractable),
FindObjectsInactive.Include, FindObjectsSortMode.None);
foreach (var obj in inactiveSaveables) {
var saveable = obj as SaveableInteractable;
if (saveable != null && !saveable.gameObject.activeInHierarchy) {
RegisterParticipant(saveable); // Register inactive objects
}
}
```
4. **RegisterParticipant()** - Check Participant's Own State:
```csharp
if (IsSaveDataLoaded && !IsRestoringState && !participant.HasBeenRestored) {
RestoreParticipantState(participant); // Only if not already restored
}
```
**Flow:**
**Initial Scene Load (with inactive objects):**
1. Scene loads
2. Active objects: Start() → RegisterParticipant() → RestoreState() → hasRestoredState = true
3. OnSceneLoadCompleted() fires
4. FindObjectsByType(includeInactive) discovers inactive SaveableInteractables
5. RegisterParticipant(inactive object) → checks HasBeenRestored → false → RestoreState() → hasRestoredState = true
6. All objects restored and tracked ✅
**Mid-Gameplay Object Enablement:**
1. State machine enables GameObject
2. Awake() runs → Start() runs → RegisterParticipant()
3. Check: `participant.HasBeenRestored` → TRUE
4. Skip RestoreParticipantState() ✅
5. **NO TELEPORTATION** ✅
**Files Modified:**
- `Assets/Scripts/Core/SaveLoad/ISaveParticipant.cs` (added HasBeenRestored property)
- `Assets/Scripts/Interactions/SaveableInteractable.cs` (exposed hasRestoredState)
- `Assets/Scripts/Core/SaveLoad/SaveLoadManager.cs` (discovery + participant check)
- `Assets/Scripts/Data/CardSystem/CardSystemManager.cs` (implemented HasBeenRestored)
---
### Phase 5: State Machine Integration Pattern
**Goal**: Provide a safe way for state machines to react to interactable restoration without replaying initialization
**Problem Analysis**:
- State machines like `GardenerBehaviour.stateSwitch(string stateName)` are called via UnityEvents
- Current flow: Pickup interaction → Event → stateSwitch() → ChangeState() → OnEnable() → Animations/Movement
- During load: We want to restore the state WITHOUT triggering animations/movement
**Solution Options**:
#### Option A: State Restoration Flag (Recommended)
1. Add `IsRestoringFromSave` static/singleton flag to SaveLoadManager
2. State machines check this flag in OnEnable():
```csharp
void OnEnable()
{
if (SaveLoadManager.Instance?.IsRestoringState == true)
{
// Silent restoration - set state variables only
return;
}
// Normal initialization with animations/movement
}
```
3. SaveableInteractable sets flag before calling RestoreState(), clears after
**Advantages**:
- Minimal changes to existing state machine code
- Clear separation of concerns
- Easy to understand and debug
**Disadvantages**:
- Requires modifying every State class
- Global flag could cause issues if restoration is async
#### Option B: Separate Restoration Methods
1. State classes implement two entry points:
- `OnEnable()` - Normal initialization with side effects
- `RestoreState()` - Silent state restoration without side effects
2. Add a `StateRestorationHelper` that can restore states silently:
```csharp
public static class StateRestorationHelper
{
public static void RestoreStateSilently(StateMachine sm, string stateName)
{
// Manually set active state without calling OnEnable
}
}
```
**Advantages**:
- More explicit, less global state
- States control their own restoration logic
**Disadvantages**:
- More code duplication
- Harder to implement with existing StateMachine plugin
#### Option C: Save State Machine State Separately
1. Don't tie state machine state to interactable state
2. Create separate `SaveableStateMachine` component
3. State machines save their own current state
4. On load, restore state machine state independently
**Advantages**:
- Clean separation of concerns
- State machines are self-contained
**Disadvantages**:
- More complex save data structure
- Need to coordinate restoration order
**Recommendation**: Use **Option A** with a restoration flag. It's the least invasive and most compatible with the existing Pixelplacement StateMachine plugin.
**Implementation Plan**:
1. Add `IsRestoringState` flag to SaveLoadManager (already exists!)
2. Create helper component `SaveableState` that wraps State classes:
```csharp
public class SaveableState : State
{
protected virtual void OnEnableBase()
{
if (SaveLoadManager.Instance?.IsRestoringState == true)
{
OnRestoreState();
return;
}
OnNormalEnable();
}
protected virtual void OnNormalEnable() { }
protected virtual void OnRestoreState() { }
}
```
3. Migrate existing states to inherit from SaveableState
4. Move initialization logic from OnEnable to OnNormalEnable
5. Add minimal restoration logic to OnRestoreState
**Files to Create**:
- `Assets/Scripts/StateMachines/SaveableState.cs`
**Files to Modify** (examples):
- `Assets/Scripts/StateMachines/Quarry/AnneLise/TakePhotoState.cs`
- Any other State classes that react to interactable events
---
### Phase 6: Unique ID Generation Strategy
**Goal**: Ensure every saveable interactable has a persistent, unique ID across sessions
**Challenge**:
- Scene instance IDs change between sessions
- Need deterministic IDs that survive scene reloads
- Manual GUID assignment is error-prone
**Solution: Hybrid Approach**
#### For Prefab Instances:
- Use prefab path + scene path + sibling index
- Example: `"Quarry/PickupApple_0"` (first apple in scene)
- Stable as long as prefab hierarchy doesn't change
#### For Unique Scene Objects:
- Add `[SerializeField] private string customSaveId`
- Editor tool validates uniqueness within scene
- Example: `"Quarry/UniquePickup_GoldenApple"`
#### Implementation:
```csharp
public abstract class SaveableInteractable : InteractableBase, ISaveParticipant
{
[SerializeField] private string customSaveId = "";
public string GetSaveId()
{
if (!string.IsNullOrEmpty(customSaveId))
{
return $"{GetSceneName()}/{customSaveId}";
}
// Auto-generate from hierarchy
string path = GetHierarchyPath();
return $"{GetSceneName()}/{path}";
}
private string GetHierarchyPath()
{
// Build path from scene root to this object
// Include sibling index for uniqueness
}
}
```
**Editor Tool**:
- Create validation window that scans scene for duplicate IDs
- Auto-generate IDs for objects missing them
- Warn if hierarchy changes break ID stability
**Files to Create**:
- `Assets/Editor/SaveIdValidator.cs`
---
### Phase 7: ItemManager Integration
**Goal**: Handle cases where slotted items need to be spawned/found during restoration
**Current ItemManager Functionality**:
- Registers/unregisters Pickups and ItemSlots
- No explicit save/load support
**Required Additions**:
1. **Item Lookup by Save ID**:
```csharp
public Pickup GetPickupBySaveId(string saveId)
```
2. **Item Reference Serialization**:
```csharp
[System.Serializable]
public class ItemReference
{
public string saveId; // For scene pickups
public string prefabPath; // For prefab items
public string itemDataGuid; // For PickupItemData
}
```
3. **Item Spawning for Restoration**:
```csharp
public GameObject SpawnItemForLoad(ItemReference itemRef)
{
// Check if item exists in scene first
// If not, instantiate from prefab
// Apply item data
// Return GameObject
}
```
**ItemSlot Restoration Flow**:
1. ItemSlot restores state, finds it had a slotted item
2. Calls `ItemManager.GetPickupBySaveId(slottedItemSaveId)`
3. If found: Use existing GameObject
4. If not found: Call `ItemManager.SpawnItemForLoad(itemReference)`
5. Call `SlotItem()` with restored GameObject
**Edge Cases**:
- Item was picked up and slotted elsewhere: Use save ID to track
- Item was combined before slotting: May not exist anymore (store flag)
- Item was from player inventory: Need FollowerController integration
**Files to Modify**:
- `Assets/Scripts/Core/ItemManager.cs`
---
### Phase 8: FollowerController Integration (Optional)
**Goal**: Save/restore items currently held by the follower character
**State to Save**:
- Currently held item reference
- Held item position/rotation
**Decision**:
- **Phase 1**: Skip this, assume player starts levels with empty inventory
- **Future Enhancement**: Add when inventory persistence is needed
**Reasoning**:
- Most levels are self-contained
- Overworld persistence can be added later
- Reduces initial complexity
---
## Implementation Order & Dependencies
### Phase 1: Foundation (No Dependencies) ✅
- Create SaveableInteractable base class
- Implement ISaveParticipant
- Add save ID generation
- Test with dummy data
**Deliverable**: SaveableInteractable.cs compiles and can register/unregister
---
### Phase 2: Pickup Migration (Depends on Phase 1)
- Modify Pickup to extend SaveableInteractable
- Implement PickupSaveData serialization
- Test pickup state save/load
- Verify picked-up items stay hidden after load
**Deliverable**: Can save/load a scene with pickups, picked-up items remain picked up
---
### Phase 3: ItemSlot Migration (Depends on Phases 1, 2, 7)
- Modify ItemSlot to extend SaveableInteractable
- Implement ItemSlotSaveData serialization
- Integrate with ItemManager for item references
- Test slotted items restore correctly
**Deliverable**: Can save/load a scene with item slots, slotted items remain slotted
---
### Phase 4: Stateless Interactables (Depends on Phase 1)
- Migrate MinigameSwitch to SaveableInteractable
- Remove direct SaveLoadData access
- Test minigame unlock persistence
**Deliverable**: MinigameSwitch uses participant pattern instead of direct access
---
### Phase 5: State Machine Integration (Independent)
- Create SaveableState base class
- Migrate example states (TakePhotoState, etc.)
- Test state restoration without side effects
- Document pattern for future states
**Deliverable**: State machines can restore without replaying animations
---
### Phase 6: Save ID System (Depends on Phase 1)
- Implement GetSaveId() with hierarchy path
- Add custom save ID field
- Create editor validation tool
- Test ID stability across sessions
**Deliverable**: All saveable interactables have stable, unique IDs
---
### Phase 7: ItemManager Integration (Independent, but needed for Phase 3)
- Add item lookup methods to ItemManager
- Implement ItemReference serialization
- Add item spawning for restoration
- Test with ItemSlot restoration
**Deliverable**: ItemManager can find/spawn items by reference during load
---
### Phase 8: Testing & Polish (Depends on all previous phases)
- Test full save/load cycle in each level
- Test edge cases (combined items, missing prefabs, etc.)
- Add error handling and logging
- Update documentation
**Deliverable**: Robust save/load system for all interactables
---
## Data Structures
### SaveableInteractable Base State
```csharp
[System.Serializable]
public class InteractableBaseSaveData
{
public bool isActive; // GameObject.activeSelf
public float remainingCooldown; // If cooldown system is used
}
```
### Pickup State
```csharp
[System.Serializable]
public class PickupSaveData
{
public InteractableBaseSaveData baseData;
public bool isPickedUp;
}
```
### ItemSlot State
```csharp
[System.Serializable]
public class ItemSlotSaveData
{
public PickupSaveData pickupData; // Inherited state
public ItemSlotState slotState;
public ItemReference slottedItem; // Null if empty
}
[System.Serializable]
public class ItemReference
{
public string saveId; // For scene objects
public string prefabPath; // For prefab spawning
public string itemDataGuid; // PickupItemData reference
}
```
### MinigameSwitch State
```csharp
[System.Serializable]
public class MinigameSwitchSaveData
{
public InteractableBaseSaveData baseData;
public bool isUnlocked;
}
```
---
## Save/Load Flow
### Save Flow
1. SaveLoadManager iterates all registered participants
2. Calls `GetSaveId()` on each SaveableInteractable
3. Calls `SerializeState()` on each
4. SaveableInteractable:
- Calls virtual `GetSerializableState()` (child override)
- Serializes to JSON
- Returns string
5. SaveLoadManager stores in `participantStates` dictionary
6. Writes entire SaveLoadData to disk
### Load Flow
1. SaveLoadManager reads SaveLoadData from disk
2. Sets `IsRestoringState = true`
3. Scene loads (interactables register during Start())
4. On registration, SaveLoadManager checks for existing state
5. If state exists, immediately calls `RestoreState(serializedData)`
6. SaveableInteractable:
- Deserializes JSON
- Calls virtual `ApplySerializableState(stateData)` (child override)
- Child applies state WITHOUT triggering events/initialization
7. SaveLoadManager sets `IsRestoringState = false`
### State Machine Flow (During Load)
1. Interactable restores, determines it should trigger state "Scared"
2. Instead of firing UnityEvent, directly calls StateMachine.ChangeState("Scared")
3. StateMachine activates "Scared" state GameObject
4. "Scared" State's OnEnable() checks `SaveLoadManager.IsRestoringState`
5. If true: Sets internal variables only (no animations/movement)
6. If false: Normal initialization
---
## Error Handling
### Missing Item References
- **Problem**: ItemSlot has slotted item, but item no longer exists
- **Solution**: Log warning, set slot to empty state
### Duplicate Save IDs
- **Problem**: Two interactables generate same save ID
- **Solution**: Editor validation tool catches this, manual fix required
### Corrupted Save Data
- **Problem**: JSON deserialization fails
- **Solution**: Log error, use default state, continue loading
### State Machine Mismatch
- **Problem**: Saved state references state that no longer exists
- **Solution**: Log warning, set to initial state
---
## Testing Strategy
### Unit Tests
1. SaveableInteractable registration/unregistration
2. Save ID generation uniqueness
3. Serialization/deserialization round-trip
### Integration Tests
1. Save scene with various interactable states
2. Load scene and verify all states restored
3. Test item slot with slotted items
4. Test state machine restoration
5. Test edge cases (missing items, corrupted data)
### Playthrough Tests
1. Play through each level
2. Save at various points
3. Load and verify game state is correct
4. Test all puzzle solutions still work
---
## Migration Checklist
- [ ] Phase 1: Create SaveableInteractable base class
- [ ] Phase 2: Migrate Pickup to SaveableInteractable
- [ ] Phase 3: Migrate ItemSlot to SaveableInteractable
- [ ] Phase 4: Migrate MinigameSwitch to SaveableInteractable
- [ ] Phase 5: Create SaveableState pattern for state machines
- [ ] Phase 6: Implement save ID validation system
- [ ] Phase 7: Extend ItemManager with lookup/spawn functionality
- [ ] Phase 8: Full integration testing and polish
---
## Future Enhancements
1. **Inventory Persistence**: Save FollowerController held items
2. **Dynamic Object Spawning**: Handle runtime-spawned interactables
3. **State Machine Auto-Save**: Automatically save StateMachine current state
4. **Incremental Saves**: Save only changed interactables (delta saves)
5. **Cloud Sync**: Sync save data across devices
6. **Save Slots**: Multiple save files per player
---
## Notes & Considerations
### Why Not Auto-Discover Interactables?
- Performance: Scanning scenes is expensive
- Determinism: Registration order affects save ID generation
- Control: Objects control their own lifecycle
### Why Scene-Based Save IDs?
- Isolation: Each scene's interactables are independent
- Clarity: Easy to debug (see which scene an ID belongs to)
- Flexibility: Can reload individual scenes without affecting others
### Why Not Save Everything?
- Stateless interactables (OneClickInteraction, LevelSwitch) don't need persistence
- Reduces save file size
- Faster save/load times
- Less complexity
### State Machine Plugin Limitations
- Pixelplacement's StateMachine doesn't support silent state changes
- OnEnable is always called when state activates
- Need wrapper pattern (SaveableState) to intercept
- Alternative: Fork plugin and add restoration mode
---
## Document Version
- **Version**: 1.0
- **Date**: November 2, 2025
- **Author**: GitHub Copilot
- **Status**: Ready for Implementation