Restructured for new direction.

This commit is contained in:
2026-05-12 12:01:09 +01:00
parent 0439b6c1d2
commit c203f836b1
1134 changed files with 125569 additions and 213519 deletions

View 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++;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 8fbce7c4a9464f1297b6045ed5c9e71b
timeCreated: 1770375662

View File

@@ -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);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: f7c446e43fa2491d888c8bb4bb48eb52
timeCreated: 1770375663

View 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();
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: d0e824631a104902b958bf53ff6803d1
timeCreated: 1778342396

View 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);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: c351000954cf4e44a18e71725f29b2b7
timeCreated: 1778343589

View File

@@ -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;
}
}

View 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);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 107dd116a1c64bd8afb3a5e93a535c7b
timeCreated: 1778344990

View 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);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 6d80f3295d5743b08ad4e7de1cfc5555
timeCreated: 1778519999