### 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
270 lines
8.5 KiB
C#
270 lines
8.5 KiB
C#
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
|
|
}
|