Restructured for new direction.
This commit is contained in:
400
Assets/Scripts/UI/Menus/Components/AnimatedSelectionButton.cs
Normal file
400
Assets/Scripts/UI/Menus/Components/AnimatedSelectionButton.cs
Normal file
@@ -0,0 +1,400 @@
|
||||
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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8fbce7c4a9464f1297b6045ed5c9e71b
|
||||
timeCreated: 1770375662
|
||||
@@ -0,0 +1,144 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
|
||||
namespace BriarQueen.UI.Menus.Components
|
||||
{
|
||||
public class AnimatedSelectionButtonGroup : MonoBehaviour
|
||||
{
|
||||
[Header("Buttons")]
|
||||
[SerializeField]
|
||||
private AnimatedSelectionButton[] _buttons = Array.Empty<AnimatedSelectionButton>();
|
||||
|
||||
[SerializeField]
|
||||
private int _defaultSelectedIndex;
|
||||
|
||||
[Header("Selection")]
|
||||
[SerializeField]
|
||||
private bool _selectDefaultOnEnable = true;
|
||||
|
||||
[SerializeField]
|
||||
private bool _syncEventSystemSelection = true;
|
||||
|
||||
public AnimatedSelectionButton Current { get; private set; }
|
||||
public AnimatedSelectionButton CurrentHovered { get; private set; }
|
||||
|
||||
private void Reset()
|
||||
{
|
||||
_buttons = GetComponentsInChildren<AnimatedSelectionButton>(true);
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
BindButtons();
|
||||
|
||||
if (_selectDefaultOnEnable)
|
||||
SelectIndex(_defaultSelectedIndex, true);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
UnbindButtons();
|
||||
CurrentHovered = null;
|
||||
}
|
||||
|
||||
public void SelectIndex(int index, bool instant = false)
|
||||
{
|
||||
if (_buttons == null || _buttons.Length == 0) return;
|
||||
SelectButton(_buttons[Mathf.Clamp(index, 0, _buttons.Length - 1)], instant);
|
||||
}
|
||||
|
||||
public void SelectButton(AnimatedSelectionButton selectedButton, bool instant = false)
|
||||
{
|
||||
if (selectedButton == null) return;
|
||||
if (Current == selectedButton && !instant) return;
|
||||
|
||||
Current = selectedButton;
|
||||
|
||||
RefreshAllButtonStates(instant);
|
||||
SyncEventSystemSelection(selectedButton);
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────
|
||||
|
||||
private void RefreshAllButtonStates(bool instant = false)
|
||||
{
|
||||
if (_buttons == null) return;
|
||||
|
||||
foreach (var button in _buttons)
|
||||
{
|
||||
if (button == null) continue;
|
||||
|
||||
// A button shows its highlight if it is selected OR it is the
|
||||
// hovered button AND no other button is being hovered that would
|
||||
// take visual priority. Selected button highlight only shows when
|
||||
// nothing is hovered, or when it itself is hovered.
|
||||
var isSelected = button == Current;
|
||||
var isHovered = button == CurrentHovered;
|
||||
|
||||
var showHighlight = isSelected
|
||||
? CurrentHovered == null || isHovered // selected: show unless something else is hovered
|
||||
: isHovered; // unselected: show only if hovered
|
||||
|
||||
button.SetHighlight(showHighlight, instant);
|
||||
button.SetSelectedState(isSelected);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleSelectionRequested(AnimatedSelectionButton selectedButton)
|
||||
{
|
||||
SelectButton(selectedButton);
|
||||
}
|
||||
|
||||
private void HandleHoverEntered(AnimatedSelectionButton button)
|
||||
{
|
||||
CurrentHovered = button;
|
||||
RefreshAllButtonStates();
|
||||
}
|
||||
|
||||
private void HandleHoverExited(AnimatedSelectionButton button)
|
||||
{
|
||||
if (CurrentHovered == button)
|
||||
CurrentHovered = null;
|
||||
|
||||
RefreshAllButtonStates();
|
||||
}
|
||||
|
||||
private void BindButtons()
|
||||
{
|
||||
if (_buttons == null) return;
|
||||
|
||||
foreach (var button in _buttons)
|
||||
{
|
||||
if (button == null) continue;
|
||||
button.SelectionRequested += HandleSelectionRequested;
|
||||
button.HoverEntered += HandleHoverEntered;
|
||||
button.HoverExited += HandleHoverExited;
|
||||
}
|
||||
}
|
||||
|
||||
private void UnbindButtons()
|
||||
{
|
||||
if (_buttons == null) return;
|
||||
|
||||
foreach (var button in _buttons)
|
||||
{
|
||||
if (button == null) continue;
|
||||
button.SelectionRequested -= HandleSelectionRequested;
|
||||
button.HoverEntered -= HandleHoverEntered;
|
||||
button.HoverExited -= HandleHoverExited;
|
||||
}
|
||||
}
|
||||
|
||||
private void SyncEventSystemSelection(AnimatedSelectionButton selectedButton)
|
||||
{
|
||||
if (!_syncEventSystemSelection || selectedButton.Button == null || EventSystem.current == null)
|
||||
return;
|
||||
|
||||
var selectedGameObject = selectedButton.Button.gameObject;
|
||||
if (EventSystem.current.currentSelectedGameObject == selectedGameObject) return;
|
||||
|
||||
EventSystem.current.SetSelectedGameObject(selectedGameObject);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f7c446e43fa2491d888c8bb4bb48eb52
|
||||
timeCreated: 1770375663
|
||||
171
Assets/Scripts/UI/Menus/Components/SaveSlotHighlight.cs
Normal file
171
Assets/Scripts/UI/Menus/Components/SaveSlotHighlight.cs
Normal file
@@ -0,0 +1,171 @@
|
||||
using PrimeTween;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace BriarQueen.UI.Menus.Components
|
||||
{
|
||||
public class SaveSlotHighlight : MonoBehaviour
|
||||
{
|
||||
[Header("Lines")]
|
||||
[SerializeField] private RectTransform _topLine;
|
||||
[SerializeField] private RectTransform _bottomLine;
|
||||
|
||||
[Header("Colours")]
|
||||
[SerializeField] private Color _hoverColour = new(0.65f, 0.10f, 0.10f, 0.5f);
|
||||
[SerializeField] private Color _selectedColour = new(0.65f, 0.10f, 0.10f, 1.0f);
|
||||
|
||||
[Header("Tween")]
|
||||
[SerializeField] private float _duration = 0.25f;
|
||||
[SerializeField] private Ease _ease = Ease.OutQuint;
|
||||
[SerializeField] private bool _useUnscaledTime = true;
|
||||
|
||||
private Image _topImage;
|
||||
private Image _bottomImage;
|
||||
private Tween _topTween;
|
||||
private Tween _bottomTween;
|
||||
private Tween _topColourTween;
|
||||
private Tween _bottomColourTween;
|
||||
|
||||
private bool _isSelected;
|
||||
private bool _isHovered;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (_topLine != null) _topImage = _topLine.GetComponent<Image>();
|
||||
if (_bottomLine != null) _bottomImage = _bottomLine.GetComponent<Image>();
|
||||
|
||||
// Start fully scaled out on X
|
||||
SetScaleImmediate(0f);
|
||||
SetColourImmediate(Color.clear);
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
_topTween.Stop();
|
||||
_bottomTween.Stop();
|
||||
_topColourTween.Stop();
|
||||
_bottomColourTween.Stop();
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────
|
||||
|
||||
public void SetSelected(bool selected)
|
||||
{
|
||||
_isSelected = selected;
|
||||
RefreshState();
|
||||
}
|
||||
|
||||
public void SetSelectedImmediate(bool selected)
|
||||
{
|
||||
_isSelected = selected;
|
||||
StopAllTweens();
|
||||
var (scale, colour) = ResolveTargetState();
|
||||
SetScaleImmediate(scale);
|
||||
SetColourImmediate(colour);
|
||||
}
|
||||
|
||||
public void SetHovered(bool hovered)
|
||||
{
|
||||
_isHovered = hovered;
|
||||
RefreshState();
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────
|
||||
|
||||
private void RefreshState()
|
||||
{
|
||||
var (scale, colour) = ResolveTargetState();
|
||||
TweenToScale(scale);
|
||||
TweenToColour(colour);
|
||||
}
|
||||
|
||||
private (float scale, Color colour) ResolveTargetState()
|
||||
{
|
||||
if (_isSelected) return (1f, _selectedColour);
|
||||
if (_isHovered) return (1f, _hoverColour);
|
||||
return (0f, Color.clear);
|
||||
}
|
||||
|
||||
private void TweenToScale(float target)
|
||||
{
|
||||
_topTween.Stop();
|
||||
_bottomTween.Stop();
|
||||
|
||||
var currentScale = _topLine != null
|
||||
? _topLine.localScale.x
|
||||
: 0f;
|
||||
|
||||
if (_topLine != null)
|
||||
{
|
||||
_topTween = Tween.ScaleX(
|
||||
_topLine,
|
||||
currentScale,
|
||||
target,
|
||||
_duration,
|
||||
_ease,
|
||||
useUnscaledTime: _useUnscaledTime);
|
||||
}
|
||||
|
||||
if (_bottomLine != null)
|
||||
{
|
||||
_bottomTween = Tween.ScaleX(
|
||||
_bottomLine,
|
||||
currentScale,
|
||||
target,
|
||||
_duration,
|
||||
_ease,
|
||||
useUnscaledTime: _useUnscaledTime);
|
||||
}
|
||||
}
|
||||
|
||||
private void TweenToColour(Color target)
|
||||
{
|
||||
_topColourTween.Stop();
|
||||
_bottomColourTween.Stop();
|
||||
|
||||
if (_topImage != null)
|
||||
{
|
||||
_topColourTween = Tween.Color(
|
||||
_topImage,
|
||||
_topImage.color,
|
||||
target,
|
||||
_duration,
|
||||
_ease,
|
||||
useUnscaledTime: _useUnscaledTime);
|
||||
}
|
||||
|
||||
if (_bottomImage != null)
|
||||
{
|
||||
_bottomColourTween = Tween.Color(
|
||||
_bottomImage,
|
||||
_bottomImage.color,
|
||||
target,
|
||||
_duration,
|
||||
_ease,
|
||||
useUnscaledTime: _useUnscaledTime);
|
||||
}
|
||||
}
|
||||
|
||||
private void SetScaleImmediate(float scaleX)
|
||||
{
|
||||
if (_topLine != null)
|
||||
_topLine.localScale = new Vector3(scaleX, 1f, 1f);
|
||||
if (_bottomLine != null)
|
||||
_bottomLine.localScale = new Vector3(scaleX, 1f, 1f);
|
||||
}
|
||||
|
||||
private void SetColourImmediate(Color colour)
|
||||
{
|
||||
if (_topImage != null) _topImage.color = colour;
|
||||
if (_bottomImage != null) _bottomImage.color = colour;
|
||||
}
|
||||
|
||||
private void StopAllTweens()
|
||||
{
|
||||
_topTween.Stop();
|
||||
_bottomTween.Stop();
|
||||
_topColourTween.Stop();
|
||||
_bottomColourTween.Stop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d0e824631a104902b958bf53ff6803d1
|
||||
timeCreated: 1778342396
|
||||
25
Assets/Scripts/UI/Menus/Components/SaveSlotPointerRelay.cs
Normal file
25
Assets/Scripts/UI/Menus/Components/SaveSlotPointerRelay.cs
Normal file
@@ -0,0 +1,25 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
|
||||
namespace BriarQueen.UI.Menus.Components
|
||||
{
|
||||
public class SaveSlotPointerRelay : MonoBehaviour, IPointerEnterHandler, IPointerExitHandler
|
||||
{
|
||||
private SaveSlotUI _slotUI;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_slotUI = GetComponentInParent<SaveSlotUI>();
|
||||
}
|
||||
|
||||
public void OnPointerEnter(PointerEventData eventData)
|
||||
{
|
||||
_slotUI?.OnPointerEnter(eventData);
|
||||
}
|
||||
|
||||
public void OnPointerExit(PointerEventData eventData)
|
||||
{
|
||||
_slotUI?.OnPointerExit(eventData);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c351000954cf4e44a18e71725f29b2b7
|
||||
timeCreated: 1778343589
|
||||
@@ -1,19 +1,14 @@
|
||||
using System;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.UI;
|
||||
|
||||
namespace BriarQueen.UI.Menus.Components
|
||||
{
|
||||
/// <summary>
|
||||
/// Save Slot UI:
|
||||
/// - No Load button.
|
||||
/// - Slot itself is clickable:
|
||||
/// - Filled => click loads
|
||||
/// - Empty => click creates (via SelectSaveWindow opening NewSaveWindow)
|
||||
/// - Delete button remains for filled saves.
|
||||
/// </summary>
|
||||
public class SaveSlotUI : MonoBehaviour
|
||||
public class SaveSlotUI : MonoBehaviour,
|
||||
IPointerEnterHandler,
|
||||
IPointerExitHandler
|
||||
{
|
||||
[Header("Clickable Root")]
|
||||
[SerializeField]
|
||||
@@ -30,17 +25,21 @@ namespace BriarQueen.UI.Menus.Components
|
||||
[SerializeField]
|
||||
private Button _deleteButton;
|
||||
|
||||
[Header("Empty Visual")]
|
||||
[Header("Empty Text")]
|
||||
[SerializeField]
|
||||
private Image _emptyImage;
|
||||
private TextMeshProUGUI _emptyImage;
|
||||
|
||||
[Header("Highlight")]
|
||||
[SerializeField]
|
||||
private SaveSlotHighlight _highlight;
|
||||
|
||||
private Action<SaveFileInfo> _onDeleteClick;
|
||||
private Action _onEmptyClick;
|
||||
|
||||
private Action _onEmptyClick;
|
||||
private Action<SaveFileInfo> _onFilledClick;
|
||||
|
||||
public SaveFileInfo SaveInfo { get; private set; }
|
||||
public bool IsEmpty { get; private set; }
|
||||
public SaveFileInfo SaveInfo { get; private set; }
|
||||
public bool IsEmpty { get; private set; }
|
||||
public bool IsSelected { get; private set; }
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
@@ -59,28 +58,52 @@ namespace BriarQueen.UI.Menus.Components
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (_slotButton != null) _slotButton.onClick.RemoveAllListeners();
|
||||
if (_slotButton != null) _slotButton.onClick.RemoveAllListeners();
|
||||
if (_deleteButton != null) _deleteButton.onClick.RemoveAllListeners();
|
||||
}
|
||||
|
||||
// ── Pointer events ────────────────────────────────────────────
|
||||
|
||||
public void OnPointerEnter(PointerEventData eventData)
|
||||
{
|
||||
_highlight?.SetHovered(true);
|
||||
}
|
||||
|
||||
public void OnPointerExit(PointerEventData eventData)
|
||||
{
|
||||
_highlight?.SetHovered(false);
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────
|
||||
|
||||
public GameObject GetSelectableGameObject()
|
||||
{
|
||||
return _slotButton != null ? _slotButton.gameObject : gameObject;
|
||||
}
|
||||
|
||||
public void SetSelected(bool selected, bool immediate = false)
|
||||
{
|
||||
IsSelected = selected;
|
||||
|
||||
if (immediate)
|
||||
_highlight?.SetSelectedImmediate(selected);
|
||||
else
|
||||
_highlight?.SetSelected(selected);
|
||||
}
|
||||
|
||||
public void SetFilled(
|
||||
SaveFileInfo saveInfo,
|
||||
Action<SaveFileInfo> onClickFilled,
|
||||
Action<SaveFileInfo> onDelete)
|
||||
{
|
||||
SaveInfo = saveInfo;
|
||||
IsEmpty = false;
|
||||
IsEmpty = false;
|
||||
|
||||
_onFilledClick = onClickFilled;
|
||||
_onEmptyClick = null;
|
||||
_onEmptyClick = null;
|
||||
_onDeleteClick = onDelete;
|
||||
|
||||
if (_emptyImage != null) _emptyImage.gameObject.SetActive(false);
|
||||
if (_emptyImage != null) _emptyImage.gameObject.SetActive(false);
|
||||
|
||||
if (_saveNameText != null)
|
||||
{
|
||||
@@ -107,16 +130,16 @@ namespace BriarQueen.UI.Menus.Components
|
||||
public void SetEmpty(Action onClickEmpty)
|
||||
{
|
||||
SaveInfo = default;
|
||||
IsEmpty = true;
|
||||
IsEmpty = true;
|
||||
|
||||
_onFilledClick = null;
|
||||
_onEmptyClick = onClickEmpty;
|
||||
_onEmptyClick = onClickEmpty;
|
||||
_onDeleteClick = null;
|
||||
|
||||
if (_saveNameText != null) _saveNameText.text = string.Empty;
|
||||
if (_saveDateText != null) _saveDateText.text = string.Empty;
|
||||
|
||||
if (_emptyImage != null) _emptyImage.gameObject.SetActive(true);
|
||||
if (_emptyImage != null) _emptyImage.gameObject.SetActive(true);
|
||||
|
||||
if (_deleteButton != null)
|
||||
{
|
||||
@@ -125,20 +148,20 @@ namespace BriarQueen.UI.Menus.Components
|
||||
}
|
||||
|
||||
if (_slotButton != null)
|
||||
_slotButton.interactable = true; // empty slot is still clickable
|
||||
_slotButton.interactable = true;
|
||||
}
|
||||
|
||||
public void SetInteractable(bool interactable)
|
||||
{
|
||||
// Slot click should still work even when empty; we fully disable during "busy" only.
|
||||
if (_slotButton != null)
|
||||
_slotButton.interactable = interactable;
|
||||
|
||||
// Delete only for filled saves.
|
||||
if (_deleteButton != null)
|
||||
_deleteButton.interactable = interactable && !IsEmpty;
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────
|
||||
|
||||
private void HandleSlotClicked()
|
||||
{
|
||||
if (IsEmpty)
|
||||
@@ -149,8 +172,7 @@ namespace BriarQueen.UI.Menus.Components
|
||||
|
||||
private void HandleDeleteClicked()
|
||||
{
|
||||
if (IsEmpty)
|
||||
return;
|
||||
if (IsEmpty) return;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(SaveInfo.FileName))
|
||||
_onDeleteClick?.Invoke(SaveInfo);
|
||||
@@ -159,12 +181,12 @@ namespace BriarQueen.UI.Menus.Components
|
||||
|
||||
public struct SaveFileInfo
|
||||
{
|
||||
public readonly string FileName;
|
||||
public readonly string FileName;
|
||||
public readonly DateTime LastModified;
|
||||
|
||||
public SaveFileInfo(string fileName, DateTime lastModified)
|
||||
{
|
||||
FileName = fileName;
|
||||
FileName = fileName;
|
||||
LastModified = lastModified;
|
||||
}
|
||||
}
|
||||
|
||||
158
Assets/Scripts/UI/Menus/Components/UnderlineButton.cs
Normal file
158
Assets/Scripts/UI/Menus/Components/UnderlineButton.cs
Normal file
@@ -0,0 +1,158 @@
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
using UnityEngine.UI;
|
||||
using System;
|
||||
|
||||
namespace BriarQueen.UI.Menus.Components
|
||||
{
|
||||
[RequireComponent(typeof(Button))]
|
||||
public class UnderlineButton : MonoBehaviour,
|
||||
IPointerEnterHandler,
|
||||
IPointerExitHandler,
|
||||
IPointerClickHandler,
|
||||
ISelectHandler,
|
||||
IDeselectHandler
|
||||
{
|
||||
[Header("References")]
|
||||
[SerializeField] private TextMeshProUGUI _label;
|
||||
|
||||
private Button _button;
|
||||
private UnderlineButtonGroup _group;
|
||||
private bool _isHovered;
|
||||
|
||||
public event Action<UnderlineButton> SelectionRequested;
|
||||
public event Action<UnderlineButton> HoverEntered;
|
||||
public event Action<UnderlineButton> HoverExited;
|
||||
|
||||
public bool IsSelected { get; private set; }
|
||||
|
||||
// True when this button is registered with a group
|
||||
public bool IsGrouped => _group != null;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_button = GetComponent<Button>();
|
||||
SetUnderline(false);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
_isHovered = false;
|
||||
SetUnderline(false);
|
||||
}
|
||||
|
||||
// ── Group registration ────────────────────────────────────────
|
||||
|
||||
/// <summary>Called by UnderlineButtonGroup when this button is bound.</summary>
|
||||
internal void RegisterGroup(UnderlineButtonGroup group)
|
||||
{
|
||||
_group = group;
|
||||
}
|
||||
|
||||
/// <summary>Called by UnderlineButtonGroup when this button is unbound.</summary>
|
||||
internal void UnregisterGroup()
|
||||
{
|
||||
_group = null;
|
||||
}
|
||||
|
||||
// ── Pointer / Selection events ────────────────────────────────
|
||||
|
||||
public void OnPointerEnter(PointerEventData eventData)
|
||||
{
|
||||
if (_button != null && !_button.interactable) return;
|
||||
|
||||
_isHovered = true;
|
||||
|
||||
if (IsGrouped)
|
||||
HoverEntered?.Invoke(this);
|
||||
else
|
||||
RefreshStandaloneState();
|
||||
}
|
||||
|
||||
public void OnPointerExit(PointerEventData eventData)
|
||||
{
|
||||
_isHovered = false;
|
||||
|
||||
if (IsGrouped)
|
||||
HoverExited?.Invoke(this);
|
||||
else
|
||||
RefreshStandaloneState();
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
_isHovered = true;
|
||||
|
||||
if (IsGrouped)
|
||||
{
|
||||
HoverEntered?.Invoke(this);
|
||||
SelectionRequested?.Invoke(this);
|
||||
}
|
||||
else
|
||||
{
|
||||
IsSelected = true;
|
||||
RefreshStandaloneState();
|
||||
}
|
||||
}
|
||||
|
||||
public void OnDeselect(BaseEventData eventData)
|
||||
{
|
||||
_isHovered = false;
|
||||
|
||||
if (IsGrouped)
|
||||
HoverExited?.Invoke(this);
|
||||
else
|
||||
{
|
||||
IsSelected = false;
|
||||
RefreshStandaloneState();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>Called by the group to update logical selected state.</summary>
|
||||
public void SetSelectedState(bool selected)
|
||||
{
|
||||
IsSelected = selected;
|
||||
}
|
||||
|
||||
/// <summary>Called by the group to drive the underline visual directly.</summary>
|
||||
public void SetUnderline(bool underlined)
|
||||
{
|
||||
if (_label == null) return;
|
||||
|
||||
if (underlined)
|
||||
_label.fontStyle |= FontStyles.Underline;
|
||||
else
|
||||
_label.fontStyle &= ~FontStyles.Underline;
|
||||
}
|
||||
|
||||
public GameObject GetSelectableGameObject() => gameObject;
|
||||
|
||||
// ── Standalone logic ──────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Used when not in a group — the button manages its own
|
||||
/// underline based on hover and selected state directly.
|
||||
/// </summary>
|
||||
private void RefreshStandaloneState()
|
||||
{
|
||||
if (_button != null && !_button.interactable)
|
||||
{
|
||||
SetUnderline(false);
|
||||
return;
|
||||
}
|
||||
|
||||
SetUnderline(IsSelected || _isHovered);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 107dd116a1c64bd8afb3a5e93a535c7b
|
||||
timeCreated: 1778344990
|
||||
175
Assets/Scripts/UI/Menus/Components/UnderlineButtonGroup.cs
Normal file
175
Assets/Scripts/UI/Menus/Components/UnderlineButtonGroup.cs
Normal file
@@ -0,0 +1,175 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
|
||||
namespace BriarQueen.UI.Menus.Components
|
||||
{
|
||||
public class UnderlineButtonGroup : MonoBehaviour
|
||||
{
|
||||
[Header("Buttons")]
|
||||
[SerializeField] private UnderlineButton[] _initialButtons = Array.Empty<UnderlineButton>();
|
||||
|
||||
[SerializeField] private int _defaultSelectedIndex;
|
||||
[SerializeField] private bool _selectDefaultOnEnable = true;
|
||||
[SerializeField] private bool _syncEventSystemSelection = true;
|
||||
|
||||
// Runtime list — combines inspector-assigned and dynamically added buttons
|
||||
private readonly List<UnderlineButton> _buttons = new();
|
||||
|
||||
public UnderlineButton Current { get; private set; }
|
||||
public UnderlineButton CurrentHovered { get; private set; }
|
||||
|
||||
private void Reset()
|
||||
{
|
||||
_initialButtons = GetComponentsInChildren<UnderlineButton>(true);
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
// Register inspector-assigned buttons first
|
||||
foreach (var button in _initialButtons)
|
||||
AddButton(button);
|
||||
|
||||
if (_selectDefaultOnEnable && _buttons.Count > 0)
|
||||
SelectIndex(_defaultSelectedIndex, instant: true);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
// Remove all — clears both initial and runtime-added buttons
|
||||
for (var i = _buttons.Count - 1; i >= 0; i--)
|
||||
RemoveButton(_buttons[i]);
|
||||
|
||||
CurrentHovered = null;
|
||||
Current = null;
|
||||
}
|
||||
|
||||
// ── Public API ────────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Registers a button with the group at runtime.
|
||||
/// Safe to call multiple times — ignores already-registered buttons.
|
||||
/// </summary>
|
||||
public void AddButton(UnderlineButton button)
|
||||
{
|
||||
if (button == null || _buttons.Contains(button)) return;
|
||||
|
||||
_buttons.Add(button);
|
||||
button.RegisterGroup(this);
|
||||
button.SelectionRequested += HandleSelectionRequested;
|
||||
button.HoverEntered += HandleHoverEntered;
|
||||
button.HoverExited += HandleHoverExited;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Unregisters a button from the group.
|
||||
/// Safe to call on buttons not in the group.
|
||||
/// </summary>
|
||||
public void RemoveButton(UnderlineButton button)
|
||||
{
|
||||
if (button == null || !_buttons.Contains(button)) return;
|
||||
|
||||
// If this was the selected or hovered button, clear those references
|
||||
if (Current == button) Current = null;
|
||||
if (CurrentHovered == button) CurrentHovered = null;
|
||||
|
||||
button.UnregisterGroup();
|
||||
button.SelectionRequested -= HandleSelectionRequested;
|
||||
button.HoverEntered -= HandleHoverEntered;
|
||||
button.HoverExited -= HandleHoverExited;
|
||||
button.SetUnderline(false);
|
||||
button.SetSelectedState(false);
|
||||
|
||||
_buttons.Remove(button);
|
||||
|
||||
RefreshAllButtonStates();
|
||||
}
|
||||
|
||||
/// <summary>Removes all dynamically added buttons, leaving inspector-assigned ones.</summary>
|
||||
public void RemoveAllDynamicButtons()
|
||||
{
|
||||
for (var i = _buttons.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var button = _buttons[i];
|
||||
if (button == null) continue;
|
||||
|
||||
// Skip buttons that were assigned in the inspector
|
||||
var isInitial = System.Array.IndexOf(_initialButtons, button) >= 0;
|
||||
if (isInitial) continue;
|
||||
|
||||
RemoveButton(button);
|
||||
}
|
||||
}
|
||||
|
||||
public void SelectIndex(int index, bool instant = false)
|
||||
{
|
||||
if (_buttons == null || _buttons.Count == 0) return;
|
||||
SelectButton(_buttons[Mathf.Clamp(index, 0, _buttons.Count - 1)], instant);
|
||||
}
|
||||
|
||||
public void SelectButton(UnderlineButton button, bool instant = false)
|
||||
{
|
||||
if (button == null) return;
|
||||
if (Current == button && !instant) return;
|
||||
|
||||
Current = button;
|
||||
RefreshAllButtonStates();
|
||||
SyncEventSystemSelection(button);
|
||||
}
|
||||
|
||||
public void ClearSelection()
|
||||
{
|
||||
Current = null;
|
||||
RefreshAllButtonStates();
|
||||
}
|
||||
|
||||
// ── Internal ──────────────────────────────────────────────────
|
||||
|
||||
private void RefreshAllButtonStates()
|
||||
{
|
||||
foreach (var button in _buttons)
|
||||
{
|
||||
if (button == null) continue;
|
||||
|
||||
var isSelected = button == Current;
|
||||
var isHovered = button == CurrentHovered;
|
||||
|
||||
var showUnderline = isSelected
|
||||
? CurrentHovered == null || isHovered
|
||||
: isHovered;
|
||||
|
||||
button.SetSelectedState(isSelected);
|
||||
button.SetUnderline(showUnderline);
|
||||
}
|
||||
}
|
||||
|
||||
private void HandleSelectionRequested(UnderlineButton button)
|
||||
{
|
||||
SelectButton(button);
|
||||
}
|
||||
|
||||
private void HandleHoverEntered(UnderlineButton button)
|
||||
{
|
||||
CurrentHovered = button;
|
||||
RefreshAllButtonStates();
|
||||
}
|
||||
|
||||
private void HandleHoverExited(UnderlineButton button)
|
||||
{
|
||||
if (CurrentHovered == button)
|
||||
CurrentHovered = null;
|
||||
|
||||
RefreshAllButtonStates();
|
||||
}
|
||||
|
||||
private void SyncEventSystemSelection(UnderlineButton button)
|
||||
{
|
||||
if (!_syncEventSystemSelection || EventSystem.current == null) return;
|
||||
|
||||
var go = button.GetSelectableGameObject();
|
||||
if (go == null || EventSystem.current.currentSelectedGameObject == go) return;
|
||||
EventSystem.current.SetSelectedGameObject(go);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 6d80f3295d5743b08ad4e7de1cfc5555
|
||||
timeCreated: 1778519999
|
||||
Reference in New Issue
Block a user