First commit for private source control. Older commits available on Github.

This commit is contained in:
2026-03-26 12:52:52 +00:00
parent a04c602626
commit 2d449c4a17
2176 changed files with 408185 additions and 0 deletions

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 84e0529b59f0487a849dde7ed79f008e
timeCreated: 1769794667

View File

@@ -0,0 +1,171 @@
using System;
using TMPro;
using UnityEngine;
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
{
[Header("Clickable Root")]
[SerializeField]
private Button _slotButton;
[Header("Labels")]
[SerializeField]
private TextMeshProUGUI _saveNameText;
[SerializeField]
private TextMeshProUGUI _saveDateText;
[Header("Delete")]
[SerializeField]
private Button _deleteButton;
[Header("Empty Visual")]
[SerializeField]
private Image _emptyImage;
private Action<SaveFileInfo> _onDeleteClick;
private Action _onEmptyClick;
private Action<SaveFileInfo> _onFilledClick;
public SaveFileInfo SaveInfo { get; private set; }
public bool IsEmpty { get; private set; }
private void Awake()
{
if (_slotButton != null)
{
_slotButton.onClick.RemoveAllListeners();
_slotButton.onClick.AddListener(HandleSlotClicked);
}
if (_deleteButton != null)
{
_deleteButton.onClick.RemoveAllListeners();
_deleteButton.onClick.AddListener(HandleDeleteClicked);
}
}
private void OnDestroy()
{
if (_slotButton != null) _slotButton.onClick.RemoveAllListeners();
if (_deleteButton != null) _deleteButton.onClick.RemoveAllListeners();
}
public GameObject GetSelectableGameObject()
{
return _slotButton != null ? _slotButton.gameObject : gameObject;
}
public void SetFilled(
SaveFileInfo saveInfo,
Action<SaveFileInfo> onClickFilled,
Action<SaveFileInfo> onDelete)
{
SaveInfo = saveInfo;
IsEmpty = false;
_onFilledClick = onClickFilled;
_onEmptyClick = null;
_onDeleteClick = onDelete;
if (_emptyImage != null) _emptyImage.gameObject.SetActive(false);
if (_saveNameText != null)
{
_saveNameText.gameObject.SetActive(true);
_saveNameText.text = saveInfo.FileName;
}
if (_saveDateText != null)
{
_saveDateText.gameObject.SetActive(true);
_saveDateText.text = saveInfo.LastModified.ToString("g");
}
if (_deleteButton != null)
{
_deleteButton.gameObject.SetActive(true);
_deleteButton.interactable = true;
}
if (_slotButton != null)
_slotButton.interactable = true;
}
public void SetEmpty(Action onClickEmpty)
{
SaveInfo = default;
IsEmpty = true;
_onFilledClick = null;
_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 (_deleteButton != null)
{
_deleteButton.gameObject.SetActive(false);
_deleteButton.interactable = false;
}
if (_slotButton != null)
_slotButton.interactable = true; // empty slot is still clickable
}
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;
}
private void HandleSlotClicked()
{
if (IsEmpty)
_onEmptyClick?.Invoke();
else if (!string.IsNullOrWhiteSpace(SaveInfo.FileName))
_onFilledClick?.Invoke(SaveInfo);
}
private void HandleDeleteClicked()
{
if (IsEmpty)
return;
if (!string.IsNullOrWhiteSpace(SaveInfo.FileName))
_onDeleteClick?.Invoke(SaveInfo);
}
}
public struct SaveFileInfo
{
public readonly string FileName;
public readonly DateTime LastModified;
public SaveFileInfo(string fileName, DateTime lastModified)
{
FileName = fileName;
LastModified = lastModified;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 738235190e6d42eb93f533aa05cf98c2
timeCreated: 1769794667

View File

@@ -0,0 +1,341 @@
using System;
using System.Threading;
using BriarQueen.UI.Menus.Components;
using Cysharp.Threading.Tasks;
using PrimeTween;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace BriarQueen.UI.Menus
{
/// <summary>
/// Confirm delete modal with tweened show/hide (fade + scale).
/// - Open(): animates in
/// - Close(): animates out
/// - CloseImmediate(): hard hide (safe for OnDisable/OnDestroy)
/// Notes:
/// - Uses unscaled time so it still animates while paused.
/// - Gates input during transitions.
/// </summary>
public class ConfirmDeleteWindow : MonoBehaviour
{
[Header("Root")]
[SerializeField]
private GameObject _root;
[Header("Animation Targets")]
[SerializeField]
private CanvasGroup _canvasGroup;
[SerializeField]
private RectTransform _panelTransform;
[Header("UI")]
[SerializeField]
private TextMeshProUGUI _titleText;
[SerializeField]
private Button _confirmButton;
[SerializeField]
private Button _cancelButton;
[Header("Tween Settings")]
[SerializeField]
private float _duration = 0.18f;
[SerializeField]
private Ease _easeIn = Ease.OutBack;
[SerializeField]
private Ease _easeOut = Ease.InQuad;
[SerializeField]
private bool _useUnscaledTime = true;
[Header("Scale")]
[SerializeField]
private float _fromScale = 0.92f;
[SerializeField]
private float _toScale = 1.0f;
private CancellationTokenSource _cts;
private bool _isAnimating;
private bool _isOpen;
private SaveFileInfo _pending;
private Sequence _sequence;
private void Awake()
{
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);
StopAnim();
}
public event Action<SaveFileInfo> OnConfirmDelete;
public event Action OnCancel;
public void Open(SaveFileInfo info)
{
_pending = info;
if (_titleText != null)
_titleText.text = $"Delete '{info.FileName}'?";
OpenAsync().Forget();
}
public void Close()
{
CloseAsync().Forget();
}
public void CloseImmediate()
{
StopAnim();
_pending = default;
_isOpen = false;
_isAnimating = false;
if (_root != null) _root.SetActive(false);
if (_canvasGroup != null)
{
_canvasGroup.alpha = 0f;
_canvasGroup.interactable = false;
_canvasGroup.blocksRaycasts = false;
}
if (_panelTransform != null)
{
var s = _toScale;
_panelTransform.localScale = new Vector3(s, s, 1f);
}
}
private async UniTask OpenAsync()
{
if (_isOpen && !_isAnimating) return;
EnsureRefs();
StopAnim();
_cts = new CancellationTokenSource();
var token = _cts.Token;
if (_root != null) _root.SetActive(true);
_isAnimating = true;
// Prep start state
if (_canvasGroup != null)
{
_canvasGroup.alpha = 0f;
_canvasGroup.interactable = false;
_canvasGroup.blocksRaycasts = false;
}
if (_panelTransform != null)
{
var s = _fromScale;
_panelTransform.localScale = new Vector3(s, s, 1f);
}
// Build sequence (fade + scale)
_sequence = Sequence.Create(useUnscaledTime: true);
if (_canvasGroup != null)
_sequence.Group(Tween.Alpha(_canvasGroup, new TweenSettings<float>
{
startValue = 0f,
endValue = 1f,
settings = new TweenSettings
{
duration = _duration,
ease = _easeIn,
useUnscaledTime = _useUnscaledTime
}
}));
if (_panelTransform != null)
_sequence.Group(Tween.Scale(_panelTransform, new TweenSettings<Vector3>
{
startValue = new Vector3(_fromScale, _fromScale, 1f),
endValue = new Vector3(_toScale, _toScale, 1f),
settings = new TweenSettings
{
duration = _duration,
ease = _easeIn,
useUnscaledTime = _useUnscaledTime
}
}));
try
{
await _sequence.ToUniTask(cancellationToken: token);
}
catch (OperationCanceledException)
{
return;
}
finally
{
_sequence = default;
_isAnimating = false;
_isOpen = true;
}
// Enable input once fully open
if (_canvasGroup != null)
{
_canvasGroup.alpha = 1f;
_canvasGroup.interactable = true;
_canvasGroup.blocksRaycasts = true;
}
// Optional: focus cancel for controller/keyboard flows
_cancelButton?.Select();
}
private async UniTask CloseAsync()
{
if (!_isOpen && !_isAnimating) return;
EnsureRefs();
StopAnim();
_cts = new CancellationTokenSource();
var token = _cts.Token;
_isAnimating = true;
// Disable input immediately while animating out
if (_canvasGroup != null)
{
_canvasGroup.interactable = false;
_canvasGroup.blocksRaycasts = false;
}
var startAlpha = _canvasGroup != null ? _canvasGroup.alpha : 1f;
var startScale = _panelTransform != null ? _panelTransform.localScale : Vector3.one;
_sequence = Sequence.Create(useUnscaledTime: true);
if (_canvasGroup != null)
_sequence.Group(Tween.Alpha(_canvasGroup, new TweenSettings<float>
{
startValue = startAlpha,
endValue = 0f,
settings = new TweenSettings
{
duration = _duration,
ease = _easeOut,
useUnscaledTime = _useUnscaledTime
}
}));
if (_panelTransform != null)
_sequence.Group(Tween.Scale(_panelTransform, new TweenSettings<Vector3>
{
startValue = startScale,
endValue = new Vector3(_fromScale, _fromScale, 1f),
settings = new TweenSettings
{
duration = _duration,
ease = _easeOut,
useUnscaledTime = _useUnscaledTime
}
}));
try
{
await _sequence.ToUniTask(cancellationToken: token);
}
catch (OperationCanceledException)
{
return;
}
finally
{
_sequence = default;
_isAnimating = false;
_isOpen = false;
}
// Fully hidden
if (_canvasGroup != null) _canvasGroup.alpha = 0f;
if (_root != null) _root.SetActive(false);
_pending = default;
}
private void Confirm()
{
if (_isAnimating) return;
var info = _pending;
Close();
OnConfirmDelete?.Invoke(info);
}
private void Cancel()
{
if (_isAnimating) return;
Close();
OnCancel?.Invoke();
}
private void StopAnim()
{
if (_sequence.isAlive)
{
_sequence.Stop();
_sequence = default;
}
if (_cts != null)
{
try
{
_cts.Cancel();
}
catch
{
}
_cts.Dispose();
_cts = null;
}
}
private void EnsureRefs()
{
// If you forgot to wire these, try to find sensible defaults.
if (_canvasGroup == null && _root != null)
_canvasGroup = _root.GetComponent<CanvasGroup>();
if (_panelTransform == null)
_panelTransform = GetComponentInChildren<RectTransform>(true);
// Root fallback: if none specified, use this GO
if (_root == null)
_root = gameObject;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 3c66bd69629f472f91038ee13ec204b9
timeCreated: 1769796211

View File

@@ -0,0 +1,657 @@
using System;
using System.Threading;
using BriarQueen.Framework.Coordinators.Events;
using BriarQueen.Framework.Events.UI;
using BriarQueen.Framework.Managers.Input;
using BriarQueen.Framework.Managers.UI.Events;
using BriarQueen.Framework.Services.Game;
using Cysharp.Threading.Tasks;
using PrimeTween;
using TMPro;
using UnityEngine;
using UnityEngine.InputSystem;
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;
[SerializeField]
private CanvasGroup _introTextCanvasGroup;
[SerializeField]
private TextMeshProUGUI _introTextText;
[SerializeField]
private CanvasGroup _introTitleCanvasGroup;
[Header("Buttons")]
[SerializeField]
private Button _startGameButton;
[SerializeField]
private Button _settingsButton;
[SerializeField]
private Button _quitButton;
[Header("Select Save Window")]
[SerializeField]
private SelectSaveWindow _selectSaveWindow;
[SerializeField]
private CanvasGroup _selectSaveWindowCanvasGroup;
[Header("Tween Settings")]
[SerializeField]
private TweenSettings _selectSaveTweenSettings = new()
{
duration = 0.25f,
ease = Ease.OutQuad,
useUnscaledTime = true
};
[Header("Intro Timing")]
[SerializeField]
private float _introLightPulseDuration = 2f;
[SerializeField]
private float _introTextDelaySeconds = 1.5f;
[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;
private CancellationTokenSource _introCts;
private CancellationTokenSource _selectSaveCts;
private Sequence _introLightPulseSequence;
private Sequence _introTextPulseSequence;
private Sequence _introTransitionSequence;
private Sequence _selectSaveSequence;
private bool _introFinished;
private bool _introTransitioning;
private DeviceInputType _lastDeviceInputType;
[Inject]
public void Construct(GameService gameService, EventCoordinator eventCoordinator, InputManager inputManager)
{
_gameService = gameService;
_eventCoordinator = eventCoordinator;
_inputManager = inputManager;
}
private void Awake()
{
ApplyInitialVisualState();
if (_selectSaveWindow != null)
{
_selectSaveWindow.OnCloseWindow += CloseSelectSaveWindow;
_selectSaveWindow.gameObject.SetActive(false);
}
UpdateSubmitText(force: true);
}
private void OnEnable()
{
BindButtons();
_eventCoordinator?.PublishImmediate(new UIToggleHudEvent(false));
_inputManager?.BindSubmitForStart(OnIntroSubmit);
StartIntroScreen().Forget();
}
private void OnDisable()
{
UnbindButtons();
_inputManager?.ResetSubmitBind(OnIntroSubmit);
StopIntroTweens();
StopSelectSaveTween();
}
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);
if (_settingsButton != null)
_settingsButton.onClick.AddListener(OnSettingsClicked);
if (_quitButton != null)
_quitButton.onClick.AddListener(OnQuitClicked);
}
private void UnbindButtons()
{
if (_startGameButton != null)
_startGameButton.onClick.RemoveListener(OnStartClicked);
if (_settingsButton != null)
_settingsButton.onClick.RemoveListener(OnSettingsClicked);
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;
_introTransitioning = false;
ResetIntroCtsAndCancelRunning();
ApplyInitialVisualState();
UpdateSubmitText(force: true);
try
{
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
}
}));
}
if (_introTextCanvasGroup != null)
{
fadeInSequence.Group(Tween.Alpha(_introTextCanvasGroup, new TweenSettings<float>
{
startValue = 0f,
endValue = 1f,
settings = new TweenSettings
{
duration = _introTextFadeInDuration,
ease = Ease.OutQuad,
useUnscaledTime = true
}
}));
}
await fadeInSequence.ToUniTask(cancellationToken: token);
if (_introTextCanvasGroup == null)
return;
_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);
}
private void OnIntroSubmit(InputAction.CallbackContext ctx)
{
if (_introFinished || _introTransitioning)
return;
if (!ctx.performed)
return;
TransitionFromIntroToMainMenu().Forget();
}
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);
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);
}
}
catch (OperationCanceledException)
{
_introTransitioning = false;
return;
}
finally
{
_introTransitionSequence = default;
}
SetCanvasGroupState(_introTextCanvasGroup, 0f, false);
SetCanvasGroupState(_introTitleCanvasGroup, 0f, false);
SetCanvasGroupState(_mainMenuIntroScreenCanvasGroup, 0f, false);
SetCanvasGroupState(_mainMenuWindowCanvasGroup, 1f, true);
if (_introScreenLightImage != null)
{
var color = _introScreenLightImage.color;
color.a = 1f;
_introScreenLightImage.color = color;
}
_introFinished = true;
_introTransitioning = false;
}
private void OnStartClicked()
{
Debug.Log("[MainMenuWindow] Starting game");
ShowSelectSaveWindow().Forget();
}
private void OnSettingsClicked()
{
_eventCoordinator?.PublishImmediate(new UIToggleSettingsWindow(true));
}
private void OnQuitClicked()
{
_gameService?.QuitGame();
}
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);
_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
}));
try
{
await _selectSaveSequence.ToUniTask(cancellationToken: _selectSaveCts.Token);
}
catch (OperationCanceledException)
{
return;
}
finally
{
_selectSaveSequence = default;
}
SetCanvasGroupState(_selectSaveWindowCanvasGroup, 1f, true);
}
private void CloseSelectSaveWindow()
{
CloseSelectSaveWindowInternal().Forget();
}
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
}));
try
{
await _selectSaveSequence.ToUniTask(cancellationToken: _selectSaveCts.Token);
}
catch (OperationCanceledException)
{
return;
}
finally
{
_selectSaveSequence = default;
}
_selectSaveWindowCanvasGroup.alpha = 0f;
_selectSaveWindow.gameObject.SetActive(false);
SetCanvasGroupState(_mainMenuWindowCanvasGroup, 1f, true);
}
private void ResetIntroCtsAndCancelRunning()
{
StopSequence(ref _introLightPulseSequence);
StopSequence(ref _introTextPulseSequence);
StopSequence(ref _introTransitionSequence);
CancelAndDispose(ref _introCts);
_introCts = new CancellationTokenSource();
}
private void StopIntroTweens()
{
StopSequence(ref _introLightPulseSequence);
StopSequence(ref _introTextPulseSequence);
StopSequence(ref _introTransitionSequence);
CancelAndDispose(ref _introCts);
}
private void ResetSelectSaveCtsAndCancelRunning()
{
StopSequence(ref _selectSaveSequence);
CancelAndDispose(ref _selectSaveCts);
_selectSaveCts = new CancellationTokenSource();
}
private void StopSelectSaveTween()
{
StopSequence(ref _selectSaveSequence);
CancelAndDispose(ref _selectSaveCts);
}
private static void StopSequence(ref Sequence sequence)
{
if (sequence.isAlive)
sequence.Stop();
sequence = default;
}
private static void CancelAndDispose(ref CancellationTokenSource cts)
{
if (cts == null)
return;
try
{
cts.Cancel();
}
catch
{
}
cts.Dispose();
cts = null;
}
private static void SetCanvasGroupState(CanvasGroup group, float alpha, bool inputEnabled)
{
if (group == null)
return;
group.alpha = alpha;
group.interactable = inputEnabled;
group.blocksRaycasts = inputEnabled;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 2251b1b163bc42aeaeaeba8a4442363c
timeCreated: 1769784245

View File

@@ -0,0 +1,425 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using BriarQueen.Framework.Managers.IO;
using Cysharp.Threading.Tasks;
using PrimeTween;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using VContainer;
namespace BriarQueen.UI.Menus
{
/// <summary>
/// New Save modal window:
/// - Opens over SelectSaveWindow
/// - User enters a save name
/// - Create -> SaveManager.CreateNewSaveGame(name) (and typically sets CurrentSave)
/// - Then immediately LoadGameData(name) (optional but robust), and raises OnSaveCreated
/// </summary>
public class NewSaveWindow : MonoBehaviour
{
[Header("Root")]
[SerializeField]
private CanvasGroup _canvasGroup;
[Header("Input")]
[SerializeField]
private TMP_InputField _nameInput;
[SerializeField]
private Button _createButton;
[SerializeField]
private Button _cancelButton;
[Header("Error UI")]
[SerializeField]
private GameObject _errorBox;
[SerializeField]
private TextMeshProUGUI _errorText;
[Header("Validation")]
[SerializeField]
private int _minNameLength = 1;
[SerializeField]
private int _maxNameLength = 24;
[SerializeField]
private bool _trimWhitespace = true;
[Header("Tween Settings")]
[SerializeField]
private TweenSettings _tweenSettings = new()
{
duration = 0.25f,
ease = Ease.OutQuad,
useUnscaledTime = true
};
private CancellationTokenSource _cts;
private bool _isBusy;
private bool _isOpen;
private SaveManager _saveManager;
private Sequence _seq;
private bool _tutorialsEnabled;
private void Awake()
{
if (_createButton != null) _createButton.onClick.AddListener(OnCreateClicked);
if (_cancelButton != null) _cancelButton.onClick.AddListener(Close);
HideError();
CloseImmediate();
}
private void OnDestroy()
{
if (_createButton != null) _createButton.onClick.RemoveListener(OnCreateClicked);
if (_cancelButton != null) _cancelButton.onClick.RemoveListener(Close);
StopTween();
}
public event Action OnCloseWindow;
public event Action<string> OnSaveCreated;
[Inject]
public void Construct(SaveManager saveManager)
{
_saveManager = saveManager;
}
public void Open()
{
if (_isOpen)
return;
Debug.Log($"Opening {nameof(NewSaveWindow)}");
OpenInternal().Forget();
}
public void Close()
{
if (!_isOpen || _isBusy) return;
CloseInternal().Forget();
}
public void CloseImmediate()
{
_isOpen = false;
_isBusy = false;
if (_nameInput != null) _nameInput.text = string.Empty;
if (_canvasGroup != null)
{
_canvasGroup.alpha = 0f;
_canvasGroup.interactable = false;
_canvasGroup.blocksRaycasts = false;
}
gameObject.SetActive(false);
HideError();
}
private async UniTask OpenInternal()
{
if (_canvasGroup == null)
{
gameObject.SetActive(true);
_isOpen = true;
return;
}
Debug.Log("Opening Internal...");
Debug.Log($"{gameObject} is {gameObject.activeSelf}");
ResetCtsAndCancelRunning();
gameObject.SetActive(true);
Debug.Log($"{gameObject} is now {gameObject.activeSelf}");
_canvasGroup.alpha = 0f;
_canvasGroup.interactable = false;
_canvasGroup.blocksRaycasts = false;
_isOpen = true;
_isBusy = true;
if (_nameInput != null) _nameInput.text = string.Empty;
HideError();
Debug.Log("Opening - Creating Sequence");
_seq = Sequence.Create(useUnscaledTime: true)
.Group(Tween.Alpha(_canvasGroup, new TweenSettings<float>
{
startValue = 0f,
endValue = 1f,
settings = _tweenSettings
}));
try
{
Debug.Log("Opening - Sequence Running.");
await _seq.ToUniTask(cancellationToken: _cts.Token);
}
catch (OperationCanceledException)
{
return;
}
catch (Exception e)
{
Debug.Log($"Opening - Sequence Error: {e.Message}");
}
finally
{
Debug.Log("Opening - Sequence over.");
_seq = default;
}
_canvasGroup.alpha = 1f;
_canvasGroup.interactable = true;
_canvasGroup.blocksRaycasts = true;
_isBusy = false;
FocusInput();
}
private async UniTask CloseInternal()
{
if (_canvasGroup == null)
{
CloseImmediate();
OnCloseWindow?.Invoke();
return;
}
ResetCtsAndCancelRunning();
_isBusy = true;
_canvasGroup.interactable = false;
_canvasGroup.blocksRaycasts = false;
_seq = Sequence.Create(useUnscaledTime: true)
.Group(Tween.Alpha(_canvasGroup, new TweenSettings<float>
{
startValue = _canvasGroup.alpha,
endValue = 0f,
settings = _tweenSettings
}));
try
{
await _seq.ToUniTask(cancellationToken: _cts.Token);
}
catch (OperationCanceledException)
{
return;
}
finally
{
_seq = default;
}
_canvasGroup.alpha = 0f;
_isOpen = false;
_isBusy = false;
gameObject.SetActive(false);
OnCloseWindow?.Invoke();
}
private void FocusInput()
{
if (_nameInput == null) return;
_nameInput.ActivateInputField();
if (EventSystem.current != null)
EventSystem.current.SetSelectedGameObject(_nameInput.gameObject);
}
private void OnCreateClicked()
{
if (_isBusy) return;
CreateSave().Forget();
}
private async UniTask CreateSave()
{
HideError();
if (_saveManager == null)
{
ShowError("Save system not available.");
return;
}
var raw = _nameInput != null ? _nameInput.text : string.Empty;
var name = _trimWhitespace ? (raw ?? string.Empty).Trim() : raw ?? string.Empty;
if (string.IsNullOrWhiteSpace(name))
{
ShowError("Please enter a save name.");
return;
}
if (name.Length < _minNameLength)
{
ShowError($"Save name must be at least {_minNameLength} character(s).");
return;
}
if (_maxNameLength > 0 && name.Length > _maxNameLength)
{
ShowError($"Save name must be {_maxNameLength} characters or fewer.");
return;
}
if (ContainsIllegalFileNameChars(name, out var illegalChars))
{
ShowError(illegalChars.Length == 1
? $"That name contains an illegal character: '{illegalChars[0]}'."
: $"That name contains illegal characters: {string.Join(" ", illegalChars.Select(c => $"'{c}'"))}.");
return;
}
if (IsWindowsReservedFileName(name))
{
ShowError("That name is reserved by the operating system. Please choose a different name.");
return;
}
if (_saveManager.DoesSaveExist(name))
{
ShowError("A save with that name already exists.");
return;
}
_isBusy = true;
SetButtonsInteractable(false);
try
{
await _saveManager.CreateNewSaveGame(name);
// Tell SelectSaveWindow to start game.
OnSaveCreated?.Invoke(name);
// Close ourselves immediately (caller will close SelectSaveWindow)
CloseImmediate();
}
catch (Exception)
{
ShowError("Failed to create save. Please try again.");
}
finally
{
_isBusy = false;
SetButtonsInteractable(true);
}
}
private void SetButtonsInteractable(bool interactable)
{
if (_createButton != null) _createButton.interactable = interactable;
if (_cancelButton != null) _cancelButton.interactable = interactable;
if (_nameInput != null) _nameInput.interactable = interactable;
}
private void ShowError(string message)
{
if (_errorText != null) _errorText.text = message;
if (_errorBox != null) _errorBox.SetActive(true);
}
private void HideError()
{
if (_errorBox != null) _errorBox.SetActive(false);
if (_errorText != null) _errorText.text = string.Empty;
}
private static bool ContainsIllegalFileNameChars(string name, out char[] illegalChars)
{
var invalid = Path.GetInvalidFileNameChars();
illegalChars = name.Where(c => invalid.Contains(c)).Distinct().ToArray();
return illegalChars.Length > 0;
}
private static bool IsWindowsReservedFileName(string name)
{
var trimmed = (name ?? string.Empty).Trim().TrimEnd('.', ' ');
if (string.IsNullOrEmpty(trimmed)) return true;
var upper = trimmed.ToUpperInvariant();
if (upper == "CON" || upper == "PRN" || upper == "AUX" || upper == "NUL") return true;
if (upper.Length == 4)
{
if (upper.StartsWith("COM") && char.IsDigit(upper[3]) && upper[3] != '0') return true;
if (upper.StartsWith("LPT") && char.IsDigit(upper[3]) && upper[3] != '0') return true;
}
return false;
}
private void ResetCtsAndCancelRunning()
{
if (_seq.isAlive)
{
_seq.Stop();
_seq = default;
}
if (_cts != null)
{
try
{
_cts.Cancel();
}
catch
{
}
_cts.Dispose();
_cts = null;
}
_cts = new CancellationTokenSource();
}
private void StopTween()
{
if (_seq.isAlive) _seq.Stop();
_seq = default;
if (_cts != null)
{
try
{
_cts.Cancel();
}
catch
{
}
_cts.Dispose();
_cts = null;
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 98d490ab66c54c3d8bafdfeae4945734
timeCreated: 1770232259

View File

@@ -0,0 +1,308 @@
using System;
using System.Threading;
using BriarQueen.Framework.Coordinators.Events;
using BriarQueen.Framework.Events.UI;
using BriarQueen.Framework.Managers.Audio;
using BriarQueen.Framework.Managers.Interaction;
using BriarQueen.Framework.Managers.IO;
using BriarQueen.Framework.Managers.UI.Base;
using BriarQueen.Framework.Managers.UI.Events;
using BriarQueen.Framework.Services.Game;
using Cysharp.Threading.Tasks;
using PrimeTween;
using UnityEngine;
using UnityEngine.UI;
using VContainer;
namespace BriarQueen.UI.Menus
{
public class PauseMenuWindow : MonoBehaviour, IUIWindow
{
[Header("Root UI")]
[SerializeField]
private CanvasGroup _canvasGroup;
[SerializeField]
private RectTransform _windowRect;
[Header("Buttons")]
[SerializeField]
private Button _resumeButton;
[SerializeField]
private Button _saveButton;
[SerializeField]
private Button _settingsButton;
[SerializeField]
private Button _exitButton;
[SerializeField]
private Button _quitToDesktopButton;
[Header("Tween Settings")]
[SerializeField]
private TweenSettings _tweenSettings = new()
{
duration = 0.25f,
ease = Ease.OutQuad,
useUnscaledTime = true
};
[Header("Scale")]
[SerializeField]
private float _hiddenScale = 0.85f;
[Header("Internal")]
[SerializeField]
private GraphicRaycaster _graphicRaycaster;
private AudioManager _audioManager;
private CancellationTokenSource _cts;
private EventCoordinator _eventCoordinator;
private GameService _gameService;
private InteractManager _interactManager;
private SaveManager _saveManager;
private Sequence _sequence;
public bool IsModal => true;
public WindowType WindowType => WindowType.PauseMenuWindow;
private void Awake()
{
// Start hidden by default
if (_canvasGroup != null)
{
_canvasGroup.alpha = 0f;
_canvasGroup.blocksRaycasts = false;
_canvasGroup.interactable = false;
}
if (_windowRect != null)
_windowRect.localScale = Vector3.one * _hiddenScale;
}
private void OnEnable()
{
if (_resumeButton != null) _resumeButton.onClick.AddListener(OnResumeButtonClick);
if (_saveButton != null) _saveButton.onClick.AddListener(OnSaveButtonClick);
if (_settingsButton != null) _settingsButton.onClick.AddListener(OnSettingsButtonClick);
if (_exitButton != null) _exitButton.onClick.AddListener(OnExitButtonClick);
if (_quitToDesktopButton != null) _quitToDesktopButton.onClick.AddListener(OnQuitToDesktopButtonClick);
_interactManager.AddUIRaycaster(_graphicRaycaster);
}
private void OnDisable()
{
if (_resumeButton != null) _resumeButton.onClick.RemoveListener(OnResumeButtonClick);
if (_saveButton != null) _saveButton.onClick.RemoveListener(OnSaveButtonClick);
if (_settingsButton != null) _settingsButton.onClick.RemoveListener(OnSettingsButtonClick);
if (_exitButton != null) _exitButton.onClick.RemoveListener(OnExitButtonClick);
if (_quitToDesktopButton != null) _quitToDesktopButton.onClick.RemoveListener(OnQuitToDesktopButtonClick);
_interactManager.RemoveUIRaycaster(_graphicRaycaster);
}
private void OnDestroy()
{
StopAndResetCancellation();
}
public async UniTask Show()
{
if (_canvasGroup == null || _windowRect == null)
{
Debug.LogError("[PauseMenuWindow] Missing CanvasGroup or WindowRect reference.");
gameObject.SetActive(true);
return;
}
StopAndResetCancellation();
gameObject.SetActive(true);
_canvasGroup.alpha = 0f;
_canvasGroup.blocksRaycasts = false;
_canvasGroup.interactable = false;
_windowRect.localScale = Vector3.one * _hiddenScale;
var alpha = new TweenSettings<float>
{
startValue = 0f,
endValue = 1f,
settings = _tweenSettings
};
var scale = new TweenSettings<Vector3>
{
startValue = Vector3.one * _hiddenScale,
endValue = Vector3.one,
settings = _tweenSettings
};
_sequence = Sequence.Create(useUnscaledTime: true)
.Group(Tween.Alpha(_canvasGroup, alpha))
.Group(Tween.Scale(_windowRect, scale));
try
{
await _sequence.ToUniTask(cancellationToken: _cts.Token);
}
catch (OperationCanceledException)
{
return;
}
finally
{
_sequence = default;
}
_canvasGroup.alpha = 1f;
_windowRect.localScale = Vector3.one;
_canvasGroup.blocksRaycasts = true;
_canvasGroup.interactable = true;
}
public async UniTask Hide()
{
if (_canvasGroup == null || _windowRect == null)
{
gameObject.SetActive(false);
return;
}
StopAndResetCancellation();
// Block clicks immediately
_canvasGroup.blocksRaycasts = false;
_canvasGroup.interactable = false;
var alpha = new TweenSettings<float>
{
startValue = _canvasGroup.alpha,
endValue = 0f,
settings = _tweenSettings
};
var scale = new TweenSettings<Vector3>
{
startValue = _windowRect.localScale,
endValue = Vector3.one * _hiddenScale,
settings = _tweenSettings
};
_sequence = Sequence.Create(useUnscaledTime: true)
.Group(Tween.Alpha(_canvasGroup, alpha))
.Group(Tween.Scale(_windowRect, scale));
try
{
await _sequence.ToUniTask(cancellationToken: _cts.Token);
}
catch (OperationCanceledException)
{
return;
}
finally
{
_sequence = default;
}
_canvasGroup.alpha = 0f;
_windowRect.localScale = Vector3.one * _hiddenScale;
gameObject.SetActive(false);
}
[Inject]
public void Construct(EventCoordinator eventCoordinator, SaveManager saveManager, GameService gameService,
InteractManager interactManager, AudioManager audioManager)
{
_eventCoordinator = eventCoordinator;
_saveManager = saveManager;
_gameService = gameService;
_interactManager = interactManager;
_audioManager = audioManager;
}
private void StopAndResetCancellation()
{
if (_sequence.isAlive)
_sequence.Stop();
if (_cts != null)
{
try
{
_cts.Cancel();
}
catch
{
}
_cts.Dispose();
_cts = null;
}
_cts = new CancellationTokenSource();
}
private void OnResumeButtonClick()
{
_eventCoordinator?.Publish(new PauseButtonClickedEvent());
}
private void OnSaveButtonClick()
{
SaveGame().Forget();
}
private void OnSettingsButtonClick()
{
_eventCoordinator.Publish(new UIToggleSettingsWindow(true));
}
private void OnExitButtonClick()
{
_eventCoordinator.Publish(new FadeEvent(false, 1f));
ExitButtonInternal().Forget();
}
private async UniTask ExitButtonInternal()
{
await UniTask.Delay(TimeSpan.FromSeconds(1));
_audioManager.StopAllAudio();
await SaveGame();
_eventCoordinator.Publish(new PauseButtonClickedEvent());
await _gameService.LoadMainMenu();
}
private void OnQuitToDesktopButtonClick()
{
QuitButtonInternal().Forget();
}
private async UniTask QuitButtonInternal()
{
_eventCoordinator.Publish(new FadeEvent(false, 1f));
await UniTask.Delay(TimeSpan.FromSeconds(1));
await SaveGame();
_gameService.QuitGame();
}
private async UniTask SaveGame()
{
if (_saveManager == null) return;
await _saveManager.SaveGameDataLatest();
// TODO: Saved feedback popup/toast
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 19b9bc67046d4238ac544f6fe36a6066
timeCreated: 1769707615

View File

@@ -0,0 +1,355 @@
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
{
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()
{
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();
}
}
private void OnDestroy()
{
if (_backButton != null) _backButton.onClick.RemoveListener(OnBackClicked);
if (_newSaveWindow != null)
{
_newSaveWindow.OnCloseWindow -= HandleNewSaveClosed;
_newSaveWindow.OnSaveCreated -= HandleSaveCreatedAndStartGame;
}
if (_confirmDeleteWindow != null)
{
_confirmDeleteWindow.OnConfirmDelete -= HandleConfirmDelete;
_confirmDeleteWindow.OnCancel -= HandleCancelDelete;
}
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++)
{
var slot = Instantiate(_saveSlotPrefab, _listContentParent);
_instantiatedSlots.Add(slot);
}
}
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 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();
}
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)
{
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);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 06df6456fa0544518f3c8d3a6802fc5c
timeCreated: 1770231671

View File

@@ -0,0 +1,726 @@
using System;
using System.Threading;
using BriarQueen.Framework.Coordinators.Events;
using BriarQueen.Framework.Managers.UI.Base;
using BriarQueen.Framework.Managers.UI.Events;
using BriarQueen.Framework.Services.Settings;
using BriarQueen.Framework.Services.Settings.Data;
using Cysharp.Threading.Tasks;
using PrimeTween;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
using VContainer;
using AudioSettings = BriarQueen.Framework.Services.Settings.Data.AudioSettings;
namespace BriarQueen.UI.Menus
{
public class SettingsWindow : MonoBehaviour, IUIWindow
{
[Header("UI Elements")]
[SerializeField]
private CanvasGroup _canvasGroup;
[SerializeField]
private RectTransform _windowRect;
[Header("Buttons")]
[SerializeField]
private Button _applyButton;
[SerializeField]
private Button _backButton;
[Header("Game")]
[SerializeField]
private Slider _popupDisplayDurationSlider;
[SerializeField]
private TextMeshProUGUI _popupDisplayDurationText;
[SerializeField]
private Toggle _tutorialsEnabledToggle;
[SerializeField]
private Toggle _tooltipsEnabledToggle;
[SerializeField]
private Toggle _autoUseToolsToggle;
[Header("Visual")]
[SerializeField]
private Toggle _fullscreenToggle;
[Tooltip("0 = VSync Off, 1 = Every V-Blank, 2 = Every 2nd V-Blank")]
[SerializeField]
private Slider _vsyncSlider;
[SerializeField]
private TextMeshProUGUI _vsyncValueText;
[SerializeField]
private Slider _maxFramerateSlider;
[SerializeField]
private TextMeshProUGUI _maxFramerateValueText;
[Header("Audio")]
[SerializeField]
private Slider _masterVolumeSlider;
[SerializeField]
private TextMeshProUGUI _masterVolumeText;
[SerializeField]
private Slider _musicVolumeSlider;
[SerializeField]
private TextMeshProUGUI _musicVolumeText;
[SerializeField]
private Slider _sfxVolumeSlider;
[SerializeField]
private TextMeshProUGUI _sfxVolumeText;
[SerializeField]
private Slider _voiceVolumeSlider;
[SerializeField]
private TextMeshProUGUI _voiceVolumeText;
[SerializeField]
private Slider _ambienceVolumeSlider;
[SerializeField]
private TextMeshProUGUI _ambienceVolumeText;
[SerializeField]
private Slider _uiVolumeSlider;
[SerializeField]
private TextMeshProUGUI _uiVolumeText;
[SerializeField]
private Toggle _muteWhenUnfocusedToggle;
[Header("Unsaved Changes Warning")]
[SerializeField]
private TMP_Text _pendingChangesText;
[SerializeField]
private string _pendingChangesMessage = "You have unsaved changes.";
[Header("Tween Settings")]
[SerializeField]
private TweenSettings _tweenSettings = new()
{
duration = 0.25f,
ease = Ease.InOutSine,
useUnscaledTime = true
};
[Header("Scale")]
[SerializeField]
private float _hiddenScale = 0.85f;
[Header("Selection")]
[SerializeField]
private Selectable _firstSelectedOnOpen;
[Header("Game Slider Ranges")]
[SerializeField]
private float _popupDisplayDurationMin = 1f;
[SerializeField]
private float _popupDisplayDurationMax = 10f;
[Header("Visual Slider Ranges")]
[SerializeField]
private int _maxFramerateMin = 30;
[SerializeField]
private int _maxFramerateMax = 240;
private CancellationTokenSource _cts;
private AudioSettings _draftAudio;
private GameSettings _draftGameSettings;
private VisualSettings _draftVisual;
private EventCoordinator _eventCoordinator;
private bool _ignoreUiCallbacks;
private AudioSettings _loadedAudio;
private GameSettings _loadedGameSettings;
private VisualSettings _loadedVisual;
private Sequence _sequence;
private SettingsService _settingsService;
private bool _waitingChanges;
public bool IsModal => true;
public WindowType WindowType => WindowType.SettingsWindow;
private void Awake()
{
if (_applyButton != null) _applyButton.onClick.AddListener(OnApplyClicked);
if (_backButton != null) _backButton.onClick.AddListener(OnBackClicked);
HookSlider(_masterVolumeSlider, _masterVolumeText, v => _draftAudio.MasterVolume = v);
HookSlider(_musicVolumeSlider, _musicVolumeText, v => _draftAudio.MusicVolume = v);
HookSlider(_sfxVolumeSlider, _sfxVolumeText, v => _draftAudio.SfxVolume = v);
HookSlider(_voiceVolumeSlider, _voiceVolumeText, v => _draftAudio.VoiceVolume = v);
HookSlider(_ambienceVolumeSlider, _ambienceVolumeText, v => _draftAudio.AmbienceVolume = v);
HookSlider(_uiVolumeSlider, _uiVolumeText, v => _draftAudio.UIVolume = v);
SetupGameSliders();
if (_popupDisplayDurationSlider != null)
_popupDisplayDurationSlider.onValueChanged.AddListener(OnPopupDisplayDurationChanged);
if (_tutorialsEnabledToggle != null)
_tutorialsEnabledToggle.onValueChanged.AddListener(OnTutorialsToggleChanged);
if (_tooltipsEnabledToggle != null)
_tooltipsEnabledToggle.onValueChanged.AddListener(OnTooltipsToggleChanged);
if(_autoUseToolsToggle != null)
_autoUseToolsToggle.onValueChanged.AddListener(OnAutoToolsToggleChanged);
if (_muteWhenUnfocusedToggle != null)
_muteWhenUnfocusedToggle.onValueChanged.AddListener(OnMuteWhenUnfocusedChanged);
if (_fullscreenToggle != null)
_fullscreenToggle.onValueChanged.AddListener(OnFullscreenToggleChanged);
SetupVsyncSlider();
if (_vsyncSlider != null)
_vsyncSlider.onValueChanged.AddListener(OnVsyncSliderChanged);
SetupMaxFramerateSlider();
if (_maxFramerateSlider != null)
_maxFramerateSlider.onValueChanged.AddListener(OnMaxFramerateSliderChanged);
SetPendingChangesVisible(false);
}
private void OnDestroy()
{
StopAndResetCancellation();
if (_sequence.isAlive)
_sequence.Stop();
if (_applyButton != null) _applyButton.onClick.RemoveListener(OnApplyClicked);
if (_backButton != null) _backButton.onClick.RemoveListener(OnBackClicked);
if (_popupDisplayDurationSlider != null)
_popupDisplayDurationSlider.onValueChanged.RemoveListener(OnPopupDisplayDurationChanged);
if (_tutorialsEnabledToggle != null)
_tutorialsEnabledToggle.onValueChanged.RemoveListener(OnTutorialsToggleChanged);
if (_tooltipsEnabledToggle != null)
_tooltipsEnabledToggle.onValueChanged.RemoveListener(OnTooltipsToggleChanged);
if (_muteWhenUnfocusedToggle != null)
_muteWhenUnfocusedToggle.onValueChanged.RemoveListener(OnMuteWhenUnfocusedChanged);
if (_fullscreenToggle != null)
_fullscreenToggle.onValueChanged.RemoveListener(OnFullscreenToggleChanged);
if (_vsyncSlider != null)
_vsyncSlider.onValueChanged.RemoveListener(OnVsyncSliderChanged);
if (_maxFramerateSlider != null)
_maxFramerateSlider.onValueChanged.RemoveListener(OnMaxFramerateSliderChanged);
}
public async UniTask Show()
{
if (_settingsService != null)
LoadSettings(_settingsService.Audio, _settingsService.Visual, _settingsService.Game);
StopAndResetCancellation();
gameObject.SetActive(true);
_windowRect.localScale = Vector3.one * _hiddenScale;
_canvasGroup.alpha = 0f;
_canvasGroup.blocksRaycasts = false;
_canvasGroup.interactable = false;
var alpha = new TweenSettings<float>
{
startValue = 0f,
endValue = 1f,
settings = _tweenSettings
};
var scale = new TweenSettings<Vector3>
{
startValue = Vector3.one * _hiddenScale,
endValue = Vector3.one,
settings = _tweenSettings
};
_sequence = Sequence.Create(useUnscaledTime: true)
.Group(Tween.Alpha(_canvasGroup, alpha))
.Group(Tween.Scale(_windowRect, scale));
try
{
await _sequence.ToUniTask(cancellationToken: _cts.Token);
}
catch (OperationCanceledException)
{
return;
}
_canvasGroup.blocksRaycasts = true;
_canvasGroup.interactable = true;
SelectDefault();
}
public async UniTask Hide()
{
StopAndResetCancellation();
_canvasGroup.blocksRaycasts = false;
_canvasGroup.interactable = false;
var alpha = new TweenSettings<float>
{
startValue = _canvasGroup.alpha,
endValue = 0f,
settings = _tweenSettings
};
var scale = new TweenSettings<Vector3>
{
startValue = _windowRect.localScale,
endValue = Vector3.one * _hiddenScale,
settings = _tweenSettings
};
_sequence = Sequence.Create(useUnscaledTime: true)
.Group(Tween.Alpha(_canvasGroup, alpha))
.Group(Tween.Scale(_windowRect, scale));
try
{
await _sequence.ToUniTask(cancellationToken: _cts.Token);
}
catch (OperationCanceledException)
{
return;
}
gameObject.SetActive(false);
}
[Inject]
public void Construct(SettingsService settingsService, EventCoordinator eventCoordinator)
{
_settingsService = settingsService;
_eventCoordinator = eventCoordinator;
}
private void OnTutorialsToggleChanged(bool value)
{
if (_ignoreUiCallbacks) return;
_draftGameSettings.TutorialsEnabled = value;
MarkDirty();
}
private void OnTooltipsToggleChanged(bool value)
{
if (_ignoreUiCallbacks) return;
_draftGameSettings.TooltipsEnabled = value;
MarkDirty();
}
private void OnAutoToolsToggleChanged(bool value)
{
if (_ignoreUiCallbacks)
return;
_draftGameSettings.AutoUseTools = value;
MarkDirty();
}
private void OnPopupDisplayDurationChanged(float value)
{
if (_ignoreUiCallbacks) return;
_draftGameSettings.PopupDisplayDuration =
Mathf.Clamp(value, _popupDisplayDurationMin, _popupDisplayDurationMax);
UpdateSecondsLabel(_popupDisplayDurationText, _draftGameSettings.PopupDisplayDuration);
MarkDirty();
}
private void OnMuteWhenUnfocusedChanged(bool value)
{
if (_ignoreUiCallbacks) return;
_draftAudio.MuteWhenUnfocused = value;
MarkDirty();
}
private void OnFullscreenToggleChanged(bool isFullscreen)
{
if (_ignoreUiCallbacks) return;
_draftVisual.FullScreenMode = isFullscreen
? FullScreenMode.FullScreenWindow
: FullScreenMode.Windowed;
MarkDirty();
}
private void OnVsyncSliderChanged(float value)
{
if (_ignoreUiCallbacks) return;
var v = Mathf.Clamp(Mathf.RoundToInt(value), 0, 2);
_draftVisual.VSyncCount = v;
UpdateVsyncLabel(v);
MarkDirty();
}
private void OnMaxFramerateSliderChanged(float value)
{
if (_ignoreUiCallbacks) return;
var fps = Mathf.Clamp(Mathf.RoundToInt(value), _maxFramerateMin, _maxFramerateMax);
_draftVisual.MaxFramerate = fps;
UpdateMaxFramerateLabel(fps);
MarkDirty();
}
private void OnApplyClicked()
{
_loadedAudio = CloneAudio(_draftAudio);
_loadedVisual = CloneVisual(_draftVisual);
_loadedGameSettings = CloneGameSettings(_draftGameSettings);
_settingsService.Apply(_loadedAudio, _loadedVisual, _loadedGameSettings);
_waitingChanges = false;
SetPendingChangesVisible(false);
}
private void OnBackClicked()
{
if (HasUnappliedChanges())
{
_draftAudio = CloneAudio(_loadedAudio);
_draftVisual = CloneVisual(_loadedVisual);
_draftGameSettings = CloneGameSettings(_loadedGameSettings);
ApplySettingsToUI();
}
_eventCoordinator.PublishImmediate(new UIToggleSettingsWindow(false));
}
private void StopAndResetCancellation()
{
if (_sequence.isAlive)
_sequence.Stop();
if (_cts != null)
{
try
{
_cts.Cancel();
}
catch
{
}
_cts.Dispose();
}
_cts = new CancellationTokenSource();
}
private void SelectDefault()
{
if (EventSystem.current == null) return;
if (_firstSelectedOnOpen != null)
EventSystem.current.SetSelectedGameObject(_firstSelectedOnOpen.gameObject);
else if (_applyButton != null)
EventSystem.current.SetSelectedGameObject(_applyButton.gameObject);
}
private void SetupGameSliders()
{
if (_popupDisplayDurationSlider == null) return;
_popupDisplayDurationSlider.minValue = _popupDisplayDurationMin;
_popupDisplayDurationSlider.maxValue = _popupDisplayDurationMax;
_popupDisplayDurationSlider.wholeNumbers = false;
}
private void SetupVsyncSlider()
{
if (_vsyncSlider == null) return;
_vsyncSlider.minValue = 0;
_vsyncSlider.maxValue = 2;
_vsyncSlider.wholeNumbers = true;
UpdateVsyncLabel((int)_vsyncSlider.value);
}
private void SetupMaxFramerateSlider()
{
if (_maxFramerateSlider == null) return;
_maxFramerateSlider.minValue = _maxFramerateMin;
_maxFramerateSlider.maxValue = _maxFramerateMax;
_maxFramerateSlider.wholeNumbers = true;
UpdateMaxFramerateLabel(Mathf.RoundToInt(_maxFramerateSlider.value));
}
private void HookSlider(Slider slider, TextMeshProUGUI label, Action<float> assignToDraft)
{
if (slider == null) return;
slider.minValue = 0f;
slider.maxValue = 1f;
slider.onValueChanged.AddListener(v =>
{
if (_ignoreUiCallbacks) return;
var clamped = Mathf.Clamp01(v);
assignToDraft?.Invoke(clamped);
UpdatePercentLabel(label, clamped);
MarkDirty();
});
}
private void LoadSettings(AudioSettings audio, VisualSettings visual, GameSettings game)
{
_loadedAudio = CloneAudio(audio ?? new AudioSettings());
_loadedVisual = CloneVisual(visual ?? new VisualSettings());
_loadedGameSettings = CloneGameSettings(game ?? new GameSettings());
_draftAudio = CloneAudio(_loadedAudio);
_draftVisual = CloneVisual(_loadedVisual);
_draftGameSettings = CloneGameSettings(_loadedGameSettings);
ApplySettingsToUI();
}
private void ApplySettingsToUI()
{
_ignoreUiCallbacks = true;
SetSlider(_masterVolumeSlider, _masterVolumeText, _draftAudio.MasterVolume);
SetSlider(_musicVolumeSlider, _musicVolumeText, _draftAudio.MusicVolume);
SetSlider(_sfxVolumeSlider, _sfxVolumeText, _draftAudio.SfxVolume);
SetSlider(_voiceVolumeSlider, _voiceVolumeText, _draftAudio.VoiceVolume);
SetSlider(_ambienceVolumeSlider, _ambienceVolumeText, _draftAudio.AmbienceVolume);
SetSlider(_uiVolumeSlider, _uiVolumeText, _draftAudio.UIVolume);
if (_tutorialsEnabledToggle != null)
_tutorialsEnabledToggle.isOn = _draftGameSettings.TutorialsEnabled;
if (_tooltipsEnabledToggle != null)
_tooltipsEnabledToggle.isOn = _draftGameSettings.TooltipsEnabled;
if(_autoUseToolsToggle != null)
_autoUseToolsToggle.isOn = _draftGameSettings.AutoUseTools;
if (_muteWhenUnfocusedToggle != null)
_muteWhenUnfocusedToggle.isOn = _draftAudio.MuteWhenUnfocused;
if (_fullscreenToggle != null)
_fullscreenToggle.isOn = _draftVisual.FullScreenMode != FullScreenMode.Windowed;
if (_vsyncSlider != null)
{
_vsyncSlider.value = Mathf.Clamp(_draftVisual.VSyncCount, 0, 2);
UpdateVsyncLabel(_draftVisual.VSyncCount);
}
if (_maxFramerateSlider != null)
{
var fps = Mathf.Clamp(_draftVisual.MaxFramerate, _maxFramerateMin, _maxFramerateMax);
_maxFramerateSlider.value = fps;
UpdateMaxFramerateLabel(fps);
}
if (_popupDisplayDurationSlider != null)
{
_popupDisplayDurationSlider.value = _draftGameSettings.PopupDisplayDuration;
UpdateSecondsLabel(_popupDisplayDurationText, _draftGameSettings.PopupDisplayDuration);
}
_ignoreUiCallbacks = false;
_waitingChanges = HasUnappliedChanges();
SetPendingChangesVisible(_waitingChanges);
}
private void SetSlider(Slider slider, TextMeshProUGUI label, float value)
{
if (slider != null)
slider.value = Mathf.Clamp01(value);
UpdatePercentLabel(label, value);
}
private void UpdatePercentLabel(TextMeshProUGUI label, float value)
{
if (label == null) return;
label.text = $"{Mathf.RoundToInt(value * 100f)}%";
}
private void UpdateSecondsLabel(TextMeshProUGUI label, float value)
{
if (label == null) return;
label.text = $"{value:0.0}s";
}
private void UpdateVsyncLabel(int v)
{
if (_vsyncValueText == null) return;
_vsyncValueText.text = v switch
{
0 => "Off",
1 => "On",
2 => "Half",
_ => "On"
};
}
private void UpdateMaxFramerateLabel(int fps)
{
if (_maxFramerateValueText == null) return;
_maxFramerateValueText.text = $"{fps} FPS";
}
private void MarkDirty()
{
if (_ignoreUiCallbacks) return;
_waitingChanges = HasUnappliedChanges();
SetPendingChangesVisible(_waitingChanges);
}
private bool HasUnappliedChanges()
{
return !AudioEquals(_draftAudio, _loadedAudio) ||
!VisualEquals(_draftVisual, _loadedVisual) ||
!GameEquals(_draftGameSettings, _loadedGameSettings);
}
private void SetPendingChangesVisible(bool visible)
{
if (_pendingChangesText == null) return;
_pendingChangesText.gameObject.SetActive(visible);
if (visible)
_pendingChangesText.text = _pendingChangesMessage;
}
private static bool AudioEquals(AudioSettings a, AudioSettings b)
{
if (ReferenceEquals(a, b)) return true;
if (a == null || b == null) return false;
const float eps = 0.0001f;
return Mathf.Abs(a.MasterVolume - b.MasterVolume) < eps &&
Mathf.Abs(a.MusicVolume - b.MusicVolume) < eps &&
Mathf.Abs(a.SfxVolume - b.SfxVolume) < eps &&
Mathf.Abs(a.VoiceVolume - b.VoiceVolume) < eps &&
Mathf.Abs(a.AmbienceVolume - b.AmbienceVolume) < eps &&
Mathf.Abs(a.UIVolume - b.UIVolume) < eps &&
a.MuteWhenUnfocused == b.MuteWhenUnfocused;
}
private static bool VisualEquals(VisualSettings a, VisualSettings b)
{
if (ReferenceEquals(a, b)) return true;
if (a == null || b == null) return false;
return a.FullScreenMode == b.FullScreenMode &&
a.VSyncCount == b.VSyncCount &&
a.MaxFramerate == b.MaxFramerate;
}
private static bool GameEquals(GameSettings a, GameSettings b)
{
if (ReferenceEquals(a, b)) return true;
if (a == null || b == null) return false;
const float eps = 0.0001f;
return Mathf.Abs(a.PopupDisplayDuration - b.PopupDisplayDuration) < eps &&
a.TutorialsEnabled == b.TutorialsEnabled &&
a.TooltipsEnabled == b.TooltipsEnabled &&
a.AutoUseTools == b.AutoUseTools;
}
private static AudioSettings CloneAudio(AudioSettings a)
{
if (a == null) a = new AudioSettings();
return new AudioSettings
{
MasterVolume = a.MasterVolume,
MusicVolume = a.MusicVolume,
SfxVolume = a.SfxVolume,
VoiceVolume = a.VoiceVolume,
AmbienceVolume = a.AmbienceVolume,
UIVolume = a.UIVolume,
MuteWhenUnfocused = a.MuteWhenUnfocused
};
}
private static VisualSettings CloneVisual(VisualSettings v)
{
if (v == null) v = new VisualSettings();
return new VisualSettings
{
FullScreenMode = v.FullScreenMode,
VSyncCount = v.VSyncCount,
MaxFramerate = v.MaxFramerate <= 0 ? 120 : v.MaxFramerate
};
}
private static GameSettings CloneGameSettings(GameSettings g)
{
if (g == null) g = new GameSettings();
return new GameSettings
{
PopupDisplayDuration = g.PopupDisplayDuration,
TutorialsEnabled = g.TutorialsEnabled,
TooltipsEnabled = g.TooltipsEnabled,
AutoUseTools = g.AutoUseTools,
};
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 37445792122d444baf3ac50efe28744f
timeCreated: 1769787064

View File

@@ -0,0 +1,364 @@
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.UI;
#if UNITY_EDITOR
using UnityEditor;
#endif
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
namespace BriarQueen.UI.Menus
{
[RequireComponent(typeof(RectTransform))]
public class VerticalScrollbar : MonoBehaviour, IDragHandler, IPointerDownHandler
{
private static readonly Vector3[] Corners = new Vector3[4];
[Header("Hierarchy")]
[SerializeField]
private RectTransform _viewport;
[SerializeField]
private RectTransform _content;
[SerializeField]
private RectTransform _trackRect;
[SerializeField]
private RectTransform _handleRect;
[Header("Scroll Settings")]
[SerializeField]
private float _wheelPixels = 80f;
[SerializeField]
private float _padSpeed = 900f;
[SerializeField]
private float _inputSystemWheelScale = 0.05f;
[Header("Handle")]
[SerializeField]
private bool _useCustomHandleSizing;
[SerializeField]
private float _minHandleHeight = 24f;
[Header("Alignment")]
[SerializeField]
private bool _centerContentWhenNotScrollable = true;
[SerializeField]
private float _topInset = 6f;
[SerializeField]
private float _bottomInset = 6f;
[Header("Track")]
[SerializeField]
private bool _hideTrackWhenNotScrollable = true;
#if UNITY_EDITOR
[Header("Editor Debug")]
[SerializeField]
[Range(0f, 1f)]
private float _debugNormalized;
#endif
private bool _isScrollable;
private float _scrollRange;
private Camera _uiCamera;
public float Normalized { get; private set; }
private void Awake()
{
var canvas = GetComponentInParent<Canvas>();
if (canvas != null && canvas.renderMode == RenderMode.ScreenSpaceCamera)
_uiCamera = canvas.worldCamera;
}
private void Start()
{
Rebuild();
}
private void Update()
{
HandleMouseWheel();
}
#if UNITY_EDITOR
private void OnValidate()
{
if (_viewport == null || _content == null || _trackRect == null || _handleRect == null)
return;
if (Application.isPlaying)
return;
Canvas.ForceUpdateCanvases();
LayoutRebuilder.ForceRebuildLayoutImmediate(_content);
if (!TryGetContentBounds(out var top, out var bottom))
return;
var contentHeight = top - bottom;
var viewportHeight = _viewport.rect.height - _topInset - _bottomInset;
_isScrollable = contentHeight > viewportHeight;
_scrollRange = Mathf.Max(0f, contentHeight - viewportHeight);
Normalized = Mathf.Clamp01(_debugNormalized);
if (_centerContentWhenNotScrollable && !_isScrollable)
{
CenterContent(top, bottom);
Normalized = 0f;
}
else
{
var offset = Mathf.Lerp(0f, _scrollRange, Normalized);
SetContentY(offset);
if (Normalized <= 0.0001f)
AlignFirstChildToTop();
}
UpdateTrackVisibility();
UpdateHandle();
}
#endif
public void OnDrag(PointerEventData eventData)
{
DragHandle(eventData);
}
public void OnPointerDown(PointerEventData eventData)
{
DragHandle(eventData);
}
public void Rebuild()
{
if (_viewport == null || _content == null)
return;
Canvas.ForceUpdateCanvases();
LayoutRebuilder.ForceRebuildLayoutImmediate(_content);
if (!TryGetContentBounds(out var top, out var bottom))
return;
var contentHeight = top - bottom;
var viewportHeight = _viewport.rect.height - _topInset - _bottomInset;
_isScrollable = contentHeight > viewportHeight;
_scrollRange = Mathf.Max(0f, contentHeight - viewportHeight);
if (_centerContentWhenNotScrollable && !_isScrollable)
{
CenterContent(top, bottom);
Normalized = 0f;
}
else
{
SetNormalized(Normalized);
}
UpdateTrackVisibility();
UpdateHandle();
}
public void SetNormalized(float normalized)
{
Normalized = Mathf.Clamp01(normalized);
if (!_isScrollable)
return;
var offset = Mathf.Lerp(0f, _scrollRange, Normalized);
SetContentY(offset);
if (Normalized <= 0.0001f)
AlignFirstChildToTop();
UpdateHandle();
}
private void CenterContent(float top, float bottom)
{
var contentCenter = (top + bottom) * 0.5f;
var viewportCenter = (_viewport.rect.yMin + _viewport.rect.yMax) * 0.5f;
var delta = viewportCenter - contentCenter;
var position = _content.anchoredPosition;
position.y += delta;
_content.anchoredPosition = position;
}
private void AlignFirstChildToTop()
{
RectTransform first = null;
for (var i = 0; i < _content.childCount; i++)
{
var child = _content.GetChild(i) as RectTransform;
if (child != null && child.gameObject.activeSelf)
{
first = child;
break;
}
}
if (first == null)
return;
first.GetWorldCorners(Corners);
var childTop = _viewport.InverseTransformPoint(Corners[1]).y;
var targetTop = _viewport.rect.yMax - _topInset;
var delta = targetTop - childTop;
var position = _content.anchoredPosition;
position.y += delta;
_content.anchoredPosition = position;
}
private void ScrollByPixels(float pixels)
{
if (!_isScrollable)
return;
var current = Normalized * _scrollRange;
var next = Mathf.Clamp(current + pixels, 0f, _scrollRange);
Normalized = _scrollRange > 0f ? next / _scrollRange : 0f;
SetNormalized(Normalized);
}
private void DragHandle(PointerEventData eventData)
{
if (!_isScrollable || _trackRect == null || _handleRect == null)
return;
if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(_trackRect, eventData.position, _uiCamera,
out var localPoint))
return;
var halfHandleHeight = _handleRect.rect.height * 0.5f;
var min = _trackRect.rect.yMin + halfHandleHeight;
var max = _trackRect.rect.yMax - halfHandleHeight;
var y = Mathf.Clamp(localPoint.y, min, max);
var normalized = 1f - Mathf.InverseLerp(min, max, y);
SetNormalized(normalized);
}
private void HandleMouseWheel()
{
var wheel = ReadMouseWheelDelta();
if (Mathf.Abs(wheel) > 0.01f)
ScrollByPixels(-wheel * _wheelPixels);
}
private float ReadMouseWheelDelta()
{
#if ENABLE_INPUT_SYSTEM
if (Mouse.current != null)
return Mouse.current.scroll.ReadValue().y * _inputSystemWheelScale;
#elif ENABLE_LEGACY_INPUT_MANAGER
return Input.mouseScrollDelta.y;
#endif
return 0f;
}
private void SetContentY(float y)
{
var position = _content.anchoredPosition;
position.y = y;
_content.anchoredPosition = position;
#if UNITY_EDITOR
if (!Application.isPlaying)
{
Canvas.ForceUpdateCanvases();
SceneView.RepaintAll();
}
#endif
}
private void UpdateHandle()
{
if (_trackRect == null || _handleRect == null)
return;
if (!_isScrollable)
{
_handleRect.anchoredPosition = Vector2.zero;
return;
}
if (_useCustomHandleSizing)
{
var ratio = Mathf.Clamp01(_viewport.rect.height / (_scrollRange + _viewport.rect.height));
var height = Mathf.Max(_trackRect.rect.height * ratio, _minHandleHeight);
_handleRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, height);
}
var half = _handleRect.rect.height * 0.5f;
var min = _trackRect.rect.yMin + half;
var max = _trackRect.rect.yMax - half;
var y = Mathf.Lerp(max, min, Normalized);
var position = _handleRect.anchoredPosition;
position.y = y;
_handleRect.anchoredPosition = position;
}
private void UpdateTrackVisibility()
{
if (_trackRect == null)
return;
if (_hideTrackWhenNotScrollable)
_trackRect.gameObject.SetActive(_isScrollable);
}
private bool TryGetContentBounds(out float top, out float bottom)
{
top = float.MinValue;
bottom = float.MaxValue;
var found = false;
for (var i = 0; i < _content.childCount; i++)
{
var child = _content.GetChild(i) as RectTransform;
if (child == null || !child.gameObject.activeSelf)
continue;
child.GetWorldCorners(Corners);
for (var c = 0; c < 4; c++)
{
var local = _viewport.InverseTransformPoint(Corners[c]);
top = Mathf.Max(top, local.y);
bottom = Mathf.Min(bottom, local.y);
}
found = true;
}
return found;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: d868730b4606b4100910e781df521d52