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

View File

@@ -0,0 +1,315 @@
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
{
public class ConfirmActionWindow : MonoBehaviour
{
[Header("Root")]
[SerializeField]
private GameObject _root;
[SerializeField]
private CanvasGroup _canvasGroup;
[SerializeField]
private RectTransform _panelTransform;
[Header("Text")]
[SerializeField]
private TextMeshProUGUI _titleText;
[SerializeField]
private TextMeshProUGUI _messageText;
[SerializeField]
private TextMeshProUGUI _confirmLabel;
[SerializeField]
private TextMeshProUGUI _cancelLabel;
[Header("Buttons")]
[SerializeField]
private Button _confirmButton;
[SerializeField]
private Button _cancelButton;
[Header("Tween Settings")]
[SerializeField]
private TweenSettings _tweenSettings = new()
{
duration = 0.18f,
ease = Ease.OutBack,
useUnscaledTime = true
};
[SerializeField]
private float _hiddenScale = 0.92f;
private Action _onCancel;
private Action _onConfirm;
private CancellationTokenSource _cts;
private Sequence _sequence;
public bool IsOpen { get; private set; }
private void Awake()
{
ResolveReferences();
if (_confirmButton != null)
{
_confirmButton.onClick.AddListener(Confirm);
}
if (_cancelButton != null)
{
_cancelButton.onClick.AddListener(Cancel);
}
CloseImmediate();
}
private void OnDestroy()
{
if (_confirmButton != null)
{
_confirmButton.onClick.RemoveListener(Confirm);
}
if (_cancelButton != null)
{
_cancelButton.onClick.RemoveListener(Cancel);
}
StopAndDispose();
}
public void Open(
string title,
string message,
string confirmText,
string cancelText,
Action onConfirm,
Action onCancel)
{
_onConfirm = onConfirm;
_onCancel = onCancel;
if (_titleText != null)
{
_titleText.text = title;
}
if (_messageText != null)
{
_messageText.text = message;
}
if (_confirmLabel != null)
{
_confirmLabel.text = confirmText;
}
if (_cancelLabel != null)
{
_cancelLabel.text = cancelText;
}
OpenInternal().Forget();
}
public void CancelFromBack()
{
Cancel();
}
public void CloseImmediate()
{
StopAndDispose();
IsOpen = false;
_onConfirm = null;
_onCancel = null;
if (_root != null)
{
_root.SetActive(false);
}
if (_canvasGroup != null)
{
_canvasGroup.alpha = 0f;
_canvasGroup.interactable = false;
_canvasGroup.blocksRaycasts = false;
}
if (_panelTransform != null)
{
_panelTransform.localScale = Vector3.one;
}
}
private async UniTaskVoid OpenInternal()
{
ResolveReferences();
StopAndDispose();
_cts = new CancellationTokenSource();
if (_root != null)
{
_root.SetActive(true);
}
if (_canvasGroup != null)
{
_canvasGroup.alpha = 0f;
_canvasGroup.interactable = false;
_canvasGroup.blocksRaycasts = false;
}
if (_panelTransform != null)
{
_panelTransform.localScale = Vector3.one * _hiddenScale;
}
IsOpen = true;
_sequence = Sequence.Create(useUnscaledTime: true);
if (_canvasGroup != null)
{
_sequence.Group(Tween.Alpha(_canvasGroup, new TweenSettings<float>
{
startValue = 0f,
endValue = 1f,
settings = _tweenSettings
}));
}
if (_panelTransform != null)
{
_sequence.Group(Tween.Scale(_panelTransform, new TweenSettings<Vector3>
{
startValue = Vector3.one * _hiddenScale,
endValue = Vector3.one,
settings = _tweenSettings
}));
}
try
{
await _sequence.ToUniTask(cancellationToken: _cts.Token);
}
catch (OperationCanceledException)
{
return;
}
finally
{
_sequence = default;
}
if (_canvasGroup != null)
{
_canvasGroup.alpha = 1f;
_canvasGroup.interactable = true;
_canvasGroup.blocksRaycasts = true;
}
SelectCancelButton();
}
private void Confirm()
{
if (!IsOpen)
{
return;
}
var onConfirm = _onConfirm;
CloseImmediate();
onConfirm?.Invoke();
}
private void Cancel()
{
if (!IsOpen)
{
return;
}
var onCancel = _onCancel;
CloseImmediate();
onCancel?.Invoke();
}
private void ResolveReferences()
{
if (_root == null)
{
_root = gameObject;
}
if (_canvasGroup == null)
{
_canvasGroup = _root.GetComponent<CanvasGroup>();
}
if (_panelTransform == null)
{
_panelTransform = transform as RectTransform;
}
}
private void SelectCancelButton()
{
if (_cancelButton == null)
{
return;
}
if (EventSystem.current != null)
{
EventSystem.current.SetSelectedGameObject(_cancelButton.gameObject);
return;
}
_cancelButton.Select();
}
private void StopAndDispose()
{
if (_sequence.isAlive)
{
_sequence.Stop();
}
_sequence = default;
if (_cts == null)
{
return;
}
try
{
_cts.Cancel();
}
catch
{
}
_cts.Dispose();
_cts = null;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 9fbd9bbf0c284c69b03e68a1b82cc74e
timeCreated: 1778300000

View File

@@ -70,6 +70,8 @@ namespace BriarQueen.UI.Menus
private Sequence _sequence;
public bool IsOpen => _isOpen;
private void Awake()
{
if (_confirmButton != null) _confirmButton.onClick.AddListener(Confirm);
@@ -104,6 +106,11 @@ namespace BriarQueen.UI.Menus
CloseAsync().Forget();
}
public void CancelFromBack()
{
Cancel();
}
public void CloseImmediate()
{
StopAnim();
@@ -338,4 +345,4 @@ namespace BriarQueen.UI.Menus
_root = gameObject;
}
}
}
}

View File

@@ -1,53 +1,59 @@
using System;
using System.Threading;
using BriarQueen.Framework.Coordinators.Events;
using BriarQueen.Framework.Effects;
using BriarQueen.Framework.Events.UI;
using BriarQueen.Framework.Managers.Input;
using BriarQueen.Framework.Managers.UI.Events;
using BriarQueen.Framework.Services.Game;
using BriarQueen.Game.Effects;
using BriarQueen.UI.Menus.Components;
using Cysharp.Threading.Tasks;
using PrimeTween;
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
using UnityEngine.Serialization;
using UnityEngine.UI;
using VContainer;
namespace BriarQueen.UI.Menus
{
/// <summary>
/// Main Menu flow:
/// - Starts on intro screen
/// - Intro light pulses
/// - Intro text/title fade in after delay
/// - Submit fades intro text/title out, pushes light to full alpha, then fades main menu in
/// - Start Game => opens SelectSaveWindow
/// - Settings => opens settings menu
/// - Quit => quits app
/// </summary>
public class MainMenuWindow : MonoBehaviour
{
[Header("Main Menu Window")]
[SerializeField]
private CanvasGroup _mainMenuIntroScreenCanvasGroup;
[SerializeField]
private CanvasGroup _mainMenuWindowCanvasGroup;
[Header("Intro Screen")]
[SerializeField]
private Image _introScreenLightImage;
[FormerlySerializedAs("_mainMenuIntroScreenCanvasGroup")]
private CanvasGroup _introScreenCanvas;
[SerializeField]
private CanvasGroup _introTextCanvasGroup;
[FormerlySerializedAs("_introTextText")]
private TextMeshProUGUI _pressStartText;
[SerializeField]
private TextMeshProUGUI _introTextText;
private UIDissolveImage _introGroupDissolveGroup;
[Header("Main Menu")]
[SerializeField]
private CanvasGroup _introTitleCanvasGroup;
[FormerlySerializedAs("_mainMenuWindowCanvasGroup")]
private CanvasGroup _mainMenuGroup;
[Header("Buttons")]
[SerializeField]
private CanvasGroup _buttonsGroup;
[SerializeField]
private AnimatedSelectionButtonGroup _mainMenuSelectionGroup;
[SerializeField]
private AnimatedSelectionButton _startGameSelectionButton;
[SerializeField]
private AnimatedSelectionButton _settingsSelectionButton;
[SerializeField]
private AnimatedSelectionButton _quitSelectionButton;
[SerializeField]
private Button _startGameButton;
@@ -61,62 +67,62 @@ namespace BriarQueen.UI.Menus
[SerializeField]
private SelectSaveWindow _selectSaveWindow;
[SerializeField]
private CanvasGroup _selectSaveContainerCanvasGroup;
[SerializeField]
private CanvasGroup _selectSaveWindowCanvasGroup;
[SerializeField]
private UIFogReveal _selectSaveWindowFog;
[Header("Tween Settings")]
[SerializeField]
private TweenSettings _selectSaveTweenSettings = new()
{
duration = 0.25f,
ease = Ease.OutQuad,
duration = 0.25f,
ease = Ease.OutQuad,
useUnscaledTime = true
};
[Header("Intro Timing")]
[SerializeField]
private float _introLightPulseDuration = 2f;
private TweenSettings _pressStartFadeTweenSettings = new()
{
duration = 0.25f,
ease = Ease.OutQuad,
useUnscaledTime = true
};
[SerializeField]
private float _introTextDelaySeconds = 1.5f;
private TweenSettings _pressStartPulseTweenSettings = new()
{
duration = 0.85f,
ease = Ease.InOutSine,
useUnscaledTime = true
};
[SerializeField]
private float _introTextFadeInDuration = 0.8f;
[SerializeField]
private float _introTextPulseDuration = 1.4f;
[SerializeField]
private float _introSubmitTextFadeOutDuration = 0.25f;
[SerializeField]
private float _introSubmitLightToFullDuration = 0.75f;
[SerializeField]
private float _introToMenuCrossfadeDuration = 0.6f;
private EventCoordinator _eventCoordinator;
private GameService _gameService;
private InputManager _inputManager;
[Range(0f, 1f)]
private float _pressStartPulseMinimumAlpha = 0.25f;
private CancellationTokenSource _introCts;
private CancellationTokenSource _selectSaveCts;
private Sequence _introLightPulseSequence;
private Sequence _introTextPulseSequence;
private Sequence _introTransitionSequence;
private Sequence _selectSaveSequence;
private EventCoordinator _eventCoordinator;
private GameService _gameService;
private InputManager _inputManager;
private Sequence _pressStartFadeSequence;
private Sequence _pressStartPulseSequence;
private Sequence _selectSaveSequence;
private bool _introFinished;
private bool _introTransitioning;
private DeviceInputType _lastDeviceInputType;
[Inject]
public void Construct(GameService gameService, EventCoordinator eventCoordinator, InputManager inputManager)
{
_gameService = gameService;
_gameService = gameService;
_eventCoordinator = eventCoordinator;
_inputManager = inputManager;
_inputManager = inputManager;
}
private void Awake()
@@ -128,8 +134,6 @@ namespace BriarQueen.UI.Menus
_selectSaveWindow.OnCloseWindow += CloseSelectSaveWindow;
_selectSaveWindow.gameObject.SetActive(false);
}
UpdateSubmitText(force: true);
}
private void OnEnable()
@@ -138,8 +142,23 @@ namespace BriarQueen.UI.Menus
_eventCoordinator?.PublishImmediate(new UIToggleHudEvent(false));
_inputManager?.BindSubmitForStart(OnIntroSubmit);
_eventCoordinator?.Subscribe<UIBackRequestedEvent>(OnBackRequested);
StartIntroScreen().Forget();
ApplyInitialVisualState();
}
private void Update()
{
if (_introFinished || _introTransitioning)
{
return;
}
if (Mouse.current?.leftButton.wasPressedThisFrame == true ||
Gamepad.current?.buttonSouth.wasPressedThisFrame == true)
{
TransitionFromIntroToMainMenu().Forget();
}
}
private void OnDisable()
@@ -147,6 +166,7 @@ namespace BriarQueen.UI.Menus
UnbindButtons();
_inputManager?.ResetSubmitBind(OnIntroSubmit);
_eventCoordinator?.Unsubscribe<UIBackRequestedEvent>(OnBackRequested);
StopIntroTweens();
StopSelectSaveTween();
@@ -155,300 +175,137 @@ namespace BriarQueen.UI.Menus
private void OnDestroy()
{
if (_selectSaveWindow != null)
{
_selectSaveWindow.OnCloseWindow -= CloseSelectSaveWindow;
}
StopIntroTweens();
StopSelectSaveTween();
}
private void LateUpdate()
{
UpdateSubmitText();
}
private void BindButtons()
{
if (_startGameButton != null)
_startGameButton.onClick.AddListener(OnStartClicked);
var startGameButton = ResolveButton(_startGameSelectionButton, _startGameButton);
if (startGameButton != null)
{
startGameButton.onClick.AddListener(OnStartClicked);
}
if (_settingsButton != null)
_settingsButton.onClick.AddListener(OnSettingsClicked);
var settingsButton = ResolveButton(_settingsSelectionButton, _settingsButton);
if (settingsButton != null)
{
settingsButton.onClick.AddListener(OnSettingsClicked);
}
if (_quitButton != null)
_quitButton.onClick.AddListener(OnQuitClicked);
var quitButton = ResolveButton(_quitSelectionButton, _quitButton);
if (quitButton != null)
{
quitButton.onClick.AddListener(OnQuitClicked);
}
}
private void UnbindButtons()
{
if (_startGameButton != null)
_startGameButton.onClick.RemoveListener(OnStartClicked);
var startGameButton = ResolveButton(_startGameSelectionButton, _startGameButton);
if (startGameButton != null)
{
startGameButton.onClick.RemoveListener(OnStartClicked);
}
if (_settingsButton != null)
_settingsButton.onClick.RemoveListener(OnSettingsClicked);
var settingsButton = ResolveButton(_settingsSelectionButton, _settingsButton);
if (settingsButton != null)
{
settingsButton.onClick.RemoveListener(OnSettingsClicked);
}
if (_quitButton != null)
_quitButton.onClick.RemoveListener(OnQuitClicked);
var quitButton = ResolveButton(_quitSelectionButton, _quitButton);
if (quitButton != null)
{
quitButton.onClick.RemoveListener(OnQuitClicked);
}
}
private void ApplyInitialVisualState()
{
SetCanvasGroupState(_mainMenuIntroScreenCanvasGroup, 1f, true);
SetCanvasGroupState(_mainMenuWindowCanvasGroup, 0f, false);
SetCanvasGroupState(_introTextCanvasGroup, 0f, false);
SetCanvasGroupState(_introTitleCanvasGroup, 0f, false);
SetCanvasGroupState(_selectSaveWindowCanvasGroup, 0f, false);
if (_introScreenLightImage != null)
{
var color = _introScreenLightImage.color;
color.a = 0f;
_introScreenLightImage.color = color;
}
}
private void UpdateSubmitText(bool force = false)
{
if (_introTextText == null || _inputManager == null)
return;
var currentDevice = _inputManager.DeviceInputType;
if (!force && currentDevice == _lastDeviceInputType)
return;
_lastDeviceInputType = currentDevice;
var isKeyboardMouse = currentDevice == DeviceInputType.KeyboardAndMouse;
_introTextText.text = isKeyboardMouse
? "Press Enter to begin."
: "Press Start to begin.";
}
private async UniTaskVoid StartIntroScreen()
{
_introFinished = false;
_introFinished = false;
_introTransitioning = false;
ResetIntroCtsAndCancelRunning();
ApplyInitialVisualState();
UpdateSubmitText(force: true);
try
SetCanvasGroupState(_introScreenCanvas, 1f, true);
if (_introScreenCanvas != null)
{
StartIntroLightPulse(_introCts.Token);
await StartIntroTextFlow(_introCts.Token);
}
catch (OperationCanceledException)
{
}
}
private void StartIntroLightPulse(CancellationToken token)
{
if (_introScreenLightImage == null)
return;
_introLightPulseSequence = Sequence.Create(
useUnscaledTime: true,
cycleMode: Sequence.SequenceCycleMode.Yoyo,
cycles: -1)
.Group(Tween.Alpha(_introScreenLightImage, new TweenSettings<float>
{
startValue = _introScreenLightImage.color.a,
endValue = 1f,
settings = new TweenSettings
{
duration = _introLightPulseDuration,
ease = Ease.InOutSine,
useUnscaledTime = true
}
}));
_introLightPulseSequence.ToUniTask(cancellationToken: token).Forget();
}
private async UniTask StartIntroTextFlow(CancellationToken token)
{
await UniTask.Delay(TimeSpan.FromSeconds(_introTextDelaySeconds), cancellationToken: token);
var fadeInSequence = Sequence.Create(useUnscaledTime: true);
if (_introTitleCanvasGroup != null)
{
fadeInSequence.Group(Tween.Alpha(_introTitleCanvasGroup, new TweenSettings<float>
{
startValue = 0f,
endValue = 1f,
settings = new TweenSettings
{
duration = _introTextFadeInDuration,
ease = Ease.OutQuad,
useUnscaledTime = true
}
}));
_introScreenCanvas.gameObject.SetActive(true);
}
if (_introTextCanvasGroup != null)
SetCanvasGroupState(_mainMenuGroup, 1f, false);
if (_mainMenuGroup != null)
{
fadeInSequence.Group(Tween.Alpha(_introTextCanvasGroup, new TweenSettings<float>
{
startValue = 0f,
endValue = 1f,
settings = new TweenSettings
{
duration = _introTextFadeInDuration,
ease = Ease.OutQuad,
useUnscaledTime = true
}
}));
_mainMenuGroup.gameObject.SetActive(true);
}
await fadeInSequence.ToUniTask(cancellationToken: token);
SetCanvasGroupState(_selectSaveContainerCanvasGroup, 0f, false);
SetCanvasGroupState(_selectSaveWindowCanvasGroup, 0f, false);
if (_introTextCanvasGroup == null)
return;
if (_pressStartText != null)
{
var color = _pressStartText.color;
color.a = 1f;
_pressStartText.color = color;
_pressStartText.gameObject.SetActive(true);
}
_introTextPulseSequence = Sequence.Create(
useUnscaledTime: true,
cycleMode: Sequence.SequenceCycleMode.Yoyo,
cycles: -1)
.Group(Tween.Alpha(_introTextCanvasGroup, new TweenSettings<float>
{
startValue = _introTextCanvasGroup.alpha,
endValue = 0.1f,
settings = new TweenSettings
{
duration = _introTextPulseDuration,
ease = Ease.InOutSine,
useUnscaledTime = true
}
}));
await _introTextPulseSequence.ToUniTask(cancellationToken: token);
StartPressStartPulse();
_introGroupDissolveGroup?.SetDissolveAmount(0f);
_selectSaveWindowFog?.FogSet(0f);
}
private void OnIntroSubmit(InputAction.CallbackContext ctx)
{
if (_introFinished || _introTransitioning)
{
return;
}
if (!ctx.performed)
{
return;
}
TransitionFromIntroToMainMenu().Forget();
}
private void OnBackRequested(UIBackRequestedEvent _)
{
if (!_introFinished || _selectSaveWindow == null || _selectSaveWindowCanvasGroup == null)
{
return;
}
if (!_selectSaveWindow.gameObject.activeInHierarchy || !_selectSaveWindowCanvasGroup.interactable)
{
return;
}
_selectSaveWindow.HandleBackRequest();
}
private async UniTaskVoid TransitionFromIntroToMainMenu()
{
if (_introFinished || _introTransitioning)
{
return;
}
_introTransitioning = true;
ResetIntroCtsAndCancelRunning();
try
{
// Phase 1: fade intro title + text fully out together, while pushing light to full.
var introElementsSequence = Sequence.Create(useUnscaledTime: true);
var fadeTextTask = FadeOutPressStartText(_introCts.Token);
var dissolveTask = _introGroupDissolveGroup != null
? _introGroupDissolveGroup.DissolveOut(false).AttachExternalCancellation(_introCts.Token)
: UniTask.CompletedTask;
if (_introTitleCanvasGroup != null)
{
introElementsSequence.Group(Tween.Alpha(_introTitleCanvasGroup, new TweenSettings<float>
{
startValue = _introTitleCanvasGroup.alpha,
endValue = 0f,
settings = new TweenSettings
{
duration = _introSubmitTextFadeOutDuration,
ease = Ease.OutQuad,
useUnscaledTime = true
}
}));
}
if (_introTextCanvasGroup != null)
{
introElementsSequence.Group(Tween.Alpha(_introTextCanvasGroup, new TweenSettings<float>
{
startValue = _introTextCanvasGroup.alpha,
endValue = 0f,
settings = new TweenSettings
{
duration = _introSubmitTextFadeOutDuration,
ease = Ease.OutQuad,
useUnscaledTime = true
}
}));
}
if (_introScreenLightImage != null)
{
introElementsSequence.Group(Tween.Alpha(_introScreenLightImage, new TweenSettings<float>
{
startValue = _introScreenLightImage.color.a,
endValue = 1f,
settings = new TweenSettings
{
duration = _introSubmitLightToFullDuration,
ease = Ease.OutQuad,
useUnscaledTime = true
}
}));
}
await introElementsSequence.ToUniTask(cancellationToken: _introCts.Token);
// Ensure intro text/title are fully gone before menu begins fading in.
SetCanvasGroupState(_introTitleCanvasGroup, 0f, false);
SetCanvasGroupState(_introTextCanvasGroup, 0f, false);
SetCanvasGroupState(_mainMenuWindowCanvasGroup, 0f, false);
// Phase 2: only after intro text/title have finished fading, crossfade to main menu.
if (_mainMenuIntroScreenCanvasGroup != null && _mainMenuWindowCanvasGroup != null)
{
_introTransitionSequence = Sequence.Create(useUnscaledTime: true)
.Group(Tween.Alpha(_mainMenuIntroScreenCanvasGroup, new TweenSettings<float>
{
startValue = _mainMenuIntroScreenCanvasGroup.alpha,
endValue = 0f,
settings = new TweenSettings
{
duration = _introToMenuCrossfadeDuration,
ease = Ease.OutQuad,
useUnscaledTime = true
}
}))
.Group(Tween.Alpha(_mainMenuWindowCanvasGroup, new TweenSettings<float>
{
startValue = 0f,
endValue = 1f,
settings = new TweenSettings
{
duration = _introToMenuCrossfadeDuration,
ease = Ease.OutQuad,
useUnscaledTime = true
}
}));
await _introTransitionSequence.ToUniTask(cancellationToken: _introCts.Token);
}
else if (_mainMenuWindowCanvasGroup != null)
{
var menuFade = Sequence.Create(useUnscaledTime: true)
.Group(Tween.Alpha(_mainMenuWindowCanvasGroup, new TweenSettings<float>
{
startValue = 0f,
endValue = 1f,
settings = new TweenSettings
{
duration = _introToMenuCrossfadeDuration,
ease = Ease.OutQuad,
useUnscaledTime = true
}
}));
await menuFade.ToUniTask(cancellationToken: _introCts.Token);
}
await UniTask.WhenAll(fadeTextTask, dissolveTask);
}
catch (OperationCanceledException)
{
@@ -457,28 +314,73 @@ namespace BriarQueen.UI.Menus
}
finally
{
_introTransitionSequence = default;
_pressStartFadeSequence = default;
}
SetCanvasGroupState(_introTextCanvasGroup, 0f, false);
SetCanvasGroupState(_introTitleCanvasGroup, 0f, false);
SetCanvasGroupState(_mainMenuIntroScreenCanvasGroup, 0f, false);
SetCanvasGroupState(_mainMenuWindowCanvasGroup, 1f, true);
if (_introScreenLightImage != null)
SetCanvasGroupState(_introScreenCanvas, 0f, false);
if (_introScreenCanvas != null)
{
var color = _introScreenLightImage.color;
color.a = 1f;
_introScreenLightImage.color = color;
_introScreenCanvas.gameObject.SetActive(false);
}
_introFinished = true;
if (_mainMenuGroup != null)
{
_mainMenuGroup.gameObject.SetActive(true);
}
SetCanvasGroupState(_mainMenuGroup, 1f, true);
_introFinished = true;
_introTransitioning = false;
_mainMenuSelectionGroup?.SelectIndex(0, true);
}
private async UniTask FadeOutPressStartText(CancellationToken token)
{
if (_pressStartText == null)
{
return;
}
_pressStartFadeSequence = Sequence.Create(useUnscaledTime: true)
.Group(Tween.Alpha(_pressStartText, new TweenSettings<float>
{
startValue = _pressStartText.color.a,
endValue = 0f,
settings = _pressStartFadeTweenSettings
}));
await _pressStartFadeSequence.ToUniTask(cancellationToken: token);
var color = _pressStartText.color;
color.a = 0f;
_pressStartText.color = color;
_pressStartText.gameObject.SetActive(false);
}
private void StartPressStartPulse()
{
StopSequence(ref _pressStartPulseSequence);
if (_pressStartText == null)
{
return;
}
_pressStartPulseSequence = Sequence.Create(
useUnscaledTime: true,
cycleMode: Sequence.SequenceCycleMode.Yoyo,
cycles: -1)
.Group(Tween.Alpha(_pressStartText, new TweenSettings<float>
{
startValue = _pressStartText.color.a,
endValue = _pressStartPulseMinimumAlpha,
settings = _pressStartPulseTweenSettings
}));
}
private void OnStartClicked()
{
Debug.Log("[MainMenuWindow] Starting game");
ShowSelectSaveWindow().Forget();
}
@@ -494,40 +396,49 @@ namespace BriarQueen.UI.Menus
private async UniTask ShowSelectSaveWindow()
{
Debug.Log("[MainMenuWindow] Showing select save window");
if (_selectSaveWindow == null || _selectSaveWindowCanvasGroup == null)
{
Debug.Log("[MainMenuWindow] SelectSaveWindow references not set.");
return;
}
ResetSelectSaveCtsAndCancelRunning();
_selectSaveWindow.gameObject.SetActive(true);
_selectSaveWindowCanvasGroup.alpha = 0f;
_selectSaveWindow.transform.localScale = Vector3.zero;
_selectSaveWindow.Refresh();
SetCanvasGroupState(_mainMenuWindowCanvasGroup, _mainMenuWindowCanvasGroup != null ? _mainMenuWindowCanvasGroup.alpha : 0f, false);
SetCanvasGroupState(_selectSaveWindowCanvasGroup, 0f, false);
SetCanvasGroupState(_selectSaveContainerCanvasGroup, 0f, false);
SetCanvasGroupState(_selectSaveWindowCanvasGroup, 1f, false); // visible but not interactable yet
SetCanvasGroupInteractivity(_mainMenuGroup, false);
_selectSaveSequence = Sequence.Create(useUnscaledTime: true)
.Group(Tween.Alpha(_selectSaveWindowCanvasGroup, new TweenSettings<float>
{
startValue = _selectSaveWindowCanvasGroup.alpha,
endValue = 1f,
settings = _selectSaveTweenSettings
}))
.Group(Tween.Scale(_selectSaveWindow.transform, new TweenSettings<Vector3>
{
startValue = _selectSaveWindow.transform.localScale,
endValue = Vector3.one,
settings = _selectSaveTweenSettings
}));
if (_selectSaveWindowCanvasGroup != null)
{
_selectSaveWindowFog.FogAmount = 0.2f;
_selectSaveWindowFog.MaxFog = 0.4f;
}
try
{
// Step 1: fog rolls in fully
if (_selectSaveWindowFog != null)
{
await _selectSaveWindowFog.FogIn().AttachExternalCancellation(_selectSaveCts.Token);
}
_selectSaveSequence = Sequence.Create(useUnscaledTime: true)
.Group(Tween.Alpha(_selectSaveContainerCanvasGroup, new TweenSettings<float>
{
startValue = 0f,
endValue = 1f,
settings = _selectSaveTweenSettings
}))
.Group(Tween.Alpha(_buttonsGroup, new TweenSettings<float>
{
startValue = _buttonsGroup.alpha,
endValue = 0f,
settings = _selectSaveTweenSettings
}));
await _selectSaveSequence.ToUniTask(cancellationToken: _selectSaveCts.Token);
}
catch (OperationCanceledException)
@@ -539,7 +450,9 @@ namespace BriarQueen.UI.Menus
_selectSaveSequence = default;
}
SetCanvasGroupState(_selectSaveWindowCanvasGroup, 1f, true);
// Step 3: tween complete — enable interaction
SetCanvasGroupInteractivity(_selectSaveContainerCanvasGroup, true);
SetCanvasGroupInteractivity(_selectSaveWindowCanvasGroup, true);
}
private void CloseSelectSaveWindow()
@@ -550,28 +463,40 @@ namespace BriarQueen.UI.Menus
private async UniTask CloseSelectSaveWindowInternal()
{
if (_selectSaveWindow == null || _selectSaveWindowCanvasGroup == null)
{
return;
}
ResetSelectSaveCtsAndCancelRunning();
SetCanvasGroupState(_selectSaveWindowCanvasGroup, _selectSaveWindowCanvasGroup.alpha, false);
_selectSaveSequence = Sequence.Create(useUnscaledTime: true)
.Group(Tween.Alpha(_selectSaveWindowCanvasGroup, new TweenSettings<float>
{
startValue = _selectSaveWindowCanvasGroup.alpha,
endValue = 0f,
settings = _selectSaveTweenSettings
}))
.Group(Tween.Scale(_selectSaveWindow.transform, new TweenSettings<Vector3>
{
startValue = _selectSaveWindow.transform.localScale,
endValue = Vector3.zero,
settings = _selectSaveTweenSettings
}));
// Disable interaction immediately, leave alpha for tween to read
SetCanvasGroupInteractivity(_selectSaveContainerCanvasGroup, false);
SetCanvasGroupInteractivity(_selectSaveWindowCanvasGroup, false);
try
{
// Step 1: container fades out
_selectSaveSequence = Sequence.Create(useUnscaledTime: true)
.Group(Tween.Alpha(_selectSaveContainerCanvasGroup, new TweenSettings<float>
{
startValue = _selectSaveContainerCanvasGroup.alpha,
endValue = 0f,
settings = _selectSaveTweenSettings
}))
.Group(Tween.Alpha(_buttonsGroup, new TweenSettings<float>
{
startValue = _buttonsGroup.alpha,
endValue = 1f,
settings = _selectSaveTweenSettings
}));
await _selectSaveSequence.ToUniTask(cancellationToken: _selectSaveCts.Token);
// Step 2: fog clears
if (_selectSaveWindowFog != null)
{
await _selectSaveWindowFog.FogOut().AttachExternalCancellation(_selectSaveCts.Token);
}
}
catch (OperationCanceledException)
{
@@ -582,27 +507,27 @@ namespace BriarQueen.UI.Menus
_selectSaveSequence = default;
}
_selectSaveWindowCanvasGroup.alpha = 0f;
// Step 3: fully cleaned up
SetCanvasGroupState(_selectSaveContainerCanvasGroup, 0f, false);
SetCanvasGroupState(_selectSaveWindowCanvasGroup, 0f, false);
_selectSaveWindow.gameObject.SetActive(false);
_selectSaveWindowFog?.FogSet(0f);
SetCanvasGroupState(_mainMenuWindowCanvasGroup, 1f, true);
SetCanvasGroupState(_mainMenuGroup, 1f, true);
}
private void ResetIntroCtsAndCancelRunning()
{
StopSequence(ref _introLightPulseSequence);
StopSequence(ref _introTextPulseSequence);
StopSequence(ref _introTransitionSequence);
StopSequence(ref _pressStartPulseSequence);
StopSequence(ref _pressStartFadeSequence);
CancelAndDispose(ref _introCts);
_introCts = new CancellationTokenSource();
}
private void StopIntroTweens()
{
StopSequence(ref _introLightPulseSequence);
StopSequence(ref _introTextPulseSequence);
StopSequence(ref _introTransitionSequence);
StopSequence(ref _pressStartPulseSequence);
StopSequence(ref _pressStartFadeSequence);
CancelAndDispose(ref _introCts);
}
@@ -622,7 +547,9 @@ namespace BriarQueen.UI.Menus
private static void StopSequence(ref Sequence sequence)
{
if (sequence.isAlive)
{
sequence.Stop();
}
sequence = default;
}
@@ -630,7 +557,9 @@ namespace BriarQueen.UI.Menus
private static void CancelAndDispose(ref CancellationTokenSource cts)
{
if (cts == null)
{
return;
}
try
{
@@ -644,13 +573,28 @@ namespace BriarQueen.UI.Menus
cts = null;
}
private static Button ResolveButton(AnimatedSelectionButton animatedSelectionButton, Button fallbackButton)
{
if (animatedSelectionButton != null && animatedSelectionButton.Button != null)
{
return animatedSelectionButton.Button;
}
return fallbackButton;
}
private static void SetCanvasGroupState(CanvasGroup group, float alpha, bool inputEnabled)
{
if (group == null)
return;
if (group == null) return;
group.alpha = alpha;
group.interactable = inputEnabled;
group.blocksRaycasts = inputEnabled;
}
group.alpha = alpha;
group.interactable = inputEnabled;
private static void SetCanvasGroupInteractivity(CanvasGroup group, bool inputEnabled)
{
if (group == null) return;
group.interactable = inputEnabled;
group.blocksRaycasts = inputEnabled;
}
}

View File

@@ -93,6 +93,8 @@ namespace BriarQueen.UI.Menus
public event Action OnCloseWindow;
public event Action<string> OnSaveCreated;
public bool IsOpen => _isOpen;
[Inject]
public void Construct(SaveManager saveManager)
{
@@ -422,4 +424,4 @@ namespace BriarQueen.UI.Menus
}
}
}
}
}

View File

@@ -11,6 +11,7 @@ using BriarQueen.Framework.Services.Game;
using Cysharp.Threading.Tasks;
using PrimeTween;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using VContainer;
@@ -41,6 +42,10 @@ namespace BriarQueen.UI.Menus
[SerializeField]
private Button _quitToDesktopButton;
[Header("Selection")]
[SerializeField]
private Selectable _firstSelectedOnOpen;
[Header("Tween Settings")]
[SerializeField]
private TweenSettings _tweenSettings = new()
@@ -96,7 +101,7 @@ namespace BriarQueen.UI.Menus
if (_quitToDesktopButton != null) _quitToDesktopButton.onClick.AddListener(OnQuitToDesktopButtonClick);
_interactManager.SetExclusiveRaycaster(_graphicRaycaster);
}
}
private void OnDisable()
{
@@ -169,6 +174,8 @@ namespace BriarQueen.UI.Menus
_canvasGroup.blocksRaycasts = true;
_canvasGroup.interactable = true;
SelectDefault();
}
public async UniTask Hide()
@@ -260,6 +267,23 @@ namespace BriarQueen.UI.Menus
_eventCoordinator?.Publish(new PauseButtonClickedEvent());
}
private void SelectDefault()
{
var selectable = _firstSelectedOnOpen != null ? _firstSelectedOnOpen : _resumeButton;
if (selectable == null)
{
return;
}
if (EventSystem.current != null)
{
EventSystem.current.SetSelectedGameObject(selectable.gameObject);
return;
}
selectable.Select();
}
private void OnSaveButtonClick()
{
SaveGame().Forget();
@@ -305,4 +329,4 @@ namespace BriarQueen.UI.Menus
// TODO: Saved feedback popup/toast
}
}
}
}

View File

@@ -1,355 +1,373 @@
using System;
using System.Collections.Generic;
using System.Linq;
using BriarQueen.Framework.Managers.IO;
using BriarQueen.Framework.Services.Game;
using BriarQueen.UI.Menus.Components;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using VContainer;
using System;
using System.Collections.Generic;
using System.Linq;
using BriarQueen.Framework.Managers.IO;
using BriarQueen.Framework.Services.Game;
using BriarQueen.UI.Menus.Components;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using VContainer;
namespace BriarQueen.UI.Menus
{
/// <summary>
/// Select Save Window:
/// - Shows EXACTLY 3 slots (filled first, then empty).
/// - Clicking a slot:
/// - Filled slot => loads that save, then StartGame
/// - Empty slot => opens NewSaveWindow (create + load), then StartGame
/// - Delete is still supported (per-slot delete button + confirm modal).
/// - Back closes this window (handled by MainMenuWindow).
/// </summary>
public class SelectSaveWindow : MonoBehaviour
namespace BriarQueen.UI.Menus
{
private const int MAX_SLOTS = 3;
[Header("UI")]
[SerializeField]
private RectTransform _listContentParent;
[SerializeField]
private SaveSlotUI _saveSlotPrefab;
[Header("Buttons")]
[SerializeField]
private Button _backButton;
[Header("New Save Window")]
[SerializeField]
private NewSaveWindow _newSaveWindow;
[Header("Confirm Delete Window (optional but recommended)")]
[SerializeField]
private ConfirmDeleteWindow _confirmDeleteWindow;
private readonly List<SaveSlotUI> _instantiatedSlots = new();
private int _currentSelectionIndex;
private GameService _gameService;
private bool _isBusy;
private SaveManager _saveManager;
private void Awake()
/// <summary>
/// Select Save Window:
/// - Shows EXACTLY 3 slots (filled first, then empty).
/// - Clicking a slot:
/// - Filled slot => loads that save, then StartGame
/// - Empty slot => opens NewSaveWindow (create + load), then StartGame
/// - Delete is still supported (per-slot delete button + confirm modal).
/// - Back closes this window (handled by MainMenuWindow).
/// </summary>
public class SelectSaveWindow : MonoBehaviour
{
if (_backButton != null) _backButton.onClick.AddListener(OnBackClicked);
private const int MAX_SLOTS = 3;
if (_newSaveWindow != null)
[Header("UI")]
[SerializeField]
private RectTransform _listContentParent;
[SerializeField]
private SaveSlotUI _saveSlotPrefab;
[Header("Buttons")]
[SerializeField]
private Button _backButton;
[Header("New Save Window")]
[SerializeField]
private NewSaveWindow _newSaveWindow;
[Header("Confirm Delete Window (optional but recommended)")]
[SerializeField]
private ConfirmDeleteWindow _confirmDeleteWindow;
private readonly List<SaveSlotUI> _instantiatedSlots = new();
private int _currentSelectionIndex;
private GameService _gameService;
private bool _isBusy;
private SaveManager _saveManager;
private void Awake()
{
_newSaveWindow.OnCloseWindow += HandleNewSaveClosed;
_newSaveWindow.OnSaveCreated += HandleSaveCreatedAndStartGame;
if (_backButton != null) _backButton.onClick.AddListener(OnBackClicked);
if (_newSaveWindow != null)
{
_newSaveWindow.OnCloseWindow += HandleNewSaveClosed;
_newSaveWindow.OnSaveCreated += HandleSaveCreatedAndStartGame;
}
if (_confirmDeleteWindow != null)
{
_confirmDeleteWindow.OnConfirmDelete += HandleConfirmDelete;
_confirmDeleteWindow.OnCancel += HandleCancelDelete;
_confirmDeleteWindow.Close();
}
}
if (_confirmDeleteWindow != null)
private void OnDestroy()
{
_confirmDeleteWindow.OnConfirmDelete += HandleConfirmDelete;
_confirmDeleteWindow.OnCancel += HandleCancelDelete;
_confirmDeleteWindow.Close();
}
}
if (_backButton != null) _backButton.onClick.RemoveListener(OnBackClicked);
private void OnDestroy()
{
if (_backButton != null) _backButton.onClick.RemoveListener(OnBackClicked);
if (_newSaveWindow != null)
{
_newSaveWindow.OnCloseWindow -= HandleNewSaveClosed;
_newSaveWindow.OnSaveCreated -= HandleSaveCreatedAndStartGame;
}
if (_newSaveWindow != null)
{
_newSaveWindow.OnCloseWindow -= HandleNewSaveClosed;
_newSaveWindow.OnSaveCreated -= HandleSaveCreatedAndStartGame;
if (_confirmDeleteWindow != null)
{
_confirmDeleteWindow.OnConfirmDelete -= HandleConfirmDelete;
_confirmDeleteWindow.OnCancel -= HandleCancelDelete;
}
ClearSlots();
}
if (_confirmDeleteWindow != null)
public event Action OnCloseWindow;
[Inject]
public void Construct(SaveManager saveManager, GameService gameService)
{
_confirmDeleteWindow.OnConfirmDelete -= HandleConfirmDelete;
_confirmDeleteWindow.OnCancel -= HandleCancelDelete;
_saveManager = saveManager;
_gameService = gameService;
}
ClearSlots();
}
public event Action OnCloseWindow;
[Inject]
public void Construct(SaveManager saveManager, GameService gameService)
{
_saveManager = saveManager;
_gameService = gameService;
}
/// <summary>Called by MainMenuWindow after enabling this GO.</summary>
public void Refresh()
{
if (_newSaveWindow != null)
_newSaveWindow.CloseImmediate();
if (_confirmDeleteWindow != null)
_confirmDeleteWindow.Close();
EnsureThreeSlotsExist();
RefreshSlotsData();
SetBusy(false);
}
private void OnBackClicked()
{
if (_isBusy)
return;
// Check if Confirm Delete or New Save Window is open.
OnCloseWindow?.Invoke();
}
private void HandleNewSaveClosed()
{
// NewSaveWindow was closed (cancel/back) => return control to the list.
SetBusy(false);
RestoreSelection();
RefreshSlotsData();
}
private void HandleSaveCreatedAndStartGame(string _)
{
// NewSaveWindow already created + loaded the save.
OnCloseWindow?.Invoke();
_gameService?.StartGame().Forget();
}
private void SetBusy(bool busy)
{
Debug.Log("[SelectSaveWindow] SetBusy: " + busy);
_isBusy = busy;
if (_backButton != null)
_backButton.interactable = !busy;
foreach (var slot in _instantiatedSlots)
if (slot != null)
slot.SetInteractable(!busy);
Debug.Log($"[SelectSaveWindow] Finished set busy: {busy}");
}
private void EnsureThreeSlotsExist()
{
if (_listContentParent == null || _saveSlotPrefab == null)
return;
if (_instantiatedSlots.Count == MAX_SLOTS)
return;
ClearSlots();
for (var i = 0; i < MAX_SLOTS; i++)
/// <summary>Called by MainMenuWindow after enabling this GO.</summary>
public void Refresh()
{
var slot = Instantiate(_saveSlotPrefab, _listContentParent);
_instantiatedSlots.Add(slot);
}
}
if (_newSaveWindow != null)
_newSaveWindow.CloseImmediate();
private void RefreshSlotsData()
{
// Always show 3 slots; if save system is missing, theyll all appear empty/disabled.
if (_saveManager == null)
{
for (var i = 0; i < _instantiatedSlots.Count; i++)
_instantiatedSlots[i]?.SetEmpty(OnEmptySlotClicked);
if (_confirmDeleteWindow != null)
_confirmDeleteWindow.Close();
SelectBackButton();
return;
}
// Newest first, cap at 3
var saveFiles = _saveManager.GetAvailableSaves();
var infos = (saveFiles ?? new List<(string, DateTime)>())
.Select(sf => new SaveFileInfo(sf.FileName, sf.LastModified))
.OrderByDescending(i => i.LastModified)
.Take(MAX_SLOTS)
.ToArray();
for (var i = 0; i < MAX_SLOTS; i++)
{
var slot = _instantiatedSlots.ElementAtOrDefault(i);
if (slot == null) continue;
if (i < infos.Length)
slot.SetFilled(infos[i], OnFilledSlotClicked, OnSlotDeleteClicked);
else
slot.SetEmpty(OnEmptySlotClicked);
}
_currentSelectionIndex = Mathf.Clamp(_currentSelectionIndex, 0, MAX_SLOTS - 1);
SelectSlot(_currentSelectionIndex);
}
private void ClearSlots()
{
foreach (var slot in _instantiatedSlots)
if (slot != null)
Destroy(slot.gameObject);
_instantiatedSlots.Clear();
_currentSelectionIndex = 0;
}
private void OnFilledSlotClicked(SaveFileInfo saveInfo)
{
if (_isBusy) return;
if (_saveManager == null) return;
if (string.IsNullOrWhiteSpace(saveInfo.FileName)) return;
if (!_saveManager.DoesSaveExist(saveInfo.FileName))
{
EnsureThreeSlotsExist();
RefreshSlotsData();
return;
SetBusy(false);
}
LoadAndStartGame(saveInfo.FileName).Forget();
}
private void OnEmptySlotClicked()
{
Debug.Log("[SelectSaveWindow] Empty slot clicked.");
if (_isBusy)
return;
if (_newSaveWindow == null)
public bool HandleBackRequest()
{
Debug.LogWarning("[SelectSaveWindow] NewSaveWindow reference not set.");
return;
if (_newSaveWindow != null && _newSaveWindow.IsOpen)
{
_newSaveWindow.Close();
return true;
}
if (_confirmDeleteWindow != null && _confirmDeleteWindow.IsOpen)
{
_confirmDeleteWindow.CancelFromBack();
return true;
}
OnBackClicked();
return true;
}
SetBusy(true);
_newSaveWindow.Open();
}
private async UniTask LoadAndStartGame(string profileName)
{
SetBusy(true);
try
private void OnBackClicked()
{
await _saveManager.LoadGameData(profileName);
if (_isBusy)
return;
// Check if Confirm Delete or New Save Window is open.
OnCloseWindow?.Invoke();
}
private void HandleNewSaveClosed()
{
// NewSaveWindow was closed (cancel/back) => return control to the list.
SetBusy(false);
RestoreSelection();
RefreshSlotsData();
}
private void HandleSaveCreatedAndStartGame(string _)
{
// NewSaveWindow already created + loaded the save.
OnCloseWindow?.Invoke();
_gameService?.StartGame().Forget();
}
catch (Exception ex)
{
Debug.LogError($"[SelectSaveWindow] Failed to load profile '{profileName}': {ex}");
SetBusy(false);
RefreshSlotsData();
RestoreSelection();
}
}
private void OnSlotDeleteClicked(SaveFileInfo saveInfo)
{
if (_isBusy) return;
if (_saveManager == null) return;
if (string.IsNullOrWhiteSpace(saveInfo.FileName)) return;
// No confirm window wired? Do a direct delete.
if (_confirmDeleteWindow == null)
private void SetBusy(bool busy)
{
TryDeleteAndRefresh(saveInfo.FileName);
return;
Debug.Log("[SelectSaveWindow] SetBusy: " + busy);
_isBusy = busy;
if (_backButton != null)
_backButton.interactable = !busy;
foreach (var slot in _instantiatedSlots)
if (slot != null)
slot.SetInteractable(!busy);
Debug.Log($"[SelectSaveWindow] Finished set busy: {busy}");
}
SetBusy(true);
_confirmDeleteWindow.Open(saveInfo);
}
private void HandleConfirmDelete(SaveFileInfo saveInfo)
{
try
private void EnsureThreeSlotsExist()
{
TryDeleteAndRefresh(saveInfo.FileName);
if (_listContentParent == null || _saveSlotPrefab == null)
return;
if (_instantiatedSlots.Count == MAX_SLOTS)
return;
ClearSlots();
for (var i = 0; i < MAX_SLOTS; i++)
{
var slot = Instantiate(_saveSlotPrefab, _listContentParent);
_instantiatedSlots.Add(slot);
}
}
finally
private void RefreshSlotsData()
{
// Always show 3 slots; if save system is missing, theyll all appear empty/disabled.
if (_saveManager == null)
{
for (var i = 0; i < _instantiatedSlots.Count; i++)
_instantiatedSlots[i]?.SetEmpty(OnEmptySlotClicked);
SelectBackButton();
return;
}
// Newest first, cap at 3
var saveFiles = _saveManager.GetAvailableSaves();
var infos = (saveFiles ?? new List<(string, DateTime)>())
.Select(sf => new SaveFileInfo(sf.FileName, sf.LastModified))
.OrderByDescending(i => i.LastModified)
.Take(MAX_SLOTS)
.ToArray();
for (var i = 0; i < MAX_SLOTS; i++)
{
var slot = _instantiatedSlots.ElementAtOrDefault(i);
if (slot == null) continue;
if (i < infos.Length)
slot.SetFilled(infos[i], OnFilledSlotClicked, OnSlotDeleteClicked);
else
slot.SetEmpty(OnEmptySlotClicked);
}
_currentSelectionIndex = Mathf.Clamp(_currentSelectionIndex, 0, MAX_SLOTS - 1);
SelectSlot(_currentSelectionIndex);
}
private void ClearSlots()
{
foreach (var slot in _instantiatedSlots)
if (slot != null)
Destroy(slot.gameObject);
_instantiatedSlots.Clear();
_currentSelectionIndex = 0;
}
private void OnFilledSlotClicked(SaveFileInfo saveInfo)
{
if (_isBusy) return;
if (_saveManager == null) return;
if (string.IsNullOrWhiteSpace(saveInfo.FileName)) return;
if (!_saveManager.DoesSaveExist(saveInfo.FileName))
{
RefreshSlotsData();
return;
}
LoadAndStartGame(saveInfo.FileName).Forget();
}
private void OnEmptySlotClicked()
{
Debug.Log("[SelectSaveWindow] Empty slot clicked.");
if (_isBusy)
return;
if (_newSaveWindow == null)
{
Debug.LogWarning("[SelectSaveWindow] NewSaveWindow reference not set.");
return;
}
SetBusy(true);
_newSaveWindow.Open();
}
private async UniTask LoadAndStartGame(string profileName)
{
SetBusy(true);
try
{
await _saveManager.LoadGameData(profileName);
OnCloseWindow?.Invoke();
_gameService?.StartGame().Forget();
}
catch (Exception ex)
{
Debug.LogError($"[SelectSaveWindow] Failed to load profile '{profileName}': {ex}");
SetBusy(false);
RefreshSlotsData();
RestoreSelection();
}
}
private void OnSlotDeleteClicked(SaveFileInfo saveInfo)
{
if (_isBusy) return;
if (_saveManager == null) return;
if (string.IsNullOrWhiteSpace(saveInfo.FileName)) return;
// No confirm window wired? Do a direct delete.
if (_confirmDeleteWindow == null)
{
TryDeleteAndRefresh(saveInfo.FileName);
return;
}
SetBusy(true);
_confirmDeleteWindow.Open(saveInfo);
}
private void HandleConfirmDelete(SaveFileInfo saveInfo)
{
try
{
TryDeleteAndRefresh(saveInfo.FileName);
}
finally
{
SetBusy(false);
RestoreSelection();
}
}
private void HandleCancelDelete()
{
SetBusy(false);
RestoreSelection();
}
}
private void HandleCancelDelete()
{
SetBusy(false);
RestoreSelection();
}
private void TryDeleteAndRefresh(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName))
return;
if (!_saveManager.DoesSaveExist(fileName))
private void TryDeleteAndRefresh(string fileName)
{
if (string.IsNullOrWhiteSpace(fileName))
return;
if (!_saveManager.DoesSaveExist(fileName))
{
RefreshSlotsData();
return;
}
var deleted = _saveManager.Delete(fileName);
if (!deleted)
Debug.LogWarning($"[SelectSaveWindow] Failed to delete save '{fileName}'.");
RefreshSlotsData();
return;
}
var deleted = _saveManager.Delete(fileName);
if (!deleted)
Debug.LogWarning($"[SelectSaveWindow] Failed to delete save '{fileName}'.");
RefreshSlotsData();
}
private void RestoreSelection()
{
_currentSelectionIndex = Mathf.Clamp(_currentSelectionIndex, 0, MAX_SLOTS - 1);
SelectSlot(_currentSelectionIndex);
}
private void SelectBackButton()
{
if (_backButton == null) return;
if (EventSystem.current != null)
EventSystem.current.SetSelectedGameObject(_backButton.gameObject);
else
_backButton.Select();
}
private void SelectSlot(int index)
{
if (_instantiatedSlots.Count == 0)
private void RestoreSelection()
{
SelectBackButton();
return;
_currentSelectionIndex = Mathf.Clamp(_currentSelectionIndex, 0, MAX_SLOTS - 1);
SelectSlot(_currentSelectionIndex);
}
index = Mathf.Clamp(index, 0, _instantiatedSlots.Count - 1);
_currentSelectionIndex = index;
private void SelectBackButton()
{
if (_backButton == null) return;
var go = _instantiatedSlots[index]?.GetSelectableGameObject();
if (go == null) return;
if (EventSystem.current != null)
EventSystem.current.SetSelectedGameObject(_backButton.gameObject);
else
_backButton.Select();
}
if (EventSystem.current != null)
EventSystem.current.SetSelectedGameObject(go);
private void SelectSlot(int index)
{
if (_instantiatedSlots.Count == 0)
{
SelectBackButton();
return;
}
index = Mathf.Clamp(index, 0, _instantiatedSlots.Count - 1);
_currentSelectionIndex = index;
var go = _instantiatedSlots[index]?.GetSelectableGameObject();
if (go == null) return;
if (EventSystem.current != null)
EventSystem.current.SetSelectedGameObject(go);
}
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -28,6 +28,9 @@ namespace BriarQueen.UI.Menus
[SerializeField]
private RectTransform _handleRect;
[SerializeField]
private Image _scrollBarImage;
[Header("Scroll Settings")]
[SerializeField]
@@ -330,7 +333,10 @@ namespace BriarQueen.UI.Menus
return;
if (_hideTrackWhenNotScrollable)
{
_trackRect.gameObject.SetActive(_isScrollable);
_scrollBarImage.enabled = _isScrollable;
}
}
private bool TryGetContentBounds(out float top, out float bottom)