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:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
257
Assets/Scripts/Core/SaveLoad/AppleMachine.cs
Normal file
257
Assets/Scripts/Core/SaveLoad/AppleMachine.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3
Assets/Scripts/Core/SaveLoad/AppleMachine.cs.meta
Normal file
3
Assets/Scripts/Core/SaveLoad/AppleMachine.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6f56763d30b94bf6873d395a6c116eb5
|
||||
timeCreated: 1762116611
|
||||
47
Assets/Scripts/Core/SaveLoad/AppleState.cs
Normal file
47
Assets/Scripts/Core/SaveLoad/AppleState.cs
Normal 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 "";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
3
Assets/Scripts/Core/SaveLoad/AppleState.cs.meta
Normal file
3
Assets/Scripts/Core/SaveLoad/AppleState.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 95e46aacea5b42888ee7881894193c11
|
||||
timeCreated: 1762121675
|
||||
34
Assets/Scripts/Core/SaveLoad/ISaveParticipant.cs
Normal file
34
Assets/Scripts/Core/SaveLoad/ISaveParticipant.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
|
||||
12
Assets/Scripts/Core/SaveLoad/ISaveParticipant.cs.meta
Normal file
12
Assets/Scripts/Core/SaveLoad/ISaveParticipant.cs.meta
Normal file
@@ -0,0 +1,12 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 7f3a4e8c9d2b1a5f6e8c4d9a2b7e5f3a
|
||||
MonoImporter:
|
||||
externalObjects: {}
|
||||
serializedVersion: 2
|
||||
defaultReferences: []
|
||||
executionOrder: 0
|
||||
icon: {instanceID: 0}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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}'");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
26
Assets/Scripts/Data/CardSystem/CardCollectionState.cs
Normal file
26
Assets/Scripts/Data/CardSystem/CardCollectionState.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: e552abbd5bd74192840939e499372ff2
|
||||
timeCreated: 1761830599
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
269
Assets/Scripts/Interactions/SaveableInteractable.cs
Normal file
269
Assets/Scripts/Interactions/SaveableInteractable.cs
Normal 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
|
||||
}
|
||||
3
Assets/Scripts/Interactions/SaveableInteractable.cs.meta
Normal file
3
Assets/Scripts/Interactions/SaveableInteractable.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: a0d7c8f7344746ce9dc863985cc3f543
|
||||
timeCreated: 1762079555
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user