424 lines
16 KiB
C#
424 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
|
||
|
||
protected override void OnManagedAwake()
|
||
{
|
||
// Set instance immediately (early initialization)
|
||
_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 OnManagedStart()
|
||
{
|
||
// Subscribe to scene load events from SceneManagerService
|
||
// This must happen in ManagedStart 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}");
|
||
}
|
||
}
|
||
}
|
||
}
|