499 lines
16 KiB
C#
499 lines
16 KiB
C#
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;
|
|
}
|
|
}
|
|
}
|