Files
AppleHillsProduction/Assets/Scripts/Input/InputManager.cs
tschesky e27bb7bfb6 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
2025-11-07 15:38:31 +00:00

426 lines
16 KiB
C#

using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.InputSystem;
using UnityEngine.SceneManagement;
using AppleHills.Core.Settings;
using Core;
using Core.Lifecycle;
namespace Input
{
public enum InputMode
{
Game,
UI,
GameAndUI,
InputDisabled
}
/// <summary>
/// Handles input events and dispatches them to the appropriate ITouchInputConsumer.
/// Supports tap and hold/drag logic, with interactable delegation and debug logging.
/// </summary>
public class InputManager : ManagedBehaviour
{
private const string UiActions = "UI";
private const string GameActions = "PlayerTouch";
private static InputManager _instance;
// Override consumer stack - using a list to support multiple overrides that can be removed in LIFO order
private readonly List<ITouchInputConsumer> _overrideConsumers = new List<ITouchInputConsumer>();
// Track which consumer is handling the current hold operation
private ITouchInputConsumer _activeHoldConsumer;
/// <summary>
/// Singleton instance of the InputManager. No longer creates an instance if one doesn't exist.
/// </summary>
public static InputManager Instance => _instance;
// Settings reference
private IInteractionSettings _interactionSettings;
private PlayerInput playerInput;
private InputAction tapMoveAction;
private InputAction holdMoveAction;
private InputAction positionAction;
private ITouchInputConsumer defaultConsumer;
private bool isHoldActive;
private LogVerbosity _logVerbosity = LogVerbosity.Warning;
public override int ManagedAwakePriority => 25; // Input infrastructure
private new void Awake()
{
base.Awake(); // CRITICAL: Register with LifecycleManager!
// Set instance immediately so it's available before OnManagedAwake() is called
_instance = this;
// Load verbosity settings early
_logVerbosity = DeveloperSettingsProvider.Instance.GetSettings<DebugSettings>().inputLogVerbosity;
// Initialize settings reference early (GameManager sets these up in its Awake)
_interactionSettings = GameManager.GetSettingsObject<IInteractionSettings>();
// Set up PlayerInput component and actions - critical for input to work
playerInput = GetComponent<PlayerInput>();
if (playerInput == null)
{
Debug.LogError("[InputManager] InputManager requires a PlayerInput component attached to the same GameObject.");
return;
}
tapMoveAction = playerInput.actions.FindAction("TapMove", false);
holdMoveAction = playerInput.actions.FindAction("HoldMove", false);
positionAction = playerInput.actions.FindAction("TouchPosition", false);
if (tapMoveAction != null)
tapMoveAction.performed += OnTapMovePerformed;
if (holdMoveAction != null)
{
holdMoveAction.performed += OnHoldMoveStarted;
holdMoveAction.canceled += OnHoldMoveCanceled;
}
// Initialize input mode for current scene
SwitchInputOnSceneLoaded(SceneManager.GetActiveScene().name);
}
protected override void OnManagedAwake()
{
// Subscribe to scene load events from SceneManagerService
// This must happen in ManagedAwake because SceneManagerService instance needs to be set first
if (SceneManagerService.Instance != null)
{
SceneManagerService.Instance.SceneLoadCompleted += OnSceneLoadCompleted;
}
}
/// <summary>
/// Called when any scene finishes loading. Restores input to GameAndUI mode.
/// </summary>
private void OnSceneLoadCompleted(string sceneName)
{
LogDebugMessage($"Scene loaded: {sceneName}, restoring input mode");
SwitchInputOnSceneLoaded(sceneName);
}
protected override void OnDestroy()
{
// Unsubscribe from SceneManagerService events
if (SceneManagerService.Instance != null)
{
SceneManagerService.Instance.SceneLoadCompleted -= OnSceneLoadCompleted;
}
base.OnDestroy();
// Input action cleanup happens automatically
}
private void SwitchInputOnSceneLoaded(string sceneName)
{
if (sceneName.ToLower().Contains("mainmenu"))
{
SetInputMode(InputMode.GameAndUI);
}
else
{
SetInputMode(InputMode.GameAndUI);
}
}
public void SetInputMode(InputMode inputMode)
{
switch (inputMode)
{
case InputMode.UI:
playerInput.actions.FindActionMap(UiActions).Enable();
playerInput.actions.FindActionMap(GameActions).Disable();
break;
case InputMode.Game:
playerInput.actions.FindActionMap(UiActions).Disable();
playerInput.actions.FindActionMap(GameActions).Enable();
break;
case InputMode.GameAndUI:
playerInput.actions.FindActionMap(UiActions).Enable();
playerInput.actions.FindActionMap(GameActions).Enable();
break;
case InputMode.InputDisabled:
playerInput.actions.FindActionMap(UiActions).Disable();
playerInput.actions.FindActionMap(GameActions).Disable();
break;
}
}
void OnDisable()
{
if (tapMoveAction != null)
tapMoveAction.performed -= OnTapMovePerformed;
if (holdMoveAction != null)
{
holdMoveAction.performed -= OnHoldMoveStarted;
holdMoveAction.canceled -= OnHoldMoveCanceled;
}
}
/// <summary>
/// Sets the default ITouchInputConsumer to receive input events.
/// </summary>
public void SetDefaultConsumer(ITouchInputConsumer consumer)
{
defaultConsumer = consumer;
}
/// <summary>
/// Handles tap input, delegates to interactable if present, otherwise to default consumer.
/// </summary>
private void OnTapMovePerformed(InputAction.CallbackContext ctx)
{
Vector2 screenPos = positionAction.ReadValue<Vector2>();
Vector3 worldPos = Camera.main.ScreenToWorldPoint(screenPos);
Vector2 worldPos2D = new Vector2(worldPos.x, worldPos.y);
LogDebugMessage($"TapMove performed at {worldPos2D}");
// First try to delegate to an override consumer if available
if (TryDelegateToOverrideConsumer(screenPos, worldPos2D))
{
LogDebugMessage("Tap delegated to override consumer");
return;
}
// Then try to delegate to any ITouchInputConsumer (UI or world interactable)
if (!TryDelegateToAnyInputConsumer(screenPos, worldPos2D))
{
LogDebugMessage("No input consumer found, forwarding tap to default consumer");
defaultConsumer?.OnTap(worldPos2D);
}
else
{
LogDebugMessage("Tap delegated to input consumer");
}
}
/// <summary>
/// Handles the start of a hold input.
/// </summary>
private void OnHoldMoveStarted(InputAction.CallbackContext ctx)
{
isHoldActive = true;
Vector2 screenPos = positionAction.ReadValue<Vector2>();
Vector3 worldPos = Camera.main.ScreenToWorldPoint(screenPos);
Vector2 worldPos2D = new Vector2(worldPos.x, worldPos.y);
LogDebugMessage($"HoldMove started at {worldPos2D}");
// First check for override consumers
if (_overrideConsumers.Count > 0)
{
_activeHoldConsumer = _overrideConsumers[_overrideConsumers.Count - 1];
LogDebugMessage($"Hold delegated to override consumer: {_activeHoldConsumer}");
_activeHoldConsumer.OnHoldStart(worldPos2D);
return;
}
// If no override consumers, use default consumer
_activeHoldConsumer = defaultConsumer;
defaultConsumer?.OnHoldStart(worldPos2D);
}
/// <summary>
/// Handles the end of a hold input.
/// </summary>
private void OnHoldMoveCanceled(InputAction.CallbackContext ctx)
{
if (!isHoldActive) return;
isHoldActive = false;
Vector2 screenPos = positionAction.ReadValue<Vector2>();
Vector3 worldPos = Camera.main.ScreenToWorldPoint(screenPos);
Vector2 worldPos2D = new Vector2(worldPos.x, worldPos.y);
LogDebugMessage($"HoldMove canceled at {worldPos2D}");
// Notify the active hold consumer that the hold has ended
_activeHoldConsumer?.OnHoldEnd(worldPos2D);
_activeHoldConsumer = null;
}
/// <summary>
/// Continuously updates hold move input while active.
/// </summary>
void Update()
{
if (isHoldActive && holdMoveAction != null && holdMoveAction.phase == InputActionPhase.Performed)
{
Vector2 screenPos = positionAction.ReadValue<Vector2>();
Vector3 worldPos = Camera.main.ScreenToWorldPoint(screenPos);
Vector2 worldPos2D = new Vector2(worldPos.x, worldPos.y);
// LogDebugMessage($"HoldMove update at {worldPos2D}");
// Send hold move updates to the active hold consumer
_activeHoldConsumer?.OnHoldMove(worldPos2D);
}
}
/// <summary>
/// Attempts to delegate a tap to any ITouchInputConsumer at the given position.
/// Checks both UI elements and world interactables.
/// </summary>
private bool TryDelegateToAnyInputConsumer(Vector2 screenPos, Vector2 worldPos)
{
// First check if we hit a UI element implementing ITouchInputConsumer
if (TryDelegateToUIInputConsumer(screenPos))
{
return true;
}
// If no UI element with ITouchInputConsumer, try world interactables
return TryDelegateToInteractable(worldPos);
}
/// <summary>
/// Attempts to delegate a tap to a UI element implementing ITouchInputConsumer.
/// </summary>
private bool TryDelegateToUIInputConsumer(Vector2 screenPos)
{
// Check for UI elements under the pointer
var eventData = new PointerEventData(EventSystem.current)
{
position = screenPos
};
var results = new System.Collections.Generic.List<RaycastResult>();
EventSystem.current.RaycastAll(eventData, results);
foreach (var result in results)
{
// Try to get ITouchInputConsumer component
var consumer = result.gameObject.GetComponent<ITouchInputConsumer>();
if (consumer == null)
{
consumer = result.gameObject.GetComponentInParent<ITouchInputConsumer>();
}
if (consumer != null)
{
LogDebugMessage($"Delegating tap to UI consumer at {screenPos} (GameObject: {result.gameObject.name})");
consumer.OnTap(screenPos);
return true;
}
}
return false;
}
/// <summary>
/// Attempts to delegate a tap to an interactable at the given world position.
/// Traces on the "Interactable" channel and logs detailed info.
/// </summary>
private bool TryDelegateToInteractable(Vector2 worldPos)
{
// First try with the interaction layer mask if available
if (_interactionSettings != null)
{
LayerMask mask = _interactionSettings.InteractableLayerMask;
Collider2D hitWithMask = Physics2D.OverlapPoint(worldPos, mask);
if (hitWithMask != null)
{
var consumer = hitWithMask.GetComponent<ITouchInputConsumer>();
if (consumer == null)
{
consumer = hitWithMask.GetComponentInParent<ITouchInputConsumer>();
}
if (consumer != null)
{
LogDebugMessage($"Delegating tap to consumer at {worldPos} (GameObject: {hitWithMask.gameObject.name})");
consumer.OnTap(worldPos);
return true;
}
}
}
// If no hit with the mask or no settings available, try with all colliders
Collider2D hit = Physics2D.OverlapPoint(worldPos);
if (hit != null)
{
var consumer = hit.GetComponent<ITouchInputConsumer>();
if (consumer != null)
{
LogDebugMessage($"Delegating tap to consumer at {worldPos} (GameObject: {hit.gameObject.name})");
consumer.OnTap(worldPos);
return true;
}
LogDebugMessage($"Collider2D hit at {worldPos} (GameObject: {hit.gameObject.name}), but no ITouchInputConsumer found.");
}
else
{
LogDebugMessage($"No Collider2D found at {worldPos} for interactable delegation.");
}
return false;
}
/// <summary>
/// Registers an override consumer to receive input events.
/// The most recently registered consumer will receive input first.
/// </summary>
public void RegisterOverrideConsumer(ITouchInputConsumer consumer)
{
if (consumer == null || _overrideConsumers.Contains(consumer))
return;
_overrideConsumers.Add(consumer);
LogDebugMessage($"Override consumer registered: {consumer}");
}
/// <summary>
/// Unregisters an override consumer, removing it from the input event delegation.
/// </summary>
public void UnregisterOverrideConsumer(ITouchInputConsumer consumer)
{
if (consumer == null || !_overrideConsumers.Contains(consumer))
return;
// If this is the active hold consumer, reset it
if (_activeHoldConsumer == consumer)
{
_activeHoldConsumer = null;
}
_overrideConsumers.Remove(consumer);
LogDebugMessage($"Override consumer unregistered: {consumer}");
}
/// <summary>
/// Clears all registered override consumers.
/// </summary>
public void ClearOverrideConsumers()
{
_activeHoldConsumer = null;
_overrideConsumers.Clear();
LogDebugMessage("All override consumers cleared.");
}
/// <summary>
/// Attempts to delegate a tap to the topmost registered override consumer, if any.
/// </summary>
private bool TryDelegateToOverrideConsumer(Vector2 screenPos, Vector2 worldPos)
{
if (_overrideConsumers.Count == 0)
return false;
// Get the topmost override consumer (last registered)
var consumer = _overrideConsumers[_overrideConsumers.Count - 1];
LogDebugMessage($"Delegating tap to override consumer at {worldPos} (GameObject: {consumer})");
consumer.OnTap(worldPos);
return true;
}
private void LogDebugMessage(string message)
{
if (_logVerbosity <= LogVerbosity.Debug)
{
Logging.Debug($"[InputManager] {message}");
}
}
}
}