feat: add reusable virtual hud input

This commit is contained in:
ud18010
2026-07-30 11:14:56 +08:00
parent 3546d16632
commit 5853c5bd83
26 changed files with 6403 additions and 31 deletions
@@ -0,0 +1,498 @@
using System;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
namespace XGame.VirtualInput
{
public enum ActionButtonId
{
Primary,
Skill1,
Skill2,
Skill3,
Skill4
}
public enum ActionButtonPhase
{
Down,
Drag,
HoldStart,
HoldTick,
HoldEnd,
Tap,
Release,
Cancel,
Blocked
}
public readonly struct ActionButtonEvent
{
public ActionButtonEvent(
ActionButtonId buttonId,
ActionButtonPhase phase,
Vector2 screenPosition,
Vector2 localVector,
Vector2 direction,
float distance01,
float heldSeconds,
bool isCanceled,
int pointerId)
{
ButtonId = buttonId;
Phase = phase;
ScreenPosition = screenPosition;
LocalVector = localVector;
Direction = direction;
Distance01 = distance01;
HeldSeconds = heldSeconds;
IsCanceled = isCanceled;
PointerId = pointerId;
}
public ActionButtonId ButtonId { get; }
public ActionButtonPhase Phase { get; }
public Vector2 ScreenPosition { get; }
public Vector2 LocalVector { get; }
public Vector2 Direction { get; }
public float Distance01 { get; }
public float HeldSeconds { get; }
public bool IsCanceled { get; }
public int PointerId { get; }
}
public sealed class VirtualActionButton : MonoBehaviour, IPointerDownHandler, IDragHandler, IPointerUpHandler
{
[SerializeField] public ActionButtonId ButtonId;
[SerializeField] private float holdThresholdSeconds = 0.35f;
[SerializeField] private float holdTickIntervalSeconds = 0.1f;
[SerializeField] private float releaseDistanceThreshold01 = 0.25f;
[SerializeField] private float maxDragRadiusMultiplier = 1.5f;
private Image icon;
private Image cooldownMask;
private TMP_Text cooldownText;
private TMP_Text chargeText;
private GameObject aimIndicator;
private RectTransform cancelArea;
private RectTransform rectTransform;
private Button unityButton;
private CanvasGroup canvasGroup;
private bool enabledByState = true;
private int charges = -1;
private float cooldownRemaining;
private float cooldownDuration;
private bool pointerDown;
private int activePointerId = int.MinValue;
private float pointerDownTime;
private bool holdStarted;
private float nextHoldTickTime;
private Vector2 lastScreenPosition;
private ActionButtonEvent lastDragEvent;
private Vector2? screenCenterOverride;
private float? screenRadiusOverride;
public bool IsInteractable => enabledByState && cooldownRemaining <= 0f && charges != 0;
public event Action<ActionButtonEvent> ButtonEvent;
private void Awake()
{
BindNodes();
RefreshVisuals();
}
private void OnDisable()
{
CancelActivePointer(Time.unscaledTime);
SetAimVisible(false);
}
private void Update()
{
if (cooldownRemaining > 0f)
{
cooldownRemaining = Mathf.Max(0f, cooldownRemaining - Time.unscaledDeltaTime);
RefreshVisuals();
}
if (pointerDown)
{
MaybeEmitHold(Time.unscaledTime, activePointerId, lastScreenPosition);
}
}
public void OnPointerDown(PointerEventData eventData)
{
HandlePointerDown(eventData.position, eventData.pointerId, Time.unscaledTime);
}
public void OnDrag(PointerEventData eventData)
{
HandleDrag(eventData.position, eventData.pointerId, Time.unscaledTime);
}
public void OnPointerUp(PointerEventData eventData)
{
HandlePointerUp(eventData.position, eventData.pointerId, Time.unscaledTime);
}
public void HandlePointerDownForTest(Vector2 screenPosition, int pointerId, float now)
{
HandlePointerDown(screenPosition, pointerId, now);
}
public void HandleDragForTest(Vector2 screenPosition, int pointerId, float now)
{
HandleDrag(screenPosition, pointerId, now);
}
public void HandlePointerUpForTest(Vector2 screenPosition, int pointerId, float now)
{
HandlePointerUp(screenPosition, pointerId, now);
}
public void SetCooldown(float remainingSeconds, float durationSeconds)
{
cooldownRemaining = Mathf.Max(0f, remainingSeconds);
cooldownDuration = Mathf.Max(0f, durationSeconds);
RefreshVisuals();
}
public void SetCharges(int count)
{
charges = count;
RefreshVisuals();
}
public void SetButtonEnabled(bool enabled)
{
enabledByState = enabled;
RefreshVisuals();
}
public void SetCancelArea(RectTransform area)
{
cancelArea = area;
}
public void SetIcon(Sprite sprite)
{
if (icon == null)
{
BindNodes();
}
if (icon != null)
{
icon.sprite = sprite;
icon.enabled = sprite != null;
}
}
private void BindNodes()
{
rectTransform = transform as RectTransform;
unityButton = GetComponent<Button>();
canvasGroup = GetComponent<CanvasGroup>();
icon = FindImage("Icon");
cooldownMask = FindImage("CooldownMask");
cooldownText = FindText("CooldownText");
chargeText = FindText("ChargeText");
Transform aim = FindRoleTransform("AimIndicator");
if (aim == null)
{
aim = FindRoleTransform("DragArrow");
}
aimIndicator = aim != null ? aim.gameObject : null;
SetAimVisible(false);
}
private Image FindImage(string childName)
{
Transform child = FindRoleTransform(childName);
return child != null ? child.GetComponent<Image>() : null;
}
private TMP_Text FindText(string childName)
{
Transform child = FindRoleTransform(childName);
return child != null ? child.GetComponent<TMP_Text>() : null;
}
private Transform FindRoleTransform(string roleName)
{
Transform child = transform.FindTransform(roleName);
if (child != null)
{
return child;
}
string suffix = "_" + roleName;
return FindDescendantEndingWith(transform, suffix);
}
private static Transform FindDescendantEndingWith(Transform root, string suffix)
{
if (root == null)
{
return null;
}
for (int i = 0; i < root.childCount; i++)
{
Transform child = root.GetChild(i);
if (child.name.EndsWith(suffix, StringComparison.Ordinal))
{
return child;
}
Transform nested = FindDescendantEndingWith(child, suffix);
if (nested != null)
{
return nested;
}
}
return null;
}
private void HandlePointerDown(Vector2 screenPosition, int pointerId, float now)
{
if (pointerDown)
{
return;
}
if (!IsInteractable)
{
Emit(MakeEvent(ActionButtonPhase.Blocked, screenPosition, pointerId, now));
return;
}
pointerDown = true;
activePointerId = pointerId;
pointerDownTime = now;
nextHoldTickTime = now + holdThresholdSeconds + holdTickIntervalSeconds;
holdStarted = false;
lastScreenPosition = screenPosition;
lastDragEvent = MakeEvent(ActionButtonPhase.Down, screenPosition, pointerId, now);
SetAimVisible(false);
Emit(lastDragEvent);
}
private void HandleDrag(Vector2 screenPosition, int pointerId, float now)
{
if (!pointerDown || pointerId != activePointerId)
{
return;
}
lastScreenPosition = screenPosition;
MaybeEmitHold(now, pointerId, screenPosition);
lastDragEvent = MakeEvent(ActionButtonPhase.Drag, screenPosition, pointerId, now);
SetAimVisible(true);
Emit(lastDragEvent);
}
private void HandlePointerUp(Vector2 screenPosition, int pointerId, float now)
{
if (!pointerDown || pointerId != activePointerId)
{
return;
}
MaybeEmitHold(now, pointerId, screenPosition);
if (holdStarted)
{
Emit(MakeEvent(ActionButtonPhase.HoldEnd, screenPosition, pointerId, now));
}
ActionButtonEvent finalEvent = MakeEvent(GetReleasePhase(screenPosition), screenPosition, pointerId, now);
Emit(finalEvent);
ResetPointerState();
SetAimVisible(false);
}
private void MaybeEmitHold(float now, int pointerId, Vector2 screenPosition)
{
if (!holdStarted && now - pointerDownTime >= holdThresholdSeconds)
{
holdStarted = true;
Emit(MakeEvent(ActionButtonPhase.HoldStart, screenPosition, pointerId, now));
}
if (!holdStarted)
{
return;
}
while (now >= nextHoldTickTime)
{
Emit(MakeEvent(ActionButtonPhase.HoldTick, screenPosition, pointerId, nextHoldTickTime));
nextHoldTickTime += holdTickIntervalSeconds;
}
}
private ActionButtonPhase GetReleasePhase(Vector2 screenPosition)
{
if (IsInCancelArea(screenPosition))
{
return ActionButtonPhase.Cancel;
}
return MakeEvent(ActionButtonPhase.Release, screenPosition, activePointerId, Time.unscaledTime).Distance01 >= releaseDistanceThreshold01
? ActionButtonPhase.Release
: ActionButtonPhase.Tap;
}
private ActionButtonEvent MakeEvent(ActionButtonPhase phase, Vector2 screenPosition, int pointerId, float now)
{
Vector2 localVector = screenPosition - GetScreenCenter();
float radius = GetScreenRadius() * maxDragRadiusMultiplier;
float distance01 = Mathf.Clamp01(localVector.magnitude / Mathf.Max(1f, radius));
Vector2 direction = localVector.sqrMagnitude > 0.000001f ? localVector.normalized : Vector2.zero;
bool canceled = phase == ActionButtonPhase.Cancel || IsInCancelArea(screenPosition);
return new ActionButtonEvent(
ButtonId,
phase,
screenPosition,
localVector,
direction,
distance01,
Mathf.Max(0f, now - pointerDownTime),
canceled,
pointerId);
}
private bool IsInCancelArea(Vector2 screenPosition)
{
if (cancelArea == null)
{
return false;
}
return RectTransformUtility.RectangleContainsScreenPoint(cancelArea, screenPosition, GetUICamera(cancelArea));
}
private void RefreshVisuals()
{
bool interactable = IsInteractable;
if (unityButton != null)
{
unityButton.interactable = interactable;
}
if (canvasGroup != null)
{
canvasGroup.alpha = interactable ? 1f : 0.55f;
canvasGroup.interactable = interactable;
}
if (cooldownMask != null)
{
cooldownMask.fillAmount = cooldownDuration > 0f ? Mathf.Clamp01(cooldownRemaining / cooldownDuration) : 0f;
cooldownMask.gameObject.SetActive(cooldownRemaining > 0f);
}
if (cooldownText != null)
{
cooldownText.text = cooldownRemaining > 0f ? Mathf.CeilToInt(cooldownRemaining).ToString() : string.Empty;
cooldownText.gameObject.SetActive(cooldownRemaining > 0f);
}
if (chargeText != null)
{
chargeText.text = charges >= 0 ? charges.ToString() : string.Empty;
chargeText.gameObject.SetActive(charges >= 0);
}
}
private void SetAimVisible(bool visible)
{
if (aimIndicator != null)
{
aimIndicator.SetActive(visible);
}
}
private void ResetPointerState()
{
pointerDown = false;
activePointerId = int.MinValue;
holdStarted = false;
lastScreenPosition = Vector2.zero;
lastDragEvent = default;
}
private void CancelActivePointer(float now)
{
if (!pointerDown)
{
ResetPointerState();
return;
}
MaybeEmitHold(now, activePointerId, lastScreenPosition);
if (holdStarted)
{
Emit(MakeEvent(ActionButtonPhase.HoldEnd, lastScreenPosition, activePointerId, now));
}
Emit(MakeEvent(ActionButtonPhase.Cancel, lastScreenPosition, activePointerId, now));
ResetPointerState();
}
private void Emit(ActionButtonEvent evt)
{
ButtonEvent?.Invoke(evt);
}
private Vector2 GetScreenCenter()
{
if (screenCenterOverride.HasValue)
{
return screenCenterOverride.Value;
}
RectTransform rect = rectTransform != null ? rectTransform : transform as RectTransform;
return rect != null ? RectTransformUtility.WorldToScreenPoint(GetUICamera(rect), rect.position) : Vector2.zero;
}
private float GetScreenRadius()
{
if (screenRadiusOverride.HasValue)
{
return Mathf.Max(1f, screenRadiusOverride.Value);
}
RectTransform rect = rectTransform != null ? rectTransform : transform as RectTransform;
if (rect == null)
{
return 1f;
}
Vector3[] corners = new Vector3[4];
rect.GetWorldCorners(corners);
Camera cam = GetUICamera(rect);
Vector2 s0 = RectTransformUtility.WorldToScreenPoint(cam, corners[0]);
Vector2 s2 = RectTransformUtility.WorldToScreenPoint(cam, corners[2]);
return Mathf.Max(1f, Mathf.Min(Mathf.Abs(s2.x - s0.x), Mathf.Abs(s2.y - s0.y)) * 0.5f);
}
private Camera GetUICamera(RectTransform rect)
{
Canvas canvas = rect != null ? rect.GetComponentInParent<Canvas>() : null;
if (canvas == null)
{
return null;
}
canvas = canvas.rootCanvas;
return canvas.renderMode == RenderMode.ScreenSpaceOverlay ? null : canvas.worldCamera;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 5e719656ace242789c22b78b47abcc66
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using UnityEngine;
namespace XGame.VirtualInput
{
public sealed class VirtualActionPad : MonoBehaviour
{
private readonly Dictionary<ActionButtonId, VirtualActionButton> buttons =
new Dictionary<ActionButtonId, VirtualActionButton>();
private RectTransform cancelArea;
public event Action<ActionButtonEvent> ButtonEvent;
private void Awake()
{
BindNodes();
}
private void OnDestroy()
{
foreach (VirtualActionButton button in buttons.Values)
{
if (button != null)
{
button.ButtonEvent -= OnChildButtonEvent;
}
}
buttons.Clear();
}
public VirtualActionButton GetButton(ActionButtonId id)
{
buttons.TryGetValue(id, out VirtualActionButton button);
return button;
}
public void SetCooldown(ActionButtonId id, float remainingSeconds, float durationSeconds)
{
VirtualActionButton button = GetButton(id);
if (button != null)
{
button.SetCooldown(remainingSeconds, durationSeconds);
}
}
public void SetCharges(ActionButtonId id, int count)
{
VirtualActionButton button = GetButton(id);
if (button != null)
{
button.SetCharges(count);
}
}
public void SetButtonEnabled(ActionButtonId id, bool enabled)
{
VirtualActionButton button = GetButton(id);
if (button != null)
{
button.SetButtonEnabled(enabled);
}
}
public void SetCancelAreaVisible(bool visible)
{
if (cancelArea != null)
{
cancelArea.gameObject.SetActive(visible);
}
}
private void BindNodes()
{
Transform cancel = transform.FindTransform("CancelArea");
cancelArea = cancel as RectTransform;
RegisterButton(ActionButtonId.Primary, "Btn_Primary");
RegisterButton(ActionButtonId.Skill1, "Btn_Skill1");
RegisterButton(ActionButtonId.Skill2, "Btn_Skill2");
RegisterButton(ActionButtonId.Skill3, "Btn_Skill3");
RegisterButton(ActionButtonId.Skill4, "Btn_Skill4");
SetCancelAreaVisible(false);
}
private void RegisterButton(ActionButtonId id, string childName)
{
Transform child = transform.FindTransform(childName);
if (child == null)
{
Debug.LogWarning("[VirtualActionPad] missing child button: " + childName, this);
return;
}
VirtualActionButton button = child.GetComponent<VirtualActionButton>();
if (button == null)
{
button = child.gameObject.AddComponent<VirtualActionButton>();
}
button.ButtonId = id;
button.SetCancelArea(cancelArea);
button.ButtonEvent -= OnChildButtonEvent;
button.ButtonEvent += OnChildButtonEvent;
buttons[id] = button;
}
private void OnChildButtonEvent(ActionButtonEvent evt)
{
if (evt.Phase == ActionButtonPhase.Drag || evt.Phase == ActionButtonPhase.HoldStart)
{
SetCancelAreaVisible(cancelArea != null);
}
else if (evt.Phase == ActionButtonPhase.Release || evt.Phase == ActionButtonPhase.Tap || evt.Phase == ActionButtonPhase.Cancel || evt.Phase == ActionButtonPhase.Blocked)
{
SetCancelAreaVisible(false);
}
ButtonEvent?.Invoke(evt);
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 7c5500d410634275b54631c131475f3d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:
@@ -0,0 +1,273 @@
using System;
using UnityEngine;
using UnityEngine.EventSystems;
namespace XGame.VirtualInput
{
public sealed class VirtualJoystick : MonoBehaviour
{
[SerializeField] private RectTransform joystickRoot;
[SerializeField] private RectTransform knob;
[SerializeField] private float deadZone = 0.18f;
[SerializeField] private float acquireRadiusMultiplier = 1.6f;
private Vector2 value;
private bool isActive;
private bool isCaptured;
private bool sentStarted;
private int touchFingerId = -1;
private Vector2? screenCenterOverride;
private float? screenRadiusOverride;
public Vector2 Value => value;
public bool IsActive => isActive;
public event Action Started;
public event Action<Vector2, bool> Changed;
public event Action Ended;
private void Awake()
{
BindNodes();
}
private void OnDisable()
{
ReleasePointer();
}
private void Update()
{
if (!isActiveAndEnabled)
{
return;
}
if (UnityEngine.Input.touchCount > 0)
{
UpdateTouchInput();
return;
}
touchFingerId = -1;
UpdateMouseInput();
}
public bool IsScreenPositionInAcquireArea(Vector2 screenPosition)
{
return Vector2.Distance(screenPosition, GetScreenCenter()) <= GetScreenRadius() * acquireRadiusMultiplier;
}
private void BindNodes()
{
if (joystickRoot == null)
{
Transform widget = transform.Find("Joystick");
joystickRoot = widget as RectTransform;
}
if (joystickRoot == null)
{
joystickRoot = transform as RectTransform;
}
if (knob == null)
{
Transform knobTransform = transform.FindTransform("Knob");
knob = knobTransform as RectTransform;
}
if (knob == null)
{
Debug.LogError("[VirtualJoystick] missing Knob node: " + name, this);
enabled = false;
}
}
private void UpdateMouseInput()
{
if (UnityEngine.Input.GetMouseButtonDown(0))
{
isCaptured = IsScreenPositionInAcquireArea(UnityEngine.Input.mousePosition);
}
if (UnityEngine.Input.GetMouseButtonUp(0))
{
isCaptured = false;
ReleasePointer();
return;
}
if (isCaptured && UnityEngine.Input.GetMouseButton(0))
{
ApplyPointer(UnityEngine.Input.mousePosition);
}
else
{
ReleasePointer();
}
}
private void UpdateTouchInput()
{
if (touchFingerId >= 0)
{
for (int i = 0; i < UnityEngine.Input.touchCount; i++)
{
Touch touch = UnityEngine.Input.GetTouch(i);
if (touch.fingerId != touchFingerId)
{
continue;
}
if (touch.phase == TouchPhase.Ended || touch.phase == TouchPhase.Canceled)
{
touchFingerId = -1;
ReleasePointer();
return;
}
ApplyPointer(touch.position);
return;
}
touchFingerId = -1;
ReleasePointer();
return;
}
for (int i = 0; i < UnityEngine.Input.touchCount; i++)
{
Touch touch = UnityEngine.Input.GetTouch(i);
if (touch.phase == TouchPhase.Ended || touch.phase == TouchPhase.Canceled)
{
continue;
}
if (IsScreenPositionInAcquireArea(touch.position))
{
touchFingerId = touch.fingerId;
ApplyPointer(touch.position);
return;
}
}
ReleasePointer();
}
private void ApplyPointer(Vector2 screenPosition)
{
if (!sentStarted)
{
sentStarted = true;
Started?.Invoke();
}
Vector2 raw = (screenPosition - GetScreenCenter()) / GetScreenRadius();
Vector2 nextValue = Vector2.ClampMagnitude(raw, 1f);
bool nextActive = nextValue.magnitude >= deadZone;
if (!nextActive)
{
nextValue = Vector2.zero;
}
bool changed = (nextValue - value).sqrMagnitude > 0.000001f || nextActive != isActive;
value = nextValue;
isActive = nextActive;
UpdateKnob();
if (changed)
{
Changed?.Invoke(value, isActive);
}
}
private void ReleasePointer()
{
bool hadState = sentStarted || isActive || value.sqrMagnitude > 0.000001f;
sentStarted = false;
isCaptured = false;
touchFingerId = -1;
value = Vector2.zero;
isActive = false;
UpdateKnob();
if (hadState)
{
Changed?.Invoke(value, isActive);
Ended?.Invoke();
}
}
private void UpdateKnob()
{
if (knob != null)
{
knob.anchoredPosition = value * GetKnobTravelRadius();
}
}
private float GetKnobTravelRadius()
{
RectTransform root = joystickRoot != null ? joystickRoot : transform as RectTransform;
if (root == null)
{
return GetScreenRadius();
}
float rootHalf = Mathf.Min(root.rect.width, root.rect.height) * 0.5f;
float knobHalf = knob != null ? Mathf.Min(knob.rect.width, knob.rect.height) * 0.5f : 0f;
return Mathf.Max(1f, rootHalf - knobHalf);
}
private Vector2 GetScreenCenter()
{
if (screenCenterOverride.HasValue)
{
return screenCenterOverride.Value;
}
RectTransform root = joystickRoot != null ? joystickRoot : transform as RectTransform;
if (root == null)
{
return Vector2.zero;
}
return RectTransformUtility.WorldToScreenPoint(GetUICamera(), root.position);
}
private float GetScreenRadius()
{
if (screenRadiusOverride.HasValue)
{
return Mathf.Max(1f, screenRadiusOverride.Value);
}
RectTransform root = joystickRoot != null ? joystickRoot : transform as RectTransform;
if (root == null)
{
return 1f;
}
Camera cam = GetUICamera();
Vector3[] corners = new Vector3[4];
root.GetWorldCorners(corners);
Vector2 s0 = RectTransformUtility.WorldToScreenPoint(cam, corners[0]);
Vector2 s2 = RectTransformUtility.WorldToScreenPoint(cam, corners[2]);
return Mathf.Max(1f, Mathf.Min(Mathf.Abs(s2.x - s0.x), Mathf.Abs(s2.y - s0.y)) * 0.5f);
}
private Camera GetUICamera()
{
RectTransform root = joystickRoot != null ? joystickRoot : transform as RectTransform;
Canvas canvas = root != null ? root.GetComponentInParent<Canvas>() : null;
if (canvas == null)
{
return null;
}
canvas = canvas.rootCanvas;
return canvas.renderMode == RenderMode.ScreenSpaceOverlay ? null : canvas.worldCamera;
}
}
}
@@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 014f26c5eef24321b81f131f41a05a1d
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant: