Introduce input modes and Pause Menu (#8)

- Add input mode switching to the Input Manager
- Automatically set input mode on scene load - UI for MainMenu and GameAndUI for other scenes
- Add PauseMenu prefab, auto-loaded via the boostrap system
- Automatically control PauseMenu visibility based on current scene

Co-authored-by: AlexanderT <alexander@foolhardyhorizons.com>
Co-authored-by: Michal Pikulski <michal.a.pikulski@gmail.com>
Reviewed-on: #8
This commit is contained in:
2025-09-25 10:06:51 +00:00
parent 63cb3f1a8c
commit e878a32263
16 changed files with 3950 additions and 203 deletions

View File

@@ -1,175 +1,240 @@
using UnityEngine;
using System;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.InputSystem;
using UnityEngine.SceneManagement;
/// <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 : MonoBehaviour
namespace Input
{
private static InputManager _instance;
private static bool _isQuitting = false;
public static InputManager Instance
public enum InputMode
{
get
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 : MonoBehaviour
{
private const string UiActions = "UI";
private const string GameActions = "PlayerTouch";
private static InputManager _instance;
private static bool _isQuitting = false;
public static InputManager Instance
{
if (_instance == null && Application.isPlaying && !_isQuitting)
get
{
_instance = FindAnyObjectByType<InputManager>();
if (_instance == null)
if (_instance == null && Application.isPlaying && !_isQuitting)
{
var go = new GameObject("InputManager");
_instance = go.AddComponent<InputManager>();
// DontDestroyOnLoad(go);
_instance = FindAnyObjectByType<InputManager>();
if (_instance == null)
{
var go = new GameObject("InputManager");
_instance = go.AddComponent<InputManager>();
// DontDestroyOnLoad(go);
}
}
return _instance;
}
return _instance;
}
}
private PlayerInput playerInput;
private InputAction tapMoveAction;
private InputAction holdMoveAction;
private InputAction positionAction;
private ITouchInputConsumer defaultConsumer;
private bool isHoldActive;
private PlayerInput playerInput;
private InputAction tapMoveAction;
private InputAction holdMoveAction;
private InputAction positionAction;
private ITouchInputConsumer defaultConsumer;
private bool isHoldActive;
void Awake()
{
_instance = this;
// DontDestroyOnLoad(gameObject);
playerInput = GetComponent<PlayerInput>();
if (playerInput == null)
void Awake()
{
Debug.LogError("[InputManager] InputManager requires a PlayerInput component attached to the same GameObject.");
return;
_instance = this;
// DontDestroyOnLoad(gameObject);
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);
}
tapMoveAction = playerInput.actions.FindAction("TapMove", false);
holdMoveAction = playerInput.actions.FindAction("HoldMove", false);
positionAction = playerInput.actions.FindAction("TouchPosition", false);
}
void OnEnable()
{
if (tapMoveAction != null)
tapMoveAction.performed += OnTapMovePerformed;
if (holdMoveAction != null)
private void Start()
{
holdMoveAction.performed += OnHoldMoveStarted;
holdMoveAction.canceled += OnHoldMoveCanceled;
SceneManagerService.Instance.SceneLoadCompleted += SwitchInputOnSceneLoaded;
SwitchInputOnSceneLoaded(SceneManager.GetActiveScene().name);
}
}
void OnDisable()
{
if (tapMoveAction != null)
tapMoveAction.performed -= OnTapMovePerformed;
if (holdMoveAction != null)
private void SwitchInputOnSceneLoaded(string sceneName)
{
holdMoveAction.performed -= OnHoldMoveStarted;
holdMoveAction.canceled -= OnHoldMoveCanceled;
if (sceneName.ToLower().Contains("mainmenu"))
{
Debug.Log("[InputManager] SwitchInputOnSceneLoaded - Setting InputMode to UI for MainMenu");
SetInputMode(InputMode.UI);
}
else
{
Debug.Log("[InputManager] SwitchInputOnSceneLoaded - Setting InputMode to PlayerTouch");
SetInputMode(InputMode.GameAndUI);
}
}
}
void OnApplicationQuit()
{
_isQuitting = true;
}
/// <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);
Debug.Log($"[InputManager] TapMove performed at {worldPos2D}");
if (!TryDelegateToInteractable(worldPos2D))
public void SetInputMode(InputMode inputMode)
{
Debug.Log("[InputManager] No interactable found, forwarding tap to default consumer");
defaultConsumer?.OnTap(worldPos2D);
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;
}
}
else
void OnEnable()
{
Debug.Log("[InputManager] Tap delegated to interactable");
if (tapMoveAction != null)
tapMoveAction.performed += OnTapMovePerformed;
if (holdMoveAction != null)
{
holdMoveAction.performed += OnHoldMoveStarted;
holdMoveAction.canceled += OnHoldMoveCanceled;
}
}
}
/// <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);
Debug.Log($"[InputManager] HoldMove started at {worldPos2D}");
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);
Debug.Log($"[InputManager] HoldMove canceled at {worldPos2D}");
defaultConsumer?.OnHoldEnd(worldPos2D);
}
/// <summary>
/// Continuously updates hold move input while active.
/// </summary>
void Update()
{
if (isHoldActive && holdMoveAction != null && holdMoveAction.phase == InputActionPhase.Performed)
void OnDisable()
{
if (tapMoveAction != null)
tapMoveAction.performed -= OnTapMovePerformed;
if (holdMoveAction != null)
{
holdMoveAction.performed -= OnHoldMoveStarted;
holdMoveAction.canceled -= OnHoldMoveCanceled;
}
}
void OnApplicationQuit()
{
_isQuitting = true;
}
/// <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)
{
if (EventSystem.current.IsPointerOverGameObject())
{
return;
}
Vector2 screenPos = positionAction.ReadValue<Vector2>();
Vector3 worldPos = Camera.main.ScreenToWorldPoint(screenPos);
Vector2 worldPos2D = new Vector2(worldPos.x, worldPos.y);
// Debug.Log($"[InputManager] HoldMove update at {worldPos2D}");
defaultConsumer?.OnHoldMove(worldPos2D);
}
}
/// <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)
{
LayerMask mask = GameManager.Instance != null ? GameManager.Instance.InteractableLayerMask : -1;
Collider2D hit = Physics2D.OverlapPoint(worldPos, mask);
if (hit != null)
{
var consumer = hit.GetComponent<ITouchInputConsumer>();
if (consumer != null)
Debug.Log($"[InputManager] TapMove performed at {worldPos2D}");
if (!TryDelegateToInteractable(worldPos2D))
{
Debug.unityLogger.Log("Interactable", $"[InputManager] Delegating tap to consumer at {worldPos} (GameObject: {hit.gameObject.name})");
consumer.OnTap(worldPos);
return true;
Debug.Log("[InputManager] No interactable found, forwarding tap to default consumer");
defaultConsumer?.OnTap(worldPos2D);
}
else
{
Debug.Log("[InputManager] Tap delegated to interactable");
}
Debug.unityLogger.Log("Interactable", $"[InputManager] Collider2D hit at {worldPos} (GameObject: {hit.gameObject.name}), but no ITouchInputConsumer found.");
}
else
/// <summary>
/// Handles the start of a hold input.
/// </summary>
private void OnHoldMoveStarted(InputAction.CallbackContext ctx)
{
Debug.unityLogger.Log("Interactable", $"[InputManager] No Collider2D found at {worldPos} for interactable delegation.");
isHoldActive = true;
Vector2 screenPos = positionAction.ReadValue<Vector2>();
Vector3 worldPos = Camera.main.ScreenToWorldPoint(screenPos);
Vector2 worldPos2D = new Vector2(worldPos.x, worldPos.y);
Debug.Log($"[InputManager] HoldMove started at {worldPos2D}");
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);
Debug.Log($"[InputManager] HoldMove canceled at {worldPos2D}");
defaultConsumer?.OnHoldEnd(worldPos2D);
}
/// <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);
// Debug.Log($"[InputManager] HoldMove update at {worldPos2D}");
defaultConsumer?.OnHoldMove(worldPos2D);
}
}
/// <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)
{
LayerMask mask = GameManager.Instance != null ? GameManager.Instance.InteractableLayerMask : -1;
Collider2D hit = Physics2D.OverlapPoint(worldPos, mask);
if (hit != null)
{
var consumer = hit.GetComponent<ITouchInputConsumer>();
if (consumer != null)
{
Debug.unityLogger.Log("Interactable", $"[InputManager] Delegating tap to consumer at {worldPos} (GameObject: {hit.gameObject.name})");
consumer.OnTap(worldPos);
return true;
}
Debug.unityLogger.Log("Interactable", $"[InputManager] Collider2D hit at {worldPos} (GameObject: {hit.gameObject.name}), but no ITouchInputConsumer found.");
}
else
{
Debug.unityLogger.Log("Interactable", $"[InputManager] No Collider2D found at {worldPos} for interactable delegation.");
}
return false;
}
return false;
}
}

View File

@@ -96,6 +96,9 @@ public class LevelSwitch : MonoBehaviour
// Setup menu with data and callbacks
menu.Setup(switchData, OnMenuConfirm, OnMenuCancel);
_isActive = false; // Prevent re-triggering until menu is closed
// Switch input mode to UI only
InputManager.Instance.SetInputMode(InputMode.UI);
}
private async void OnMenuConfirm()

View File

@@ -1,5 +1,6 @@
using UnityEngine;
using AppleHills.Core.Settings;
using Input;
namespace Minigames.DivingForPictures
{

8
Assets/Scripts/Test.meta Normal file
View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 23fdb60880dd05846a6e0d3681a02242
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,142 @@
using System;
using UnityEngine;
using UnityEngine.SceneManagement;
using Input;
namespace UI
{
public class PauseMenu : MonoBehaviour
{
[Header("UI References")]
[SerializeField] private GameObject pauseMenuPanel;
[SerializeField] private GameObject pauseButton;
private void Start()
{
// Subscribe to scene loaded events
SceneManagerService.Instance.SceneLoadCompleted += SetPauseMenuByLevel;
// Set initial state based on current scene
SetPauseMenuByLevel(SceneManager.GetActiveScene().name);
// Initialize pause menu state
HidePauseMenu();
}
private void OnDestroy()
{
// Unsubscribe when destroyed
if (SceneManagerService.Instance != null)
{
SceneManagerService.Instance.SceneLoadCompleted -= SetPauseMenuByLevel;
}
}
/// <summary>
/// Sets the pause menu game object active or inactive based on the current level
/// </summary>
/// <param name="levelName">The name of the level/scene</param>
public void SetPauseMenuByLevel(string levelName)
{
if (string.IsNullOrEmpty(levelName))
return;
bool isMainMenu = levelName.ToLower().Contains("mainmenu");
gameObject.SetActive(!isMainMenu);
if(!isMainMenu)
HidePauseMenu(); // Ensure menu is hidden when switching to a game level
Debug.Log($"[PauseMenu] Setting pause menu active: {!isMainMenu} for scene: {levelName}");
}
/// <summary>
/// Shows the pause menu and hides the pause button. Sets input mode to UI.
/// </summary>
public void ShowPauseMenu()
{
if (pauseMenuPanel != null)
pauseMenuPanel.SetActive(true);
if (pauseButton != null)
pauseButton.SetActive(false);
// Change input mode to UI when menu is open
InputManager.Instance.SetInputMode(InputMode.UI);
}
/// <summary>
/// Hides the pause menu and shows the pause button. Sets input mode to Game.
/// </summary>
public void HidePauseMenu()
{
if (pauseMenuPanel != null)
pauseMenuPanel.SetActive(false);
if (pauseButton != null)
pauseButton.SetActive(true);
// Change input mode back to Game when menu is closed
InputManager.Instance.SetInputMode(InputMode.Game);
}
/// <summary>
/// Resumes the game by hiding the pause menu.
/// </summary>
public void ResumeGame()
{
HidePauseMenu();
}
/// <summary>
/// Exits to the main menu scene.
/// </summary>
public async void ExitToMainMenu()
{
// Replace with the actual scene name as set in Build Settings
var progress = new Progress<float>(p => Debug.Log($"Loading progress: {p * 100:F0}%"));
await SceneManagerService.Instance.SwitchSceneAsync("MainMenu", progress);
}
/// <summary>
/// Exits the application.
/// </summary>
public void ExitGame()
{
#if UNITY_EDITOR
UnityEditor.EditorApplication.isPlaying = false;
#else
Application.Quit();
#endif
}
/// <summary>
/// Loads a level based on the selection from a dropdown menu.
/// Connect this to a Dropdown's onValueChanged event and pass the selected option text.
/// </summary>
/// <param name="levelSelection">The selected level name or identifier from the dropdown</param>
public async void LoadLevel(int levelSelection)
{
// Hide the pause menu before loading a new level
HidePauseMenu();
// Replace with the actual scene name as set in Build Settings
var progress = new Progress<float>(p => Debug.Log($"Loading progress: {p * 100:F0}%"));
switch (levelSelection)
{
case 0:
await SceneManagerService.Instance.SwitchSceneAsync("MainMenu", progress);
break;
case 1:
await SceneManagerService.Instance.SwitchSceneAsync("AppleHillsOverworld", progress);
break;
case 2:
await SceneManagerService.Instance.SwitchSceneAsync("Quarry", progress);
break;
case 3:
await SceneManagerService.Instance.SwitchSceneAsync("DivingForPictures", progress);
break;
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: cb36c2845dc855a4c980ef9dec6ca127