chunk 1: core gameplay scripts scenes runtime assets
This commit is contained in:
197
Assets/Scripts/Input/MobileControlManagerAction.cs
Normal file
197
Assets/Scripts/Input/MobileControlManagerAction.cs
Normal file
@@ -0,0 +1,197 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.InputSystem.EnhancedTouch;
|
||||
using UnityEngine.InputSystem.OnScreen;
|
||||
using UnityEngine.InputSystem.Layouts;
|
||||
using EnhancedTouchSupport = UnityEngine.InputSystem.EnhancedTouch.EnhancedTouchSupport;
|
||||
|
||||
namespace DeviantMobile.Input
|
||||
{
|
||||
// Variant that targets actions directly instead of raw control paths.
|
||||
[RequireComponent(typeof(RectTransform))]
|
||||
public class MobileControlManagerAction : MonoBehaviour, IPointerDownHandler, IPointerUpHandler
|
||||
{
|
||||
[Header("Platform / Visibility")]
|
||||
[Tooltip("If true, on-screen controls will be visible when running in the Editor (useful for testing). If false, controls are hidden in editor/desktop builds and only shown on mobile platforms.")]
|
||||
[SerializeField] private bool allowShowInEditor = false;
|
||||
|
||||
// Helper to determine whether on-screen controls should be active for the current platform.
|
||||
private bool ShouldShowOnScreenControls()
|
||||
{
|
||||
if (Application.platform == RuntimePlatform.Android || Application.platform == RuntimePlatform.IPhonePlayer)
|
||||
return true;
|
||||
if (Application.isEditor && allowShowInEditor)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
[System.Serializable]
|
||||
public class ActionBinding
|
||||
{
|
||||
[Tooltip("InputActionReference to trigger for this tap count. Leave empty to ignore.")]
|
||||
public InputActionReference action;
|
||||
|
||||
[Tooltip("Optional label for clarity only (not used at runtime)")]
|
||||
public string label;
|
||||
}
|
||||
|
||||
[Header("Tap to Action Bindings (index 0=single, 1=double, 2=triple, 3=quad)")]
|
||||
public ActionBinding[] actions = new ActionBinding[4];
|
||||
|
||||
[Header("Tap Settings")]
|
||||
[Tooltip("Max delay between taps to register as a multi-tap (seconds)")]
|
||||
[Range(0.1f, 0.6f)] public float multiTapWindow = 0.3f;
|
||||
[Tooltip("How long to hold the virtual press (seconds)")]
|
||||
[Range(0.01f, 0.2f)] public float pressDuration = 0.05f;
|
||||
|
||||
private int tapCount = 0;
|
||||
private float lastTapTime = -1f;
|
||||
private Coroutine finalizeCoroutine;
|
||||
|
||||
// Internally we build OnScreenPulseControl(s) that point at the bound actions' control paths.
|
||||
private readonly List<OnScreenPulseControl> _pulsers = new List<OnScreenPulseControl>(4);
|
||||
|
||||
void Awake()
|
||||
{
|
||||
BuildOrRefreshPulsers();
|
||||
// Ensure visibility matches platform (do this early so UI is correct in hierarchy)
|
||||
gameObject.SetActive(ShouldShowOnScreenControls());
|
||||
}
|
||||
|
||||
void OnEnable()
|
||||
{
|
||||
// Ensure EnhancedTouch is available so Unity's UI and On-Screen controls receive multi-touch data.
|
||||
EnhancedTouchSupport.Enable();
|
||||
BuildOrRefreshPulsers();
|
||||
gameObject.SetActive(ShouldShowOnScreenControls());
|
||||
}
|
||||
|
||||
void OnDisable()
|
||||
{
|
||||
// Balance the Enable call; safe to invoke even if already disabled elsewhere.
|
||||
EnhancedTouchSupport.Disable();
|
||||
}
|
||||
|
||||
public void OnPointerDown(PointerEventData eventData) { }
|
||||
|
||||
public void OnPointerUp(PointerEventData eventData)
|
||||
{
|
||||
var now = Time.unscaledTime;
|
||||
if (lastTapTime > 0 && (now - lastTapTime) <= multiTapWindow)
|
||||
tapCount++;
|
||||
else
|
||||
tapCount = 1;
|
||||
|
||||
lastTapTime = now;
|
||||
if (finalizeCoroutine != null)
|
||||
StopCoroutine(finalizeCoroutine);
|
||||
finalizeCoroutine = StartCoroutine(FinalizeTapAfterWindow());
|
||||
}
|
||||
|
||||
private IEnumerator FinalizeTapAfterWindow()
|
||||
{
|
||||
yield return new WaitForSecondsRealtime(multiTapWindow);
|
||||
TriggerForTapCount(tapCount);
|
||||
tapCount = 0;
|
||||
lastTapTime = -1f;
|
||||
finalizeCoroutine = null;
|
||||
}
|
||||
|
||||
private void TriggerForTapCount(int taps)
|
||||
{
|
||||
int index = Mathf.Clamp(taps, 1, 4) - 1;
|
||||
if (index >= _pulsers.Count) return;
|
||||
var pulser = _pulsers[index];
|
||||
if (pulser == null) return;
|
||||
pulser.Pulse(1f, pressDuration);
|
||||
}
|
||||
|
||||
private void BuildOrRefreshPulsers()
|
||||
{
|
||||
_pulsers.Clear();
|
||||
|
||||
foreach (var existing in GetComponents<OnScreenPulseControl>())
|
||||
{
|
||||
if (Application.isPlaying)
|
||||
Destroy(existing);
|
||||
else
|
||||
DestroyImmediate(existing);
|
||||
}
|
||||
|
||||
// If actions not set, nothing to do.
|
||||
if (actions == null) return;
|
||||
|
||||
// We resolve each InputActionReference to a suitable control path.
|
||||
for (int i = 0; i < Mathf.Min(4, actions.Length); i++)
|
||||
{
|
||||
var binding = actions[i];
|
||||
if (binding == null || binding.action == null || binding.action.action == null)
|
||||
{
|
||||
_pulsers.Add(null);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Determine a control path. Prefer the first non-empty binding path.
|
||||
string path = ResolvePreferredControlPath(binding.action.action);
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
{
|
||||
_pulsers.Add(null);
|
||||
continue;
|
||||
}
|
||||
|
||||
var pulser = gameObject.AddComponent<OnScreenPulseControl>();
|
||||
// Set the control path resolved from the InputAction.
|
||||
// In the InputAction asset, ensure the action is bound to a control that matches this path
|
||||
// (for example a Button control on a touch/gamepad). The OnScreenPulseControl will send
|
||||
// press/release values to that control path, which the InputSystem action can receive.
|
||||
pulser.controlPath = path;
|
||||
_pulsers.Add(pulser);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolvePreferredControlPath(InputAction action)
|
||||
{
|
||||
// Try active controls if the action is enabled
|
||||
if (action.enabled && action.controls.Count > 0)
|
||||
{
|
||||
// Use the first control's path
|
||||
return action.controls[0].path;
|
||||
}
|
||||
|
||||
// Fallback to the first binding that has a path
|
||||
foreach (var b in action.bindings)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(b.effectivePath))
|
||||
return b.effectivePath;
|
||||
if (!string.IsNullOrWhiteSpace(b.path))
|
||||
return b.path;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
void OnValidate()
|
||||
{
|
||||
if (actions == null || actions.Length != 4)
|
||||
{
|
||||
var old = actions;
|
||||
actions = new ActionBinding[4];
|
||||
if (old != null)
|
||||
{
|
||||
for (int i = 0; i < Mathf.Min(4, old.Length); i++)
|
||||
actions[i] = old[i];
|
||||
}
|
||||
}
|
||||
|
||||
if (Application.isPlaying == false)
|
||||
UnityEditor.EditorApplication.delayCall += () => {
|
||||
if (this != null)
|
||||
BuildOrRefreshPulsers();
|
||||
};
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Input/MobileControlManagerAction.cs.meta
Normal file
2
Assets/Scripts/Input/MobileControlManagerAction.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: df57cec325f81fc53bea32bab961e921
|
||||
152
Assets/Scripts/Input/MobileOnScreenStick.cs
Normal file
152
Assets/Scripts/Input/MobileOnScreenStick.cs
Normal file
@@ -0,0 +1,152 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.InputSystem;
|
||||
using UnityEngine.InputSystem.EnhancedTouch;
|
||||
using UnityEngine.InputSystem.OnScreen;
|
||||
using UnityEngine.InputSystem.Layouts;
|
||||
using EnhancedTouchSupport = UnityEngine.InputSystem.EnhancedTouch.EnhancedTouchSupport;
|
||||
|
||||
namespace DeviantMobile.Input
|
||||
{
|
||||
// Drag-based on-screen stick that writes a Vector2 to the Input System.
|
||||
// Bind your Move action to the same control path (e.g. <Gamepad>/leftStick).
|
||||
[AddComponentMenu("Input/Mobile On-Screen Stick")]
|
||||
public class MobileOnScreenStick : OnScreenControl, IPointerDownHandler, IPointerUpHandler, IDragHandler
|
||||
{
|
||||
[Header("Platform / Visibility")]
|
||||
[Tooltip("If true, on-screen stick will be visible when running in the Editor (useful for testing). If false, controls are hidden in editor/desktop builds and only shown on mobile platforms.")]
|
||||
[SerializeField] private bool allowShowInEditor = false;
|
||||
|
||||
// Helper to determine whether on-screen controls should be active for the current platform.
|
||||
private bool ShouldShowOnScreenControls()
|
||||
{
|
||||
if (Application.platform == RuntimePlatform.Android || Application.platform == RuntimePlatform.IPhonePlayer)
|
||||
return true;
|
||||
if (Application.isEditor && allowShowInEditor)
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
[Header("Control Path")]
|
||||
[Tooltip("Input System control path to write the Vector2 to. Commonly '<Gamepad>/leftStick'.")]
|
||||
[SerializeField, InputControl(layout = "Vector2")] private string m_ControlPath = "<Gamepad>/leftStick";
|
||||
|
||||
protected override string controlPathInternal
|
||||
{
|
||||
get => m_ControlPath;
|
||||
set => m_ControlPath = value;
|
||||
}
|
||||
|
||||
[Header("UI References")]
|
||||
[SerializeField] private RectTransform background; // Joystick background area
|
||||
[SerializeField] private RectTransform handle; // Joystick handle/knob
|
||||
|
||||
[Header("Behavior")]
|
||||
[Tooltip("Max radius in pixels the handle can travel from center.")]
|
||||
[SerializeField] private float maxRadius = 100f;
|
||||
[Tooltip("Deadzone as a fraction of radius (0..1).")]
|
||||
[Range(0f, 0.5f)] [SerializeField] private float deadZone = 0.1f;
|
||||
[Tooltip("Return handle to center on pointer up.")]
|
||||
[SerializeField] private bool snapBackOnRelease = true;
|
||||
|
||||
private bool _pressed;
|
||||
private Vector2 _centerLocal;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
if (background == null)
|
||||
background = GetComponent<RectTransform>();
|
||||
if (handle == null && background != null && background.childCount > 0)
|
||||
handle = background.GetChild(0) as RectTransform;
|
||||
|
||||
// Disable the UI for non-mobile platforms by default.
|
||||
gameObject.SetActive(ShouldShowOnScreenControls());
|
||||
}
|
||||
|
||||
void OnEnable()
|
||||
{
|
||||
// Ensure the Enhanced Touch pipeline is active so pointer/touch events map correctly to OnScreenControl.
|
||||
EnhancedTouchSupport.Enable();
|
||||
}
|
||||
|
||||
void OnDisable()
|
||||
{
|
||||
EnhancedTouchSupport.Disable();
|
||||
}
|
||||
|
||||
public void OnPointerDown(PointerEventData eventData)
|
||||
{
|
||||
_pressed = true;
|
||||
CacheCenter();
|
||||
OnDrag(eventData); // update immediately
|
||||
}
|
||||
|
||||
public void OnDrag(PointerEventData eventData)
|
||||
{
|
||||
if (background == null)
|
||||
return;
|
||||
|
||||
RectTransformUtility.ScreenPointToLocalPointInRectangle(background, eventData.position, eventData.pressEventCamera, out var localPoint);
|
||||
var delta = localPoint - _centerLocal;
|
||||
|
||||
// Clamp to radius
|
||||
if (delta.magnitude > maxRadius)
|
||||
delta = delta.normalized * maxRadius;
|
||||
|
||||
// Normalize to [-1,1]
|
||||
var norm = (maxRadius > 0f) ? (delta / maxRadius) : Vector2.zero;
|
||||
|
||||
// Apply deadzone
|
||||
var mag = norm.magnitude;
|
||||
if (mag < deadZone)
|
||||
norm = Vector2.zero;
|
||||
else if (mag > 1f)
|
||||
norm = norm.normalized;
|
||||
|
||||
// Move handle visually
|
||||
if (handle != null)
|
||||
handle.anchoredPosition = delta;
|
||||
|
||||
// Send to Input System
|
||||
SendValueToControl(norm);
|
||||
}
|
||||
|
||||
public void OnPointerUp(PointerEventData eventData)
|
||||
{
|
||||
_pressed = false;
|
||||
if (snapBackOnRelease)
|
||||
{
|
||||
ResetHandle();
|
||||
}
|
||||
SendValueToControl(Vector2.zero);
|
||||
}
|
||||
|
||||
private void CacheCenter()
|
||||
{
|
||||
if (background != null)
|
||||
{
|
||||
// Center is the pivot point local position (0 if centered pivot). Use rect center for safety.
|
||||
_centerLocal = background.rect.center;
|
||||
}
|
||||
else
|
||||
{
|
||||
_centerLocal = Vector2.zero;
|
||||
}
|
||||
}
|
||||
|
||||
private void ResetHandle()
|
||||
{
|
||||
if (handle != null)
|
||||
handle.anchoredPosition = Vector2.zero;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
void OnValidate()
|
||||
{
|
||||
if (background == null)
|
||||
background = GetComponent<RectTransform>();
|
||||
if (handle == null && background != null && background.childCount > 0)
|
||||
handle = background.GetChild(0) as RectTransform;
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Input/MobileOnScreenStick.cs.meta
Normal file
2
Assets/Scripts/Input/MobileOnScreenStick.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 30717cdf983b9fcbfb655ca63637870e
|
||||
41
Assets/Scripts/Input/OnScreenPulseControl.cs
Normal file
41
Assets/Scripts/Input/OnScreenPulseControl.cs
Normal file
@@ -0,0 +1,41 @@
|
||||
using System.Collections;
|
||||
using UnityEngine;
|
||||
using UnityEngine.InputSystem.OnScreen;
|
||||
using UnityEngine.InputSystem.Layouts;
|
||||
|
||||
namespace DeviantMobile.Input
|
||||
{
|
||||
// A lightweight on-screen control we can pulse to simulate a button press.
|
||||
[AddComponentMenu("Input/On-Screen Pulse Control")]
|
||||
public class OnScreenPulseControl : OnScreenControl
|
||||
{
|
||||
// Shows an Input Control picker in the inspector; default layout Button.
|
||||
[SerializeField]
|
||||
[InputControl(layout = "Button")]
|
||||
private string m_ControlPath;
|
||||
|
||||
protected override string controlPathInternal
|
||||
{
|
||||
get => m_ControlPath;
|
||||
set => m_ControlPath = value;
|
||||
}
|
||||
|
||||
public void Pulse(float value = 1f, float duration = 0.05f)
|
||||
{
|
||||
// Send press then release after duration
|
||||
SendValueToControl(value);
|
||||
if (duration > 0f)
|
||||
// Use unscaled time so UI pulses are consistent across timeScale changes.
|
||||
StartCoroutine(ReleaseAfter(duration));
|
||||
else
|
||||
SendValueToControl(0f);
|
||||
}
|
||||
|
||||
private IEnumerator ReleaseAfter(float duration)
|
||||
{
|
||||
// Use real-time wait to avoid being affected by Time.timeScale changes.
|
||||
yield return new WaitForSecondsRealtime(duration);
|
||||
SendValueToControl(0f);
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Assets/Scripts/Input/OnScreenPulseControl.cs.meta
Normal file
2
Assets/Scripts/Input/OnScreenPulseControl.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d632909be334f1811bb832cb9bfc35cb
|
||||
Reference in New Issue
Block a user