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

@@ -2,17 +2,18 @@
using System.Collections.Generic;
using System.Threading.Tasks;
using AppleHills.Core.Settings;
using Core.Lifecycle;
using Core.SaveLoad;
using UI;
using UnityEngine;
using UnityEngine.SceneManagement;
using Bootstrap;
namespace Core
{
/// <summary>
/// Singleton service for loading and unloading Unity scenes asynchronously, with events for progress and completion.
/// </summary>
public class SceneManagerService : MonoBehaviour
public class SceneManagerService : ManagedBehaviour
{
private LoadingScreenController _loadingScreen;
private static SceneManagerService _instance;
@@ -23,29 +24,39 @@ namespace Core
public static SceneManagerService Instance => _instance;
// Events for scene lifecycle
// NOTE: Most components should use lifecycle hooks (OnSceneReady, OnSceneUnloading)
// instead of subscribing to these events. Events are primarily for orchestration.
/// <summary>
/// Fired when a scene starts loading. Used by loading screen orchestration.
/// </summary>
public event Action<string> SceneLoadStarted;
public event Action<string, float> SceneLoadProgress;
/// <summary>
/// Fired when a scene finishes loading.
/// Used by loading screen orchestration and cross-scene components (e.g., PauseMenu).
/// For component initialization, use OnSceneReady() lifecycle hook instead.
/// </summary>
public event Action<string> SceneLoadCompleted;
public event Action<string> SceneUnloadStarted;
public event Action<string, float> SceneUnloadProgress;
public event Action<string> SceneUnloadCompleted;
private readonly Dictionary<string, AsyncOperation> _activeLoads = new();
private readonly Dictionary<string, AsyncOperation> _activeUnloads = new();
private LogVerbosity _logVerbosity = LogVerbosity.Debug;
private const string BootstrapSceneName = "BootstrapScene";
void Awake()
// ManagedBehaviour configuration
public override int ManagedAwakePriority => 15; // Core infrastructure, after GameManager
private new void Awake()
{
base.Awake(); // CRITICAL: Register with LifecycleManager!
// Set instance immediately so it's available before OnManagedAwake() is called
_instance = this;
// DontDestroyOnLoad(gameObject);
// Initialize current scene tracking immediately in Awake
// Initialize current scene tracking - critical for scene management
InitializeCurrentSceneTracking();
// Register for post-boot initialization
BootCompletionService.RegisterInitAction(InitializePostBoot);
// Ensure BootstrapScene is loaded at startup
var bootstrap = SceneManager.GetSceneByName(BootstrapSceneName);
if (!bootstrap.isLoaded)
@@ -54,9 +65,17 @@ namespace Core
}
}
private void Start()
protected override void OnManagedAwake()
{
// Set up loading screen reference and events
// This must happen in ManagedAwake because LoadingScreenController instance needs to be set first
_loadingScreen = LoadingScreenController.Instance;
SetupLoadingScreenEvents();
// Load verbosity settings
_logVerbosity = DeveloperSettingsProvider.Instance.GetSettings<DebugSettings>().sceneLogVerbosity;
LogDebugMessage($"SceneManagerService initialized, current scene is: {CurrentGameplayScene}");
}
/// <summary>
@@ -89,17 +108,6 @@ namespace Core
LogDebugMessage($"No valid active scene, defaulting to: {CurrentGameplayScene}");
}
}
private void InitializePostBoot()
{
// Set up loading screen reference and events after boot is complete
_loadingScreen = LoadingScreenController.Instance;
// Set up loading screen event handlers if available
SetupLoadingScreenEvents();
LogDebugMessage($"Post-boot initialization complete, current scene is: {CurrentGameplayScene}");
}
private void SetupLoadingScreenEvents()
{
@@ -122,7 +130,6 @@ namespace Core
while (!op.isDone)
{
progress?.Report(op.progress);
SceneLoadProgress?.Invoke(sceneName, op.progress);
await Task.Yield();
}
_activeLoads.Remove(sceneName);
@@ -142,17 +149,15 @@ namespace Core
Logging.Warning($"[SceneManagerService] Attempted to unload scene '{sceneName}', but it is not loaded.");
return;
}
SceneUnloadStarted?.Invoke(sceneName);
var op = SceneManager.UnloadSceneAsync(sceneName);
_activeUnloads[sceneName] = op;
while (!op.isDone)
{
progress?.Report(op.progress);
SceneUnloadProgress?.Invoke(sceneName, op.progress);
await Task.Yield();
}
_activeUnloads.Remove(sceneName);
SceneUnloadCompleted?.Invoke(sceneName);
}
/// <summary>
@@ -230,7 +235,6 @@ namespace Core
var op = SceneManager.UnloadSceneAsync(name);
_activeUnloads[name] = op;
ops.Add(op);
SceneUnloadStarted?.Invoke(name);
}
while (done < total)
@@ -251,7 +255,6 @@ namespace Core
foreach (var name in sceneNames)
{
_activeUnloads.Remove(name);
SceneUnloadCompleted?.Invoke(name);
}
// Hide loading screen after all scenes are unloaded
@@ -280,9 +283,9 @@ namespace Core
// Tracks the currently loaded gameplay scene (not persistent/bootstrapper)
public string CurrentGameplayScene { get; set; } = "AppleHillsOverworld";
public async Task ReloadCurrentScene(IProgress<float> progress = null, bool autoHideLoadingScreen = true)
public async Task ReloadCurrentScene(IProgress<float> progress = null, bool autoHideLoadingScreen = true, bool skipSave = false)
{
await SwitchSceneAsync(CurrentGameplayScene, progress, autoHideLoadingScreen);
await SwitchSceneAsync(CurrentGameplayScene, progress, autoHideLoadingScreen, skipSave);
}
/// <summary>
@@ -291,15 +294,45 @@ namespace Core
/// <param name="newSceneName">Name of the scene to load</param>
/// <param name="progress">Optional progress reporter</param>
/// <param name="autoHideLoadingScreen">Whether to automatically hide the loading screen when complete. If false, caller must hide it manually.</param>
public async Task SwitchSceneAsync(string newSceneName, IProgress<float> progress = null, bool autoHideLoadingScreen = true)
/// <param name="skipSave">If true, skips saving scene data during transition. Useful for level restart to prevent re-saving cleared data.</param>
public async Task SwitchSceneAsync(string newSceneName, IProgress<float> progress = null, bool autoHideLoadingScreen = true, bool skipSave = false)
{
// Show loading screen at the start (whether using auto-hide or not)
if (_loadingScreen != null && !_loadingScreen.IsActive)
string oldSceneName = CurrentGameplayScene;
// PHASE 1: Show loading screen at the start
// Use explicit progress provider to combine unload + load progress
if (_loadingScreen != null)
{
_loadingScreen.ShowLoadingScreen();
_loadingScreen.ShowLoadingScreen(() => GetAggregateLoadProgress());
}
// Remove all AstarPath (A* Pathfinder) singletons before loading the new scene
// PHASE 2: Broadcast scene unloading - notify components to cleanup
LogDebugMessage($"Broadcasting OnSceneUnloading for: {oldSceneName}");
LifecycleManager.Instance?.BroadcastSceneUnloading(oldSceneName);
// PHASE 3: Save scene-specific data via SaveLoadManager (unless skipSave is true)
if (!skipSave && SaveLoadManager.Instance != null)
{
var debugSettings = DeveloperSettingsProvider.Instance.GetSettings<DebugSettings>();
if (debugSettings.useSaveLoadSystem)
{
LogDebugMessage($"Saving scene data for: {oldSceneName}");
SaveLoadManager.Instance.SaveSceneData();
}
}
else if (skipSave)
{
LogDebugMessage($"Skipping save for: {oldSceneName} (skipSave=true)");
}
// PHASE 4: Clear PuzzleManager state before scene transition
if (PuzzleS.PuzzleManager.Instance != null)
{
LogDebugMessage($"Clearing puzzle state before scene transition");
PuzzleS.PuzzleManager.Instance.ClearPuzzleState();
}
// PHASE 5: Remove all AstarPath (A* Pathfinder) singletons before loading the new scene
var astarPaths = FindObjectsByType<AstarPath>(FindObjectsSortMode.None);
foreach (var astar in astarPaths)
{
@@ -308,31 +341,56 @@ namespace Core
else
DestroyImmediate(astar.gameObject);
}
// Unload previous gameplay scene (if not BootstrapScene and not same as new)
if (!string.IsNullOrEmpty(CurrentGameplayScene)&& CurrentGameplayScene != BootstrapSceneName)
// PHASE 6: Unload previous gameplay scene (Unity will call OnDestroy → OnManagedDestroy)
if (!string.IsNullOrEmpty(oldSceneName) && oldSceneName != BootstrapSceneName)
{
var prevScene = SceneManager.GetSceneByName(CurrentGameplayScene);
var prevScene = SceneManager.GetSceneByName(oldSceneName);
if (prevScene.isLoaded)
{
await UnloadSceneAsync(CurrentGameplayScene);
await UnloadSceneAsync(oldSceneName);
}
else
{
Logging.Warning($"[SceneManagerService] Previous scene '{CurrentGameplayScene}' is not loaded, skipping unload.");
Logging.Warning($"[SceneManagerService] Previous scene '{oldSceneName}' is not loaded, skipping unload.");
}
}
// Ensure BootstrapScene is loaded before loading new scene
// PHASE 7: Ensure BootstrapScene is loaded before loading new scene
var bootstrap = SceneManager.GetSceneByName(BootstrapSceneName);
if (!bootstrap.isLoaded)
{
SceneManager.LoadScene(BootstrapSceneName, LoadSceneMode.Additive);
}
// Load new gameplay scene
// PHASE 8: Begin scene loading mode - enables priority-ordered component initialization
LogDebugMessage($"Beginning scene load for: {newSceneName}");
LifecycleManager.Instance?.BeginSceneLoad(newSceneName);
// PHASE 9: Load new gameplay scene
await LoadSceneAsync(newSceneName, progress);
// Update tracker
CurrentGameplayScene = newSceneName;
// Only hide the loading screen if autoHideLoadingScreen is true
// PHASE 10: Broadcast scene ready - processes batched components in priority order, then calls OnSceneReady
LogDebugMessage($"Broadcasting OnSceneReady for: {newSceneName}");
LifecycleManager.Instance?.BroadcastSceneReady(newSceneName);
// PHASE 11: Restore scene-specific data via SaveLoadManager
if (!skipSave && SaveLoadManager.Instance != null)
{
var debugSettings = DeveloperSettingsProvider.Instance.GetSettings<DebugSettings>();
if (debugSettings.useSaveLoadSystem)
{
LogDebugMessage($"Restoring scene data for: {newSceneName}");
SaveLoadManager.Instance.RestoreSceneData();
}
}
else if (skipSave)
{
LogDebugMessage($"Skipping restore for: {newSceneName} (skipSave=true)");
}
// PHASE 12: Only hide the loading screen if autoHideLoadingScreen is true
if (autoHideLoadingScreen && _loadingScreen != null)
{
_loadingScreen.HideLoadingScreen();