476 lines
16 KiB
C#
476 lines
16 KiB
C#
|
using System.Collections.Generic;
|
||
|
using UnityEngine;
|
||
|
using UnityEngine.EventSystems;
|
||
|
using UnityEngine.InputSystem;
|
||
|
using UnityEngine.UI;
|
||
|
|
||
|
#if UNITY_EDITOR
|
||
|
using UnityEditor;
|
||
|
using UnityEditorInternal;
|
||
|
#endif
|
||
|
|
||
|
public class UIvsGameInputHandler : MonoBehaviour
|
||
|
{
|
||
|
public Text statusBarText;
|
||
|
public GameObject inGameUI;
|
||
|
public GameObject mainMenuUI;
|
||
|
public GameObject menuButton;
|
||
|
public GameObject firstButtonInMainMenu;
|
||
|
public GameObject firstNavigationSelection;
|
||
|
[Space]
|
||
|
public PlayerInput playerInput;
|
||
|
public GameObject projectile;
|
||
|
|
||
|
[Space]
|
||
|
[Tooltip("Multiplier for Pointer.delta values when adding to rotation.")]
|
||
|
public float m_MouseLookSensitivity = 0.1f;
|
||
|
[Tooltip("Rotation per second with fully actuated Gamepad/joystick stick.")]
|
||
|
public float m_GamepadLookSpeed = 10f;
|
||
|
|
||
|
private bool m_OpenMenuActionTriggered;
|
||
|
private bool m_ResetCameraActionTriggered;
|
||
|
private bool m_FireActionTriggered;
|
||
|
internal bool m_UIEngaged;
|
||
|
|
||
|
private Vector2 m_Rotation;
|
||
|
private InputAction m_LookEngageAction;
|
||
|
private InputAction m_LookAction;
|
||
|
private InputAction m_CancelAction;
|
||
|
private InputAction m_UIEngageAction;
|
||
|
private GameObject m_LastNavigationSelection;
|
||
|
|
||
|
private Mouse m_Mouse;
|
||
|
private Vector2? m_MousePositionToWarpToAfterCursorUnlock;
|
||
|
|
||
|
internal enum State
|
||
|
{
|
||
|
InGame,
|
||
|
InGameControllingCamera,
|
||
|
InMenu,
|
||
|
}
|
||
|
|
||
|
internal State m_State;
|
||
|
|
||
|
internal enum ControlStyle
|
||
|
{
|
||
|
None,
|
||
|
KeyboardMouse,
|
||
|
Touch,
|
||
|
GamepadJoystick,
|
||
|
}
|
||
|
|
||
|
internal ControlStyle m_ControlStyle;
|
||
|
|
||
|
public void OnEnable()
|
||
|
{
|
||
|
// By default, hide menu and show game UI.
|
||
|
inGameUI.SetActive(true);
|
||
|
mainMenuUI.SetActive(false);
|
||
|
menuButton.SetActive(false);
|
||
|
|
||
|
// Look up InputActions on the player so we don't have to do this over and over.
|
||
|
m_LookEngageAction = playerInput.actions["LookEngage"];
|
||
|
m_LookAction = playerInput.actions["Look"];
|
||
|
m_CancelAction = playerInput.actions["UI/Cancel"];
|
||
|
m_UIEngageAction = playerInput.actions["UIEngage"];
|
||
|
|
||
|
m_State = State.InGame;
|
||
|
}
|
||
|
|
||
|
// This is called when PlayerInput updates the controls bound to its InputActions.
|
||
|
public void OnControlsChanged()
|
||
|
{
|
||
|
// We could determine the types of controls we have from the names of the control schemes or their
|
||
|
// contents. However, a way that is both easier and more robust is to simply look at the kind of
|
||
|
// devices we have assigned to us. We do not support mixed models this way but this does correspond
|
||
|
// to the limitations of the current control code.
|
||
|
|
||
|
if (playerInput.GetDevice<Touchscreen>() != null) // Note that Touchscreen is also a Pointer so check this first.
|
||
|
m_ControlStyle = ControlStyle.Touch;
|
||
|
else if (playerInput.GetDevice<Pointer>() != null)
|
||
|
m_ControlStyle = ControlStyle.KeyboardMouse;
|
||
|
else if (playerInput.GetDevice<Gamepad>() != null || playerInput.GetDevice<Joystick>() != null)
|
||
|
m_ControlStyle = ControlStyle.GamepadJoystick;
|
||
|
else
|
||
|
Debug.LogError("Control scheme not recognized: " + playerInput.currentControlScheme);
|
||
|
|
||
|
m_Mouse = default;
|
||
|
m_MousePositionToWarpToAfterCursorUnlock = default;
|
||
|
|
||
|
// Enable button for main menu depending on whether we use touch or not.
|
||
|
// With kb&mouse and gamepad, not necessary but with touch, we have no "Cancel" control.
|
||
|
menuButton.SetActive(m_ControlStyle == ControlStyle.Touch);
|
||
|
|
||
|
// If we're using navigation-style input, start with UI control disengaged.
|
||
|
if (m_ControlStyle == ControlStyle.GamepadJoystick)
|
||
|
SetUIEngaged(false);
|
||
|
|
||
|
RepaintInspector();
|
||
|
}
|
||
|
|
||
|
public void Update()
|
||
|
{
|
||
|
switch (m_State)
|
||
|
{
|
||
|
case State.InGame:
|
||
|
{
|
||
|
if (m_OpenMenuActionTriggered)
|
||
|
{
|
||
|
m_State = State.InMenu;
|
||
|
|
||
|
// Bring up main menu.
|
||
|
inGameUI.SetActive(false);
|
||
|
mainMenuUI.SetActive(true);
|
||
|
|
||
|
// Disable gameplay inputs.
|
||
|
playerInput.DeactivateInput();
|
||
|
|
||
|
// Select topmost button.
|
||
|
EventSystem.current.SetSelectedGameObject(firstButtonInMainMenu);
|
||
|
}
|
||
|
|
||
|
var pointerIsOverUI = IsPointerOverUI();
|
||
|
if (pointerIsOverUI)
|
||
|
break;
|
||
|
|
||
|
if (m_ResetCameraActionTriggered)
|
||
|
transform.rotation = default;
|
||
|
|
||
|
// When using a pointer-based control scheme, we engage camera look explicitly.
|
||
|
if (m_ControlStyle != ControlStyle.GamepadJoystick && m_LookEngageAction.WasPressedThisFrame() && IsPointerInsideScreen())
|
||
|
EngageCameraControl();
|
||
|
|
||
|
// With gamepad/joystick, we can freely rotate the camera at any time.
|
||
|
if (m_ControlStyle == ControlStyle.GamepadJoystick)
|
||
|
ProcessCameraLook();
|
||
|
|
||
|
if (m_FireActionTriggered)
|
||
|
Fire();
|
||
|
|
||
|
break;
|
||
|
}
|
||
|
|
||
|
case State.InGameControllingCamera:
|
||
|
|
||
|
if (m_ResetCameraActionTriggered && !IsPointerOverUI())
|
||
|
transform.rotation = default;
|
||
|
|
||
|
if (m_FireActionTriggered && !IsPointerOverUI())
|
||
|
Fire();
|
||
|
|
||
|
// Rotate camera.
|
||
|
ProcessCameraLook();
|
||
|
|
||
|
// Keep track of distance we travel with the mouse while in mouse lock so
|
||
|
// that when we unlock, we can jump to a position that feels "right".
|
||
|
if (m_Mouse != null)
|
||
|
m_MousePositionToWarpToAfterCursorUnlock = m_MousePositionToWarpToAfterCursorUnlock.Value + m_Mouse.delta.ReadValue();
|
||
|
|
||
|
if (m_CancelAction.WasPressedThisFrame() || !m_LookEngageAction.IsPressed())
|
||
|
DisengageCameraControl();
|
||
|
|
||
|
break;
|
||
|
|
||
|
case State.InMenu:
|
||
|
|
||
|
if (m_CancelAction.WasPressedThisFrame())
|
||
|
OnContinueClicked();
|
||
|
|
||
|
break;
|
||
|
}
|
||
|
|
||
|
m_ResetCameraActionTriggered = default;
|
||
|
m_OpenMenuActionTriggered = default;
|
||
|
m_FireActionTriggered = default;
|
||
|
}
|
||
|
|
||
|
private void ProcessCameraLook()
|
||
|
{
|
||
|
var rotate = m_LookAction.ReadValue<Vector2>();
|
||
|
if (!(rotate.sqrMagnitude > 0.01))
|
||
|
return;
|
||
|
|
||
|
// For gamepad and joystick, we rotate continuously based on stick actuation.
|
||
|
float rotateScaleFactor;
|
||
|
if (m_ControlStyle == ControlStyle.GamepadJoystick)
|
||
|
rotateScaleFactor = m_GamepadLookSpeed * Time.deltaTime;
|
||
|
else
|
||
|
rotateScaleFactor = m_MouseLookSensitivity;
|
||
|
|
||
|
m_Rotation.y += rotate.x * rotateScaleFactor;
|
||
|
m_Rotation.x = Mathf.Clamp(m_Rotation.x - rotate.y * rotateScaleFactor, -89, 89);
|
||
|
transform.localEulerAngles = m_Rotation;
|
||
|
}
|
||
|
|
||
|
private void EngageCameraControl()
|
||
|
{
|
||
|
// With a mouse, it's annoying to always end up with the pointer centered in the middle of
|
||
|
// the screen after we come out of a cursor lock. So, what we do is we simply remember where
|
||
|
// the cursor was when we locked and then warp the mouse back to that position after the cursor
|
||
|
// lock is released.
|
||
|
m_Mouse = playerInput.GetDevice<Mouse>();
|
||
|
m_MousePositionToWarpToAfterCursorUnlock = m_Mouse?.position.ReadValue();
|
||
|
|
||
|
Cursor.lockState = CursorLockMode.Locked;
|
||
|
|
||
|
m_State = State.InGameControllingCamera;
|
||
|
|
||
|
RepaintInspector();
|
||
|
}
|
||
|
|
||
|
private void DisengageCameraControl()
|
||
|
{
|
||
|
Cursor.lockState = CursorLockMode.None;
|
||
|
|
||
|
if (m_MousePositionToWarpToAfterCursorUnlock != null)
|
||
|
m_Mouse?.WarpCursorPosition(m_MousePositionToWarpToAfterCursorUnlock.Value);
|
||
|
|
||
|
m_State = State.InGame;
|
||
|
|
||
|
RepaintInspector();
|
||
|
}
|
||
|
|
||
|
public void OnTopLeftClicked()
|
||
|
{
|
||
|
statusBarText.text = "'Top Left' button clicked";
|
||
|
}
|
||
|
|
||
|
public void OnBottomLeftClicked()
|
||
|
{
|
||
|
statusBarText.text = "'Bottom Left' button clicked";
|
||
|
}
|
||
|
|
||
|
public void OnTopRightClicked()
|
||
|
{
|
||
|
statusBarText.text = "'Top Right' button clicked";
|
||
|
}
|
||
|
|
||
|
public void OnBottomRightClicked()
|
||
|
{
|
||
|
statusBarText.text = "'Bottom Right' button clicked";
|
||
|
}
|
||
|
|
||
|
public void OnMenuClicked()
|
||
|
{
|
||
|
m_OpenMenuActionTriggered = true;
|
||
|
}
|
||
|
|
||
|
public void OnContinueClicked()
|
||
|
{
|
||
|
mainMenuUI.SetActive(false);
|
||
|
inGameUI.SetActive(true);
|
||
|
|
||
|
// Reenable gameplay inputs.
|
||
|
playerInput.ActivateInput();
|
||
|
|
||
|
m_State = State.InGame;
|
||
|
|
||
|
RepaintInspector();
|
||
|
}
|
||
|
|
||
|
public void OnExitClicked()
|
||
|
{
|
||
|
#if UNITY_EDITOR
|
||
|
EditorApplication.ExitPlaymode();
|
||
|
#else
|
||
|
Application.Quit();
|
||
|
#endif
|
||
|
}
|
||
|
|
||
|
public void OnMenu(InputAction.CallbackContext context)
|
||
|
{
|
||
|
if (context.performed)
|
||
|
m_OpenMenuActionTriggered = true;
|
||
|
}
|
||
|
|
||
|
public void OnResetCamera(InputAction.CallbackContext context)
|
||
|
{
|
||
|
if (context.performed)
|
||
|
m_ResetCameraActionTriggered = true;
|
||
|
}
|
||
|
|
||
|
public void OnUIEngage(InputAction.CallbackContext context)
|
||
|
{
|
||
|
if (!context.performed)
|
||
|
return;
|
||
|
|
||
|
// From here, we could also do things such as showing UI that we only
|
||
|
// have up while the UI is engaged. For example, the same approach as
|
||
|
// here could be used to display a radial selection dials for items.
|
||
|
|
||
|
SetUIEngaged(!m_UIEngaged);
|
||
|
}
|
||
|
|
||
|
private void SetUIEngaged(bool value)
|
||
|
{
|
||
|
if (value)
|
||
|
{
|
||
|
playerInput.actions.FindActionMap("UI").Enable();
|
||
|
SetPlayerActionsEnabled(false);
|
||
|
|
||
|
// Select the GO that was selected last time.
|
||
|
if (m_LastNavigationSelection == null)
|
||
|
m_LastNavigationSelection = firstNavigationSelection;
|
||
|
EventSystem.current.SetSelectedGameObject(m_LastNavigationSelection);
|
||
|
}
|
||
|
else
|
||
|
{
|
||
|
m_LastNavigationSelection = EventSystem.current.currentSelectedGameObject; // If this happens to be null, we will automatically pick up firstNavigationSelection again.
|
||
|
EventSystem.current.SetSelectedGameObject(null);
|
||
|
|
||
|
playerInput.actions.FindActionMap("UI").Disable();
|
||
|
SetPlayerActionsEnabled(true);
|
||
|
}
|
||
|
|
||
|
m_UIEngaged = value;
|
||
|
|
||
|
RepaintInspector();
|
||
|
}
|
||
|
|
||
|
// Enable/disable every in-game action other than the UI toggle.
|
||
|
private void SetPlayerActionsEnabled(bool value)
|
||
|
{
|
||
|
var actions = playerInput.actions.FindActionMap("Player");
|
||
|
foreach (var action in actions)
|
||
|
{
|
||
|
if (action == m_UIEngageAction)
|
||
|
continue;
|
||
|
|
||
|
if (value)
|
||
|
action.Enable();
|
||
|
else
|
||
|
action.Disable();
|
||
|
}
|
||
|
}
|
||
|
|
||
|
// There's two different approaches taken here. The first OnFire() just does the same as the action
|
||
|
// callbacks above and just sets some state to leave action responses to Update().
|
||
|
// The second OnFire() puts the response logic directly inside the callback.
|
||
|
|
||
|
#if false
|
||
|
|
||
|
public void OnFire(InputAction.CallbackContext context)
|
||
|
{
|
||
|
if (context.performed)
|
||
|
m_FireActionTriggered = true;
|
||
|
}
|
||
|
|
||
|
#else
|
||
|
|
||
|
public void OnFire(InputAction.CallbackContext context)
|
||
|
{
|
||
|
// For this action, let's try something different. Let's say we want to trigger a response
|
||
|
// right away every time the "fire" action triggers. Theoretically, this would allow us
|
||
|
// to correctly respond even if there is multiple activations in a single frame. In practice,
|
||
|
// this will realistically only happen with low framerates (and even then it can be questionable
|
||
|
// whether we want to respond this way).
|
||
|
|
||
|
if (!context.performed)
|
||
|
return;
|
||
|
|
||
|
var device = playerInput.GetDevice<Pointer>();
|
||
|
if (device != null && IsRaycastHittingUIObject(device.position.ReadValue()))
|
||
|
return;
|
||
|
|
||
|
Fire();
|
||
|
}
|
||
|
|
||
|
// Can't use IsPointerOverGameObject() from within InputAction callbacks as the UI won't update
|
||
|
// until after input processing is complete. So, need to explicitly raycast here.
|
||
|
// NOTE: This is not something we'd want to do from a high-frequency action. If, for example, this
|
||
|
// is called from an action bound to `<Mouse>/position`, there will be an immense amount of
|
||
|
// raycasts performed per frame.
|
||
|
private bool IsRaycastHittingUIObject(Vector2 position)
|
||
|
{
|
||
|
if (m_PointerData == null)
|
||
|
m_PointerData = new PointerEventData(EventSystem.current);
|
||
|
m_PointerData.position = position;
|
||
|
EventSystem.current.RaycastAll(m_PointerData, m_RaycastResults);
|
||
|
return m_RaycastResults.Count > 0;
|
||
|
}
|
||
|
|
||
|
private PointerEventData m_PointerData;
|
||
|
private List<RaycastResult> m_RaycastResults = new List<RaycastResult>();
|
||
|
|
||
|
#endif
|
||
|
|
||
|
private bool IsPointerOverUI()
|
||
|
{
|
||
|
// If we're not controlling the UI with a pointer, we can early out of this.
|
||
|
if (m_ControlStyle == ControlStyle.GamepadJoystick)
|
||
|
return false;
|
||
|
|
||
|
// Otherwise, check if the primary pointer is currently over a UI object.
|
||
|
return EventSystem.current.IsPointerOverGameObject();
|
||
|
}
|
||
|
|
||
|
////REVIEW: check this together with the focus PR; ideally, the code here should not be necessary
|
||
|
private bool IsPointerInsideScreen()
|
||
|
{
|
||
|
var pointer = playerInput.GetDevice<Pointer>();
|
||
|
if (pointer == null)
|
||
|
return true;
|
||
|
|
||
|
return Screen.safeArea.Contains(pointer.position.ReadValue());
|
||
|
}
|
||
|
|
||
|
private void Fire()
|
||
|
{
|
||
|
var transform = this.transform;
|
||
|
var newProjectile = Instantiate(projectile);
|
||
|
newProjectile.transform.position = transform.position + transform.forward * 0.6f;
|
||
|
newProjectile.transform.rotation = transform.rotation;
|
||
|
const int kSize = 1;
|
||
|
newProjectile.transform.localScale *= kSize;
|
||
|
newProjectile.GetComponent<Rigidbody>().mass = Mathf.Pow(kSize, 3);
|
||
|
newProjectile.GetComponent<Rigidbody>().AddForce(transform.forward * 20f, ForceMode.Impulse);
|
||
|
newProjectile.GetComponent<MeshRenderer>().material.color =
|
||
|
new Color(Random.value, Random.value, Random.value, 1.0f);
|
||
|
}
|
||
|
|
||
|
private void RepaintInspector()
|
||
|
{
|
||
|
// We have a custom inspector below that prints some debugging information for internal state.
|
||
|
// When we change state, this will not result in an automatic repaint of the inspector as Unity
|
||
|
// doesn't know about the change.
|
||
|
//
|
||
|
// We thus manually force a refresh. There's more elegant ways to do this but the easiest by
|
||
|
// far is to just globally force a repaint of the entire editor window.
|
||
|
|
||
|
#if UNITY_EDITOR
|
||
|
InternalEditorUtility.RepaintAllViews();
|
||
|
#endif
|
||
|
}
|
||
|
}
|
||
|
|
||
|
#if UNITY_EDITOR
|
||
|
[CustomEditor(typeof(UIvsGameInputHandler))]
|
||
|
internal class UIvsGameInputHandlerEditor : Editor
|
||
|
{
|
||
|
public override void OnInspectorGUI()
|
||
|
{
|
||
|
base.OnInspectorGUI();
|
||
|
|
||
|
using (new EditorGUI.DisabledScope(true))
|
||
|
{
|
||
|
EditorGUILayout.Space();
|
||
|
EditorGUILayout.LabelField("Debug");
|
||
|
EditorGUILayout.Space();
|
||
|
|
||
|
using (new EditorGUI.IndentLevelScope())
|
||
|
{
|
||
|
var state = ((UIvsGameInputHandler)target).m_State;
|
||
|
EditorGUILayout.LabelField("State", state.ToString());
|
||
|
var style = ((UIvsGameInputHandler)target).m_ControlStyle;
|
||
|
EditorGUILayout.LabelField("Controls", style.ToString());
|
||
|
if (style == UIvsGameInputHandler.ControlStyle.GamepadJoystick)
|
||
|
{
|
||
|
var uiEngaged = ((UIvsGameInputHandler)target).m_UIEngaged;
|
||
|
EditorGUILayout.LabelField("UI Engaged?", uiEngaged ? "Yes" : "No");
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
}
|
||
|
#endif
|