Files
AppleHillsProduction/Assets/Scripts/Interactions/SaveableInteractable.cs
tschesky 011901eb8f 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
2025-11-03 10:12:51 +00:00

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
}