A game about forced loneliness, made by TACStudios
1using System.Collections.Generic;
2using UnityEngine;
3using UnityEngine.EventSystems;
4using UnityEngine.InputSystem;
5using UnityEngine.UI;
6
7#if UNITY_EDITOR
8using UnityEditor;
9using UnityEditorInternal;
10#endif
11
12public class UIvsGameInputHandler : MonoBehaviour
13{
14 public Text statusBarText;
15 public GameObject inGameUI;
16 public GameObject mainMenuUI;
17 public GameObject menuButton;
18 public GameObject firstButtonInMainMenu;
19 public GameObject firstNavigationSelection;
20 [Space]
21 public PlayerInput playerInput;
22 public GameObject projectile;
23
24 [Space]
25 [Tooltip("Multiplier for Pointer.delta values when adding to rotation.")]
26 public float m_MouseLookSensitivity = 0.1f;
27 [Tooltip("Rotation per second with fully actuated Gamepad/joystick stick.")]
28 public float m_GamepadLookSpeed = 10f;
29
30 private bool m_OpenMenuActionTriggered;
31 private bool m_ResetCameraActionTriggered;
32 private bool m_FireActionTriggered;
33 internal bool m_UIEngaged;
34
35 private Vector2 m_Rotation;
36 private InputAction m_LookEngageAction;
37 private InputAction m_LookAction;
38 private InputAction m_CancelAction;
39 private InputAction m_UIEngageAction;
40 private GameObject m_LastNavigationSelection;
41
42 private Mouse m_Mouse;
43 private Vector2? m_MousePositionToWarpToAfterCursorUnlock;
44
45 internal enum State
46 {
47 InGame,
48 InGameControllingCamera,
49 InMenu,
50 }
51
52 internal State m_State;
53
54 internal enum ControlStyle
55 {
56 None,
57 KeyboardMouse,
58 Touch,
59 GamepadJoystick,
60 }
61
62 internal ControlStyle m_ControlStyle;
63
64 public void OnEnable()
65 {
66 // By default, hide menu and show game UI.
67 inGameUI.SetActive(true);
68 mainMenuUI.SetActive(false);
69 menuButton.SetActive(false);
70
71 // Look up InputActions on the player so we don't have to do this over and over.
72 m_LookEngageAction = playerInput.actions["LookEngage"];
73 m_LookAction = playerInput.actions["Look"];
74 m_CancelAction = playerInput.actions["UI/Cancel"];
75 m_UIEngageAction = playerInput.actions["UIEngage"];
76
77 m_State = State.InGame;
78 }
79
80 // This is called when PlayerInput updates the controls bound to its InputActions.
81 public void OnControlsChanged()
82 {
83 // We could determine the types of controls we have from the names of the control schemes or their
84 // contents. However, a way that is both easier and more robust is to simply look at the kind of
85 // devices we have assigned to us. We do not support mixed models this way but this does correspond
86 // to the limitations of the current control code.
87
88 if (playerInput.GetDevice<Touchscreen>() != null) // Note that Touchscreen is also a Pointer so check this first.
89 m_ControlStyle = ControlStyle.Touch;
90 else if (playerInput.GetDevice<Pointer>() != null)
91 m_ControlStyle = ControlStyle.KeyboardMouse;
92 else if (playerInput.GetDevice<Gamepad>() != null || playerInput.GetDevice<Joystick>() != null)
93 m_ControlStyle = ControlStyle.GamepadJoystick;
94 else
95 Debug.LogError("Control scheme not recognized: " + playerInput.currentControlScheme);
96
97 m_Mouse = default;
98 m_MousePositionToWarpToAfterCursorUnlock = default;
99
100 // Enable button for main menu depending on whether we use touch or not.
101 // With kb&mouse and gamepad, not necessary but with touch, we have no "Cancel" control.
102 menuButton.SetActive(m_ControlStyle == ControlStyle.Touch);
103
104 // If we're using navigation-style input, start with UI control disengaged.
105 if (m_ControlStyle == ControlStyle.GamepadJoystick)
106 SetUIEngaged(false);
107
108 RepaintInspector();
109 }
110
111 public void Update()
112 {
113 switch (m_State)
114 {
115 case State.InGame:
116 {
117 if (m_OpenMenuActionTriggered)
118 {
119 m_State = State.InMenu;
120
121 // Bring up main menu.
122 inGameUI.SetActive(false);
123 mainMenuUI.SetActive(true);
124
125 // Disable gameplay inputs.
126 playerInput.DeactivateInput();
127
128 // Select topmost button.
129 EventSystem.current.SetSelectedGameObject(firstButtonInMainMenu);
130 }
131
132 var pointerIsOverUI = IsPointerOverUI();
133 if (pointerIsOverUI)
134 break;
135
136 if (m_ResetCameraActionTriggered)
137 transform.rotation = default;
138
139 // When using a pointer-based control scheme, we engage camera look explicitly.
140 if (m_ControlStyle != ControlStyle.GamepadJoystick && m_LookEngageAction.WasPressedThisFrame() && IsPointerInsideScreen())
141 EngageCameraControl();
142
143 // With gamepad/joystick, we can freely rotate the camera at any time.
144 if (m_ControlStyle == ControlStyle.GamepadJoystick)
145 ProcessCameraLook();
146
147 if (m_FireActionTriggered)
148 Fire();
149
150 break;
151 }
152
153 case State.InGameControllingCamera:
154
155 if (m_ResetCameraActionTriggered && !IsPointerOverUI())
156 transform.rotation = default;
157
158 if (m_FireActionTriggered && !IsPointerOverUI())
159 Fire();
160
161 // Rotate camera.
162 ProcessCameraLook();
163
164 // Keep track of distance we travel with the mouse while in mouse lock so
165 // that when we unlock, we can jump to a position that feels "right".
166 if (m_Mouse != null)
167 m_MousePositionToWarpToAfterCursorUnlock = m_MousePositionToWarpToAfterCursorUnlock.Value + m_Mouse.delta.ReadValue();
168
169 if (m_CancelAction.WasPressedThisFrame() || !m_LookEngageAction.IsPressed())
170 DisengageCameraControl();
171
172 break;
173
174 case State.InMenu:
175
176 if (m_CancelAction.WasPressedThisFrame())
177 OnContinueClicked();
178
179 break;
180 }
181
182 m_ResetCameraActionTriggered = default;
183 m_OpenMenuActionTriggered = default;
184 m_FireActionTriggered = default;
185 }
186
187 private void ProcessCameraLook()
188 {
189 var rotate = m_LookAction.ReadValue<Vector2>();
190 if (!(rotate.sqrMagnitude > 0.01))
191 return;
192
193 // For gamepad and joystick, we rotate continuously based on stick actuation.
194 float rotateScaleFactor;
195 if (m_ControlStyle == ControlStyle.GamepadJoystick)
196 rotateScaleFactor = m_GamepadLookSpeed * Time.deltaTime;
197 else
198 rotateScaleFactor = m_MouseLookSensitivity;
199
200 m_Rotation.y += rotate.x * rotateScaleFactor;
201 m_Rotation.x = Mathf.Clamp(m_Rotation.x - rotate.y * rotateScaleFactor, -89, 89);
202 transform.localEulerAngles = m_Rotation;
203 }
204
205 private void EngageCameraControl()
206 {
207 // With a mouse, it's annoying to always end up with the pointer centered in the middle of
208 // the screen after we come out of a cursor lock. So, what we do is we simply remember where
209 // the cursor was when we locked and then warp the mouse back to that position after the cursor
210 // lock is released.
211 m_Mouse = playerInput.GetDevice<Mouse>();
212 m_MousePositionToWarpToAfterCursorUnlock = m_Mouse?.position.ReadValue();
213
214 Cursor.lockState = CursorLockMode.Locked;
215
216 m_State = State.InGameControllingCamera;
217
218 RepaintInspector();
219 }
220
221 private void DisengageCameraControl()
222 {
223 Cursor.lockState = CursorLockMode.None;
224
225 if (m_MousePositionToWarpToAfterCursorUnlock != null)
226 m_Mouse?.WarpCursorPosition(m_MousePositionToWarpToAfterCursorUnlock.Value);
227
228 m_State = State.InGame;
229
230 RepaintInspector();
231 }
232
233 public void OnTopLeftClicked()
234 {
235 statusBarText.text = "'Top Left' button clicked";
236 }
237
238 public void OnBottomLeftClicked()
239 {
240 statusBarText.text = "'Bottom Left' button clicked";
241 }
242
243 public void OnTopRightClicked()
244 {
245 statusBarText.text = "'Top Right' button clicked";
246 }
247
248 public void OnBottomRightClicked()
249 {
250 statusBarText.text = "'Bottom Right' button clicked";
251 }
252
253 public void OnMenuClicked()
254 {
255 m_OpenMenuActionTriggered = true;
256 }
257
258 public void OnContinueClicked()
259 {
260 mainMenuUI.SetActive(false);
261 inGameUI.SetActive(true);
262
263 // Reenable gameplay inputs.
264 playerInput.ActivateInput();
265
266 m_State = State.InGame;
267
268 RepaintInspector();
269 }
270
271 public void OnExitClicked()
272 {
273 #if UNITY_EDITOR
274 EditorApplication.ExitPlaymode();
275 #else
276 Application.Quit();
277 #endif
278 }
279
280 public void OnMenu(InputAction.CallbackContext context)
281 {
282 if (context.performed)
283 m_OpenMenuActionTriggered = true;
284 }
285
286 public void OnResetCamera(InputAction.CallbackContext context)
287 {
288 if (context.performed)
289 m_ResetCameraActionTriggered = true;
290 }
291
292 public void OnUIEngage(InputAction.CallbackContext context)
293 {
294 if (!context.performed)
295 return;
296
297 // From here, we could also do things such as showing UI that we only
298 // have up while the UI is engaged. For example, the same approach as
299 // here could be used to display a radial selection dials for items.
300
301 SetUIEngaged(!m_UIEngaged);
302 }
303
304 private void SetUIEngaged(bool value)
305 {
306 if (value)
307 {
308 playerInput.actions.FindActionMap("UI").Enable();
309 SetPlayerActionsEnabled(false);
310
311 // Select the GO that was selected last time.
312 if (m_LastNavigationSelection == null)
313 m_LastNavigationSelection = firstNavigationSelection;
314 EventSystem.current.SetSelectedGameObject(m_LastNavigationSelection);
315 }
316 else
317 {
318 m_LastNavigationSelection = EventSystem.current.currentSelectedGameObject; // If this happens to be null, we will automatically pick up firstNavigationSelection again.
319 EventSystem.current.SetSelectedGameObject(null);
320
321 playerInput.actions.FindActionMap("UI").Disable();
322 SetPlayerActionsEnabled(true);
323 }
324
325 m_UIEngaged = value;
326
327 RepaintInspector();
328 }
329
330 // Enable/disable every in-game action other than the UI toggle.
331 private void SetPlayerActionsEnabled(bool value)
332 {
333 var actions = playerInput.actions.FindActionMap("Player");
334 foreach (var action in actions)
335 {
336 if (action == m_UIEngageAction)
337 continue;
338
339 if (value)
340 action.Enable();
341 else
342 action.Disable();
343 }
344 }
345
346 // There's two different approaches taken here. The first OnFire() just does the same as the action
347 // callbacks above and just sets some state to leave action responses to Update().
348 // The second OnFire() puts the response logic directly inside the callback.
349
350 #if false
351
352 public void OnFire(InputAction.CallbackContext context)
353 {
354 if (context.performed)
355 m_FireActionTriggered = true;
356 }
357
358 #else
359
360 public void OnFire(InputAction.CallbackContext context)
361 {
362 // For this action, let's try something different. Let's say we want to trigger a response
363 // right away every time the "fire" action triggers. Theoretically, this would allow us
364 // to correctly respond even if there is multiple activations in a single frame. In practice,
365 // this will realistically only happen with low framerates (and even then it can be questionable
366 // whether we want to respond this way).
367
368 if (!context.performed)
369 return;
370
371 var device = playerInput.GetDevice<Pointer>();
372 if (device != null && IsRaycastHittingUIObject(device.position.ReadValue()))
373 return;
374
375 Fire();
376 }
377
378 // Can't use IsPointerOverGameObject() from within InputAction callbacks as the UI won't update
379 // until after input processing is complete. So, need to explicitly raycast here.
380 // NOTE: This is not something we'd want to do from a high-frequency action. If, for example, this
381 // is called from an action bound to `<Mouse>/position`, there will be an immense amount of
382 // raycasts performed per frame.
383 private bool IsRaycastHittingUIObject(Vector2 position)
384 {
385 if (m_PointerData == null)
386 m_PointerData = new PointerEventData(EventSystem.current);
387 m_PointerData.position = position;
388 EventSystem.current.RaycastAll(m_PointerData, m_RaycastResults);
389 return m_RaycastResults.Count > 0;
390 }
391
392 private PointerEventData m_PointerData;
393 private List<RaycastResult> m_RaycastResults = new List<RaycastResult>();
394
395 #endif
396
397 private bool IsPointerOverUI()
398 {
399 // If we're not controlling the UI with a pointer, we can early out of this.
400 if (m_ControlStyle == ControlStyle.GamepadJoystick)
401 return false;
402
403 // Otherwise, check if the primary pointer is currently over a UI object.
404 return EventSystem.current.IsPointerOverGameObject();
405 }
406
407 ////REVIEW: check this together with the focus PR; ideally, the code here should not be necessary
408 private bool IsPointerInsideScreen()
409 {
410 var pointer = playerInput.GetDevice<Pointer>();
411 if (pointer == null)
412 return true;
413
414 return Screen.safeArea.Contains(pointer.position.ReadValue());
415 }
416
417 private void Fire()
418 {
419 var transform = this.transform;
420 var newProjectile = Instantiate(projectile);
421 newProjectile.transform.position = transform.position + transform.forward * 0.6f;
422 newProjectile.transform.rotation = transform.rotation;
423 const int kSize = 1;
424 newProjectile.transform.localScale *= kSize;
425 newProjectile.GetComponent<Rigidbody>().mass = Mathf.Pow(kSize, 3);
426 newProjectile.GetComponent<Rigidbody>().AddForce(transform.forward * 20f, ForceMode.Impulse);
427 newProjectile.GetComponent<MeshRenderer>().material.color =
428 new Color(Random.value, Random.value, Random.value, 1.0f);
429 }
430
431 private void RepaintInspector()
432 {
433 // We have a custom inspector below that prints some debugging information for internal state.
434 // When we change state, this will not result in an automatic repaint of the inspector as Unity
435 // doesn't know about the change.
436 //
437 // We thus manually force a refresh. There's more elegant ways to do this but the easiest by
438 // far is to just globally force a repaint of the entire editor window.
439
440 #if UNITY_EDITOR
441 InternalEditorUtility.RepaintAllViews();
442 #endif
443 }
444}
445
446#if UNITY_EDITOR
447[CustomEditor(typeof(UIvsGameInputHandler))]
448internal class UIvsGameInputHandlerEditor : Editor
449{
450 public override void OnInspectorGUI()
451 {
452 base.OnInspectorGUI();
453
454 using (new EditorGUI.DisabledScope(true))
455 {
456 EditorGUILayout.Space();
457 EditorGUILayout.LabelField("Debug");
458 EditorGUILayout.Space();
459
460 using (new EditorGUI.IndentLevelScope())
461 {
462 var state = ((UIvsGameInputHandler)target).m_State;
463 EditorGUILayout.LabelField("State", state.ToString());
464 var style = ((UIvsGameInputHandler)target).m_ControlStyle;
465 EditorGUILayout.LabelField("Controls", style.ToString());
466 if (style == UIvsGameInputHandler.ControlStyle.GamepadJoystick)
467 {
468 var uiEngaged = ((UIvsGameInputHandler)target).m_UIEngaged;
469 EditorGUILayout.LabelField("UI Engaged?", uiEngaged ? "Yes" : "No");
470 }
471 }
472 }
473 }
474}
475#endif