First go around with save load system
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
261
Assets/Scripts/Interactions/SaveableInteractable.cs
Normal file
261
Assets/Scripts/Interactions/SaveableInteractable.cs
Normal 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
|
||||
}
|
||||
|
||||
3
Assets/Scripts/Interactions/SaveableInteractable.cs.meta
Normal file
3
Assets/Scripts/Interactions/SaveableInteractable.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a0d7c8f7344746ce9dc863985cc3f543
|
||||
timeCreated: 1762079555
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user