400 lines
14 KiB
C#
400 lines
14 KiB
C#
using System;
|
|
using System.Threading;
|
|
using Cysharp.Threading.Tasks;
|
|
using PrimeTween;
|
|
using TMPro;
|
|
using UnityEngine;
|
|
using UnityEngine.EventSystems;
|
|
using UnityEngine.UI;
|
|
|
|
namespace BriarQueen.UI.Menus.Components
|
|
{
|
|
[ExecuteAlways]
|
|
[RequireComponent(typeof(Button))]
|
|
public class AnimatedSelectionButton : MonoBehaviour,
|
|
IPointerEnterHandler,
|
|
IPointerExitHandler,
|
|
IPointerClickHandler,
|
|
ISelectHandler,
|
|
IDeselectHandler
|
|
{
|
|
private const string DefaultRevealPropertyName = "_Reveal";
|
|
|
|
[Header("References")]
|
|
[SerializeField] private Button _button;
|
|
[SerializeField] private RectTransform _selectionBackground;
|
|
[SerializeField] private TextMeshProUGUI _label;
|
|
|
|
[Header("Background Sizing")]
|
|
[SerializeField] private Vector2 _backgroundPadding = new(24f, 6f);
|
|
[SerializeField] private bool _matchBackgroundToLabelPosition = true;
|
|
|
|
[Header("Background Material")]
|
|
[SerializeField] private bool _useShaderReveal = true;
|
|
[SerializeField] private Material _selectionMaterial;
|
|
[SerializeField] private Color _selectionColor = new(0.02f, 0.018f, 0.015f, 0.35f);
|
|
[SerializeField] private string _revealPropertyName = "_Reveal";
|
|
|
|
[Header("Edit Preview")]
|
|
[SerializeField] private bool _previewInEditMode;
|
|
[SerializeField][Range(0f, 1f)] private float _previewReveal = 1f;
|
|
|
|
[Header("Tween Settings")]
|
|
[SerializeField] private float _selectDuration = 0.18f;
|
|
[SerializeField] private float _deselectDuration = 0.12f;
|
|
[SerializeField] private Ease _ease = Ease.InOutSine;
|
|
[SerializeField] private bool _useUnscaledTime = true;
|
|
|
|
private CancellationTokenSource _selectionCts;
|
|
private Graphic _selectionBackgroundGraphic;
|
|
private Material _sourceSelectionMaterial;
|
|
private Material _runtimeSelectionMaterial;
|
|
private Sequence _selectionSequence;
|
|
private int _animationVersion;
|
|
private int _colorPropertyId;
|
|
private int _revealPropertyId;
|
|
private float _backgroundProgress;
|
|
|
|
private bool _isSelected;
|
|
|
|
// Group listens to these to coordinate highlight state
|
|
public event Action<AnimatedSelectionButton> SelectionRequested;
|
|
public event Action<AnimatedSelectionButton> HoverEntered;
|
|
public event Action<AnimatedSelectionButton> HoverExited;
|
|
|
|
public Button Button
|
|
{
|
|
get
|
|
{
|
|
if (_button == null)
|
|
_button = GetComponent<Button>();
|
|
return _button;
|
|
}
|
|
}
|
|
|
|
public bool IsSelected => _isSelected;
|
|
|
|
// ── Unity lifecycle ───────────────────────────────────────────
|
|
|
|
private void Reset() => ResolveReferences();
|
|
|
|
private void Awake()
|
|
{
|
|
ResolveReferences();
|
|
EnsureRuntimeSelectionMaterial();
|
|
|
|
if (!Application.isPlaying)
|
|
{
|
|
ApplyEditPreview();
|
|
return;
|
|
}
|
|
|
|
ApplyInstant(false);
|
|
}
|
|
|
|
private void OnValidate()
|
|
{
|
|
ResolveReferences();
|
|
|
|
if (Application.isPlaying) return;
|
|
|
|
DestroyRuntimeSelectionMaterial();
|
|
EnsureRuntimeSelectionMaterial();
|
|
ApplyEditPreview();
|
|
}
|
|
|
|
private void Update()
|
|
{
|
|
if (Application.isPlaying || !_previewInEditMode) return;
|
|
ApplyEditPreview();
|
|
}
|
|
|
|
private void OnDisable()
|
|
{
|
|
CancelSelectionTween();
|
|
}
|
|
|
|
private void OnDestroy()
|
|
{
|
|
CancelSelectionTween();
|
|
DestroyRuntimeSelectionMaterial();
|
|
}
|
|
|
|
// ── Pointer / selection events ────────────────────────────────
|
|
|
|
public void OnPointerEnter(PointerEventData eventData)
|
|
{
|
|
if (Button != null && !Button.interactable) return;
|
|
HoverEntered?.Invoke(this);
|
|
}
|
|
|
|
public void OnPointerExit(PointerEventData eventData)
|
|
{
|
|
HoverExited?.Invoke(this);
|
|
}
|
|
|
|
public void OnPointerClick(PointerEventData eventData)
|
|
{
|
|
if (Button != null && !Button.interactable) return;
|
|
SelectionRequested?.Invoke(this);
|
|
}
|
|
|
|
public void OnSelect(BaseEventData eventData)
|
|
{
|
|
if (Button != null && !Button.interactable) return;
|
|
HoverEntered?.Invoke(this);
|
|
SelectionRequested?.Invoke(this);
|
|
}
|
|
|
|
public void OnDeselect(BaseEventData eventData)
|
|
{
|
|
HoverExited?.Invoke(this);
|
|
}
|
|
|
|
// ── Public API ────────────────────────────────────────────────
|
|
|
|
/// <summary>Called by the group to set whether this button's selected state.</summary>
|
|
public void SetSelectedState(bool selected)
|
|
{
|
|
_isSelected = selected;
|
|
}
|
|
|
|
/// <summary>Called by the group to drive the highlight visual directly.</summary>
|
|
public void SetHighlight(bool show, bool instant = false)
|
|
{
|
|
if (instant || !gameObject.activeInHierarchy)
|
|
{
|
|
CancelSelectionTween();
|
|
RefreshBackgroundSize();
|
|
ApplyBackgroundProgress(show ? 1f : 0f);
|
|
if (_selectionBackground != null)
|
|
_selectionBackground.gameObject.SetActive(show);
|
|
return;
|
|
}
|
|
|
|
AnimateSelection(show).Forget();
|
|
}
|
|
|
|
/// <summary>Legacy — drives both state and highlight together for non-group usage.</summary>
|
|
public void SetSelected(bool selected, bool instant = false)
|
|
{
|
|
_isSelected = selected;
|
|
SetHighlight(selected, instant);
|
|
}
|
|
|
|
public void ApplyInstant(bool selected)
|
|
{
|
|
_isSelected = selected;
|
|
RefreshBackgroundSize();
|
|
ApplyBackgroundProgress(selected ? 1f : 0f);
|
|
|
|
if (_selectionBackground != null)
|
|
_selectionBackground.gameObject.SetActive(selected);
|
|
}
|
|
|
|
public GameObject GetSelectableGameObject() => gameObject;
|
|
|
|
// ── Internal ──────────────────────────────────────────────────
|
|
|
|
private async UniTaskVoid AnimateSelection(bool show)
|
|
{
|
|
CancelSelectionTween();
|
|
|
|
if (_selectionBackground == null) return;
|
|
|
|
RefreshBackgroundSize();
|
|
|
|
var duration = show ? _selectDuration : _deselectDuration;
|
|
if (duration <= 0f)
|
|
{
|
|
ApplyInstant(_isSelected);
|
|
return;
|
|
}
|
|
|
|
_selectionBackground.gameObject.SetActive(true);
|
|
EnsureRuntimeSelectionMaterial();
|
|
_selectionCts = new CancellationTokenSource();
|
|
_animationVersion++;
|
|
|
|
var localVersion = _animationVersion;
|
|
var target = show ? 1f : 0f;
|
|
var start = _backgroundProgress;
|
|
|
|
_selectionSequence = Sequence.Create(useUnscaledTime: _useUnscaledTime)
|
|
.Group(Tween.Custom(
|
|
start,
|
|
target,
|
|
duration,
|
|
ApplyBackgroundProgress,
|
|
_ease,
|
|
useUnscaledTime: _useUnscaledTime));
|
|
|
|
try
|
|
{
|
|
await _selectionSequence.ToUniTask(cancellationToken: _selectionCts.Token);
|
|
ApplyBackgroundProgress(target);
|
|
|
|
if (!show)
|
|
_selectionBackground.gameObject.SetActive(false);
|
|
}
|
|
catch (OperationCanceledException)
|
|
{
|
|
}
|
|
finally
|
|
{
|
|
if (localVersion == _animationVersion)
|
|
{
|
|
_selectionSequence = default;
|
|
|
|
if (_selectionCts != null)
|
|
{
|
|
_selectionCts.Dispose();
|
|
_selectionCts = null;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private void ApplyBackgroundProgress(float progress)
|
|
{
|
|
if (_selectionBackground == null) return;
|
|
|
|
EnsureRuntimeSelectionMaterial();
|
|
|
|
var scale = _selectionBackground.localScale;
|
|
_backgroundProgress = Mathf.Clamp01(progress);
|
|
scale.x = UsesShaderReveal() ? 1f : _backgroundProgress;
|
|
_selectionBackground.localScale = scale;
|
|
|
|
if (UsesShaderReveal())
|
|
ApplyRuntimeMaterialProperties();
|
|
}
|
|
|
|
private void RefreshBackgroundSize()
|
|
{
|
|
if (_selectionBackground == null || _label == null) return;
|
|
|
|
_label.ForceMeshUpdate();
|
|
|
|
var preferredSize = _label.GetPreferredValues(_label.text, Mathf.Infinity, Mathf.Infinity);
|
|
var backgroundSize = preferredSize + _backgroundPadding;
|
|
|
|
_selectionBackground.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, backgroundSize.x);
|
|
_selectionBackground.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, backgroundSize.y);
|
|
|
|
if (!_matchBackgroundToLabelPosition) return;
|
|
|
|
var labelRect = _label.rectTransform;
|
|
_selectionBackground.position = labelRect.TransformPoint(labelRect.rect.center);
|
|
}
|
|
|
|
private void ResolveReferences()
|
|
{
|
|
if (_button == null)
|
|
_button = GetComponent<Button>();
|
|
|
|
if (_label == null)
|
|
_label = GetComponentInChildren<TextMeshProUGUI>(true);
|
|
|
|
if (_selectionBackground != null && _selectionBackgroundGraphic == null)
|
|
_selectionBackgroundGraphic = _selectionBackground.GetComponent<Graphic>();
|
|
|
|
_revealPropertyId = Shader.PropertyToID(string.IsNullOrWhiteSpace(_revealPropertyName)
|
|
? DefaultRevealPropertyName
|
|
: _revealPropertyName);
|
|
_colorPropertyId = Shader.PropertyToID("_Color");
|
|
}
|
|
|
|
private void ApplyEditPreview()
|
|
{
|
|
if (_selectionBackground == null) return;
|
|
|
|
RefreshBackgroundSize();
|
|
|
|
if (!_previewInEditMode)
|
|
{
|
|
ApplyBackgroundProgress(0f);
|
|
_selectionBackground.gameObject.SetActive(false);
|
|
return;
|
|
}
|
|
|
|
_selectionBackground.gameObject.SetActive(true);
|
|
ApplyBackgroundProgress(_previewReveal);
|
|
}
|
|
|
|
private void EnsureRuntimeSelectionMaterial()
|
|
{
|
|
if (!_useShaderReveal || _selectionBackground == null) return;
|
|
|
|
if (_selectionBackgroundGraphic == null)
|
|
_selectionBackgroundGraphic = _selectionBackground.GetComponent<Graphic>();
|
|
|
|
if (_selectionBackgroundGraphic == null || _runtimeSelectionMaterial != null) return;
|
|
|
|
var sourceMaterial = _selectionMaterial != null
|
|
? _selectionMaterial
|
|
: _selectionBackgroundGraphic.material;
|
|
|
|
if (sourceMaterial == null || !sourceMaterial.HasProperty(_revealPropertyId)) return;
|
|
|
|
_sourceSelectionMaterial = sourceMaterial;
|
|
_runtimeSelectionMaterial = new Material(sourceMaterial)
|
|
{
|
|
name = $"{sourceMaterial.name} (Runtime)",
|
|
hideFlags = HideFlags.DontSave
|
|
};
|
|
ApplyRuntimeMaterialProperties();
|
|
_selectionBackgroundGraphic.material = _runtimeSelectionMaterial;
|
|
}
|
|
|
|
private void ApplyRuntimeMaterialProperties()
|
|
{
|
|
if (_runtimeSelectionMaterial == null) return;
|
|
|
|
if (_runtimeSelectionMaterial.HasProperty(_revealPropertyId))
|
|
_runtimeSelectionMaterial.SetFloat(_revealPropertyId, _backgroundProgress);
|
|
|
|
if (_runtimeSelectionMaterial.HasProperty(_colorPropertyId))
|
|
_runtimeSelectionMaterial.SetColor(_colorPropertyId, _selectionColor);
|
|
}
|
|
|
|
private bool UsesShaderReveal()
|
|
{
|
|
return _useShaderReveal
|
|
&& _runtimeSelectionMaterial != null
|
|
&& _runtimeSelectionMaterial.HasProperty(_revealPropertyId);
|
|
}
|
|
|
|
private void DestroyRuntimeSelectionMaterial()
|
|
{
|
|
if (_runtimeSelectionMaterial == null) return;
|
|
|
|
if (_selectionBackgroundGraphic != null &&
|
|
_selectionBackgroundGraphic.material == _runtimeSelectionMaterial)
|
|
_selectionBackgroundGraphic.material = _sourceSelectionMaterial;
|
|
|
|
if (Application.isPlaying)
|
|
Destroy(_runtimeSelectionMaterial);
|
|
else
|
|
DestroyImmediate(_runtimeSelectionMaterial);
|
|
|
|
_runtimeSelectionMaterial = null;
|
|
_sourceSelectionMaterial = null;
|
|
}
|
|
|
|
private void CancelSelectionTween()
|
|
{
|
|
if (_selectionSequence.isAlive)
|
|
_selectionSequence.Stop();
|
|
|
|
_selectionSequence = default;
|
|
|
|
if (_selectionCts == null) return;
|
|
|
|
try { _selectionCts.Cancel(); } catch { }
|
|
_selectionCts.Dispose();
|
|
_selectionCts = null;
|
|
_animationVersion++;
|
|
}
|
|
}
|
|
} |