# 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
225 lines
7.3 KiB
C#
225 lines
7.3 KiB
C#
using System.Collections;
|
|
using Core;
|
|
using Core.Lifecycle;
|
|
using Core.SaveLoad;
|
|
using Input;
|
|
using Pixelplacement;
|
|
using UI.Core;
|
|
using UnityEngine;
|
|
|
|
namespace UI.Tutorial
|
|
{
|
|
public class DivingTutorial : ManagedBehaviour, ITouchInputConsumer
|
|
{
|
|
public enum ProgressType
|
|
{
|
|
Manual, // Wait for player tap after animation loop
|
|
Auto // Automatically progress after animation loop
|
|
}
|
|
|
|
private StateMachine _stateMachine;
|
|
public bool playTutorial;
|
|
[SerializeField] private ProgressType progressType = ProgressType.Auto;
|
|
|
|
// gating for input until current state's animation finishes first loop
|
|
[SerializeField] private GameObject tapPrompt;
|
|
|
|
private bool _canAcceptInput;
|
|
private Coroutine _waitLoopCoroutine;
|
|
|
|
public override int ManagedAwakePriority => 200; // Tutorial runs late, after other systems
|
|
|
|
protected override void OnManagedAwake()
|
|
{
|
|
// Ensure prompt is hidden initially (even before tutorial initialization)
|
|
if (tapPrompt != null)
|
|
tapPrompt.SetActive(false);
|
|
|
|
if (playTutorial && !SaveLoadManager.Instance.currentSaveData.playedDivingTutorial)
|
|
{
|
|
// TODO: Possibly do it better, but for now just mark tutorial as played immediately
|
|
SaveLoadManager.Instance.currentSaveData.playedDivingTutorial = true;
|
|
// pause the game, hide UI, and register for input overrides
|
|
GameManager.Instance.RequestPause(this);
|
|
UIPageController.Instance.HideAllUI();
|
|
InputManager.Instance.RegisterOverrideConsumer(this);
|
|
|
|
// Setup references
|
|
_stateMachine = GetComponentInChildren<StateMachine>();
|
|
_stateMachine.OnLastStateExited.AddListener(RemoveTutorial);
|
|
|
|
// prepare gating for the initial active state
|
|
SetupInputGateForCurrentState();
|
|
}
|
|
else
|
|
{
|
|
RemoveTutorial();
|
|
}
|
|
}
|
|
|
|
void RemoveTutorial()
|
|
{
|
|
Debug.Log("Remove me!");
|
|
if (_waitLoopCoroutine != null)
|
|
{
|
|
StopCoroutine(_waitLoopCoroutine);
|
|
_waitLoopCoroutine = null;
|
|
}
|
|
|
|
// Unpause, unregister input, and show UI
|
|
InputManager.Instance.UnregisterOverrideConsumer(this);
|
|
UIPageController.Instance.ShowAllUI();
|
|
GameManager.Instance.ReleasePause(this);
|
|
|
|
// hide prompt if present
|
|
if (tapPrompt != null)
|
|
tapPrompt.SetActive(false);
|
|
|
|
Destroy(gameObject);
|
|
}
|
|
|
|
public void OnTap(Vector2 position)
|
|
{
|
|
if (!_canAcceptInput) return; // block taps until allowed
|
|
|
|
// consume this tap and immediately block further taps
|
|
SetInputEnabled(false);
|
|
|
|
// move to next state
|
|
_stateMachine.Next(true);
|
|
|
|
// after the state changes, set up gating for the new active state's animation
|
|
SetupInputGateForCurrentState();
|
|
}
|
|
|
|
public void OnHoldStart(Vector2 position)
|
|
{
|
|
}
|
|
|
|
public void OnHoldMove(Vector2 position)
|
|
{
|
|
}
|
|
|
|
public void OnHoldEnd(Vector2 position)
|
|
{
|
|
}
|
|
|
|
// centralize enabling/disabling input and the tap prompt
|
|
private void SetInputEnabled(bool allow)
|
|
{
|
|
_canAcceptInput = allow;
|
|
if (tapPrompt != null)
|
|
{
|
|
// Only show tap prompt in Manual mode
|
|
tapPrompt.SetActive(allow && progressType == ProgressType.Manual);
|
|
}
|
|
}
|
|
|
|
private void SetupInputGateForCurrentState()
|
|
{
|
|
if (_waitLoopCoroutine != null)
|
|
{
|
|
StopCoroutine(_waitLoopCoroutine);
|
|
_waitLoopCoroutine = null;
|
|
}
|
|
_waitLoopCoroutine = StartCoroutine(WaitForFirstLoopOnActiveState());
|
|
}
|
|
|
|
private IEnumerator WaitForFirstLoopOnActiveState()
|
|
{
|
|
// wait a frame to ensure StateMachine has activated the correct state GameObject
|
|
yield return null;
|
|
|
|
// find the active child under the StateMachine (the current state)
|
|
Transform smTransform = _stateMachine != null ? _stateMachine.transform : transform;
|
|
Transform activeState = null;
|
|
for (int i = 0; i < smTransform.childCount; i++)
|
|
{
|
|
var child = smTransform.GetChild(i);
|
|
if (child.gameObject.activeInHierarchy)
|
|
{
|
|
activeState = child;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (activeState == null)
|
|
{
|
|
// if we can't find an active state, fail open: allow input
|
|
SetInputEnabled(true);
|
|
yield break;
|
|
}
|
|
|
|
// look for a legacy Animation component on the active state
|
|
var anim = activeState.GetComponent<Animation>();
|
|
if (anim == null)
|
|
{
|
|
// no animation to wait for; allow input immediately
|
|
SetInputEnabled(true);
|
|
yield break;
|
|
}
|
|
|
|
// determine a clip/state to observe
|
|
string clipName = anim.clip != null ? anim.clip.name : null;
|
|
AnimationState observedState = null;
|
|
|
|
if (!string.IsNullOrEmpty(clipName))
|
|
{
|
|
observedState = anim[clipName];
|
|
}
|
|
else
|
|
{
|
|
// fallback: take the first enabled state in the Animation
|
|
foreach (AnimationState st in anim)
|
|
{
|
|
observedState = st;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (observedState == null)
|
|
{
|
|
// nothing to observe; allow input
|
|
SetInputEnabled(true);
|
|
yield break;
|
|
}
|
|
|
|
// wait until the animation starts playing the observed clip
|
|
float safetyTimer = 0f;
|
|
while (anim.isActiveAndEnabled && activeState.gameObject.activeInHierarchy && !anim.IsPlaying(observedState.name) && safetyTimer < 2f)
|
|
{
|
|
safetyTimer += Time.deltaTime;
|
|
yield return null;
|
|
}
|
|
|
|
// wait until the first loop completes (normalizedTime >= 1)
|
|
while (anim.isActiveAndEnabled && activeState.gameObject.activeInHierarchy)
|
|
{
|
|
// if state changed (not playing anymore), allow input to avoid deadlock
|
|
if (!anim.IsPlaying(observedState.name)) break;
|
|
|
|
if (observedState.normalizedTime >= 1f)
|
|
{
|
|
break;
|
|
}
|
|
yield return null;
|
|
}
|
|
|
|
// After first loop completes, handle based on progress type
|
|
if (progressType == ProgressType.Auto)
|
|
{
|
|
// Auto mode: immediately progress to next state
|
|
_stateMachine.Next(true);
|
|
SetupInputGateForCurrentState();
|
|
}
|
|
else
|
|
{
|
|
// Manual mode: enable input and wait for player tap
|
|
SetInputEnabled(true);
|
|
}
|
|
|
|
_waitLoopCoroutine = null;
|
|
}
|
|
}
|
|
}
|