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,12 +1,10 @@
using Interactions;
using UnityEngine;
using Pathfinding;
using UnityEngine.SceneManagement;
using Utils;
using AppleHills.Core.Settings;
using Core;
using Core.SaveLoad;
using Bootstrap;
using Core.Lifecycle;
using UnityEngine.Events;
/// <summary>
@@ -18,13 +16,13 @@ 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
public string heldItemDataAssetPath; // ItemId of the PickupItemData (for fallback restoration)
}
/// <summary>
/// Controls the follower character, including following the player, handling pickups, and managing held items.
/// </summary>
public class FollowerController : MonoBehaviour, ISaveParticipant
public class FollowerController : ManagedBehaviour
{
private static readonly int CombineTrigger = Animator.StringToHash("Combine");
@@ -54,6 +52,12 @@ public class FollowerController : MonoBehaviour, ISaveParticipant
// Direction variables for 2D blend tree animation
private float _lastDirX = 0f; // -1 (left) to 1 (right)
private float _lastDirY = -1f; // -1 (down) to 1 (up)
// Save system configuration
public override bool AutoRegisterForSave => true;
// Scene-specific SaveId - each level has its own follower state
public override string SaveId => $"{gameObject.scene.name}/FollowerController";
private float _currentSpeed = 0f;
private Animator _animator;
private Transform _artTransform;
@@ -98,12 +102,13 @@ public class FollowerController : MonoBehaviour, ISaveParticipant
private Input.PlayerTouchController _playerTouchController;
// Save system tracking
private bool hasBeenRestored;
// Save system tracking for bilateral restoration
private bool _hasRestoredHeldItem; // Track if held item restoration completed
private string _expectedHeldItemSaveId; // Expected saveId during restoration
void Awake()
public override int ManagedAwakePriority => 110; // Follower after player
protected override void OnManagedAwake()
{
_aiPath = GetComponent<AIPath>();
// Find art prefab and animator
@@ -122,44 +127,11 @@ public class FollowerController : MonoBehaviour, ISaveParticipant
// 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()
{
SceneManager.sceneLoaded += OnSceneLoaded;
FindPlayerReference();
}
void OnDisable()
{
SceneManager.sceneLoaded -= OnSceneLoaded;
// Unregister from save system
if (SaveLoadManager.Instance != null)
{
SaveLoadManager.Instance.UnregisterParticipant(GetSaveId());
}
}
void OnSceneLoaded(Scene scene, LoadSceneMode mode)
protected override void OnSceneReady()
{
// Find player reference when scene is ready (called for every scene load)
FindPlayerReference();
}
@@ -167,9 +139,7 @@ public class FollowerController : MonoBehaviour, ISaveParticipant
{
if (_playerTransform == null)
{
FindPlayerReference();
if (_playerTransform == null)
return;
return;
}
// Skip all movement logic when playing a stationary animation
@@ -583,19 +553,31 @@ public class FollowerController : MonoBehaviour, ISaveParticipant
#endregion StationaryAnimations
#region ItemInteractions
// TODO: Move TryCombineItems to ItemManager/InteractionHelpers
// This is currently interaction logic living in a movement controller.
// Pros of moving: Separates game logic from character logic, easier to test
// Cons: More coordination needed, follower still needs animation callbacks
/// <summary>
/// Try to pickup an item. If already holding something, optionally drop it first.
/// </summary>
/// <param name="itemObject">The GameObject to pick up (must have Pickup component)</param>
/// <param name="itemData">The item data (redundant - can be extracted from GameObject)</param>
/// <param name="dropItem">Whether to drop currently held item before picking up new one</param>
public void TryPickupItem(GameObject itemObject, PickupItemData itemData, bool dropItem = true)
{
if (itemObject == null) return;
// Drop current item if holding something
if (_currentlyHeldItemData != null && _cachedPickupObject != null && dropItem)
{
// Drop the currently held item at the current position
DropHeldItemAt(transform.position);
}
// Pick up the new item
SetHeldItem(itemData, itemObject.GetComponent<SpriteRenderer>());
_animator.SetBool("IsCarrying", true);
_cachedPickupObject = itemObject;
_cachedPickupObject.SetActive(false);
// Use helper to set held item (handles data extraction, caching, animator)
SetHeldItemFromObject(itemObject);
itemObject.SetActive(false);
}
public enum CombinationResult
@@ -609,41 +591,43 @@ public class FollowerController : MonoBehaviour, ISaveParticipant
{
_animator.ResetTrigger(CombineTrigger);
newItem = null;
// Validation
if (_cachedPickupObject == null)
{
return CombinationResult.NotApplicable;
}
Pickup pickupB = _cachedPickupObject.GetComponent<Pickup>();
if (pickupA == null || pickupB == null)
{
return CombinationResult.NotApplicable;
}
// Use the InteractionSettings directly instead of GameManager
// Find combination rule
CombinationRule matchingRule = _interactionSettings.GetCombinationRule(pickupA.itemData, pickupB.itemData);
Vector3 spawnPos = pickupA.gameObject.transform.position;
if (matchingRule != null && matchingRule.resultPrefab != null)
{
newItem = Instantiate(matchingRule.resultPrefab, spawnPos, Quaternion.identity);
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);
PlayAnimationStationary("Combine", 10.0f);
PulverIsCombining.Invoke();
return CombinationResult.Successful;
}
if (matchingRule == null || matchingRule.resultPrefab == null)
return CombinationResult.Unsuccessful;
// If no combination found, return Unsuccessful
return CombinationResult.Unsuccessful;
// Execute combination
Vector3 spawnPos = pickupA.gameObject.transform.position;
newItem = Instantiate(matchingRule.resultPrefab, spawnPos, Quaternion.identity);
var resultPickup = newItem.GetComponent<Pickup>();
// Mark items as picked up before disabling (for save system)
pickupA.IsPickedUp = true;
pickupB.IsPickedUp = true;
// Disable instead of destroying immediately so they can save their state
// The save system will mark them as picked up and won't restore them
pickupA.gameObject.SetActive(false);
pickupB.gameObject.SetActive(false);
// Pickup the result (don't drop it!)
TryPickupItem(newItem, resultPickup.itemData, dropItem: false);
// Visual feedback
PlayAnimationStationary("Combine", 10.0f);
PulverIsCombining.Invoke();
return CombinationResult.Successful;
}
/// <summary>
@@ -673,6 +657,10 @@ public class FollowerController : MonoBehaviour, ISaveParticipant
return _cachedPickupObject;
}
/// <summary>
/// Set held item from a GameObject. Extracts Pickup component and sets up visuals.
/// Centralizes held item state management including animator.
/// </summary>
public void SetHeldItemFromObject(GameObject obj)
{
if (obj == null)
@@ -680,11 +668,13 @@ public class FollowerController : MonoBehaviour, ISaveParticipant
ClearHeldItem();
return;
}
var pickup = obj.GetComponent<Pickup>();
if (pickup != null)
{
SetHeldItem(pickup.itemData, pickup.iconRenderer);
_cachedPickupObject = obj;
_animator.SetBool("IsCarrying", true); // Centralized animator management
}
else
{
@@ -692,11 +682,15 @@ public class FollowerController : MonoBehaviour, ISaveParticipant
}
}
/// <summary>
/// Clear the currently held item. Centralizes state cleanup including animator.
/// </summary>
public void ClearHeldItem()
{
_cachedPickupObject = null;
_currentlyHeldItemData = null;
_animator.SetBool("IsCarrying", false);
_animator.SetBool("IsCarrying", false); // Centralized animator management
if (heldObjectRenderer != null)
{
heldObjectRenderer.sprite = null;
@@ -704,44 +698,36 @@ public class FollowerController : MonoBehaviour, ISaveParticipant
}
}
public void DropItem(FollowerController follower, Vector3 position)
/// <summary>
/// Drop the currently held item at the specified position.
/// </summary>
public void DropHeldItemAt(Vector3 position)
{
var item = follower.GetHeldPickupObject();
var item = GetHeldPickupObject();
if (item == null) return;
// Place item in world
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
// Reset pickup state so it can be picked up again
var pickup = item.GetComponent<Pickup>();
if (pickup != null)
{
pickup.ResetPickupState();
}
follower.ClearHeldItem();
_animator.SetBool("IsCarrying", false);
// Optionally: fire event, update UI, etc.
}
public void DropHeldItemAt(Vector3 position)
{
DropItem(this, position);
// Clear held item state (includes animator)
ClearHeldItem();
}
#endregion ItemInteractions
#region ISaveParticipant Implementation
#region Save/Load Lifecycle Hooks
public bool HasBeenRestored => hasBeenRestored;
public string GetSaveId()
{
return "FollowerController";
}
public string SerializeState()
protected override string OnSceneSaveRequested()
{
var saveData = new FollowerSaveData
{
@@ -755,26 +741,24 @@ public class FollowerController : MonoBehaviour, ISaveParticipant
var pickup = _cachedPickupObject.GetComponent<Pickup>();
if (pickup is SaveableInteractable saveable)
{
saveData.heldItemSaveId = saveable.GetSaveId();
saveData.heldItemSaveId = saveable.SaveId;
}
// Save the itemId for build-compatible restoration
if (_currentlyHeldItemData != null)
{
#if UNITY_EDITOR
saveData.heldItemDataAssetPath = UnityEditor.AssetDatabase.GetAssetPath(_currentlyHeldItemData);
#endif
saveData.heldItemDataAssetPath = _currentlyHeldItemData.itemId;
}
}
return JsonUtility.ToJson(saveData);
}
public void RestoreState(string serializedData)
protected override void OnSceneRestoreRequested(string serializedData)
{
if (string.IsNullOrEmpty(serializedData))
{
Logging.Debug("[FollowerController] No saved state to restore");
hasBeenRestored = true;
return;
}
@@ -794,7 +778,6 @@ public class FollowerController : MonoBehaviour, ISaveParticipant
TryRestoreHeldItem(saveData.heldItemSaveId, saveData.heldItemDataAssetPath);
}
hasBeenRestored = true;
Logging.Debug($"[FollowerController] Restored position: {saveData.worldPosition}");
}
}
@@ -806,9 +789,10 @@ public class FollowerController : MonoBehaviour, ISaveParticipant
/// <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.
/// If pickup doesn't exist in the scene (e.g., dynamically spawned combined item),
/// spawns it from the itemData.
/// </summary>
private void TryRestoreHeldItem(string heldItemSaveId, string heldItemDataAssetPath)
private void TryRestoreHeldItem(string heldItemSaveId, string itemDataId)
{
if (_hasRestoredHeldItem)
{
@@ -816,10 +800,30 @@ public class FollowerController : MonoBehaviour, ISaveParticipant
return;
}
// Try to find the pickup immediately
// Try to find the pickup in the scene by SaveId
GameObject heldObject = ItemManager.Instance?.FindPickupBySaveId(heldItemSaveId);
if (heldObject == null)
if (heldObject == null && !string.IsNullOrEmpty(itemDataId))
{
// Item not found in scene - it might be a dynamically spawned combined item
// Try to spawn it from the itemDataId
Logging.Debug($"[FollowerController] Held item not found in scene: {heldItemSaveId}, attempting to spawn from itemId: {itemDataId}");
GameObject prefab = _interactionSettings?.FindPickupPrefabByItemId(itemDataId);
if (prefab != null)
{
// Spawn the item (inactive, since it's being held)
heldObject = Instantiate(prefab, transform.position, Quaternion.identity);
heldObject.SetActive(false);
Logging.Debug($"[FollowerController] Successfully spawned combined item: {itemDataId}");
}
else
{
Logging.Warning($"[FollowerController] Could not find prefab for itemId: {itemDataId}");
return;
}
}
else 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
@@ -828,12 +832,14 @@ public class FollowerController : MonoBehaviour, ISaveParticipant
var pickup = heldObject.GetComponent<Pickup>();
if (pickup == null)
{
Logging.Warning($"[FollowerController] Found object but no Pickup component: {heldItemSaveId}");
Logging.Warning($"[FollowerController] Found/spawned object but no Pickup component: {heldItemSaveId}");
if (heldObject != null)
Destroy(heldObject);
return;
}
// Claim the pickup
TakeOwnership(pickup, heldItemDataAssetPath);
TakeOwnership(pickup, itemDataId);
}
/// <summary>
@@ -854,9 +860,9 @@ public class FollowerController : MonoBehaviour, ISaveParticipant
// Verify this is the expected pickup
if (pickup is SaveableInteractable saveable)
{
if (saveable.GetSaveId() != _expectedHeldItemSaveId)
if (saveable.SaveId != _expectedHeldItemSaveId)
{
Logging.Warning($"[FollowerController] Pickup tried to claim but saveId mismatch: {saveable.GetSaveId()} != {_expectedHeldItemSaveId}");
Logging.Warning($"[FollowerController] Pickup tried to claim but saveId mismatch: {saveable.SaveId} != {_expectedHeldItemSaveId}");
return false;
}
}
@@ -869,28 +875,29 @@ public class FollowerController : MonoBehaviour, ISaveParticipant
/// <summary>
/// Takes ownership of a pickup during restoration. Called by both restoration paths.
/// </summary>
private void TakeOwnership(Pickup pickup, string itemDataAssetPath)
private void TakeOwnership(Pickup pickup, string itemDataIdOrPath)
{
if (_hasRestoredHeldItem)
return; // Already claimed
// Get the item data
// Get the item data from the pickup
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
// Fallback: If pickup doesn't have itemData, log detailed error
if (heldData == null)
{
Logging.Warning($"[FollowerController] Could not get item data for pickup: {pickup.gameObject.name}");
Logging.Warning($"[FollowerController] Pickup {pickup.gameObject.name} has null itemData!");
Logging.Warning($"[FollowerController] Expected itemId: {itemDataIdOrPath}");
Logging.Warning($"[FollowerController] This pickup prefab may be missing its PickupItemData reference.");
return;
}
// Verify itemId matches if we have it (additional safety check)
if (!string.IsNullOrEmpty(itemDataIdOrPath) && heldData.itemId != itemDataIdOrPath)
{
Logging.Warning($"[FollowerController] ItemId mismatch! Pickup has '{heldData.itemId}' but expected '{itemDataIdOrPath}'");
}
// Setup the held item
_cachedPickupObject = pickup.gameObject;
_cachedPickupObject.SetActive(false); // Held items should be hidden
@@ -898,7 +905,7 @@ public class FollowerController : MonoBehaviour, ISaveParticipant
_animator.SetBool("IsCarrying", true);
_hasRestoredHeldItem = true;
Logging.Debug($"[FollowerController] Successfully restored held item: {heldData.itemName}");
Logging.Debug($"[FollowerController] Successfully restored held item: {heldData.itemName} (itemId: {heldData.itemId})");
}
/// <summary>
@@ -910,7 +917,7 @@ public class FollowerController : MonoBehaviour, ISaveParticipant
return FindObjectOfType<FollowerController>();
}
#endregion ISaveParticipant Implementation
#endregion Save/Load Lifecycle Hooks
#if UNITY_EDITOR
void OnDrawGizmos()