Refactoring of the interaction system and preliminary integration of save/load functionality across the game. (#44)

### Interactables Architecture Refactor
- Converted composition to inheritance, moved from component-based to class-based interactables. No more requirement for chain of "Interactable -> Item" etc.
- Created `InteractableBase` abstract base class with common functionality that replaces the old component
- Specialized child classes: `Pickup`, `ItemSlot`, `LevelSwitch`, `MinigameSwitch`, `CombinationItem`, `OneClickInteraction` are now children classes
- Light updates to the interactable inspector, moved some things arround, added collapsible inspector sections in the  UI for better editor experience

### State Machine Integration
- Custom `AppleMachine` inheritong from Pixelplacement's StateMachine which implements our own interface for saving, easy place for future improvements
- Replaced all previous StateMachines by `AppleMachine`
- Custom `AppleState`  extends from default `State`. Added serialization, split state logic into "EnterState", "RestoreState", "ExitState" allowing for separate logic when triggering in-game vs loading game
- Restores directly to target state without triggering transitional logic
- Migration tool converts existing instances

### Prefab Organization
- Saved changes from scenes into prefabs
- Cleaned up duplicated components, confusing prefabs hierarchies
- Created prefab variants where possible
- Consolidated Environment prefabs and moved them out of Placeholders subfolder into main Environment folder
- Organized item prefabs from PrefabsPLACEHOLDER into proper Items folder
- Updated prefab references - All scene references updated to new locations
- Removed placeholder files from Characters, Levels, UI, and Minigames folders

### Scene Updates
- Quarry scene with major updates
- Saved multiple working versions (Quarry, Quarry_Fixed, Quarry_OLD)
- Added proper lighting data
- Updated all interactable components to new architecture

### Minor editor tools
- New tool for testing cards from an editor window (no in-scene object required)
- Updated Interactable Inspector
- New debug option to opt in-and-out of the save/load system
- Tooling for easier migration

Co-authored-by: Michal Pikulski <michal.a.pikulski@gmail.com>
Reviewed-on: #44
This commit is contained in:
2025-11-03 10:12:51 +00:00
parent d317fffad7
commit 011901eb8f
148 changed files with 969503 additions and 10746 deletions

View File

@@ -1,36 +1,55 @@
using System;
using System.Collections;
using UnityEngine;
using Pixelplacement;
using Pixelplacement.TweenSystem;
using Core.SaveLoad;
using Pixelplacement;
using UnityEngine.Serialization;
public class GardenerChaseBehavior : MonoBehaviour
public class GardenerChaseBehavior : AppleState
{
public Spline ChaseSpline;
public Transform GardenerObject;
private static readonly int Property = Animator.StringToHash("IsIdle?");
public Spline chaseSpline;
public Transform runningGardenerTransform;
public float chaseDuration;
public float chaseDelay;
[SerializeField] private Animator animator;
[SerializeField] public GameObject lawnMowerRef;
private TweenBase tweenRef;
public GardenerAudioController audioController;
public GameObject lawnmowerAnchor;
// Start is called once before the first execution of Update after the MonoBehaviour is created
void Start()
public override void OnEnterState()
{
tweenRef = Tween.Spline (ChaseSpline, GardenerObject, 0, 1, false, chaseDuration, chaseDelay, Tween.EaseLinear, Tween.LoopType.None, HandleTweenStarted, HandleTweenFinished);
tweenRef = Tween.Spline(chaseSpline, runningGardenerTransform, 0, 1, false, chaseDuration, chaseDelay, Tween.EaseLinear,
Tween.LoopType.None, HandleTweenStarted, HandleTweenFinished);
}
public override void OnRestoreState(string data)
{
animator.SetBool("IsIdle?", false);
var gardenerSpriteRef = runningGardenerTransform.gameObject;
gardenerSpriteRef.transform.SetPositionAndRotation(lawnmowerAnchor.transform.position, gardenerSpriteRef.transform.rotation);
HandleTweenFinished();
}
void HandleTweenFinished ()
{
//Debug.Log ("Tween finished!");
tweenRef.Stop();
Destroy(ChaseSpline);
var gardenerSpriteRef = gameObject.transform.Find("GardenerRunningSprite");
Debug.Log ("Tween finished!");
tweenRef?.Stop();
Destroy(chaseSpline);
var gardenerSpriteRef = runningGardenerTransform.gameObject;
gardenerSpriteRef.transform.SetParent(lawnMowerRef.transform, true);
animator.SetBool(Property, false);
StartCoroutine(UpdateAnimatorBoolAfterDelay(0.5f));
}
private IEnumerator UpdateAnimatorBoolAfterDelay(float delay)
{
yield return new WaitForSeconds(delay);
animator.SetBool(Property, false);
}
void HandleTweenStarted ()
{
@@ -38,3 +57,4 @@ public class GardenerChaseBehavior : MonoBehaviour
animator.SetBool("IsIdle?", false);
}
}

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using UnityEngine;
using Interactions;
using Bootstrap;
using Core.SaveLoad;
namespace Core
{
@@ -59,6 +60,7 @@ namespace Core
{
// Subscribe to scene load completed so we can clear registrations when scenes change
SceneManagerService.Instance.SceneLoadStarted += OnSceneLoadStarted;
Logging.Debug("[ItemManager] Subscribed to SceneManagerService events");
}
@@ -244,5 +246,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

@@ -0,0 +1,257 @@
using UnityEngine;
using Pixelplacement;
using Bootstrap;
namespace Core.SaveLoad
{
// SaveableStateMachine - Inherits from StateMachine, uses SaveableState for states
// Auto-generates Save ID from scene name and hierarchy path (like SaveableInteractable)
/// <summary>
/// Extended StateMachine that integrates with the AppleHills save/load system.
/// Inherits from Pixelplacement.StateMachine and adds save/load functionality.
/// Use SaveableState (not State) for child states to get save/load hooks.
/// </summary>
public class AppleMachine : StateMachine, ISaveParticipant
{
[SerializeField]
[Tooltip("Optional custom save ID. If empty, will auto-generate from scene name and hierarchy path.")]
private string customSaveId = "";
/// <summary>
/// Is this state machine currently being restored from a save file?
/// </summary>
public bool IsRestoring { get; private set; }
/// <summary>
/// Has this state machine been restored from save data?
/// </summary>
public bool HasBeenRestored { get; private set; }
// Override ChangeState to call OnEnterState on SaveableState components
public new GameObject ChangeState(GameObject state)
{
var result = base.ChangeState(state);
// If not restoring and change was successful, call OnEnterState
if (!IsRestoring && result != null && currentState != null)
{
var saveableState = currentState.GetComponent<AppleState>();
if (saveableState != null)
{
saveableState.OnEnterState();
}
}
return result;
}
public new GameObject ChangeState(string state)
{
var result = base.ChangeState(state);
// If not restoring and change was successful, call OnEnterState
if (!IsRestoring && result != null && currentState != null)
{
var saveableState = currentState.GetComponent<AppleState>();
if (saveableState != null)
{
saveableState.OnEnterState();
}
}
return result;
}
public new GameObject ChangeState(int childIndex)
{
var result = base.ChangeState(childIndex);
// If not restoring and change was successful, call OnEnterState
if (!IsRestoring && result != null && currentState != null)
{
var saveableState = currentState.GetComponent<AppleState>();
if (saveableState != null)
{
saveableState.OnEnterState();
}
}
return result;
}
private void Start()
{
// Register with save system (no validation needed - we auto-generate ID)
BootCompletionService.RegisterInitAction(() =>
{
if (SaveLoadManager.Instance != null)
{
SaveLoadManager.Instance.RegisterParticipant(this);
}
else
{
Debug.LogWarning($"[SaveableStateMachine] SaveLoadManager.Instance is null, cannot register '{name}'", this);
}
});
}
#if UNITY_EDITOR
private void OnValidate()
{
// Optional: Log the auto-generated ID in verbose mode
if (verbose && string.IsNullOrEmpty(customSaveId))
{
Debug.Log($"[SaveableStateMachine] '{name}' will use auto-generated Save ID: {GetSaveId()}", this);
}
}
#endif
private void OnDestroy()
{
// Unregister from save system
if (SaveLoadManager.Instance != null)
{
SaveLoadManager.Instance.UnregisterParticipant(GetSaveId());
}
}
#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}/StateMachine_{hierarchyPath}";
}
private string GetSceneName()
{
return gameObject.scene.name;
}
private string GetHierarchyPath()
{
string path = gameObject.name;
Transform parent = transform.parent;
while (parent != null)
{
path = parent.name + "/" + path;
parent = parent.parent;
}
return path;
}
public string SerializeState()
{
if (currentState == null)
{
return JsonUtility.ToJson(new StateMachineSaveData { stateName = "", stateData = "" });
}
AppleState appleState = currentState.GetComponent<AppleState>();
string stateData = appleState?.SerializeState() ?? "";
var saveData = new StateMachineSaveData
{
stateName = currentState.name,
stateData = stateData
};
return JsonUtility.ToJson(saveData);
}
public void RestoreState(string data)
{
if (string.IsNullOrEmpty(data))
{
if (verbose)
{
Debug.LogWarning($"[SaveableStateMachine] No data to restore for '{name}'", this);
}
return;
}
try
{
StateMachineSaveData saveData = JsonUtility.FromJson<StateMachineSaveData>(data);
if (string.IsNullOrEmpty(saveData.stateName))
{
if (verbose)
{
Debug.LogWarning($"[SaveableStateMachine] No state name in save data for '{name}'", this);
}
return;
}
// Set IsRestoring flag so we won't call OnEnterState
IsRestoring = true;
// Change to the saved state
ChangeState(saveData.stateName);
// Now explicitly call OnRestoreState with the saved data
if (currentState != null)
{
AppleState appleState = currentState.GetComponent<AppleState>();
if (appleState != null)
{
appleState.OnRestoreState(saveData.stateData);
}
}
HasBeenRestored = true;
IsRestoring = false;
if (verbose)
{
Debug.Log($"[SaveableStateMachine] Restored '{name}' to state: {saveData.stateName}", this);
}
}
catch (System.Exception ex)
{
Debug.LogError($"[SaveableStateMachine] Exception restoring '{name}': {ex.Message}", this);
IsRestoring = false;
}
}
#endregion
#region Editor Utilities
#if UNITY_EDITOR
[ContextMenu("Log Save ID")]
private void LogSaveId()
{
Debug.Log($"Save ID: {GetSaveId()}", this);
}
[ContextMenu("Test Serialize")]
private void TestSerialize()
{
string serialized = SerializeState();
Debug.Log($"Serialized state: {serialized}", this);
}
#endif
#endregion
[System.Serializable]
private class StateMachineSaveData
{
public string stateName;
public string stateData;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 6f56763d30b94bf6873d395a6c116eb5
timeCreated: 1762116611

View File

@@ -0,0 +1,47 @@
using Pixelplacement;
namespace Core.SaveLoad
{
/// <summary>
/// Base class for states that need save/load functionality.
/// Inherit from this instead of Pixelplacement.State for states in SaveableStateMachines.
/// </summary>
public class AppleState : State
{
/// <summary>
/// Called when this state is entered during normal gameplay.
/// Override this method to implement state initialization logic
/// (animations, player movement, event subscriptions, etc.).
/// This is NOT called when restoring from a save file.
/// </summary>
public virtual void OnEnterState()
{
// Default: Do nothing
// States override this to implement their entry logic
}
/// <summary>
/// Called when this state is being restored from a save file.
/// Override this method to restore state from saved data without
/// playing animations or triggering side effects.
/// </summary>
/// <param name="data">Serialized state data from SerializeState()</param>
public virtual void OnRestoreState(string data)
{
// Default: Do nothing
// States override this to implement their restoration logic
}
/// <summary>
/// Called when the state machine is being saved.
/// Override this method to serialize this state's internal data.
/// </summary>
/// <returns>Serialized state data as a string (JSON recommended)</returns>
public virtual string SerializeState()
{
// Default: No state data to save
return "";
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 95e46aacea5b42888ee7881894193c11
timeCreated: 1762121675

View File

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

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

View File

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

View File

@@ -1,18 +1,21 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AppleHills.Core.Settings;
using Bootstrap;
using UnityEngine;
namespace Core.SaveLoad
{
/// <summary>
/// Simple Save/Load manager that follows the project's bootstrap pattern.
/// Save/Load manager that follows the project's bootstrap pattern.
/// - Singleton instance
/// - Registers a post-boot init action with BootCompletionService
/// - Exposes simple async Save/Load methods (PlayerPrefs-backed placeholder)
/// - Manages participant registration for save/load operations
/// - Exposes simple async Save/Load methods
/// - Fires events on completion
/// This is intended as boilerplate to be expanded with a real serialization backend.
/// </summary>
public class SaveLoadManager : MonoBehaviour
{
@@ -23,51 +26,318 @@ namespace Core.SaveLoad
private static string DefaultSaveFolder => Path.Combine(Application.persistentDataPath, "GameSaves");
public SaveLoadData currentSaveData;
// Participant registry
private readonly Dictionary<string, ISaveParticipant> participants = new Dictionary<string, ISaveParticipant>();
// Pending participants (registered during restoration)
private readonly List<ISaveParticipant> pendingParticipants = new List<ISaveParticipant>();
// State
public bool IsSaving { get; private set; }
public bool IsLoading { get; private set; }
public bool IsSaveDataLoaded { get; private set; }
public bool IsRestoringState { get; private set; }
// Events
public event Action<string> OnSaveCompleted;
public event Action<string> OnLoadCompleted;
public event Action OnParticipantStatesRestored;
void Awake()
{
_instance = this;
IsSaveDataLoaded = false;
IsRestoringState = false;
BootCompletionService.RegisterInitAction(InitializePostBoot);
}
private void Start()
{
Load();
#if UNITY_EDITOR
OnSceneLoadCompleted("RestoreInEditor");
#endif
if (DeveloperSettingsProvider.Instance.GetSettings<DebugSettings>().useSaveLoadSystem)
{
Load();
}
}
private void OnApplicationQuit()
{
Save();
if (DeveloperSettingsProvider.Instance.GetSettings<DebugSettings>().useSaveLoadSystem)
{
Save();
}
}
private void InitializePostBoot()
{
Logging.Debug("[SaveLoadManager] Post-boot initialization complete");
// Subscribe to scene lifecycle events if SceneManagerService is available
if (SceneManagerService.Instance != null)
{
SceneManagerService.Instance.SceneLoadCompleted += OnSceneLoadCompleted;
SceneManagerService.Instance.SceneUnloadStarted += OnSceneUnloadStarted;
Logging.Debug("[SaveLoadManager] Subscribed to SceneManagerService events");
}
}
void OnDestroy()
{
if (_instance == this)
{
// Unsubscribe from scene events
if (SceneManagerService.Instance != null)
{
SceneManagerService.Instance.SceneLoadCompleted -= OnSceneLoadCompleted;
SceneManagerService.Instance.SceneUnloadStarted -= OnSceneUnloadStarted;
}
_instance = null;
}
}
#region Participant Registration
/// <summary>
/// Registers a participant with the save/load system.
/// Should be called by participants during their initialization (post-boot).
/// </summary>
public void RegisterParticipant(ISaveParticipant participant)
{
if (participant == null)
{
Logging.Warning("[SaveLoadManager] Attempted to register null participant");
return;
}
string saveId = participant.GetSaveId();
if (string.IsNullOrEmpty(saveId))
{
Logging.Warning("[SaveLoadManager] Participant provided null or empty save ID");
return;
}
if (participants.ContainsKey(saveId))
{
Logging.Warning($"[SaveLoadManager] Participant with ID '{saveId}' is already registered. Overwriting.");
}
participants[saveId] = participant;
Logging.Debug($"[SaveLoadManager] Registered participant: {saveId}");
// If we have save data loaded and the participant hasn't been restored yet
if (IsSaveDataLoaded && currentSaveData != null && !participant.HasBeenRestored)
{
if (IsRestoringState)
{
// We're currently restoring - queue this participant for later restoration
pendingParticipants.Add(participant);
Logging.Debug($"[SaveLoadManager] Queued participant for pending restoration: {saveId}");
}
else
{
// Not currently restoring - restore this participant's state immediately
RestoreParticipantState(participant);
}
}
}
/// <summary>
/// Unregisters a participant from the save/load system.
/// Should be called by participants during their destruction.
/// </summary>
public void UnregisterParticipant(string saveId)
{
if (string.IsNullOrEmpty(saveId))
{
Logging.Warning("[SaveLoadManager] Attempted to unregister with null or empty save ID");
return;
}
if (participants.Remove(saveId))
{
Logging.Debug($"[SaveLoadManager] Unregistered participant: {saveId}");
}
}
/// <summary>
/// Gets a registered participant by their save ID. Returns null if not found.
/// </summary>
public ISaveParticipant GetParticipant(string saveId)
{
participants.TryGetValue(saveId, out var participant);
return participant;
}
#endregion
#region Scene Lifecycle
private void OnSceneLoadCompleted(string sceneName)
{
Logging.Debug($"[SaveLoadManager] Scene '{sceneName}' loaded. Discovering inactive SaveableInteractables...");
// 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)
{
Logging.Debug($"[SaveLoadManager] Scene '{sceneName}' unloading. Note: Participants should unregister themselves.");
// We don't force-clear here because participants should manage their own lifecycle
// This allows for proper cleanup in OnDestroy
}
#endregion
#region State Restoration
/// <summary>
/// Restores state for a single participant from the current save data.
/// </summary>
private void RestoreParticipantState(ISaveParticipant participant)
{
if (currentSaveData == null || currentSaveData.participantStates == null)
return;
string saveId = participant.GetSaveId();
// Find the participant state in the list
var entry = currentSaveData.participantStates.Find(e => e.saveId == saveId);
if (entry != null && !string.IsNullOrEmpty(entry.serializedState))
{
try
{
participant.RestoreState(entry.serializedState);
Logging.Debug($"[SaveLoadManager] Restored state for participant: {saveId}");
}
catch (Exception ex)
{
Logging.Warning($"[SaveLoadManager] Exception while restoring state for '{saveId}': {ex}");
}
}
else
{
Logging.Debug($"[SaveLoadManager] No saved state found for participant: {saveId}");
}
}
/// <summary>
/// Restores state for all currently registered participants.
/// Called after loading save data.
/// Uses pending queue to handle participants that register during restoration.
/// </summary>
private void RestoreAllParticipantStates()
{
if (currentSaveData == null || currentSaveData.participantStates == null)
return;
IsRestoringState = true;
int restoredCount = 0;
// Clear pending queue at the start
pendingParticipants.Clear();
// Create a snapshot to avoid collection modification during iteration
// (RestoreState can trigger GameObject activation which can register new participants)
foreach (var kvp in participants.ToList())
{
string saveId = kvp.Key;
ISaveParticipant participant = kvp.Value;
// Find the participant state in the list
var entry = currentSaveData.participantStates.Find(e => e.saveId == saveId);
if (entry != null && !string.IsNullOrEmpty(entry.serializedState))
{
try
{
participant.RestoreState(entry.serializedState);
restoredCount++;
Logging.Debug($"[SaveLoadManager] Restored state for participant: {saveId}");
}
catch (Exception ex)
{
Logging.Warning($"[SaveLoadManager] Exception while restoring state for '{saveId}': {ex}");
}
}
}
// Process pending participants that registered during the main restoration loop
const int maxPendingPasses = 10;
int pendingPass = 0;
int totalPendingRestored = 0;
while (pendingParticipants.Count > 0 && pendingPass < maxPendingPasses)
{
pendingPass++;
// Take snapshot of current pending list and clear the main list
// (restoring pending participants might add more pending participants)
var currentPending = new List<ISaveParticipant>(pendingParticipants);
pendingParticipants.Clear();
int passRestored = 0;
foreach (var participant in currentPending)
{
try
{
RestoreParticipantState(participant);
passRestored++;
totalPendingRestored++;
}
catch (Exception ex)
{
Logging.Warning($"[SaveLoadManager] Exception while restoring pending participant: {ex}");
}
}
Logging.Debug($"[SaveLoadManager] Pending pass {pendingPass}: Restored {passRestored} participants");
}
if (pendingParticipants.Count > 0)
{
Logging.Warning($"[SaveLoadManager] Reached maximum pending passes ({maxPendingPasses}). {pendingParticipants.Count} participants remain unrestored.");
}
// Final cleanup
pendingParticipants.Clear();
IsRestoringState = false;
Logging.Debug($"[SaveLoadManager] Restored state for {restoredCount} participants + {totalPendingRestored} pending participants");
OnParticipantStatesRestored?.Invoke();
}
#endregion
private string GetFilePath(string slot)
{
return Path.Combine(DefaultSaveFolder, $"save_{slot}.json");
}
/// <summary>
/// Entry point to save to a named slot. Starts an async coroutine that writes to PlayerPrefs
/// (placeholder behavior). Fires OnSaveCompleted when finished.
/// Entry point to save to a named slot. Starts an async coroutine that writes to disk.
/// Fires OnSaveCompleted when finished.
/// </summary>
public void Save(string slot = "default")
{
@@ -81,8 +351,8 @@ namespace Core.SaveLoad
}
/// <summary>
/// Entry point to load from a named slot. Starts an async coroutine that reads from PlayerPrefs
/// (placeholder behavior). Fires OnLoadCompleted when finished.
/// Entry point to load from a named slot. Starts an async coroutine that reads from disk.
/// Fires OnLoadCompleted when finished.
/// </summary>
public void Load(string slot = "default")
{
@@ -95,7 +365,6 @@ namespace Core.SaveLoad
StartCoroutine(LoadAsync(slot));
}
// TODO: This is stupid overkill, but over verbose logging is king for now
private IEnumerator SaveAsync(string slot)
{
Logging.Debug($"[SaveLoadManager] Starting save for slot '{slot}'");
@@ -106,7 +375,7 @@ namespace Core.SaveLoad
string json = null;
bool prepFailed = false;
// Prep phase: ensure folder exists and serialize (no yields allowed inside try/catch)
// Prep phase: ensure folder exists and serialize
try
{
Directory.CreateDirectory(DefaultSaveFolder);
@@ -117,11 +386,43 @@ namespace Core.SaveLoad
currentSaveData = new SaveLoadData();
}
// Pull latest card collection snapshot from CardSystem before serializing (don't overwrite with null)
if (Data.CardSystem.CardSystemManager.Instance != null)
// Ensure participantStates list exists
if (currentSaveData.participantStates == null)
{
currentSaveData.cardCollection = Data.CardSystem.CardSystemManager.Instance.ExportCardCollectionState();
currentSaveData.participantStates = new List<ParticipantStateEntry>();
}
else
{
currentSaveData.participantStates.Clear();
}
// Capture state from all registered participants directly into the list
// Create a snapshot to avoid collection modification during iteration
int savedCount = 0;
foreach (var kvp in participants.ToList())
{
string saveId = kvp.Key;
ISaveParticipant participant = kvp.Value;
try
{
string serializedState = participant.SerializeState();
currentSaveData.participantStates.Add(new ParticipantStateEntry
{
saveId = saveId,
serializedState = serializedState
});
savedCount++;
Logging.Debug($"[SaveLoadManager] Captured state for participant: {saveId}");
}
catch (Exception ex)
{
Logging.Warning($"[SaveLoadManager] Exception while serializing participant '{saveId}': {ex}");
}
}
Logging.Debug($"[SaveLoadManager] Captured state from {savedCount} participants");
json = JsonUtility.ToJson(currentSaveData, true);
}
@@ -133,7 +434,6 @@ namespace Core.SaveLoad
if (prepFailed || string.IsNullOrEmpty(json))
{
// Ensure we clean up state and notify listeners outside of the try/catch
IsSaving = false;
OnSaveCompleted?.Invoke(slot);
Logging.Warning($"[SaveLoadManager] Aborting save for slot '{slot}' due to prep failure");
@@ -172,7 +472,6 @@ namespace Core.SaveLoad
}
}
// TODO: This is stupid overkill, but over verbose logging is king for now
private IEnumerator LoadAsync(string slot)
{
Logging.Debug($"[SaveLoadManager] Starting load for slot '{slot}'");
@@ -185,16 +484,18 @@ namespace Core.SaveLoad
Logging.Debug($"[SaveLoadManager] No save found at '{path}', creating defaults");
currentSaveData = new SaveLoadData();
// Simulate async operation and finish
yield return null;
IsLoading = false;
IsSaveDataLoaded = true;
// Restore any already-registered participants (e.g., those initialized during boot)
RestoreAllParticipantStates();
OnLoadCompleted?.Invoke(slot);
yield break;
}
// Simulate async operation (optional)
yield return null;
try
@@ -208,7 +509,6 @@ namespace Core.SaveLoad
}
else
{
// Attempt to deserialize; if it fails or returns null, fall back to defaults
var loaded = JsonUtility.FromJson<SaveLoadData>(json);
if (loaded == null)
{
@@ -218,6 +518,12 @@ namespace Core.SaveLoad
else
{
currentSaveData = loaded;
// Ensure participantStates list exists even if not in save file
if (currentSaveData.participantStates == null)
{
currentSaveData.participantStates = new List<ParticipantStateEntry>();
}
}
}
}
@@ -230,6 +536,10 @@ namespace Core.SaveLoad
{
IsLoading = false;
IsSaveDataLoaded = true;
// Restore state for any already-registered participants
RestoreAllParticipantStates();
OnLoadCompleted?.Invoke(slot);
Logging.Debug($"[SaveLoadManager] Load completed for slot '{slot}'");
}

View File

@@ -28,6 +28,10 @@ namespace AppleHills.Core.Settings
[Tooltip("Should Time.timeScale be set to 0 when the game is paused")]
[SerializeField] public bool pauseTimeOnPauseGame = true;
[Header("Save Load Options")]
[Tooltip("Should use save laod system?")]
[SerializeField] public bool useSaveLoadSystem = true;
[Header("Logging Options")]
[Tooltip("Logging level for bootstrap services")]
[SerializeField] public LogVerbosity bootstrapLogVerbosity = LogVerbosity.Warning;

View File

@@ -1,21 +1,15 @@
using System.Security.Cryptography.X509Certificates;
using Core;
using Core.SaveLoad;
using Pixelplacement;
using UnityEngine;
public class GardenerBehaviour : MonoBehaviour
public class GardenerBehaviour : AppleMachine
{
private StateMachine stateMachineRef;
// Start is called once before the first execution of Update after the MonoBehaviour is created
void Start()
{
stateMachineRef = GetComponent<StateMachine>();
}
public void stateSwitch (string StateName)
{
Logging.Debug("State Switch to: " + StateName);
stateMachineRef.ChangeState(StateName);
ChangeState(StateName);
}
}

View File

@@ -1,17 +1,9 @@
using Core;
using UnityEngine;
using Core.SaveLoad;
using Pixelplacement;
public class LawnMowerBehaviour : MonoBehaviour
public class LawnMowerBehaviour : AppleMachine
{
private StateMachine stateMachineRef;
// Start is called once before the first execution of Update after the MonoBehaviour is created
void Start()
{
stateMachineRef = GetComponent<StateMachine>();
}
public void mowerTouched()
{
Logging.Debug("Mower Touched");
@@ -20,6 +12,6 @@ public class LawnMowerBehaviour : MonoBehaviour
public void stateSwitch(string StateName)
{
Logging.Debug("State Switch to: " + StateName);
stateMachineRef.ChangeState(StateName);
ChangeState(StateName);
}
}

View File

@@ -1,7 +1,8 @@
using Core.SaveLoad;
using UnityEngine;
using Pixelplacement;
public class LawnMowerChaseBehaviour : MonoBehaviour
public class LawnMowerChaseBehaviour : AppleState
{
public Spline ChaseSpline;
public Transform LawnMowerObject;
@@ -23,7 +24,7 @@ public class LawnMowerChaseBehaviour : MonoBehaviour
public bool gardenerChasing = true;
public GardenerAudioController gardenerAudioController;
void Start()
public override void OnEnterState()
{
LawnMowerObject.position = ChaseSpline.GetPosition(startPercentage);
@@ -66,6 +67,11 @@ public class LawnMowerChaseBehaviour : MonoBehaviour
_initialTweenActive = true;
}
public override void OnRestoreState(string data)
{
OnEnterState();
}
void Update()
{
float percentage = ChaseSpline.ClosestPoint(LawnMowerObject.position);

View File

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

View File

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

View File

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

View File

@@ -46,7 +46,7 @@ namespace Dialogue
Debug.LogError("SpeechBubble component is missing on Dialogue Component");
}
var interactable = GetComponent<Interactable>();
var interactable = GetComponent<InteractableBase>();
if (interactable != null)
{
interactable.characterArrived.AddListener(OnCharacterArrived);
@@ -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

@@ -2,14 +2,26 @@
using Pathfinding;
using AppleHills.Core.Settings;
using Core;
using Core.SaveLoad;
using Bootstrap;
namespace Input
{
/// <summary>
/// Saveable data for PlayerTouchController state
/// </summary>
[System.Serializable]
public class PlayerSaveData
{
public Vector3 worldPosition;
public Quaternion worldRotation;
}
/// <summary>
/// Handles player movement in response to tap and hold input events.
/// Supports both direct and pathfinding movement modes, and provides event/callbacks for arrival/cancellation.
/// </summary>
public class PlayerTouchController : MonoBehaviour, ITouchInputConsumer
public class PlayerTouchController : MonoBehaviour, ITouchInputConsumer, ISaveParticipant
{
// --- Movement State ---
private Vector3 targetPosition;
@@ -55,6 +67,9 @@ namespace Input
private bool interruptMoveTo;
private LogVerbosity _logVerbosity = LogVerbosity.Warning;
// Save system tracking
private bool hasBeenRestored;
void Awake()
{
aiPath = GetComponent<AIPath>();
@@ -71,6 +86,9 @@ namespace Input
// Initialize settings reference using GetSettingsObject
_settings = GameManager.GetSettingsObject<IPlayerFollowerSettings>();
// Register for post-boot initialization
BootCompletionService.RegisterInitAction(InitializePostBoot);
}
void Start()
@@ -79,6 +97,29 @@ namespace Input
_logVerbosity = DeveloperSettingsProvider.Instance.GetSettings<DebugSettings>().inputLogVerbosity;
}
private void InitializePostBoot()
{
// Register with save system after boot
if (SaveLoadManager.Instance != null)
{
SaveLoadManager.Instance.RegisterParticipant(this);
Logging.Debug("[PlayerTouchController] Registered with SaveLoadManager");
}
else
{
Logging.Warning("[PlayerTouchController] SaveLoadManager not available for registration");
}
}
void OnDestroy()
{
// Unregister from save system
if (SaveLoadManager.Instance != null)
{
SaveLoadManager.Instance.UnregisterParticipant(GetSaveId());
}
}
/// <summary>
/// Handles tap input. Always uses pathfinding to move to the tapped location.
/// Cancels any in-progress MoveToAndNotify.
@@ -415,5 +456,52 @@ namespace Input
Logging.Debug($"[PlayerTouchController] {message}");
}
}
#region ISaveParticipant Implementation
public bool HasBeenRestored => hasBeenRestored;
public string GetSaveId()
{
return "PlayerController";
}
public string SerializeState()
{
var saveData = new PlayerSaveData
{
worldPosition = transform.position,
worldRotation = transform.rotation
};
return JsonUtility.ToJson(saveData);
}
public void RestoreState(string serializedData)
{
if (string.IsNullOrEmpty(serializedData))
{
Logging.Debug("[PlayerTouchController] No saved state to restore");
hasBeenRestored = true;
return;
}
try
{
var saveData = JsonUtility.FromJson<PlayerSaveData>(serializedData);
if (saveData != null)
{
transform.position = saveData.worldPosition;
transform.rotation = saveData.worldRotation;
hasBeenRestored = true;
Logging.Debug($"[PlayerTouchController] Restored position: {saveData.worldPosition}");
}
}
catch (System.Exception ex)
{
Logging.Warning($"[PlayerTouchController] Failed to restore state: {ex.Message}");
}
}
#endregion
}
}

View File

@@ -47,7 +47,7 @@ namespace Interactions
Gizmos.DrawSphere(targetPos, 0.2f);
// Draw a line from the parent interactable to this target
Interactable parentInteractable = GetComponentInParent<Interactable>();
InteractableBase parentInteractable = GetComponentInParent<InteractableBase>();
if (parentInteractable != null)
{
Gizmos.DrawLine(parentInteractable.transform.position, targetPos);

View File

@@ -17,9 +17,10 @@ namespace Interactions
}
/// <summary>
/// Represents an interactable object that can respond to tap input events.
/// Base class for interactable objects that can respond to tap input events.
/// Subclasses should override OnCharacterArrived() to implement interaction-specific logic.
/// </summary>
public class Interactable : MonoBehaviour, ITouchInputConsumer
public class InteractableBase : MonoBehaviour, ITouchInputConsumer
{
[Header("Interaction Settings")]
public bool isOneTime;
@@ -34,8 +35,8 @@ namespace Interactions
// Helpers for managing interaction state
private bool _interactionInProgress;
private PlayerTouchController _playerRef;
private FollowerController _followerController;
protected PlayerTouchController _playerRef;
protected FollowerController _followerController;
private bool _isActive = true;
private InteractionEventType _currentEventType;
@@ -420,7 +421,7 @@ namespace Interactions
if (step != null && !step.IsStepUnlocked() && slot == null)
{
DebugUIMessage.Show("This step is locked!", Color.yellow);
BroadcastInteractionComplete(false);
CompleteInteraction(false);
// Reset variables for next time
_interactionInProgress = false;
_playerRef = null;
@@ -434,6 +435,9 @@ namespace Interactions
// Broadcast appropriate event
characterArrived?.Invoke();
// Call the virtual method for subclasses to override
OnCharacterArrived();
// Reset variables for next time
_interactionInProgress = false;
_playerRef = null;
@@ -441,6 +445,17 @@ namespace Interactions
return Task.CompletedTask;
}
/// <summary>
/// Called when the character has arrived at the interaction point.
/// Subclasses should override this to implement interaction-specific logic
/// and call CompleteInteraction(bool success) when done.
/// </summary>
protected virtual void OnCharacterArrived()
{
// Default implementation does nothing - subclasses should override
// and call CompleteInteraction when their logic is complete
}
private async void OnInteractionComplete(bool success)
{
// Dispatch InteractionComplete event
@@ -481,11 +496,25 @@ namespace Interactions
throw new NotImplementedException();
}
public void BroadcastInteractionComplete(bool success)
/// <summary>
/// Call this from subclasses to mark the interaction as complete.
/// </summary>
/// <param name="success">Whether the interaction was successful</param>
protected void CompleteInteraction(bool success)
{
interactionComplete?.Invoke(success);
}
/// <summary>
/// Legacy method for backward compatibility. Use CompleteInteraction instead.
/// </summary>
/// TODO: Remove this method in future versions
[Obsolete("Use CompleteInteraction instead")]
public void BroadcastInteractionComplete(bool success)
{
CompleteInteraction(success);
}
#if UNITY_EDITOR
/// <summary>
/// Draws gizmos for pickup interaction range in the editor.

View File

@@ -18,12 +18,12 @@ namespace Interactions
[Tooltip("Whether the interaction flow should wait for this action to complete")]
public bool pauseInteractionFlow = true;
protected Interactable parentInteractable;
protected InteractableBase parentInteractable;
protected virtual void Awake()
{
// Get the parent interactable component
parentInteractable = GetComponentInParent<Interactable>();
parentInteractable = GetComponentInParent<InteractableBase>();
if (parentInteractable == null)
{

View File

@@ -16,10 +16,22 @@ 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>
[RequireComponent(typeof(Interactable))]
public class ItemSlot : Pickup
{
// Tracks the current state of the slotted item
@@ -53,7 +65,7 @@ namespace Interactions
private PickupItemData _currentlySlottedItemData;
public SpriteRenderer slottedItemRenderer;
private GameObject _currentlySlottedItemObject = null;
private GameObject _currentlySlottedItemObject;
public GameObject GetSlottedObject()
{
@@ -69,7 +81,7 @@ namespace Interactions
}
}
public override void Awake()
protected override void Awake()
{
base.Awake();
@@ -82,8 +94,8 @@ namespace Interactions
{
Logging.Debug("[ItemSlot] OnCharacterArrived");
var heldItemData = FollowerController.CurrentlyHeldItemData;
var heldItemObj = FollowerController.GetHeldPickupObject();
var heldItemData = _followerController.CurrentlyHeldItemData;
var heldItemObj = _followerController.GetHeldPickupObject();
var config = _interactionSettings?.GetSlotItemConfig(itemData);
var forbidden = config?.forbiddenItems ?? new List<PickupItemData>();
@@ -97,7 +109,7 @@ namespace Interactions
onForbiddenItemSlotted?.Invoke();
OnForbiddenItemSlotted?.Invoke(itemData, heldItemData);
_currentState = ItemSlotState.Forbidden;
Interactable.BroadcastInteractionComplete(false);
CompleteInteraction(false);
return;
}
@@ -115,7 +127,7 @@ namespace Interactions
var slottedPickup = _currentlySlottedItemObject?.GetComponent<Pickup>();
if (slottedPickup != null)
{
var comboResult = FollowerController.TryCombineItems(slottedPickup, out var combinationResultItem);
var comboResult = _followerController.TryCombineItems(slottedPickup, out var combinationResultItem);
if (combinationResultItem != null && comboResult == FollowerController.CombinationResult.Successful)
{
// Combination succeeded: fire slot-removed events and clear internals (don't call SlotItem to avoid duplicate events)
@@ -128,14 +140,14 @@ namespace Interactions
_currentlySlottedItemData = null;
UpdateSlottedSprite();
Interactable.BroadcastInteractionComplete(false);
CompleteInteraction(false);
return;
}
}
}
// No combination (or not applicable) -> perform normal swap/pickup behavior
FollowerController.TryPickupItem(_currentlySlottedItemObject, _currentlySlottedItemData, false);
_followerController.TryPickupItem(_currentlySlottedItemObject, _currentlySlottedItemData, false);
onItemSlotRemoved?.Invoke();
OnItemSlotRemoved?.Invoke(_currentlySlottedItemData);
_currentState = ItemSlotState.None;
@@ -163,7 +175,6 @@ namespace Interactions
float desiredHeight = _playerFollowerSettings?.HeldIconDisplayHeight ?? 2.0f;
var sprite = _currentlySlottedItemData.mapSprite;
float spriteHeight = sprite.bounds.size.y;
float spriteWidth = sprite.bounds.size.x;
Vector3 parentScale = slottedItemRenderer.transform.parent != null
? slottedItemRenderer.transform.parent.localScale
: Vector3.one;
@@ -180,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;
@@ -190,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);
}
@@ -207,50 +340,53 @@ namespace Interactions
_currentlySlottedItemData = itemToSlotData;
}
if (clearFollowerHeldItem)
if (clearFollowerHeldItem && _followerController != null)
{
FollowerController.ClearHeldItem();
_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);
}
Interactable.BroadcastInteractionComplete(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);
}
Interactable.BroadcastInteractionComplete(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)
{
ItemManager.Instance?.RegisterItemSlot(this);
ApplySlottedItemState(itemToSlot, itemToSlotData, triggerEvents: true, clearFollowerHeldItem);
}
void OnDestroy()
{
ItemManager.Instance?.UnregisterItemSlot(this);
}
#endregion
}
}

View File

@@ -1,37 +1,21 @@
using UnityEngine;
using System;
using Input;
using Interactions;
/// <summary>
/// MonoBehaviour that immediately completes an interaction when started.
/// </summary>
public class OneClickInteraction : MonoBehaviour
namespace Interactions
{
private Interactable interactable;
void Awake()
/// <summary>
/// Interactable that immediately completes when the character arrives at the interaction point.
/// Useful for simple trigger interactions that don't require additional logic.
/// </summary>
public class OneClickInteraction : InteractableBase
{
interactable = GetComponent<Interactable>();
if (interactable != null)
/// <summary>
/// Override: Immediately completes the interaction with success when character arrives.
/// </summary>
protected override void OnCharacterArrived()
{
interactable.interactionStarted.AddListener(OnInteractionStarted);
}
}
void OnDestroy()
{
if (interactable != null)
{
interactable.interactionStarted.RemoveListener(OnInteractionStarted);
}
}
private void OnInteractionStarted(PlayerTouchController playerRef, FollowerController followerRef)
{
if (interactable != null)
{
interactable.BroadcastInteractionComplete(true);
CompleteInteraction(true);
}
}
}

View File

@@ -1,21 +1,32 @@
using Input;
using UnityEngine;
using System; // added for Action<T>
using System;
using System.Linq;
using Bootstrap; // added for Action<T>
using Core; // register with ItemManager
namespace Interactions
{
[RequireComponent(typeof(Interactable))]
public class Pickup : MonoBehaviour
/// <summary>
/// Saveable data for Pickup state
/// </summary>
[System.Serializable]
public class PickupSaveData
{
public bool isPickedUp;
public bool wasHeldByFollower; // Track if held by follower for bilateral restoration
public Vector3 worldPosition;
public Quaternion worldRotation;
public bool isActive;
}
public class Pickup : SaveableInteractable
{
public PickupItemData itemData;
public SpriteRenderer iconRenderer;
protected Interactable Interactable;
private PlayerTouchController _playerRef;
protected FollowerController FollowerController;
// 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;
@@ -24,19 +35,14 @@ namespace Interactions
public event Action<PickupItemData, PickupItemData, PickupItemData> OnItemsCombined;
/// <summary>
/// Unity Awake callback. Sets up icon, interactable, and event handlers.
/// Unity Awake callback. Sets up icon and applies item data.
/// </summary>
public virtual void Awake()
protected override void Awake()
{
base.Awake(); // Register with save system
if (iconRenderer == null)
iconRenderer = GetComponent<SpriteRenderer>();
Interactable = GetComponent<Interactable>();
if (Interactable != null)
{
Interactable.interactionStarted.AddListener(OnInteractionStarted);
Interactable.characterArrived.AddListener(OnCharacterArrived);
}
ApplyItemData();
}
@@ -44,22 +50,26 @@ 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
// Always register with ItemManager, even if picked up
// This allows the save/load system to find held items when restoring state
BootCompletionService.RegisterInitAction(() =>
{
ItemManager.Instance?.RegisterPickup(this);
});
}
/// <summary>
/// Unity OnDestroy callback. Cleans up event handlers.
/// Unity OnDestroy callback. Unregisters from ItemManager.
/// </summary>
void OnDestroy()
protected override void OnDestroy()
{
if (Interactable != null)
{
Interactable.interactionStarted.RemoveListener(OnInteractionStarted);
Interactable.characterArrived.RemoveListener(OnCharacterArrived);
}
base.OnDestroy(); // Unregister from save system
// Unregister from ItemManager
ItemManager.Instance?.UnregisterPickup(this);
}
@@ -76,6 +86,7 @@ namespace Interactions
}
#endif
/// <summary>
/// Applies the item data to the pickup (icon, name, etc).
/// </summary>
@@ -93,22 +104,17 @@ namespace Interactions
}
/// <summary>
/// Handles the start of an interaction (for feedback/UI only).
/// Override: Called when character arrives at the interaction point.
/// Handles item pickup and combination logic.
/// </summary>
private void OnInteractionStarted(PlayerTouchController playerRef, FollowerController followerRef)
{
_playerRef = playerRef;
FollowerController = followerRef;
}
protected virtual void OnCharacterArrived()
protected override void OnCharacterArrived()
{
Logging.Debug("[Pickup] OnCharacterArrived");
var combinationResult = FollowerController.TryCombineItems(this, out var combinationResultItem);
var combinationResult = _followerController.TryCombineItems(this, out var combinationResultItem);
if (combinationResultItem != null)
{
Interactable.BroadcastInteractionComplete(true);
CompleteInteraction(true);
// Fire the combination event when items are successfully combined
if (combinationResult == FollowerController.CombinationResult.Successful)
@@ -118,7 +124,7 @@ namespace Interactions
{
// Get the combined item data
var resultItemData = resultPickup.itemData;
var heldItem = FollowerController.GetHeldPickupObject();
var heldItem = _followerController.GetHeldPickupObject();
if (heldItem != null)
{
@@ -135,25 +141,101 @@ namespace Interactions
return;
}
FollowerController?.TryPickupItem(gameObject, itemData);
_followerController?.TryPickupItem(gameObject, itemData);
var step = GetComponent<PuzzleS.ObjectiveStepBehaviour>();
if (step != null && !step.IsStepUnlocked())
{
Interactable.BroadcastInteractionComplete(false);
CompleteInteraction(false);
return;
}
bool wasPickedUp = (combinationResult == FollowerController.CombinationResult.NotApplicable
|| combinationResult == FollowerController.CombinationResult.Unsuccessful);
Interactable.BroadcastInteractionComplete(wasPickedUp);
CompleteInteraction(wasPickedUp);
// 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()
{
// Check if this pickup is currently held by the follower
bool isHeldByFollower = IsPickedUp && !gameObject.activeSelf && transform.parent != null;
return new PickupSaveData
{
isPickedUp = this.IsPickedUp,
wasHeldByFollower = isHeldByFollower,
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);
// If this was held by the follower, try bilateral restoration
if (data.wasHeldByFollower)
{
// Try to give this pickup to the follower
// This might succeed or fail depending on timing
var follower = FollowerController.FindInstance();
if (follower != null)
{
follower.TryClaimHeldItem(this);
}
}
}
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,269 @@
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>
/// Sets a custom save ID for this interactable.
/// Used when spawning dynamic objects that need stable save IDs.
/// </summary>
public void SetCustomSaveId(string saveId)
{
customSaveId = saveId;
}
/// <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

@@ -13,33 +13,27 @@ namespace Levels
/// <summary>
/// Handles level switching when interacted with. Applies switch data and triggers scene transitions.
/// </summary>
public class LevelSwitch : MonoBehaviour
public class LevelSwitch : InteractableBase
{
/// <summary>
/// Data for this level switch (target scene, icon, etc).
/// </summary>
public LevelSwitchData switchData;
private SpriteRenderer _iconRenderer;
private Interactable _interactable;
// 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>();
_interactable = GetComponent<Interactable>();
if (_interactable != null)
{
_interactable.characterArrived.AddListener(OnCharacterArrived);
}
// Initialize settings reference
_interactionSettings = GameManager.GetSettingsObject<IInteractionSettings>();
@@ -47,17 +41,6 @@ namespace Levels
ApplySwitchData();
}
/// <summary>
/// Unity OnDestroy callback. Cleans up event handlers.
/// </summary>
void OnDestroy()
{
if (_interactable != null)
{
_interactable.characterArrived.RemoveListener(OnCharacterArrived);
}
}
#if UNITY_EDITOR
/// <summary>
/// Unity OnValidate callback. Ensures icon and data are up to date in editor.
@@ -87,9 +70,9 @@ namespace Levels
/// <summary>
/// Handles the start of an interaction (shows confirmation menu and switches the level if confirmed).
/// </summary>
private void OnCharacterArrived()
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;
@@ -109,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);
@@ -140,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,104 +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 : MonoBehaviour
public class MinigameSwitch : SaveableInteractable
{
/// <summary>
/// Data for this level switch (target scene, icon, etc).
/// </summary>
public LevelSwitchData switchData;
private SpriteRenderer _iconRenderer;
private Interactable _interactable;
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>();
_interactable = GetComponent<Interactable>();
if (_interactable != null)
{
_interactable.characterArrived.AddListener(OnCharacterArrived);
}
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()
{
PuzzleManager.Instance.OnAllPuzzlesComplete -= HandleAllPuzzlesComplete;
if (_interactable != null)
{
_interactable.characterArrived.RemoveListener(OnCharacterArrived);
}
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
@@ -119,8 +88,8 @@ namespace Levels
/// </summary>
void OnValidate()
{
if (_iconRenderer == null)
_iconRenderer = GetComponent<SpriteRenderer>();
if (iconRenderer == null)
iconRenderer = GetComponent<SpriteRenderer>();
ApplySwitchData();
}
#endif
@@ -132,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
}
@@ -142,12 +111,12 @@ namespace Levels
/// <summary>
/// Handles the start of an interaction (shows confirmation menu and switches the level if confirmed).
/// </summary>
private void OnCharacterArrived()
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!");
@@ -164,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);
@@ -183,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);
}
@@ -191,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

@@ -5,12 +5,26 @@ using UnityEngine.SceneManagement;
using Utils;
using AppleHills.Core.Settings;
using Core;
using Core.SaveLoad;
using Bootstrap;
using UnityEngine.Events;
/// <summary>
/// Saveable data for FollowerController state
/// </summary>
[System.Serializable]
public class FollowerSaveData
{
public Vector3 worldPosition;
public Quaternion worldRotation;
public string heldItemSaveId; // Save ID of held pickup (if any)
public string heldItemDataAssetPath; // Asset path to PickupItemData
}
/// <summary>
/// Controls the follower character, including following the player, handling pickups, and managing held items.
/// </summary>
public class FollowerController: MonoBehaviour
public class FollowerController : MonoBehaviour, ISaveParticipant
{
private static readonly int CombineTrigger = Animator.StringToHash("Combine");
@@ -83,6 +97,11 @@ public class FollowerController: MonoBehaviour
public UnityEvent PulverIsCombining;
private Input.PlayerTouchController _playerTouchController;
// Save system tracking
private bool hasBeenRestored;
private bool _hasRestoredHeldItem; // Track if held item restoration completed
private string _expectedHeldItemSaveId; // Expected saveId during restoration
void Awake()
{
@@ -103,6 +122,23 @@ public class FollowerController: MonoBehaviour
// Initialize settings references
_settings = GameManager.GetSettingsObject<IPlayerFollowerSettings>();
_interactionSettings = GameManager.GetSettingsObject<IInteractionSettings>();
// Register for post-boot initialization
BootCompletionService.RegisterInitAction(InitializePostBoot);
}
private void InitializePostBoot()
{
// Register with save system after boot
if (SaveLoadManager.Instance != null)
{
SaveLoadManager.Instance.RegisterParticipant(this);
Logging.Debug("[FollowerController] Registered with SaveLoadManager");
}
else
{
Logging.Warning("[FollowerController] SaveLoadManager not available for registration");
}
}
void OnEnable()
@@ -114,6 +150,12 @@ public class FollowerController: MonoBehaviour
void OnDisable()
{
SceneManager.sceneLoaded -= OnSceneLoaded;
// Unregister from save system
if (SaveLoadManager.Instance != null)
{
SaveLoadManager.Instance.UnregisterParticipant(GetSaveId());
}
}
void OnSceneLoaded(Scene scene, LoadSceneMode mode)
@@ -584,7 +626,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 +711,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.
@@ -675,6 +732,186 @@ public class FollowerController: MonoBehaviour
#endregion ItemInteractions
#region ISaveParticipant Implementation
public bool HasBeenRestored => hasBeenRestored;
public string GetSaveId()
{
return "FollowerController";
}
public string SerializeState()
{
var saveData = new FollowerSaveData
{
worldPosition = transform.position,
worldRotation = transform.rotation
};
// Save held item if any
if (_cachedPickupObject != null)
{
var pickup = _cachedPickupObject.GetComponent<Pickup>();
if (pickup is SaveableInteractable saveable)
{
saveData.heldItemSaveId = saveable.GetSaveId();
}
if (_currentlyHeldItemData != null)
{
#if UNITY_EDITOR
saveData.heldItemDataAssetPath = UnityEditor.AssetDatabase.GetAssetPath(_currentlyHeldItemData);
#endif
}
}
return JsonUtility.ToJson(saveData);
}
public void RestoreState(string serializedData)
{
if (string.IsNullOrEmpty(serializedData))
{
Logging.Debug("[FollowerController] No saved state to restore");
hasBeenRestored = true;
return;
}
try
{
var saveData = JsonUtility.FromJson<FollowerSaveData>(serializedData);
if (saveData != null)
{
// Restore position and rotation
transform.position = saveData.worldPosition;
transform.rotation = saveData.worldRotation;
// Try bilateral restoration of held item
if (!string.IsNullOrEmpty(saveData.heldItemSaveId))
{
_expectedHeldItemSaveId = saveData.heldItemSaveId;
TryRestoreHeldItem(saveData.heldItemSaveId, saveData.heldItemDataAssetPath);
}
hasBeenRestored = true;
Logging.Debug($"[FollowerController] Restored position: {saveData.worldPosition}");
}
}
catch (System.Exception ex)
{
Logging.Warning($"[FollowerController] Failed to restore state: {ex.Message}");
}
}
/// <summary>
/// Bilateral restoration: Follower tries to find and claim the held item.
/// If pickup doesn't exist yet, it will try to claim us when it restores.
/// </summary>
private void TryRestoreHeldItem(string heldItemSaveId, string heldItemDataAssetPath)
{
if (_hasRestoredHeldItem)
{
Logging.Debug("[FollowerController] Held item already restored");
return;
}
// Try to find the pickup immediately
GameObject heldObject = ItemManager.Instance?.FindPickupBySaveId(heldItemSaveId);
if (heldObject == null)
{
Logging.Debug($"[FollowerController] Held item not found yet: {heldItemSaveId}, waiting for pickup to restore");
return; // Pickup will find us when it restores
}
var pickup = heldObject.GetComponent<Pickup>();
if (pickup == null)
{
Logging.Warning($"[FollowerController] Found object but no Pickup component: {heldItemSaveId}");
return;
}
// Claim the pickup
TakeOwnership(pickup, heldItemDataAssetPath);
}
/// <summary>
/// Bilateral restoration entry point: Pickup calls this to offer itself to the Follower.
/// Returns true if claim was successful, false if Follower already has an item or wrong pickup.
/// </summary>
public bool TryClaimHeldItem(Pickup pickup)
{
if (pickup == null)
return false;
if (_hasRestoredHeldItem)
{
Logging.Debug("[FollowerController] Already restored held item, rejecting claim");
return false;
}
// Verify this is the expected pickup
if (pickup is SaveableInteractable saveable)
{
if (saveable.GetSaveId() != _expectedHeldItemSaveId)
{
Logging.Warning($"[FollowerController] Pickup tried to claim but saveId mismatch: {saveable.GetSaveId()} != {_expectedHeldItemSaveId}");
return false;
}
}
// Claim the pickup
TakeOwnership(pickup, null);
return true;
}
/// <summary>
/// Takes ownership of a pickup during restoration. Called by both restoration paths.
/// </summary>
private void TakeOwnership(Pickup pickup, string itemDataAssetPath)
{
if (_hasRestoredHeldItem)
return; // Already claimed
// Get the item data
PickupItemData heldData = pickup.itemData;
#if UNITY_EDITOR
// Try loading from asset path if available and pickup doesn't have data
if (heldData == null && !string.IsNullOrEmpty(itemDataAssetPath))
{
heldData = UnityEditor.AssetDatabase.LoadAssetAtPath<PickupItemData>(itemDataAssetPath);
}
#endif
if (heldData == null)
{
Logging.Warning($"[FollowerController] Could not get item data for pickup: {pickup.gameObject.name}");
return;
}
// Setup the held item
_cachedPickupObject = pickup.gameObject;
_cachedPickupObject.SetActive(false); // Held items should be hidden
SetHeldItem(heldData, pickup.iconRenderer);
_animator.SetBool("IsCarrying", true);
_hasRestoredHeldItem = true;
Logging.Debug($"[FollowerController] Successfully restored held item: {heldData.itemName}");
}
/// <summary>
/// Static method to find the FollowerController instance in the scene.
/// Used by Pickup during bilateral restoration.
/// </summary>
public static FollowerController FindInstance()
{
return FindObjectOfType<FollowerController>();
}
#endregion ISaveParticipant Implementation
#if UNITY_EDITOR
void OnDrawGizmos()
{

View File

@@ -8,7 +8,7 @@ namespace PuzzleS
/// <summary>
/// Manages the state and interactions for a single puzzle step, including unlock/lock logic and event handling.
/// </summary>
[RequireComponent(typeof(Interactable))]
[RequireComponent(typeof(InteractableBase))]
public class ObjectiveStepBehaviour : MonoBehaviour, IPuzzlePrompt
{
/// <summary>
@@ -20,7 +20,7 @@ namespace PuzzleS
[SerializeField] private GameObject puzzleIndicator;
[SerializeField] private bool drawPromptRangeGizmo = true;
private Interactable _interactable;
private InteractableBase _interactable;
private bool _isUnlocked = false;
private bool _isCompleted = false;
private IPuzzlePrompt _indicator;
@@ -33,7 +33,7 @@ namespace PuzzleS
void Awake()
{
_interactable = GetComponent<Interactable>();
_interactable = GetComponent<InteractableBase>();
// Initialize the indicator if it exists, but ensure it's hidden initially
if (puzzleIndicator != null)
@@ -60,7 +60,7 @@ namespace PuzzleS
void OnEnable()
{
if (_interactable == null)
_interactable = GetComponent<Interactable>();
_interactable = GetComponent<InteractableBase>();
if (_interactable != null)
{

View File

@@ -7,16 +7,28 @@ using UnityEngine.SceneManagement;
using AppleHills.Core.Settings;
using Bootstrap;
using Core;
using Core.SaveLoad;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using Utils;
namespace PuzzleS
{
/// <summary>
/// Save data structure for puzzle progress
/// </summary>
[Serializable]
public class PuzzleSaveData
{
public string levelId;
public List<string> completedStepIds;
public List<string> unlockedStepIds;
}
/// <summary>
/// Manages puzzle step registration, dependency management, and step completion for the puzzle system.
/// </summary>
public class PuzzleManager : MonoBehaviour
public class PuzzleManager : MonoBehaviour, ISaveParticipant
{
private static PuzzleManager _instance;
@@ -48,10 +60,26 @@ namespace PuzzleS
public event Action<PuzzleLevelDataSO> OnLevelDataLoaded;
public event Action<PuzzleLevelDataSO> OnAllPuzzlesComplete;
private HashSet<PuzzleStepSO> _completedSteps = new HashSet<PuzzleStepSO>();
private HashSet<PuzzleStepSO> _unlockedSteps = new HashSet<PuzzleStepSO>();
// Save/Load state tracking - string-based for timing independence
private HashSet<string> _completedSteps = new HashSet<string>();
private HashSet<string> _unlockedSteps = new HashSet<string>();
// Save/Load restoration tracking
private bool _isDataRestored = false;
private bool _hasBeenRestored = false;
private List<ObjectiveStepBehaviour> _pendingRegistrations = new List<ObjectiveStepBehaviour>();
// Registration for ObjectiveStepBehaviour
private Dictionary<PuzzleStepSO, ObjectiveStepBehaviour> _stepBehaviours = new Dictionary<PuzzleStepSO, ObjectiveStepBehaviour>();
// Track pending unlocks for steps that were unlocked before their behavior registered
private HashSet<string> _pendingUnlocks = new HashSet<string>();
/// <summary>
/// Returns true if this participant has already had its state restored.
/// Used by SaveLoadManager to prevent double-restoration.
/// </summary>
public bool HasBeenRestored => _hasBeenRestored;
void Awake()
{
@@ -70,6 +98,13 @@ namespace PuzzleS
SceneManagerService.Instance.SceneLoadCompleted += OnSceneLoadCompleted;
SceneManagerService.Instance.SceneLoadStarted += OnSceneLoadStarted;
// Register with save/load system
BootCompletionService.RegisterInitAction(() =>
{
SaveLoadManager.Instance.RegisterParticipant(this);
Logging.Debug("[PuzzleManager] Registered with SaveLoadManager");
});
// Find player transform
_playerTransform = GameObject.FindGameObjectWithTag("Player")?.transform;
@@ -96,6 +131,11 @@ namespace PuzzleS
SceneManagerService.Instance.SceneLoadStarted -= OnSceneLoadStarted;
}
// Unregister from save/load system
SaveLoadManager.Instance.UnregisterParticipant(GetSaveId());
Logging.Debug("[PuzzleManager] Unregistered from SaveLoadManager");
// Release addressable handle if needed
if (_levelDataLoadOperation.IsValid())
{
@@ -296,12 +336,27 @@ namespace PuzzleS
_stepBehaviours.Add(behaviour.stepData, behaviour);
Logging.Debug($"[Puzzles] Registered step: {behaviour.stepData.stepId} on {behaviour.gameObject.name}");
// Only update state if data is already loaded
if (_isDataLoaded && _currentLevelData != null)
// Check if this step has a pending unlock
if (_pendingUnlocks.Contains(behaviour.stepData.stepId))
{
// Step was unlocked before behavior registered - unlock it now!
behaviour.UnlockStep();
_pendingUnlocks.Remove(behaviour.stepData.stepId);
Logging.Debug($"[Puzzles] Fulfilled pending unlock for step: {behaviour.stepData.stepId}");
}
else if (_isDataRestored)
{
// Data already restored - update immediately
UpdateStepState(behaviour);
}
// Otherwise, the state will be updated when data loads in UpdateAllRegisteredBehaviors
else
{
// Data not restored yet - add to pending queue
if (!_pendingRegistrations.Contains(behaviour))
{
_pendingRegistrations.Add(behaviour);
}
}
}
}
@@ -313,11 +368,11 @@ namespace PuzzleS
if (behaviour?.stepData == null) return;
// If step is already completed, ignore
if (_completedSteps.Contains(behaviour.stepData))
if (_completedSteps.Contains(behaviour.stepData.stepId))
return;
// If step is already unlocked, update the behaviour
if (_unlockedSteps.Contains(behaviour.stepData))
if (_unlockedSteps.Contains(behaviour.stepData.stepId))
{
behaviour.UnlockStep();
}
@@ -349,6 +404,13 @@ namespace PuzzleS
{
if (_currentLevelData == null) return;
// Don't unlock initial steps if we've restored from save
if (_isDataRestored)
{
Logging.Debug("[Puzzles] Skipping UnlockInitialSteps - data was restored from save");
return;
}
// Unlock initial steps
foreach (var step in _currentLevelData.initialSteps)
{
@@ -364,10 +426,10 @@ namespace PuzzleS
/// <param name="step">The completed step.</param>
public void MarkPuzzleStepCompleted(PuzzleStepSO step)
{
if (_completedSteps.Contains(step)) return;
if (_completedSteps.Contains(step.stepId)) return;
if (_currentLevelData == null) return;
_completedSteps.Add(step);
_completedSteps.Add(step.stepId);
Logging.Debug($"[Puzzles] Step completed: {step.stepId}");
// Broadcast completion
@@ -408,18 +470,11 @@ namespace PuzzleS
{
foreach (var depId in dependencies)
{
// Find the dependency step
bool dependencyMet = false;
foreach (var completedStep in _completedSteps)
// Check if dependency is in completed steps
if (!_completedSteps.Contains(depId))
{
if (completedStep.stepId == depId)
{
dependencyMet = true;
break;
}
return false;
}
if (!dependencyMet) return false;
}
}
@@ -432,14 +487,21 @@ namespace PuzzleS
/// <param name="step">The step to unlock.</param>
private void UnlockStep(PuzzleStepSO step)
{
if (_unlockedSteps.Contains(step)) return;
_unlockedSteps.Add(step);
if (_unlockedSteps.Contains(step.stepId)) return;
_unlockedSteps.Add(step.stepId);
if (_stepBehaviours.TryGetValue(step, out var behaviour))
{
// Behavior exists - unlock it immediately
behaviour.UnlockStep();
Logging.Debug($"[Puzzles] Step unlocked: {step.stepId}");
}
else
{
// Behavior hasn't registered yet - add to pending unlocks
_pendingUnlocks.Add(step.stepId);
Logging.Debug($"[Puzzles] Step unlocked but behavior not registered yet, added to pending: {step.stepId}");
}
Logging.Debug($"[Puzzles] Step unlocked: {step.stepId}");
// Broadcast unlock
OnStepUnlocked?.Invoke(step);
@@ -452,7 +514,18 @@ namespace PuzzleS
{
if (_currentLevelData == null) return;
if (_currentLevelData.IsLevelComplete(_completedSteps))
// Check if all steps are completed
bool allComplete = true;
foreach (var step in _currentLevelData.allSteps)
{
if (step != null && !_completedSteps.Contains(step.stepId))
{
allComplete = false;
break;
}
}
if (allComplete)
{
Logging.Debug("[Puzzles] All puzzles complete! Level finished.");
@@ -466,7 +539,7 @@ namespace PuzzleS
/// </summary>
public bool IsStepUnlocked(PuzzleStepSO step)
{
return _unlockedSteps.Contains(step);
return step != null && _unlockedSteps.Contains(step.stepId);
}
/// <summary>
@@ -476,7 +549,7 @@ namespace PuzzleS
/// <returns>True if the step has been completed, false otherwise</returns>
public bool IsPuzzleStepCompleted(string stepId)
{
return _completedSteps.Any(step => step.stepId == stepId);
return _completedSteps.Contains(stepId); // O(1) lookup!
}
/// <summary>
@@ -495,5 +568,89 @@ namespace PuzzleS
{
return _isDataLoaded;
}
#region ISaveParticipant Implementation
/// <summary>
/// Get unique save ID for this puzzle manager instance
/// </summary>
public string GetSaveId()
{
string sceneName = SceneManager.GetActiveScene().name;
return $"{sceneName}/PuzzleManager";
}
/// <summary>
/// Serialize current puzzle state to JSON
/// </summary>
public string SerializeState()
{
if (_currentLevelData == null)
{
Logging.Warning("[PuzzleManager] Cannot serialize state - no level data loaded");
return "{}";
}
var saveData = new PuzzleSaveData
{
levelId = _currentLevelData.levelId,
completedStepIds = _completedSteps.ToList(),
unlockedStepIds = _unlockedSteps.ToList()
};
string json = JsonUtility.ToJson(saveData);
Logging.Debug($"[PuzzleManager] Serialized puzzle state: {_completedSteps.Count} completed, {_unlockedSteps.Count} unlocked");
return json;
}
/// <summary>
/// Restore puzzle state from serialized JSON data
/// </summary>
public void RestoreState(string data)
{
if (string.IsNullOrEmpty(data) || data == "{}")
{
Logging.Debug("[PuzzleManager] No puzzle save data to restore");
_isDataRestored = true;
_hasBeenRestored = true;
return;
}
try
{
var saveData = JsonUtility.FromJson<PuzzleSaveData>(data);
if (saveData == null)
{
Logging.Warning("[PuzzleManager] Failed to deserialize puzzle save data");
_isDataRestored = true;
_hasBeenRestored = true;
return;
}
// Restore step IDs directly - no timing dependency on level data!
_completedSteps = new HashSet<string>(saveData.completedStepIds ?? new List<string>());
_unlockedSteps = new HashSet<string>(saveData.unlockedStepIds ?? new List<string>());
_isDataRestored = true;
_hasBeenRestored = true;
Logging.Debug($"[PuzzleManager] Restored puzzle state: {_completedSteps.Count} completed, {_unlockedSteps.Count} unlocked steps");
// Update any behaviors that registered before RestoreState was called
foreach (var behaviour in _pendingRegistrations)
{
UpdateStepState(behaviour);
}
_pendingRegistrations.Clear();
}
catch (System.Exception e)
{
Debug.LogError($"[PuzzleManager] Error restoring puzzle state: {e.Message}");
_isDataRestored = true;
_hasBeenRestored = true;
}
}
#endregion
}
}