Refactor interactions, introduce template-method lifecycle management, work on save-load system (#51)

# Lifecycle Management & Save System Revamp

## Overview
Complete overhaul of game lifecycle management, interactable system, and save/load architecture. Introduces centralized `ManagedBehaviour` base class for consistent initialization ordering and lifecycle hooks across all systems.

## Core Architecture

### New Lifecycle System
- **`LifecycleManager`**: Centralized coordinator for all managed objects
- **`ManagedBehaviour`**: Base class replacing ad-hoc initialization patterns
  - `OnManagedAwake()`: Priority-based initialization (0-100, lower = earlier)
  - `OnSceneReady()`: Scene-specific setup after managers ready
  - Replaces `BootCompletionService` (deleted)
- **Priority groups**: Infrastructure (0-20) → Game Systems (30-50) → Data (60-80) → UI/Gameplay (90-100)
- **Editor support**: `EditorLifecycleBootstrap` ensures lifecycle works in editor mode

### Unified SaveID System
- Consistent format: `{ParentName}_{ComponentType}`
- Auto-registration via `AutoRegisterForSave = true`
- New `DebugSaveIds` editor tool for inspection

## Save/Load Improvements

### Enhanced State Management
- **Extended SaveLoadData**: Unlocked minigames, card collection states, combination items, slot occupancy
- **Async loading**: `ApplyCardCollectionState()` waits for card definitions before restoring
- **New `SaveablePlayableDirector`**: Timeline sequences save/restore playback state
- **Fixed race conditions**: Proper initialization ordering prevents data corruption

## Interactable & Pickup System

- Migrated to `OnManagedAwake()` for consistent initialization
- Template method pattern for state restoration (`RestoreInteractionState()`)
- Fixed combination item save/load bugs (items in slots vs. follower hand)
- Dynamic spawning support for combined items on load
- **Breaking**: `Interactable.Awake()` now sealed, use `OnManagedAwake()` instead

##  UI System Changes

- **AlbumViewPage** and **BoosterNotificationDot**: Migrated to `ManagedBehaviour`
- **Fixed menu persistence bug**: Menus no longer reappear after scene transitions
- **Pause Menu**: Now reacts to all scene loads (not just first scene)
- **Orientation Enforcer**: Enforces per-scene via `SceneManagementService`
- **Loading Screen**: Integrated with new lifecycle

## ⚠️ Breaking Changes

1. **`BootCompletionService` removed** → Use `ManagedBehaviour.OnManagedAwake()` with priority
2. **`Interactable.Awake()` sealed** → Override `OnManagedAwake()` instead
3. **SaveID format changed** → Now `{ParentName}_{ComponentType}` consistently
4. **MonoBehaviours needing init ordering** → Must inherit from `ManagedBehaviour`

Co-authored-by: Michal Pikulski <michal.a.pikulski@gmail.com>
Co-authored-by: Michal Pikulski <michal@foolhardyhorizons.com>
Reviewed-on: #51
This commit is contained in:
2025-11-07 15:38:31 +00:00
parent dfa42b2296
commit e27bb7bfb6
93 changed files with 7900 additions and 4347 deletions

View File

@@ -1,6 +1,5 @@
using UnityEngine;
using Pixelplacement;
using Bootstrap;
namespace Core.SaveLoad
{
@@ -82,18 +81,15 @@ namespace Core.SaveLoad
private void Start()
{
// Register with save system (no validation needed - we auto-generate ID)
BootCompletionService.RegisterInitAction(() =>
// Direct registration - SaveLoadManager guaranteed available (priority 25)
if (SaveLoadManager.Instance != null)
{
if (SaveLoadManager.Instance != null)
{
SaveLoadManager.Instance.RegisterParticipant(this);
}
else
{
Debug.LogWarning($"[SaveableStateMachine] SaveLoadManager.Instance is null, cannot register '{name}'", this);
}
});
SaveLoadManager.Instance.RegisterParticipant(this);
}
else
{
Debug.LogWarning($"[AppleMachine] SaveLoadManager not available for '{name}'", this);
}
}
#if UNITY_EDITOR
@@ -127,9 +123,8 @@ namespace Core.SaveLoad
return $"{sceneName}/{customSaveId}";
}
// Auto-generate from hierarchy path
string hierarchyPath = GetHierarchyPath();
return $"{sceneName}/StateMachine_{hierarchyPath}";
// Match ManagedBehaviour convention: SceneName/GameObjectName/ComponentType
return $"{sceneName}/{gameObject.name}/AppleMachine";
}
private string GetSceneName()
@@ -137,19 +132,6 @@ namespace Core.SaveLoad
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()
{

View File

@@ -4,20 +4,20 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using AppleHills.Core.Settings;
using Bootstrap;
using Core.Lifecycle;
using UnityEngine;
namespace Core.SaveLoad
{
/// <summary>
/// Save/Load manager that follows the project's bootstrap pattern.
/// Save/Load manager that follows the project's lifecycle pattern.
/// - Singleton instance
/// - Registers a post-boot init action with BootCompletionService
/// - Inherits from ManagedBehaviour for lifecycle integration
/// - Manages participant registration for save/load operations
/// - Exposes simple async Save/Load methods
/// - Fires events on completion
/// </summary>
public class SaveLoadManager : MonoBehaviour
public class SaveLoadManager : ManagedBehaviour
{
private static SaveLoadManager _instance;
public static SaveLoadManager Instance => _instance;
@@ -43,24 +43,49 @@ namespace Core.SaveLoad
public event Action<string> OnLoadCompleted;
public event Action OnParticipantStatesRestored;
void Awake()
// ManagedBehaviour configuration
public override int ManagedAwakePriority => 20; // After GameManager and SceneManagerService
private new void Awake()
{
base.Awake(); // CRITICAL: Register with LifecycleManager!
// Set instance immediately so it's available before OnManagedAwake() is called
_instance = this;
// Initialize critical state immediately
IsSaveDataLoaded = false;
IsRestoringState = false;
BootCompletionService.RegisterInitAction(InitializePostBoot);
}
private void Start()
protected override void OnManagedAwake()
{
#if UNITY_EDITOR
OnSceneLoadCompleted("RestoreInEditor");
#endif
Logging.Debug("[SaveLoadManager] Initialized");
// Load save data if save system is enabled (depends on settings from GameManager)
if (DeveloperSettingsProvider.Instance.GetSettings<DebugSettings>().useSaveLoadSystem)
{
Load();
}
}
protected override void OnSceneReady()
{
// SaveableInteractables now auto-register via ManagedBehaviour lifecycle
// No need to discover and register them manually
}
protected override string OnSceneSaveRequested()
{
// SaveLoadManager orchestrates saves, doesn't participate in them
return null;
}
protected override string OnGlobalSaveRequested()
{
// SaveLoadManager orchestrates saves, doesn't participate in them
return null;
}
private void OnApplicationQuit()
{
@@ -70,30 +95,14 @@ namespace Core.SaveLoad
}
}
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");
}
}
// ...existing code...
void OnDestroy()
protected override void OnDestroy()
{
base.OnDestroy(); // Important: call base to unregister from LifecycleManager
if (_instance == this)
{
// Unsubscribe from scene events
if (SceneManagerService.Instance != null)
{
SceneManagerService.Instance.SceneLoadCompleted -= OnSceneLoadCompleted;
SceneManagerService.Instance.SceneUnloadStarted -= OnSceneUnloadStarted;
}
_instance = null;
}
}
@@ -173,40 +182,93 @@ namespace Core.SaveLoad
#endregion
#region Scene Lifecycle
#region Unlocked Minigames Management
private void OnSceneLoadCompleted(string sceneName)
/// <summary>
/// Marks a minigame as unlocked in the global save data.
/// This is separate from scene-specific participant states and persists across all saves.
/// </summary>
/// <param name="minigameName">The name/identifier of the minigame (typically scene name)</param>
public void UnlockMinigame(string minigameName)
{
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)
if (string.IsNullOrEmpty(minigameName))
{
var saveable = obj as Interactions.SaveableInteractable;
if (saveable != null && !saveable.gameObject.activeInHierarchy)
{
// Only register if it's actually inactive
RegisterParticipant(saveable);
registeredCount++;
}
Logging.Warning("[SaveLoadManager] Attempted to unlock minigame with null or empty name");
return;
}
if (currentSaveData == null)
{
Logging.Warning("[SaveLoadManager] Cannot unlock minigame - no save data loaded");
return;
}
if (currentSaveData.unlockedMinigames == null)
{
currentSaveData.unlockedMinigames = new System.Collections.Generic.List<string>();
}
if (!currentSaveData.unlockedMinigames.Contains(minigameName))
{
currentSaveData.unlockedMinigames.Add(minigameName);
Logging.Debug($"[SaveLoadManager] Unlocked minigame: {minigameName}");
}
Logging.Debug($"[SaveLoadManager] Discovered and registered {registeredCount} inactive SaveableInteractables");
}
private void OnSceneUnloadStarted(string sceneName)
/// <summary>
/// Checks if a minigame has been unlocked.
/// </summary>
/// <param name="minigameName">The name/identifier of the minigame</param>
/// <returns>True if the minigame is unlocked, false otherwise</returns>
public bool IsMinigameUnlocked(string minigameName)
{
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
if (string.IsNullOrEmpty(minigameName))
return false;
if (currentSaveData == null || currentSaveData.unlockedMinigames == null)
return false;
return currentSaveData.unlockedMinigames.Contains(minigameName);
}
/// <summary>
/// Gets a read-only list of all unlocked minigames.
/// </summary>
public System.Collections.Generic.IReadOnlyList<string> GetUnlockedMinigames()
{
if (currentSaveData == null || currentSaveData.unlockedMinigames == null)
return new System.Collections.Generic.List<string>();
return currentSaveData.unlockedMinigames.AsReadOnly();
}
/// <summary>
/// Clears all save data for a specific level/scene.
/// Removes all participant states that belong to the specified scene.
/// Useful for "restart level" functionality to wipe progress.
/// </summary>
/// <param name="sceneName">The name of the scene to clear data for</param>
public void ClearLevelData(string sceneName)
{
if (string.IsNullOrEmpty(sceneName))
{
Logging.Warning("[SaveLoadManager] Cannot clear level data - scene name is null or empty");
return;
}
if (currentSaveData == null || currentSaveData.participantStates == null)
{
Logging.Warning("[SaveLoadManager] Cannot clear level data - no save data loaded");
return;
}
// SaveId format is "SceneName/ObjectName/ComponentType"
// Remove all entries that start with "sceneName/"
string scenePrefix = $"{sceneName}/";
int removedCount = currentSaveData.participantStates.RemoveAll(entry =>
entry.saveId.StartsWith(scenePrefix));
Logging.Debug($"[SaveLoadManager] Cleared {removedCount} save entries for level: {sceneName}");
}
#endregion
@@ -254,8 +316,31 @@ namespace Core.SaveLoad
return;
IsRestoringState = true;
int restoredCount = 0;
// Build dictionary for efficient lookup
var saveDataDict = new Dictionary<string, string>();
foreach (var entry in currentSaveData.participantStates)
{
saveDataDict[entry.saveId] = entry.serializedState;
}
// NEW: Restore GLOBAL data via LifecycleManager (called ONCE on boot)
if (Lifecycle.LifecycleManager.Instance != null)
{
Lifecycle.LifecycleManager.Instance.BroadcastGlobalRestoreRequested(saveDataDict);
Logging.Debug($"[SaveLoadManager] Broadcast GLOBAL restore to LifecycleManager");
}
// NEW: Restore SCENE data via LifecycleManager (for currently loaded scenes)
if (Lifecycle.LifecycleManager.Instance != null)
{
Lifecycle.LifecycleManager.Instance.BroadcastSceneRestoreRequested(saveDataDict);
Logging.Debug($"[SaveLoadManager] Broadcast SCENE restore to LifecycleManager");
}
// EXISTING: Restore ISaveParticipants (backward compatibility)
int restoredCount = 0;
// Clear pending queue at the start
pendingParticipants.Clear();
@@ -266,19 +351,17 @@ namespace Core.SaveLoad
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))
if (saveDataDict.TryGetValue(saveId, out string serializedState))
{
try
{
participant.RestoreState(entry.serializedState);
participant.RestoreState(serializedState);
restoredCount++;
Logging.Debug($"[SaveLoadManager] Restored state for participant: {saveId}");
Logging.Debug($"[SaveLoadManager] Restored ISaveParticipant: {saveId}");
}
catch (Exception ex)
{
Logging.Warning($"[SaveLoadManager] Exception while restoring state for '{saveId}': {ex}");
Logging.Warning($"[SaveLoadManager] Exception while restoring '{saveId}': {ex}");
}
}
}
@@ -324,7 +407,7 @@ namespace Core.SaveLoad
pendingParticipants.Clear();
IsRestoringState = false;
Logging.Debug($"[SaveLoadManager] Restored state for {restoredCount} participants + {totalPendingRestored} pending participants");
Logging.Debug($"[SaveLoadManager] Restored {restoredCount} ISaveParticipants + {totalPendingRestored} pending participants");
OnParticipantStatesRestored?.Invoke();
}
@@ -335,6 +418,115 @@ namespace Core.SaveLoad
return Path.Combine(DefaultSaveFolder, $"save_{slot}.json");
}
/// <summary>
/// Saves scene-specific data during scene transitions.
/// This updates the in-memory save data but does NOT write to disk.
/// Call Save() to persist to disk.
/// </summary>
public void SaveSceneData()
{
if (currentSaveData == null)
{
Logging.Warning("[SaveLoadManager] Cannot save scene data - no save data loaded");
return;
}
Logging.Debug("[SaveLoadManager] Saving scene-specific data...");
// Build a dictionary of all data to save
var allSceneData = new Dictionary<string, string>();
// Collect scene data from ManagedBehaviours via LifecycleManager
if (Lifecycle.LifecycleManager.Instance != null)
{
var sceneData = Lifecycle.LifecycleManager.Instance.BroadcastSceneSaveRequested();
foreach (var kvp in sceneData)
{
allSceneData[kvp.Key] = kvp.Value;
}
Logging.Debug($"[SaveLoadManager] Collected {sceneData.Count} ManagedBehaviour scene states");
}
// Collect data from ISaveParticipants (all currently registered, identified by SaveId)
foreach (var kvp in participants.ToList())
{
string saveId = kvp.Key;
ISaveParticipant participant = kvp.Value;
try
{
string serializedState = participant.SerializeState();
allSceneData[saveId] = serializedState;
Logging.Debug($"[SaveLoadManager] Captured state for ISaveParticipant: {saveId}");
}
catch (Exception ex)
{
Logging.Warning($"[SaveLoadManager] Exception while serializing ISaveParticipant '{saveId}': {ex}");
}
}
// Update existing entries or add new ones (matches SaveAsync() pattern)
if (currentSaveData.participantStates != null)
{
int updatedCount = 0;
foreach (var kvp in allSceneData)
{
var existingEntry = currentSaveData.participantStates.Find(e => e.saveId == kvp.Key);
if (existingEntry != null)
{
// Update existing entry in place
existingEntry.serializedState = kvp.Value;
}
else
{
// Add new entry
currentSaveData.participantStates.Add(new ParticipantStateEntry
{
saveId = kvp.Key,
serializedState = kvp.Value
});
}
updatedCount++;
}
Logging.Debug($"[SaveLoadManager] Updated {updatedCount} scene data entries in memory");
}
else
{
Logging.Warning("[SaveLoadManager] participantStates list is null, cannot save scene data");
}
}
/// <summary>
/// Restores scene-specific data after scene load.
/// Distributes data to components in the newly loaded scene.
/// </summary>
public void RestoreSceneData()
{
if (currentSaveData == null || currentSaveData.participantStates == null)
{
Logging.Debug("[SaveLoadManager] No scene data to restore");
return;
}
Logging.Debug("[SaveLoadManager] Restoring scene-specific data...");
// Build dictionary for efficient lookup
var saveDataDict = new Dictionary<string, string>();
foreach (var entry in currentSaveData.participantStates)
{
saveDataDict[entry.saveId] = entry.serializedState;
}
// Restore scene data via LifecycleManager
if (Lifecycle.LifecycleManager.Instance != null)
{
Lifecycle.LifecycleManager.Instance.BroadcastSceneRestoreRequested(saveDataDict);
Logging.Debug($"[SaveLoadManager] Broadcast scene restore to LifecycleManager");
}
}
/// <summary>
/// Entry point to save to a named slot. Starts an async coroutine that writes to disk.
/// Fires OnSaveCompleted when finished.
@@ -391,14 +583,44 @@ namespace Core.SaveLoad
{
currentSaveData.participantStates = new List<ParticipantStateEntry>();
}
else
// NOTE: We do NOT clear participantStates here!
// We preserve data from all scenes and update/add as needed.
// This allows Level A data to persist when saving from Level B.
int savedCount = 0;
// NEW: Broadcast global save started event (ONCE)
if (Lifecycle.LifecycleManager.Instance != null)
{
currentSaveData.participantStates.Clear();
Lifecycle.LifecycleManager.Instance.BroadcastGlobalSaveStarted();
}
// Capture state from all registered participants directly into the list
// Create a snapshot to avoid collection modification during iteration
int savedCount = 0;
// Build a dictionary of all new data to save
var allNewData = new Dictionary<string, string>();
// NEW: Collect GLOBAL data from ManagedBehaviours via LifecycleManager
if (Lifecycle.LifecycleManager.Instance != null)
{
var globalData = Lifecycle.LifecycleManager.Instance.BroadcastGlobalSaveRequested();
foreach (var kvp in globalData)
{
allNewData[kvp.Key] = kvp.Value;
}
Logging.Debug($"[SaveLoadManager] Collected {globalData.Count} GLOBAL save states");
}
// NEW: Collect SCENE data from all loaded scenes
if (Lifecycle.LifecycleManager.Instance != null)
{
var sceneData = Lifecycle.LifecycleManager.Instance.BroadcastSceneSaveRequested();
foreach (var kvp in sceneData)
{
allNewData[kvp.Key] = kvp.Value;
}
Logging.Debug($"[SaveLoadManager] Collected {sceneData.Count} SCENE save states");
}
// EXISTING: Collect data from ISaveParticipants (backward compatibility)
foreach (var kvp in participants.ToList())
{
string saveId = kvp.Key;
@@ -407,13 +629,8 @@ namespace Core.SaveLoad
try
{
string serializedState = participant.SerializeState();
currentSaveData.participantStates.Add(new ParticipantStateEntry
{
saveId = saveId,
serializedState = serializedState
});
savedCount++;
Logging.Debug($"[SaveLoadManager] Captured state for participant: {saveId}");
allNewData[saveId] = serializedState;
Logging.Debug($"[SaveLoadManager] Captured state for ISaveParticipant: {saveId}");
}
catch (Exception ex)
{
@@ -421,7 +638,28 @@ namespace Core.SaveLoad
}
}
Logging.Debug($"[SaveLoadManager] Captured state from {savedCount} participants");
// Update existing entries or add new ones (preserves data from unloaded scenes)
foreach (var kvp in allNewData)
{
var existingEntry = currentSaveData.participantStates.Find(e => e.saveId == kvp.Key);
if (existingEntry != null)
{
// Update existing entry
existingEntry.serializedState = kvp.Value;
}
else
{
// Add new entry
currentSaveData.participantStates.Add(new ParticipantStateEntry
{
saveId = kvp.Key,
serializedState = kvp.Value
});
}
savedCount++;
}
Logging.Debug($"[SaveLoadManager] Captured state from {savedCount} total participants");
json = JsonUtility.ToJson(currentSaveData, true);
@@ -540,6 +778,12 @@ namespace Core.SaveLoad
// Restore state for any already-registered participants
RestoreAllParticipantStates();
// NEW: Broadcast global load completed event (ONCE, on boot)
if (Lifecycle.LifecycleManager.Instance != null)
{
Lifecycle.LifecycleManager.Instance.BroadcastGlobalLoadCompleted();
}
OnLoadCompleted?.Invoke(slot);
Logging.Debug($"[SaveLoadManager] Load completed for slot '{slot}'");
}