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,24 @@
{
"name": "BriarQueen.UI",
"rootNamespace": "BriarQueen",
"references": [
"GUID:ac1be664c635c449eb9f3f52cf5c97f5",
"GUID:bdf0eff65032c4178bf18aa9c96b1c70",
"GUID:80ecb87cae9c44d19824e70ea7229748",
"GUID:6055be8ebefd69e48b49212b09b47b2f",
"GUID:9e24947de15b9834991c9d8411ea37cf",
"GUID:f51ebe6a0ceec4240a699833d6309b23",
"GUID:b0214a6008ed146ff8f122a6a9c2f6cc",
"GUID:75469ad4d38634e559750d17036d5f7c",
"GUID:d525ad6bd40672747bde77962f1c401e"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [],
"noEngineReferences": false
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: fbf8e360025c642d9ab65c9f6e98e5c5
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 28c4e41fa6af42919d5479a5faeb8192
timeCreated: 1773683598

View File

@@ -0,0 +1,50 @@
using System;
using BriarQueen.Data.Identifiers;
using UnityEngine;
using UnityEngine.UI;
namespace BriarQueen.UI.Codex
{
public class CodexCategoryButton : MonoBehaviour
{
[SerializeField]
private Button _button;
[SerializeField]
private Image _selectedBackground;
public CodexType Category { get; private set; }
private void Awake()
{
if (_button != null)
_button.onClick.AddListener(HandleClicked);
}
private void OnDestroy()
{
if (_button != null)
_button.onClick.RemoveListener(HandleClicked);
}
public event Action<CodexType> OnCategoryClicked;
public void Initialize(CodexType category)
{
Category = category;
SetSelected(false);
}
public void SetSelected(bool selected)
{
if (_selectedBackground != null)
_selectedBackground.enabled = selected;
}
private void HandleClicked()
{
OnCategoryClicked?.Invoke(Category);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 2cfb06e8e46d402e86eb8dc6e9f6c96e
timeCreated: 1773684333

View File

@@ -0,0 +1,263 @@
using System;
using BriarQueen.Framework.Managers.Player.Data;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace BriarQueen.UI.Codex
{
[ExecuteAlways]
public class CodexEntryButton : MonoBehaviour
{
private const float MIN_WIDTH = 300f;
private const float MAX_WIDTH = 400f;
private const float FIXED_HEIGHT = 70f;
[SerializeField]
private Button _button;
[SerializeField]
private Image _buttonBackgroundImage;
[SerializeField]
private Sprite _defaultSprite;
[SerializeField]
private Sprite _selectedSprite;
[SerializeField]
private TextMeshProUGUI _label;
[SerializeField]
private float _leftPadding = 24f;
[SerializeField]
private float _rightPadding = 24f;
[SerializeField]
private float _extraWidthSafety = 20f;
[SerializeField]
private float _autoSizeMinFontSize = 18f;
[SerializeField]
private bool _debugInEditor = true;
private bool _defaultSpriteCachedFromImage;
private string _lastText;
private float _lastWidth = -1f;
public CodexEntrySo Entry { get; private set; }
private void Awake()
{
ResolveReferences();
CacheDefaultSpriteFromImageIfNeeded();
AddButtonListener();
RefreshVisuals();
ApplyCurrentBackground(false);
}
#if UNITY_EDITOR
private void Update()
{
if (Application.isPlaying)
return;
if (_label == null)
return;
if (_lastText != _label.text)
RefreshVisuals();
}
#endif
private void OnEnable()
{
ResolveReferences();
CacheDefaultSpriteFromImageIfNeeded();
AddButtonListener();
RefreshVisuals();
ApplyCurrentBackground(false);
}
private void OnDestroy()
{
RemoveButtonListener();
}
private void OnValidate()
{
ResolveReferences();
CacheDefaultSpriteFromImageIfNeeded();
RefreshVisuals();
#if UNITY_EDITOR
if (!Application.isPlaying)
ApplyCurrentBackground(false);
#endif
}
public event Action<CodexEntrySo> OnEntryClicked;
public void Initialize(CodexEntrySo entry)
{
Entry = entry;
ResolveReferences();
CacheDefaultSpriteFromImageIfNeeded();
if (_label != null)
_label.text = entry != null ? entry.Title : string.Empty;
RefreshVisuals();
SetSelected(false);
}
public void SetSelected(bool selected)
{
ApplyCurrentBackground(selected);
}
private void HandleClicked()
{
if (Entry == null)
return;
OnEntryClicked?.Invoke(Entry);
}
private void RefreshVisuals()
{
ResizeToFitLabel();
}
private void ResizeToFitLabel()
{
if (_button == null || _label == null)
return;
var buttonRect = _button.GetComponent<RectTransform>();
var labelRect = _label.rectTransform;
if (buttonRect == null || labelRect == null)
return;
StretchLabelToButton(labelRect);
_label.margin = new Vector4(_leftPadding, _label.margin.y, _rightPadding, _label.margin.w);
_label.textWrappingMode = TextWrappingModes.NoWrap;
_label.overflowMode = TextOverflowModes.Overflow;
_label.enableAutoSizing = false;
_label.ForceMeshUpdate();
var preferredSize = _label.GetPreferredValues(_label.text, Mathf.Infinity, Mathf.Infinity);
var desiredWidth = preferredSize.x + _leftPadding + _rightPadding + _extraWidthSafety;
var targetWidth = Mathf.Clamp(desiredWidth, MIN_WIDTH, MAX_WIDTH);
var needsAutoSizing = desiredWidth > MAX_WIDTH;
if (needsAutoSizing)
{
_label.enableAutoSizing = true;
_label.fontSizeMin = _autoSizeMinFontSize;
_label.fontSizeMax = _label.fontSize;
_label.overflowMode = TextOverflowModes.Truncate;
}
buttonRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, targetWidth);
buttonRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, FIXED_HEIGHT);
#if UNITY_EDITOR
if (_debugInEditor && (!Mathf.Approximately(targetWidth, _lastWidth) || _lastText != _label.text))
Debug.Log(
$"[CodexEntryButton] '{_label.text}'\n" +
$"Preferred: {preferredSize.x:F1} | Desired: {desiredWidth:F1} | Final: {targetWidth:F1}\n" +
$"AutoSize: {needsAutoSizing}",
this);
#endif
_lastText = _label.text;
_lastWidth = targetWidth;
LayoutRebuilder.ForceRebuildLayoutImmediate(buttonRect);
_label.ForceMeshUpdate();
}
private void StretchLabelToButton(RectTransform labelRect)
{
labelRect.anchorMin = new Vector2(0f, 0f);
labelRect.anchorMax = new Vector2(1f, 1f);
labelRect.offsetMin = Vector2.zero;
labelRect.offsetMax = Vector2.zero;
labelRect.pivot = new Vector2(0.5f, 0.5f);
}
private void ResolveReferences()
{
if (_buttonBackgroundImage == null && _button != null)
_buttonBackgroundImage = _button.GetComponent<Image>();
}
private void CacheDefaultSpriteFromImageIfNeeded()
{
if (_buttonBackgroundImage == null)
return;
if (_defaultSprite != null)
return;
if (_buttonBackgroundImage.sprite == null)
return;
_defaultSprite = _buttonBackgroundImage.sprite;
_defaultSpriteCachedFromImage = true;
}
private void ApplyCurrentBackground(bool selected)
{
if (_buttonBackgroundImage == null)
return;
Sprite targetSprite = null;
if (selected && _selectedSprite != null)
targetSprite = _selectedSprite;
else if (_defaultSprite != null)
targetSprite = _defaultSprite;
else if (_buttonBackgroundImage.sprite != null) targetSprite = _buttonBackgroundImage.sprite;
if (targetSprite != null)
{
_buttonBackgroundImage.enabled = true;
_buttonBackgroundImage.sprite = targetSprite;
}
else
{
Debug.LogWarning(
"[CodexEntryButton] No default background sprite is available. " +
"Assign _defaultSprite in the inspector or ensure the Image has a sprite on the prefab.",
this);
}
}
private void AddButtonListener()
{
if (_button == null)
return;
_button.onClick.RemoveListener(HandleClicked);
_button.onClick.AddListener(HandleClicked);
}
private void RemoveButtonListener()
{
if (_button == null)
return;
_button.onClick.RemoveListener(HandleClicked);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: fcb40c65f84248078513824345046145
timeCreated: 1773684301

View File

@@ -0,0 +1,179 @@
using System;
using BriarQueen.Data.Identifiers;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace BriarQueen.UI.Codex
{
[ExecuteAlways]
public class CodexLocationButton : MonoBehaviour
{
private const float MIN_WIDTH = 300f;
private const float MAX_WIDTH = 400f;
private const float FIXED_HEIGHT = 70f;
[SerializeField]
private Button _button;
[SerializeField]
private TextMeshProUGUI _label;
[SerializeField]
private float _leftPadding = 24f;
[SerializeField]
private float _rightPadding = 24f;
[SerializeField]
private float _extraWidthSafety = 20f;
[SerializeField]
private float _autoSizeMinFontSize = 18f;
[SerializeField]
private bool _debugInEditor = true;
private string _lastText;
private float _lastWidth = -1f;
public Location Location { get; private set; }
private void Awake()
{
AddButtonListener();
RefreshVisuals();
}
#if UNITY_EDITOR
private void Update()
{
if (Application.isPlaying)
return;
if (_label == null)
return;
if (_lastText != _label.text)
RefreshVisuals();
}
#endif
private void OnEnable()
{
AddButtonListener();
RefreshVisuals();
}
private void OnDestroy()
{
RemoveButtonListener();
}
private void OnValidate()
{
RefreshVisuals();
}
public event Action<Location> OnLocationClicked;
public void Initialize(Location location, string displayText = null)
{
Location = location;
if (_label != null)
_label.text = string.IsNullOrWhiteSpace(displayText) ? location.ToString() : displayText;
RefreshVisuals();
}
private void HandleClicked()
{
OnLocationClicked?.Invoke(Location);
}
private void RefreshVisuals()
{
ResizeToFitLabel();
}
private void ResizeToFitLabel()
{
if (_button == null || _label == null)
return;
var buttonRect = _button.GetComponent<RectTransform>();
var labelRect = _label.rectTransform;
if (buttonRect == null || labelRect == null)
return;
StretchLabelToButton(labelRect);
_label.margin = new Vector4(_leftPadding, _label.margin.y, _rightPadding, _label.margin.w);
_label.textWrappingMode = TextWrappingModes.NoWrap;
_label.overflowMode = TextOverflowModes.Overflow;
_label.enableAutoSizing = false;
_label.ForceMeshUpdate();
var preferredSize = _label.GetPreferredValues(_label.text, Mathf.Infinity, Mathf.Infinity);
var desiredWidth = preferredSize.x + _leftPadding + _rightPadding + _extraWidthSafety;
var targetWidth = Mathf.Clamp(desiredWidth, MIN_WIDTH, MAX_WIDTH);
var needsAutoSizing = desiredWidth > MAX_WIDTH;
if (needsAutoSizing)
{
_label.enableAutoSizing = true;
_label.fontSizeMin = _autoSizeMinFontSize;
_label.fontSizeMax = _label.fontSize;
_label.overflowMode = TextOverflowModes.Truncate;
}
buttonRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, targetWidth);
buttonRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, FIXED_HEIGHT);
#if UNITY_EDITOR
if (_debugInEditor && (!Mathf.Approximately(targetWidth, _lastWidth) || _lastText != _label.text))
Debug.Log(
$"[CodexLocationButton] '{_label.text}'\n" +
$"Preferred: {preferredSize.x:F1} | Desired: {desiredWidth:F1} | Final: {targetWidth:F1}\n" +
$"AutoSize: {needsAutoSizing}",
this);
#endif
_lastText = _label.text;
_lastWidth = targetWidth;
LayoutRebuilder.ForceRebuildLayoutImmediate(buttonRect);
_label.ForceMeshUpdate();
}
private void StretchLabelToButton(RectTransform labelRect)
{
labelRect.anchorMin = new Vector2(0f, 0f);
labelRect.anchorMax = new Vector2(1f, 1f);
labelRect.offsetMin = Vector2.zero;
labelRect.offsetMax = Vector2.zero;
labelRect.pivot = new Vector2(0.5f, 0.5f);
}
private void AddButtonListener()
{
if (_button == null)
return;
_button.onClick.RemoveListener(HandleClicked);
_button.onClick.AddListener(HandleClicked);
}
private void RemoveButtonListener()
{
if (_button == null)
return;
_button.onClick.RemoveListener(HandleClicked);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: f2ac52c1bcad4ad3bb23ad8fa61d0e04
timeCreated: 1773685516

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 21589b43d12d4df8b6f58a8dba281eac
timeCreated: 1773683605

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: d6b7ef755c724a8eaaf5f0c00cedc8b7
timeCreated: 1769713926

View File

@@ -0,0 +1,366 @@
using BriarQueen.Data.Identifiers;
using BriarQueen.Framework.Coordinators.Events;
using BriarQueen.Framework.Events.Gameplay;
using BriarQueen.Framework.Events.Input;
using BriarQueen.Framework.Events.Save;
using BriarQueen.Framework.Managers.Input;
using BriarQueen.Framework.Managers.Interaction.Data;
using BriarQueen.Framework.Managers.Player.Data;
using BriarQueen.Framework.Managers.UI;
using BriarQueen.Framework.Services.Settings;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
using VContainer;
namespace BriarQueen.UI.HUD
{
/// <summary>
/// Cursor-follow tooltip that positions itself in the ROOT canvas coordinate space,
/// sizes its background to the text, and clamps to screen/canvas bounds.
/// </summary>
public class CursorTooltip : MonoBehaviour
{
[Header("Tooltips")]
[SerializeField]
private TextMeshProUGUI _tooltipText;
[SerializeField]
private Image _tooltipBackground;
[Header("Follow Cursor")]
[SerializeField]
private bool _clampToScreen = true;
[SerializeField]
private Vector2 _screenPadding = new(12f, 12f);
[Header("Background Sizing")]
[Tooltip("Padding added around the text when sizing the background.")]
[SerializeField]
private Vector2 _backgroundPadding = new(14f, 8f);
[Tooltip("Clamp tooltip width so long strings wrap instead of creating a huge background.")]
[SerializeField]
private float _maxTooltipWidth = 420f;
[Tooltip("Minimum background size so it doesn't look tiny on short words.")]
[SerializeField]
private Vector2 _minBackgroundSize = new(60f, 28f);
[Tooltip("Maximum background size as a final safety clamp.")]
[SerializeField]
private Vector2 _maxBackgroundSize = new(520f, 200f);
[Header("Canvas")]
[Tooltip("Optional override. If set, this canvas (or its rootCanvas) will be used for positioning/clamping.")]
[SerializeField]
private Canvas _canvasOverride;
private RectTransform _bgRect;
private EventCoordinator _eventCoordinator;
private IInteractable _hoveredInteractable;
private InputManager _inputManager;
private UICursorService _cursorService;
// IMPORTANT: We always position relative to the ROOT canvas.
private Canvas _rootCanvas;
private RectTransform _rootCanvasRect;
private RectTransform _rootRect;
private ItemDataSo _selectedItem;
private ToolID _selectedTool = ToolID.None;
private SettingsService _settingsService;
private RectTransform _textRect;
private Camera _uiCamera;
private void Awake()
{
_rootRect = transform as RectTransform;
if (_tooltipBackground) _bgRect = _tooltipBackground.rectTransform;
if (_tooltipText) _textRect = _tooltipText.rectTransform;
ResolveRootCanvas();
if (_tooltipBackground)
_tooltipBackground.transform.SetAsFirstSibling();
SetVisible(false);
}
private void Update()
{
if (!AreTooltipsEnabled())
{
SetVisible(false);
return;
}
FollowCursor();
}
private void OnEnable()
{
if (_eventCoordinator == null)
return;
_eventCoordinator.Subscribe<SelectedItemChangedEvent>(OnSelectedItemChanged);
_eventCoordinator.Subscribe<SelectedToolChangedEvent>(OnSelectedToolChanged);
_eventCoordinator.Subscribe<HoverInteractableChangedEvent>(OnHoveredInteractableChanged);
_eventCoordinator.Subscribe<SettingsChangedEvent>(OnSettingsChanged);
UpdateTooltip();
}
private void OnDisable()
{
if (_eventCoordinator == null)
return;
_eventCoordinator.Unsubscribe<SelectedItemChangedEvent>(OnSelectedItemChanged);
_eventCoordinator.Unsubscribe<SelectedToolChangedEvent>(OnSelectedToolChanged);
_eventCoordinator.Unsubscribe<HoverInteractableChangedEvent>(OnHoveredInteractableChanged);
_eventCoordinator.Unsubscribe<SettingsChangedEvent>(OnSettingsChanged);
}
[Inject]
private void Construct(EventCoordinator eventCoordinator, InputManager inputManager, SettingsService settingsService,
UICursorService cursorService)
{
_eventCoordinator = eventCoordinator;
_inputManager = inputManager;
_settingsService = settingsService;
_cursorService = cursorService;
}
private void ResolveRootCanvas()
{
var candidate = _canvasOverride != null ? _canvasOverride : GetComponentInParent<Canvas>();
if (candidate == null)
{
_rootCanvas = null;
_rootCanvasRect = null;
_uiCamera = null;
return;
}
_rootCanvas = candidate.rootCanvas;
_rootCanvasRect = _rootCanvas.transform as RectTransform;
_uiCamera = _rootCanvas.renderMode == RenderMode.ScreenSpaceOverlay ? null : _rootCanvas.worldCamera;
}
private void OnSelectedItemChanged(SelectedItemChangedEvent args)
{
_selectedItem = args.Item;
UpdateTooltip();
}
private void OnSelectedToolChanged(SelectedToolChangedEvent args)
{
// Change args.Tool if your event exposes a different property name.
_selectedTool = args.SelectedTool;
UpdateTooltip();
}
private void OnHoveredInteractableChanged(HoverInteractableChangedEvent args)
{
_hoveredInteractable = args.Interactable;
UpdateTooltip();
}
private void OnSettingsChanged(SettingsChangedEvent _)
{
UpdateTooltip();
}
private bool AreTooltipsEnabled()
{
return _settingsService == null || _settingsService.AreTooltipsEnabled();
}
private void UpdateTooltip()
{
if (!_tooltipText)
return;
if (!AreTooltipsEnabled())
{
_tooltipText.text = string.Empty;
SetVisible(false);
return;
}
var text = BuildTooltipText();
var show = !string.IsNullOrWhiteSpace(text);
if (!show)
{
_tooltipText.text = string.Empty;
SetVisible(false);
return;
}
_tooltipText.text = text;
SetVisible(true);
UpdateLayoutToText();
}
private string BuildTooltipText()
{
if (_hoveredInteractable == null)
return string.Empty;
var targetName = GetInteractableName(_hoveredInteractable);
if (string.IsNullOrWhiteSpace(targetName))
return string.Empty;
var selectedItemName = GetSelectedItemName();
if (!string.IsNullOrWhiteSpace(selectedItemName))
return $"Use {selectedItemName} on {targetName}";
var selectedToolName = GetSelectedToolName();
if (!string.IsNullOrWhiteSpace(selectedToolName))
return $"Use {selectedToolName} on {targetName}";
return targetName;
}
private string GetSelectedItemName()
{
return _selectedItem != null && !string.IsNullOrWhiteSpace(_selectedItem.ItemName)
? _selectedItem.ItemName
: string.Empty;
}
private string GetSelectedToolName()
{
return GetToolDisplayName(_selectedTool);
}
private static string GetInteractableName(IInteractable interactable)
{
return interactable == null ? string.Empty : interactable.InteractableName;
}
private static string GetToolDisplayName(ToolID toolID)
{
return toolID switch
{
ToolID.None => string.Empty,
ToolID.Knife => "Knife",
_ => toolID.ToString()
};
}
private void SetVisible(bool visible)
{
if (_tooltipText) _tooltipText.enabled = visible;
if (_tooltipBackground) _tooltipBackground.enabled = visible;
}
/// <summary>
/// Sizes the text rect (clamped) and the background rect (text size + padding),
/// so the bubble never looks comically huge or tiny.
/// </summary>
private void UpdateLayoutToText()
{
if (!_tooltipText || !_tooltipBackground || !_textRect || !_bgRect)
return;
_tooltipText.textWrappingMode = TextWrappingModes.Normal;
_tooltipText.ForceMeshUpdate();
var preferred = _tooltipText.GetPreferredValues(_tooltipText.text, _maxTooltipWidth, 0f);
var textW = Mathf.Min(preferred.x, _maxTooltipWidth);
var textH = preferred.y;
_textRect.sizeDelta = new Vector2(textW, textH);
var bgW = textW + _backgroundPadding.x * 2f;
var bgH = textH + _backgroundPadding.y * 2f;
bgW = Mathf.Clamp(bgW, _minBackgroundSize.x, _maxBackgroundSize.x);
bgH = Mathf.Clamp(bgH, _minBackgroundSize.y, _maxBackgroundSize.y);
_bgRect.sizeDelta = new Vector2(bgW, bgH);
}
private void FollowCursor()
{
if (_rootRect == null || _inputManager == null)
return;
var pointerScreen = _inputManager.PointerPosition;
var cursorOffset = _cursorService.CurrentStyleEntry.TooltipOffset;
var targetScreen = pointerScreen + cursorOffset;
if (_rootCanvasRect != null)
{
RectTransformUtility.ScreenPointToLocalPointInRectangle(
_rootCanvasRect,
targetScreen,
_uiCamera,
out var localPoint
);
_rootRect.anchoredPosition = localPoint;
if (_clampToScreen)
ClampAnchoredToRootCanvas();
}
else
{
_rootRect.position = targetScreen;
if (_clampToScreen)
ClampWorldToScreen();
}
}
private void ClampAnchoredToRootCanvas()
{
if (_rootCanvasRect == null || _bgRect == null)
return;
var bgSize = _bgRect.rect.size;
var canvasRect = _rootCanvasRect.rect;
var pos = _rootRect.anchoredPosition;
var minX = canvasRect.xMin + _screenPadding.x;
var maxX = canvasRect.xMax - _screenPadding.x - bgSize.x;
var minY = canvasRect.yMin + _screenPadding.y + bgSize.y;
var maxY = canvasRect.yMax - _screenPadding.y;
pos.x = Mathf.Clamp(pos.x, minX, maxX);
pos.y = Mathf.Clamp(pos.y, minY, maxY);
_rootRect.anchoredPosition = pos;
}
private void ClampWorldToScreen()
{
if (_bgRect == null)
return;
var bgSize = _bgRect.rect.size;
Vector2 pos = _rootRect.position;
var minX = _screenPadding.x;
var maxX = Screen.width - _screenPadding.x - bgSize.x;
var minY = _screenPadding.y + bgSize.y;
var maxY = Screen.height - _screenPadding.y;
pos.x = Mathf.Clamp(pos.x, minX, maxX);
pos.y = Mathf.Clamp(pos.y, minY, maxY);
_rootRect.position = pos;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: feab9a2f47fa4c39836de3ffbcd9aa7e
timeCreated: 1769719100

View File

@@ -0,0 +1,282 @@
using System;
using System.Threading;
using BriarQueen.Framework.Managers.UI;
using BriarQueen.Framework.Managers.UI.Base;
using Cysharp.Threading.Tasks;
using PrimeTween;
using UnityEngine;
using UnityEngine.UI;
using VContainer;
namespace BriarQueen.UI.HUD
{
/// <summary>
/// Minimal HUD holder:
/// - Registers itself with UIManager
/// - Handles HUD visibility (alpha tween only)
/// </summary>
public class HUDContainer : MonoBehaviour, IHud
{
private const float DISPLAY_TIME = 4f;
[Header("UI")]
[SerializeField]
private CursorTooltip _cursorTooltip;
[SerializeField]
private InteractTextUI _interactText;
[Header("Inventory")]
[SerializeField]
private InventoryBar _inventoryBar;
[Header("Components")]
[SerializeField]
private CanvasGroup _mainCanvasGroup;
[SerializeField]
private CanvasGroup _interactTextCanvasGroup;
[Header("Tweens")]
[SerializeField]
private TweenSettings _tweenSettings = new()
{
duration = 0.2f,
ease = Ease.InOutSine,
useUnscaledTime = true
};
[SerializeField]
private GraphicRaycaster _graphicRaycaster;
public GraphicRaycaster Raycaster => _graphicRaycaster;
private CancellationTokenSource _cancellationTokenSource;
private Sequence _hudSequence;
private CancellationTokenSource _interactCancellationTokenSource;
private Sequence _interactErrorSequence;
private UIManager _uiManager;
public CursorTooltip CursorTooltip => _cursorTooltip;
public InventoryBar InventoryBar => _inventoryBar;
private void Awake()
{
if (_mainCanvasGroup != null)
{
_mainCanvasGroup.alpha = 1f;
_mainCanvasGroup.interactable = true;
_mainCanvasGroup.blocksRaycasts = true;
}
if (_interactTextCanvasGroup != null)
{
_interactTextCanvasGroup.alpha = 0f;
_interactTextCanvasGroup.blocksRaycasts = false;
_interactTextCanvasGroup.interactable = false;
}
}
private void OnDestroy()
{
StopTween();
StopInteractErrorTween();
}
[Inject]
public void Construct(UIManager uiManager)
{
_uiManager = uiManager;
}
// --------------------
// Visibility
// --------------------
public async UniTask DisplayInteractText(string interactText)
{
if (_interactTextCanvasGroup == null || _interactText == null)
return;
// Cancel any in-flight error display and restart from current alpha.
StopInteractErrorTween();
// Set text immediately.
var text = interactText ?? string.Empty;
_interactText.SetText(text);
_interactCancellationTokenSource = new CancellationTokenSource();
var token = _interactCancellationTokenSource.Token;
// Make sure it can show (but don't let it steal clicks).
_interactTextCanvasGroup.blocksRaycasts = false;
_interactTextCanvasGroup.interactable = false;
var fadeIn = new TweenSettings<float>
{
startValue = _interactTextCanvasGroup.alpha,
endValue = 1f,
settings = _tweenSettings
};
var fadeOut = new TweenSettings<float>
{
startValue = 1f,
endValue = 0f,
settings = _tweenSettings
};
try
{
// Fade in
_interactErrorSequence = Sequence.Create(useUnscaledTime: true)
.Group(Tween.Alpha(_interactTextCanvasGroup, fadeIn));
await _interactErrorSequence.ToUniTask(cancellationToken: token);
_interactTextCanvasGroup.alpha = 1f;
// Hold
if (DISPLAY_TIME > 0f)
await UniTask.Delay(TimeSpan.FromSeconds(DISPLAY_TIME), DelayType.UnscaledDeltaTime,
cancellationToken: token);
// Fade out
_interactErrorSequence = Sequence.Create(useUnscaledTime: true)
.Group(Tween.Alpha(_interactTextCanvasGroup, fadeOut));
await _interactErrorSequence.ToUniTask(cancellationToken: token);
_interactTextCanvasGroup.alpha = 0f;
}
catch (OperationCanceledException)
{
// If interrupted by a newer DisplayError call or destroy, just stop cleanly.
}
finally
{
if (_interactCancellationTokenSource != null)
{
_interactCancellationTokenSource.Dispose();
_interactCancellationTokenSource = null;
}
_interactErrorSequence = default;
}
}
public async UniTask Show()
{
if (_mainCanvasGroup == null)
return;
StopTween();
_cancellationTokenSource = new CancellationTokenSource();
_mainCanvasGroup.blocksRaycasts = true;
_mainCanvasGroup.interactable = true;
var alphaTween = new TweenSettings<float>
{
startValue = _mainCanvasGroup.alpha,
endValue = 1f,
settings = _tweenSettings
};
_hudSequence = Sequence.Create(useUnscaledTime: true)
.Group(Tween.Alpha(_mainCanvasGroup, alphaTween));
try
{
await _hudSequence.ToUniTask(cancellationToken: _cancellationTokenSource.Token);
}
catch (OperationCanceledException)
{
// interrupted by Hide / destroy — safe to ignore
}
_mainCanvasGroup.alpha = 1f;
}
public async UniTask Hide()
{
if (_mainCanvasGroup == null)
return;
StopTween();
_cancellationTokenSource = new CancellationTokenSource();
_mainCanvasGroup.blocksRaycasts = false;
_mainCanvasGroup.interactable = false;
var alphaTween = new TweenSettings<float>
{
startValue = _mainCanvasGroup.alpha,
endValue = 0f,
settings = _tweenSettings
};
_hudSequence = Sequence.Create(useUnscaledTime: true)
.Group(Tween.Alpha(_mainCanvasGroup, alphaTween));
try
{
await _hudSequence.ToUniTask(cancellationToken: _cancellationTokenSource.Token);
}
catch (OperationCanceledException)
{
// interrupted — safe
}
_mainCanvasGroup.alpha = 0f;
}
private void StopTween()
{
if (_hudSequence.isAlive)
_hudSequence.Stop();
_hudSequence = default;
if (_cancellationTokenSource != null)
{
_cancellationTokenSource.Cancel();
_cancellationTokenSource.Dispose();
_cancellationTokenSource = null;
}
}
private void StopInteractErrorTween()
{
if (_interactErrorSequence.isAlive)
_interactErrorSequence.Stop();
_interactErrorSequence = default;
if (_interactCancellationTokenSource != null)
{
try
{
_interactCancellationTokenSource.Cancel();
}
catch
{
}
_interactCancellationTokenSource.Dispose();
_interactCancellationTokenSource = null;
}
}
private void OnMapClicked()
{
// Placeholder for later:
// - open map window
// - toggle map overlay
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: e8b77a48c3af4a8c8858359a537523a9
timeCreated: 1769713926

View File

@@ -0,0 +1,433 @@
using System;
using System.Collections.Generic;
using System.Threading;
using BriarQueen.Framework.Managers.UI.Base;
using Cysharp.Threading.Tasks;
using PrimeTween;
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace BriarQueen.UI.HUD
{
public class InfoPopup : MonoBehaviour, IPopup
{
[SerializeField]
private TextMeshProUGUI _text;
[SerializeField]
private RectTransform _rectTransform;
[SerializeField]
private CanvasGroup _canvasGroup;
[Header("Sizing")]
[SerializeField]
private float _minWidth = 160f;
[SerializeField]
private float _maxWidth = 500f;
[SerializeField]
private float _minHeight = 60f;
[SerializeField]
private float _extraWidthSafety = 4f;
[SerializeField]
private float _extraHeightSafety = 4f;
[Header("Animation")]
[SerializeField]
private float _slideDuration = 1.2f;
[SerializeField]
private Vector2 _hiddenAnchoredPosition = new(0f, -250f);
[SerializeField]
private Vector2 _shownAnchoredPosition = new(0f, 0f);
[SerializeField]
private float _shownAlpha = 1f;
[Header("Queue")]
[SerializeField]
private bool _suppressDuplicateMessages = true;
[Header("Editor Debug")]
[SerializeField]
private bool _debugResizeInEditor = true;
private readonly Queue<PopupRequest> _queue = new();
private string _currentMessage = string.Empty;
private CancellationTokenSource _destroyCts;
private bool _isProcessingQueue;
private string _lastEditorText = string.Empty;
private float _lastHeight = -1f;
private float _lastWidth = -1f;
private Sequence _sequence;
private CancellationTokenSource _sequenceCts;
public bool IsModal => false;
public GameObject GameObject => gameObject;
private void Awake()
{
_destroyCts = new CancellationTokenSource();
if (_rectTransform == null)
_rectTransform = GetComponent<RectTransform>();
if (_canvasGroup == null)
_canvasGroup = GetComponent<CanvasGroup>();
if (_rectTransform != null)
_rectTransform.anchoredPosition = _hiddenAnchoredPosition;
if (_canvasGroup != null)
{
_canvasGroup.alpha = 0f;
_canvasGroup.blocksRaycasts = false;
_canvasGroup.interactable = false;
}
}
#if UNITY_EDITOR
private void Update()
{
if (Application.isPlaying)
return;
if (_text == null)
return;
if (_lastEditorText != _text.text)
ResizeToFitText();
}
#endif
private void OnDestroy()
{
CancelTweenIfRunning();
if (_destroyCts != null)
{
_destroyCts.Cancel();
_destroyCts.Dispose();
_destroyCts = null;
}
_queue.Clear();
}
private void OnValidate()
{
if (_rectTransform == null)
_rectTransform = GetComponent<RectTransform>();
if (_canvasGroup == null)
_canvasGroup = GetComponent<CanvasGroup>();
ResizeToFitText();
}
public async UniTask Show()
{
if (_rectTransform == null || _canvasGroup == null)
return;
_canvasGroup.blocksRaycasts = false;
_canvasGroup.interactable = false;
CancelTweenIfRunning();
var localTweenCts = new CancellationTokenSource();
_sequenceCts = localTweenCts;
_rectTransform.anchoredPosition = _hiddenAnchoredPosition;
_canvasGroup.alpha = 0f;
_sequence = Sequence.Create(useUnscaledTime: true)
.Group(Tween.UIAnchoredPosition(_rectTransform, new TweenSettings<Vector2>
{
startValue = _hiddenAnchoredPosition,
endValue = _shownAnchoredPosition,
settings = new TweenSettings
{
duration = _slideDuration,
ease = Ease.OutCubic,
useUnscaledTime = true
}
}))
.Group(Tween.Alpha(_canvasGroup, new TweenSettings<float>
{
startValue = 0f,
endValue = _shownAlpha,
settings = new TweenSettings
{
duration = _slideDuration,
ease = Ease.OutCubic,
useUnscaledTime = true
}
}));
try
{
await _sequence.ToUniTask(cancellationToken: localTweenCts.Token);
_rectTransform.anchoredPosition = _shownAnchoredPosition;
_canvasGroup.alpha = _shownAlpha;
}
catch (OperationCanceledException)
{
}
finally
{
if (ReferenceEquals(_sequenceCts, localTweenCts))
_sequenceCts = null;
localTweenCts.Dispose();
_sequence = default;
}
}
public async UniTask Hide()
{
if (_rectTransform == null || _canvasGroup == null)
return;
CancelTweenIfRunning();
var localTweenCts = new CancellationTokenSource();
_sequenceCts = localTweenCts;
_sequence = Sequence.Create(useUnscaledTime: true)
.Group(Tween.UIAnchoredPosition(_rectTransform, new TweenSettings<Vector2>
{
startValue = _rectTransform.anchoredPosition,
endValue = _hiddenAnchoredPosition,
settings = new TweenSettings
{
duration = _slideDuration,
ease = Ease.InCubic,
useUnscaledTime = true
}
}))
.Group(Tween.Alpha(_canvasGroup, new TweenSettings<float>
{
startValue = _canvasGroup.alpha,
endValue = 0f,
settings = new TweenSettings
{
duration = _slideDuration,
ease = Ease.InCubic,
useUnscaledTime = true
}
}));
try
{
await _sequence.ToUniTask(cancellationToken: localTweenCts.Token);
_rectTransform.anchoredPosition = _hiddenAnchoredPosition;
_canvasGroup.alpha = 0f;
_canvasGroup.blocksRaycasts = false;
_canvasGroup.interactable = false;
}
catch (OperationCanceledException)
{
}
finally
{
if (ReferenceEquals(_sequenceCts, localTweenCts))
_sequenceCts = null;
localTweenCts.Dispose();
_sequence = default;
}
}
public void SetText(string text)
{
if (_text != null)
_text.text = text ?? string.Empty;
ResizeToFitText();
}
public UniTask Play(string text, float duration)
{
if (string.IsNullOrWhiteSpace(text))
return UniTask.CompletedTask;
if (_suppressDuplicateMessages && IsDuplicateMessage(text))
return UniTask.CompletedTask;
_queue.Enqueue(new PopupRequest(text, duration));
if (!_isProcessingQueue)
ProcessQueue().Forget();
return UniTask.CompletedTask;
}
public void ClearQueue()
{
_queue.Clear();
_currentMessage = string.Empty;
CancelTweenIfRunning();
if (_rectTransform != null)
_rectTransform.anchoredPosition = _hiddenAnchoredPosition;
if (_canvasGroup != null)
{
_canvasGroup.alpha = 0f;
_canvasGroup.blocksRaycasts = false;
_canvasGroup.interactable = false;
}
SetText(string.Empty);
_isProcessingQueue = false;
}
private bool IsDuplicateMessage(string text)
{
if (!string.IsNullOrEmpty(_currentMessage) &&
string.Equals(_currentMessage, text, StringComparison.Ordinal))
return true;
foreach (var queued in _queue)
if (string.Equals(queued.Text, text, StringComparison.Ordinal))
return true;
return false;
}
private async UniTaskVoid ProcessQueue()
{
if (_isProcessingQueue)
return;
_isProcessingQueue = true;
try
{
while (_destroyCts != null && !_destroyCts.IsCancellationRequested && _queue.Count > 0)
{
var request = _queue.Dequeue();
_currentMessage = request.Text;
SetText(request.Text);
await Show();
if (request.Duration > 0f)
await UniTask.Delay(
TimeSpan.FromSeconds(request.Duration),
cancellationToken: _destroyCts.Token);
await Hide();
_currentMessage = string.Empty;
}
}
catch (OperationCanceledException)
{
}
finally
{
_currentMessage = string.Empty;
_isProcessingQueue = false;
}
}
private void ResizeToFitText()
{
if (_text == null || _rectTransform == null)
return;
_text.ForceMeshUpdate();
var margin = _text.margin;
var leftInset = margin.x;
var topInset = margin.y;
var rightInset = margin.z;
var bottomInset = margin.w;
var totalHorizontalInset = leftInset + rightInset;
var totalVerticalInset = topInset + bottomInset;
var maxTextWidth = Mathf.Max(0f, _maxWidth - totalHorizontalInset - _extraWidthSafety);
var preferredSize = _text.GetPreferredValues(_text.text, maxTextWidth, Mathf.Infinity);
var width = Mathf.Clamp(
preferredSize.x + totalHorizontalInset + _extraWidthSafety,
_minWidth,
_maxWidth);
var availableTextWidth = Mathf.Max(0f, width - totalHorizontalInset - _extraWidthSafety);
var wrappedPreferredSize = _text.GetPreferredValues(_text.text, availableTextWidth, Mathf.Infinity);
var height = Mathf.Max(
_minHeight,
wrappedPreferredSize.y + totalVerticalInset + _extraHeightSafety);
_rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, width);
_rectTransform.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, height);
LayoutRebuilder.ForceRebuildLayoutImmediate(_rectTransform);
Canvas.ForceUpdateCanvases();
#if UNITY_EDITOR
var changed =
!Mathf.Approximately(width, _lastWidth) ||
!Mathf.Approximately(height, _lastHeight) ||
_lastEditorText != _text.text;
if (_debugResizeInEditor && changed)
Debug.Log(
$"[InfoPopup] '{_text.text}'\n" +
$"Preferred: {preferredSize.x:F1} x {preferredSize.y:F1}\n" +
$"Wrapped: {wrappedPreferredSize.x:F1} x {wrappedPreferredSize.y:F1}\n" +
$"Insets: L{leftInset:F1} T{topInset:F1} R{rightInset:F1} B{bottomInset:F1}\n" +
$"Final Size: {width:F1} x {height:F1}",
this);
#endif
_lastEditorText = _text.text;
_lastWidth = width;
_lastHeight = height;
}
private void CancelTweenIfRunning()
{
if (_sequence.isAlive)
_sequence.Stop();
_sequence = default;
if (_sequenceCts != null)
{
_sequenceCts.Cancel();
_sequenceCts.Dispose();
_sequenceCts = null;
}
}
private struct PopupRequest
{
public readonly string Text;
public readonly float Duration;
public PopupRequest(string text, float duration)
{
Text = text;
Duration = duration;
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 79bc6a48c54849a295fba449628a13ed
timeCreated: 1773762669

View File

@@ -0,0 +1,166 @@
using TMPro;
using UnityEngine;
using UnityEngine.UI;
namespace BriarQueen.UI.HUD
{
[RequireComponent(typeof(RectTransform))]
[RequireComponent(typeof(LayoutElement))]
public class InteractTextUI : MonoBehaviour
{
[Header("UI Elements")]
[SerializeField]
private TextMeshProUGUI _text;
[SerializeField]
private Image _background;
[Header("Sizing")]
[SerializeField]
private Vector2 _backgroundPadding = new(20f, 10f);
[SerializeField]
private Vector2 _minBackgroundSize = new(80f, 36f);
[SerializeField]
private Vector2 _maxBackgroundSize = new(1400f, 240f);
private RectTransform _backgroundRect;
private string _lastText = string.Empty;
private LayoutElement _rootLayoutElement;
private RectTransform _rootRect;
private RectTransform _textRect;
public string Text
{
get => _text != null ? _text.text : string.Empty;
set
{
if (_text == null)
return;
var safeValue = value ?? string.Empty;
if (_text.text == safeValue)
return;
_text.text = safeValue;
RefreshLayout();
}
}
private void Awake()
{
CacheRefs();
RefreshLayout();
}
private void LateUpdate()
{
if (_text == null)
return;
if (_lastText != _text.text)
RefreshLayout();
}
#if UNITY_EDITOR
private void OnValidate()
{
CacheRefs();
if (!Application.isPlaying)
RefreshLayout();
}
#endif
private void CacheRefs()
{
_rootRect = transform as RectTransform;
_rootLayoutElement = GetComponent<LayoutElement>();
if (_text != null)
_textRect = _text.rectTransform;
if (_background != null)
_backgroundRect = _background.rectTransform;
}
public void SetText(string value)
{
Text = value;
}
public void ClearText()
{
Text = string.Empty;
}
public void RefreshLayout()
{
if (_rootRect == null || _rootLayoutElement == null || _textRect == null || _backgroundRect == null ||
_text == null || _background == null)
return;
_lastText = _text.text ?? string.Empty;
var hasText = !string.IsNullOrWhiteSpace(_lastText);
_text.enabled = hasText;
_background.enabled = hasText;
if (!hasText)
return;
_backgroundRect.anchorMin = Vector2.zero;
_backgroundRect.anchorMax = Vector2.one;
_backgroundRect.pivot = new Vector2(0.5f, 0.5f);
_backgroundRect.offsetMin = Vector2.zero;
_backgroundRect.offsetMax = Vector2.zero;
_backgroundRect.anchoredPosition = Vector2.zero;
_background.transform.SetAsFirstSibling();
_textRect.anchorMin = new Vector2(0.5f, 0.5f);
_textRect.anchorMax = new Vector2(0.5f, 0.5f);
_textRect.pivot = new Vector2(0.5f, 0.5f);
_textRect.anchoredPosition = Vector2.zero;
_text.textWrappingMode = TextWrappingModes.NoWrap;
_text.overflowMode = TextOverflowModes.Overflow;
_text.enableAutoSizing = false;
Canvas.ForceUpdateCanvases();
_text.ForceMeshUpdate();
var preferred = _text.GetPreferredValues(_lastText);
var textWidth = preferred.x;
var textHeight = preferred.y;
_textRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, textWidth);
_textRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, textHeight);
var rootWidth = Mathf.Clamp(
textWidth + _backgroundPadding.x * 2f,
_minBackgroundSize.x,
_maxBackgroundSize.x);
var rootHeight = Mathf.Clamp(
textHeight + _backgroundPadding.y * 2f,
_minBackgroundSize.y,
_maxBackgroundSize.y);
_rootRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Horizontal, rootWidth);
_rootRect.SetSizeWithCurrentAnchors(RectTransform.Axis.Vertical, rootHeight);
_rootLayoutElement.minWidth = rootWidth;
_rootLayoutElement.preferredWidth = rootWidth;
_rootLayoutElement.flexibleWidth = 0f;
_rootLayoutElement.minHeight = rootHeight;
_rootLayoutElement.preferredHeight = rootHeight;
_rootLayoutElement.flexibleHeight = 0f;
LayoutRebuilder.ForceRebuildLayoutImmediate(_rootRect);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: e23149ea93ce4ccf9c43ed9e88896dfc
timeCreated: 1773669738

View File

@@ -0,0 +1,566 @@
using System;
using System.Collections.Generic;
using System.Threading;
using BriarQueen.Data.Identifiers;
using BriarQueen.Framework.Assets;
using BriarQueen.Framework.Coordinators.Events;
using BriarQueen.Framework.Events.Gameplay;
using BriarQueen.Framework.Managers.Player;
using BriarQueen.Framework.Managers.Player.Data;
using BriarQueen.Framework.Registries;
using BriarQueen.Framework.Services.Tutorials;
using Cysharp.Threading.Tasks;
using PrimeTween;
using UnityEngine;
using UnityEngine.UI;
using VContainer;
namespace BriarQueen.UI.HUD
{
public class InventoryBar : MonoBehaviour
{
private const int ITEMS_PER_PAGE = 4;
[Header("Buttons")]
[SerializeField]
private Button _nextButton;
[SerializeField]
private Button _prevButton;
[Header("Paging")]
[SerializeField]
private RectTransform _content;
[SerializeField]
private float _pageTweenSeconds = 5f;
private readonly List<UIInventorySlot> _inventorySlots = new();
private AddressableManager _addressableManager;
private AssetRegistry _assetRegistry;
private EventCoordinator _eventCoordinator;
private PlayerManager _playerManager;
private TutorialService _tutorialService;
private int _currentPage;
private int _selectedIndex = -1;
private CancellationTokenSource _pageCts;
private CancellationTokenSource _rebuildCts;
private Sequence _pageSequence;
private float _pageZeroFirstSlotX;
private int PageCount
{
get
{
if (_inventorySlots.Count <= 0)
return 0;
return (_inventorySlots.Count + ITEMS_PER_PAGE - 1) / ITEMS_PER_PAGE;
}
}
[Inject]
public void Construct(
EventCoordinator eventCoordinator,
PlayerManager playerManager,
AddressableManager addressableManager,
AssetRegistry assetRegistry,
TutorialService tutorialService)
{
_eventCoordinator = eventCoordinator;
_playerManager = playerManager;
_addressableManager = addressableManager;
_assetRegistry = assetRegistry;
_tutorialService = tutorialService;
}
private void Awake()
{
if (_nextButton)
_nextButton.onClick.AddListener(OnNextClicked);
if (_prevButton)
_prevButton.onClick.AddListener(OnPrevClicked);
}
private void OnEnable()
{
if (_eventCoordinator != null)
{
_eventCoordinator.Subscribe<InventoryChangedEvent>(OnInventoryChanged);
_eventCoordinator.Subscribe<SelectedItemChangedEvent>(OnSelectedItemCleared);
_eventCoordinator.Subscribe<OnNextItemClickedEvent>(OnNextItem);
_eventCoordinator.Subscribe<OnPreviousItemClickedEvent>(OnPreviousItem);
}
UpdateButtonState();
}
private void OnDisable()
{
if (_eventCoordinator != null)
{
_eventCoordinator.Unsubscribe<InventoryChangedEvent>(OnInventoryChanged);
_eventCoordinator.Unsubscribe<SelectedItemChangedEvent>(OnSelectedItemCleared);
_eventCoordinator.Unsubscribe<OnNextItemClickedEvent>(OnNextItem);
_eventCoordinator.Unsubscribe<OnPreviousItemClickedEvent>(OnPreviousItem);
}
StopPageTween();
StopRebuild();
}
private void OnDestroy()
{
if (_nextButton)
_nextButton.onClick.RemoveListener(OnNextClicked);
if (_prevButton)
_prevButton.onClick.RemoveListener(OnPrevClicked);
StopPageTween();
StopRebuild();
}
private void OnInventoryChanged(InventoryChangedEvent evt)
{
RebuildInventoryAsync().Forget();
}
private void OnSelectedItemCleared(SelectedItemChangedEvent evt)
{
if (evt.Item != null)
return;
ClearSelectionVisualOnly();
}
public void ClearSelection()
{
ClearSelectionVisualOnly();
_eventCoordinator?.Publish(new SelectedItemChangedEvent(null));
}
public void SelectIndex(int index)
{
SetSelectedIndex(index, true);
}
public void OnSlotInteracted(UIInventorySlot slot)
{
if (slot == null || slot.Item == null)
return;
var index = _inventorySlots.IndexOf(slot);
if (index < 0)
return;
if (_selectedIndex == index)
{
ClearSelection();
return;
}
SelectIndex(index);
}
private void ClearContentChildrenImmediateVisual()
{
if (!_content)
return;
for (var i = _content.childCount - 1; i >= 0; i--)
{
var child = _content.GetChild(i);
if (child == null)
continue;
child.gameObject.SetActive(false);
Destroy(child.gameObject);
}
}
private async UniTask RebuildInventoryAsync()
{
StopRebuild();
_rebuildCts = new CancellationTokenSource();
var token = _rebuildCts.Token;
try
{
if (!_content)
{
ClearSelectionVisualOnly();
_eventCoordinator?.Publish(new SelectedItemChangedEvent(null));
UpdateButtonState();
return;
}
if (!_assetRegistry ||
!_assetRegistry.TryGetReference(AssetKeyIdentifiers.Get(UIKey.InventorySlot), out var slotRef))
{
ClearSelectionVisualOnly();
_eventCoordinator?.Publish(new SelectedItemChangedEvent(null));
UpdateButtonState();
return;
}
StopPageTween();
var inventoryItems = _playerManager.GetInventoryItems();
var items = new List<ItemDataSo>();
if (inventoryItems != null)
{
for (var i = 0; i < inventoryItems.Count; i++)
{
var item = inventoryItems[i];
if (item != null)
items.Add(item);
}
}
Debug.Log($"Rebuilding inventory with {items.Count} valid items");
_inventorySlots.Clear();
ClearContentChildrenImmediateVisual();
await UniTask.NextFrame(token);
Canvas.ForceUpdateCanvases();
LayoutRebuilder.ForceRebuildLayoutImmediate(_content);
for (var i = 0; i < items.Count; i++)
{
token.ThrowIfCancellationRequested();
var item = items[i];
if (item == null)
continue;
var slotObj = await _addressableManager.InstantiateAsync(slotRef, parent: _content);
token.ThrowIfCancellationRequested();
if (slotObj == null)
{
Debug.LogWarning("[InventoryBar] AddressableManager returned null slot object.");
continue;
}
var slot = slotObj.GetComponent<UIInventorySlot>();
if (slot == null)
{
Debug.LogWarning("[InventoryBar] Instantiated slot prefab is missing UIInventorySlot.");
Destroy(slotObj);
continue;
}
slot.Initialize(this, item);
_inventorySlots.Add(slot);
}
await UniTask.Yield(PlayerLoopTiming.LastPostLateUpdate, token);
Canvas.ForceUpdateCanvases();
LayoutRebuilder.ForceRebuildLayoutImmediate(_content);
CachePageAnchorData();
_currentPage = Mathf.Clamp(_currentPage, 0, Mathf.Max(0, PageCount - 1));
SnapToPage(_currentPage);
ClearSelectionVisualOnly();
_eventCoordinator.Publish(new SelectedItemChangedEvent(null));
UpdateButtonState();
}
catch (OperationCanceledException)
{
}
}
private void StopRebuild()
{
_rebuildCts?.Cancel();
_rebuildCts?.Dispose();
_rebuildCts = null;
}
private void SetSelectedIndex(int index, bool scrollToSelection)
{
if (_inventorySlots.Count == 0)
{
ClearSelectionVisualOnly();
_eventCoordinator.Publish(new SelectedItemChangedEvent(null));
UpdateButtonState();
return;
}
if (index < 0)
{
ClearSelection();
return;
}
index = Mathf.Clamp(index, 0, _inventorySlots.Count - 1);
if (_selectedIndex >= 0 && _selectedIndex < _inventorySlots.Count)
_inventorySlots[_selectedIndex].SetSelected(false);
_selectedIndex = index;
_inventorySlots[_selectedIndex].SetSelected(true);
_eventCoordinator.Publish(new SelectedItemChangedEvent(_inventorySlots[_selectedIndex].Item));
CheckSelectedItemTutorial();
if (scrollToSelection)
{
var targetPage = IndexToPage(_selectedIndex);
GoToPage(targetPage).Forget();
}
UpdateButtonState();
}
private void CheckSelectedItemTutorial()
{
_tutorialService.DisplayTutorial(TutorialPopupID.ItemsAway);
}
private void ClearSelectionVisualOnly()
{
if (_selectedIndex >= 0 && _selectedIndex < _inventorySlots.Count)
_inventorySlots[_selectedIndex].SetSelected(false);
_selectedIndex = -1;
UpdateButtonState();
}
private int IndexToPage(int index)
{
if (index < 0)
return 0;
return index / ITEMS_PER_PAGE;
}
private void CachePageAnchorData()
{
_pageZeroFirstSlotX = 0f;
if (_inventorySlots.Count == 0)
return;
Canvas.ForceUpdateCanvases();
LayoutRebuilder.ForceRebuildLayoutImmediate(_content);
var first = _inventorySlots[0].transform as RectTransform;
if (!first)
return;
_pageZeroFirstSlotX = first.anchoredPosition.x;
}
private float PageToAnchoredX(int pageIndex)
{
if (_inventorySlots.Count == 0)
return 0f;
pageIndex = Mathf.Clamp(pageIndex, 0, Mathf.Max(0, PageCount - 1));
var firstIndexOnPage = pageIndex * ITEMS_PER_PAGE;
if (firstIndexOnPage < 0 || firstIndexOnPage >= _inventorySlots.Count)
return 0f;
var pageFirstSlot = _inventorySlots[firstIndexOnPage].transform as RectTransform;
if (!pageFirstSlot)
return 0f;
return _pageZeroFirstSlotX - pageFirstSlot.anchoredPosition.x;
}
private async UniTask GoToPage(int pageIndex)
{
if (!_content)
return;
var pages = PageCount;
if (pages <= 1)
{
_currentPage = 0;
SnapToPage(0);
UpdateButtonState();
return;
}
Canvas.ForceUpdateCanvases();
LayoutRebuilder.ForceRebuildLayoutImmediate(_content);
var clamped = Mathf.Clamp(pageIndex, 0, pages - 1);
if (clamped == _currentPage && !_pageSequence.isAlive)
{
UpdateButtonState();
return;
}
_currentPage = clamped;
StopPageTween();
_pageCts = new CancellationTokenSource();
var targetX = PageToAnchoredX(_currentPage);
var from = _content.anchoredPosition;
var to = from;
to.x = targetX;
var tweenSettings = new TweenSettings
{
duration = Mathf.Max(0f, _pageTweenSeconds),
ease = Ease.OutCubic,
useUnscaledTime = true
};
TweenSettings<Vector2> vecSettings = new TweenSettings<Vector2>
{
startValue = from,
endValue = to,
settings = tweenSettings
};
_pageSequence = Sequence.Create(useUnscaledTime: true).
Group(Tween.Custom(vecSettings, newValue => _content.anchoredPosition = newValue));
try
{
await _pageSequence.ToUniTask(cancellationToken: _pageCts.Token);
}
catch (OperationCanceledException)
{
}
finally
{
_pageSequence = default;
UpdateButtonState();
}
}
private void SnapToPage(int pageIndex)
{
if (!_content)
return;
var pages = PageCount;
if (pages <= 1)
{
var p0 = _content.anchoredPosition;
p0.x = 0f;
_content.anchoredPosition = p0;
return;
}
Canvas.ForceUpdateCanvases();
LayoutRebuilder.ForceRebuildLayoutImmediate(_content);
pageIndex = Mathf.Clamp(pageIndex, 0, pages - 1);
var pos = _content.anchoredPosition;
pos.x = PageToAnchoredX(pageIndex);
_content.anchoredPosition = pos;
}
private void StopPageTween()
{
if (_pageSequence.isAlive)
_pageSequence.Stop();
_pageSequence = default;
_pageCts?.Cancel();
_pageCts?.Dispose();
_pageCts = null;
}
private void OnNextClicked()
{
if (PageCount <= 1)
return;
GoToPage(_currentPage + 1).Forget();
}
private void OnPrevClicked()
{
if (PageCount <= 1)
return;
GoToPage(_currentPage - 1).Forget();
}
private void UpdateButtonState()
{
var pages = PageCount;
var showNav = pages > 1;
if (_prevButton)
{
var showPrev = showNav && _currentPage > 0;
_prevButton.gameObject.SetActive(showPrev);
}
if (_nextButton)
{
var showNext = showNav && _currentPage < pages - 1;
_nextButton.gameObject.SetActive(showNext);
}
}
private void OnNextItem(OnNextItemClickedEvent e)
{
if (_inventorySlots.Count == 0)
return;
int nextIndex;
if (_selectedIndex < 0)
{
nextIndex = 0;
}
else
{
nextIndex = _selectedIndex + 1;
if (nextIndex >= _inventorySlots.Count)
nextIndex = 0;
}
SetSelectedIndex(nextIndex, true);
}
private void OnPreviousItem(OnPreviousItemClickedEvent e)
{
if (_inventorySlots.Count == 0)
return;
int prevIndex;
if (_selectedIndex < 0)
{
prevIndex = _inventorySlots.Count - 1;
}
else
{
prevIndex = _selectedIndex - 1;
if (prevIndex < 0)
prevIndex = _inventorySlots.Count - 1;
}
SetSelectedIndex(prevIndex, true);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 21400b75a7244d418a6315a802dc66f7
timeCreated: 1769714388

View File

@@ -0,0 +1,291 @@
using System;
using System.Collections.Generic;
using System.Threading;
using BriarQueen.Framework.Managers.UI.Base;
using Cysharp.Threading.Tasks;
using PrimeTween;
using TMPro;
using UnityEngine;
namespace BriarQueen.UI.HUD
{
public class TutorialPopup : MonoBehaviour, IPopup
{
[SerializeField]
private TextMeshProUGUI _text;
[SerializeField]
private CanvasGroup _canvasGroup;
[Header("Animation")]
[SerializeField]
private float _fadeDuration = 0.3f;
[Header("Queue")]
[SerializeField]
private bool _suppressDuplicateMessages = true;
private readonly Queue<PopupRequest> _queue = new();
private string _currentMessage = string.Empty;
private CancellationTokenSource _destroyCts;
private bool _isProcessingQueue;
private Sequence _sequence;
private CancellationTokenSource _sequenceCts;
public bool IsModal => false;
public GameObject GameObject => gameObject;
private void Awake()
{
_destroyCts = new CancellationTokenSource();
if (_canvasGroup != null)
{
_canvasGroup.alpha = 0f;
_canvasGroup.blocksRaycasts = false;
_canvasGroup.interactable = false;
}
gameObject.SetActive(false);
}
private void OnDestroy()
{
CancelTweenIfRunning();
if (_destroyCts != null)
{
_destroyCts.Cancel();
_destroyCts.Dispose();
_destroyCts = null;
}
_queue.Clear();
}
public async UniTask Show()
{
if (_canvasGroup == null)
return;
gameObject.SetActive(true);
_canvasGroup.blocksRaycasts = false;
_canvasGroup.interactable = false;
CancelTweenIfRunning();
var localTweenCts = new CancellationTokenSource();
_sequenceCts = localTweenCts;
_canvasGroup.alpha = 0f;
_sequence = Sequence.Create(useUnscaledTime: true)
.Group(Tween.Alpha(_canvasGroup, new TweenSettings<float>
{
startValue = _canvasGroup.alpha,
endValue = 1f,
settings = new TweenSettings
{
duration = _fadeDuration,
useUnscaledTime = true
}
}));
try
{
await _sequence.ToUniTask(cancellationToken: localTweenCts.Token);
_canvasGroup.alpha = 1f;
}
catch (OperationCanceledException)
{
// Interrupted by another tween or destroy.
}
finally
{
if (ReferenceEquals(_sequenceCts, localTweenCts))
_sequenceCts = null;
localTweenCts.Dispose();
_sequence = default;
}
}
public async UniTask Hide()
{
if (_canvasGroup == null)
return;
CancelTweenIfRunning();
var localTweenCts = new CancellationTokenSource();
_sequenceCts = localTweenCts;
_sequence = Sequence.Create(useUnscaledTime: true)
.Group(Tween.Alpha(_canvasGroup, new TweenSettings<float>
{
startValue = _canvasGroup.alpha,
endValue = 0f,
settings = new TweenSettings
{
duration = _fadeDuration,
useUnscaledTime = true
}
}));
try
{
await _sequence.ToUniTask(cancellationToken: localTweenCts.Token);
_canvasGroup.alpha = 0f;
_canvasGroup.blocksRaycasts = false;
_canvasGroup.interactable = false;
gameObject.SetActive(false);
}
catch (OperationCanceledException)
{
// Interrupted by another tween or destroy.
}
finally
{
if (ReferenceEquals(_sequenceCts, localTweenCts))
_sequenceCts = null;
localTweenCts.Dispose();
_sequence = default;
}
}
private void OnClose()
{
Hide().Forget();
}
public void SetText(string text)
{
if (_text != null)
_text.text = text ?? string.Empty;
}
/// <summary>
/// Enqueue a popup to be shown in order. Does not interrupt the current popup.
/// Duplicate messages can be suppressed if they are currently showing or already queued.
/// </summary>
public UniTask Play(string text, float duration)
{
if (string.IsNullOrWhiteSpace(text))
return UniTask.CompletedTask;
if (_suppressDuplicateMessages && IsDuplicateMessage(text))
return UniTask.CompletedTask;
_queue.Enqueue(new PopupRequest(text, duration));
if (!_isProcessingQueue)
ProcessQueue().Forget();
return UniTask.CompletedTask;
}
/// <summary>
/// Clears pending popups and hides the current popup immediately.
/// Useful for scene transitions or hard UI resets.
/// </summary>
public void ClearQueue()
{
_queue.Clear();
_currentMessage = string.Empty;
CancelTweenIfRunning();
if (_canvasGroup != null)
{
_canvasGroup.alpha = 0f;
_canvasGroup.blocksRaycasts = false;
_canvasGroup.interactable = false;
}
gameObject.SetActive(false);
SetText(string.Empty);
_isProcessingQueue = false;
}
private bool IsDuplicateMessage(string text)
{
if (!string.IsNullOrEmpty(_currentMessage) &&
string.Equals(_currentMessage, text, StringComparison.Ordinal))
return true;
foreach (var queued in _queue)
if (string.Equals(queued.Text, text, StringComparison.Ordinal))
return true;
return false;
}
private async UniTaskVoid ProcessQueue()
{
if (_isProcessingQueue)
return;
_isProcessingQueue = true;
try
{
while (_destroyCts != null && !_destroyCts.IsCancellationRequested && _queue.Count > 0)
{
var request = _queue.Dequeue();
_currentMessage = request.Text;
SetText(request.Text);
await Show();
if (request.Duration > 0f)
await UniTask.Delay(
TimeSpan.FromSeconds(request.Duration),
cancellationToken: _destroyCts.Token);
await Hide();
_currentMessage = string.Empty;
}
}
catch (OperationCanceledException)
{
// Object destroyed or queue processing canceled.
}
finally
{
_currentMessage = string.Empty;
_isProcessingQueue = false;
}
}
private void CancelTweenIfRunning()
{
if (_sequence.isAlive)
_sequence.Stop();
_sequence = default;
if (_sequenceCts != null)
{
_sequenceCts.Cancel();
_sequenceCts.Dispose();
_sequenceCts = null;
}
}
private struct PopupRequest
{
public readonly string Text;
public readonly float Duration;
public PopupRequest(string text, float duration)
{
Text = text;
Duration = duration;
}
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 9b1451fbb217493e8e7d5fb086195021
timeCreated: 1772821477

View File

@@ -0,0 +1,114 @@
using BriarQueen.Data.Identifiers;
using BriarQueen.Framework.Coordinators.Events;
using BriarQueen.Framework.Events.UI;
using BriarQueen.Framework.Managers.Interaction.Data;
using BriarQueen.Framework.Managers.Player;
using BriarQueen.Framework.Managers.Player.Data;
using BriarQueen.Framework.Managers.UI;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;
using VContainer;
namespace BriarQueen.UI.HUD
{
/// <summary>
/// UI slot is now an IInteractable so InteractManager (UI raycast path) can drive hover + click.
/// No IPointerEnter/Exit, no OnClickEvent subscription.
/// </summary>
public class UIInventorySlot : MonoBehaviour, IInteractable
{
[SerializeField]
private Image _slotBase;
[SerializeField]
private Image _selectedVisual;
[SerializeField]
private Image _icon;
public ItemDataSo Item;
private EventCoordinator _eventCoordinator;
private InventoryBar _owner;
private PlayerManager _playerManager;
// Cursor while hovering inventory slots (tweak if you have a “hand” cursor etc.)
public UICursorService.CursorStyle ApplicableCursorStyle => UICursorService.CursorStyle.UseItem;
public string InteractableName => Item.ItemName;
public UniTask EnterHover()
{
return UniTask.CompletedTask;
}
public UniTask ExitHover()
{
return UniTask.CompletedTask;
}
public async UniTask OnInteract(ItemDataSo selectedItem = null)
{
Debug.Log("[UI Inventory Slot] Interacted");
if (Item == null)
{
_eventCoordinator.Publish(new DisplayInteractEvent(InteractEventIDs.Get(UIInteractKey.EmptySlot)));
return;
}
// normal click: selection
if (selectedItem == null)
{
_owner?.OnSlotInteracted(this);
return;
}
// use selected item on this item (prefer selected item's logic)
if (selectedItem.Interaction != null)
{
var handled = await selectedItem.Interaction.TryUseWith(
selectedItem, Item, _playerManager, _eventCoordinator);
if (handled)
return;
}
// fallback: allow target to handle it
if (Item.Interaction != null)
{
var handled = await Item.Interaction.TryUseWith(
Item, selectedItem, _playerManager, _eventCoordinator);
if (handled)
return;
}
}
[Inject]
public void Construct(PlayerManager playerManager, EventCoordinator eventCoordinator)
{
_playerManager = playerManager;
_eventCoordinator = eventCoordinator;
}
public void Initialize(InventoryBar owner, ItemDataSo item)
{
Debug.Log("Initializing Slot");
_owner = owner;
Item = item;
if (_icon != null && Item != null)
_icon.sprite = Item.Icon;
SetSelected(false);
Debug.Log($"Set Slot to {Item}");
}
public void SetSelected(bool selected)
{
if (_selectedVisual != null) _selectedVisual.gameObject.SetActive(selected);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 75a4776058b6492394b866b5e04bc73f
timeCreated: 1769714789

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 4f373062d2284007a2999e0c9ead9ca9
timeCreated: 1769707583

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

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 74dc4af3d5024f99a5c29e4c3e2d542f
timeCreated: 1773786938

View File

@@ -0,0 +1,31 @@
using BriarQueen.UI.Menus;
using BriarQueen.UI.Menus.Components;
using UnityEngine;
using VContainer;
using VContainer.Unity;
namespace BriarQueen.UI.Scopes
{
public class MainMenuLifetimeScope : LifetimeScope
{
[SerializeField] private MainMenuWindow _mainMenuWindow;
[SerializeField] private SelectSaveWindow _saveWindow;
[SerializeField] private SaveSlotUI _saveSlotUI;
[SerializeField] private NewSaveWindow _newSaveWindow;
protected override void Configure(IContainerBuilder builder)
{
if (_mainMenuWindow != null)
builder.RegisterComponent(_mainMenuWindow);
if (_saveWindow != null)
builder.RegisterComponent(_saveWindow);
if (_saveSlotUI != null)
builder.RegisterComponent(_saveSlotUI);
if (_newSaveWindow != null)
builder.RegisterComponent(_newSaveWindow);
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 2fe9705cca9c4e78b764174b3ba75697
timeCreated: 1770242616

View File

@@ -0,0 +1,104 @@
using BriarQueen.Framework.Managers.UI;
using BriarQueen.UI.Codex;
using BriarQueen.UI.HUD;
using BriarQueen.UI.Menus;
using UnityEngine;
using VContainer;
using VContainer.Unity;
namespace BriarQueen.UI.Scopes
{
public class UISceneLifetimeScope : LifetimeScope
{
[Header("Windows")]
[SerializeField]
private PauseMenuWindow _pauseMenuWindow;
[SerializeField]
private SettingsWindow _settingsWindow;
[SerializeField]
private CodexWindow _codexWindow;
[Header("Popups")]
[SerializeField]
private TutorialPopup _tutorialPopupWindow;
[SerializeField]
private InfoPopup _infoPopup;
[Header("UI Objects")]
[SerializeField]
private ScreenFader _screenFader;
[Header("HUD")]
[SerializeField]
private HUDContainer _hudContainer;
[SerializeField]
private CursorTooltip _cursorTooltip;
[SerializeField]
private InventoryBar _inventoryBar;
protected override void Configure(IContainerBuilder builder)
{
if (_pauseMenuWindow != null)
builder.RegisterComponent(_pauseMenuWindow);
if (_settingsWindow != null)
builder.RegisterComponent(_settingsWindow);
if (_codexWindow != null)
builder.RegisterComponent(_codexWindow);
if (_tutorialPopupWindow != null)
builder.RegisterComponent(_tutorialPopupWindow);
if (_infoPopup != null)
builder.RegisterComponent(_infoPopup);
if (_screenFader != null)
builder.RegisterComponent(_screenFader);
if (_hudContainer != null)
builder.RegisterComponent(_hudContainer);
if (_cursorTooltip != null)
builder.RegisterComponent(_cursorTooltip);
if (_inventoryBar != null)
builder.RegisterComponent(_inventoryBar);
builder.RegisterBuildCallback(container =>
{
var uiManager = container.Resolve<UIManager>();
if (_pauseMenuWindow != null)
uiManager.RegisterWindow(_pauseMenuWindow);
if (_settingsWindow != null)
uiManager.RegisterWindow(_settingsWindow);
if (_codexWindow != null)
uiManager.RegisterWindow(_codexWindow);
if (_tutorialPopupWindow != null)
uiManager.RegisterTutorialPopup(_tutorialPopupWindow);
if (_infoPopup != null)
uiManager.RegisterInfoPopup(_infoPopup);
if (_screenFader != null)
uiManager.RegisterScreenFader(_screenFader);
if (_hudContainer != null)
uiManager.RegisterHUD(_hudContainer);
Debug.Log($"[UISceneLifetimeScope] UI registered with {uiManager}");
});
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: c2fcd047574341aa800c79051b738e60
timeCreated: 1769710416

View File

@@ -0,0 +1,181 @@
using System;
using System.Threading;
using BriarQueen.Framework.Coordinators.Events;
using BriarQueen.Framework.Events.UI;
using BriarQueen.Framework.Managers.UI.Base;
using Cysharp.Threading.Tasks;
using PrimeTween;
using UnityEngine;
using UnityEngine.UI;
using VContainer;
namespace BriarQueen.UI
{
public class ScreenFader : MonoBehaviour, IScreenFader
{
private const float ALPHA_EPSILON = 0.001f;
[Header("UI Elements")]
[SerializeField]
private CanvasGroup _canvasGroup;
[Tooltip("Used for black / solid-color fades.")]
[SerializeField]
private Image _solidImage;
private Sequence _currentFadeSequence;
private EventCoordinator _eventCoordinator;
private CancellationTokenSource _fadeCts;
public bool IsModal => true;
private void Awake()
{
if (_canvasGroup == null)
Debug.LogError($"{nameof(ScreenFader)} on {name} is missing a CanvasGroup reference.", this);
SetInteractionState(_canvasGroup != null && _canvasGroup.alpha > ALPHA_EPSILON);
}
private void OnDestroy()
{
CancelAndDisposeFadeToken();
StopCurrentSequence();
}
// Window Stubs - Fader is Non-Interactive
public async UniTask Show()
{
await FadeToAsync( 1f);
}
public async UniTask Hide()
{
await FadeFromAsync(1f);
}
[Inject]
public void Construct(EventCoordinator eventCoordinator)
{
Debug.Log("ScreenFader constructed");
_eventCoordinator = eventCoordinator;
}
public async UniTask FadeToAsync(float duration)
{
if (_canvasGroup == null)
return;
_solidImage.color = Color.black;
BeginNewFade();
gameObject.SetActive(true);
SetInteractionState(true);
await FadeScreen(1f, duration, _fadeCts.Token);
}
public async UniTask FadeFromAsync(float duration)
{
if (_canvasGroup == null) return;
BeginNewFade();
await FadeScreen(0f, duration, _fadeCts.Token);
if (!_fadeCts.IsCancellationRequested)
{
_canvasGroup.alpha = 0f;
SetInteractionState(false);
}
}
public UniTask FadeToAsync(FadeStyle style, System.Drawing.Color tint, float duration)
{
throw new NotImplementedException();
}
private void ApplyTint(FadeStyle style, Color tint)
{
if (_solidImage != null)
{
_solidImage.enabled = true;
_solidImage.color = Color.black;
}
}
private async UniTask FadeScreen(float targetAlpha, float duration, CancellationToken token)
{
targetAlpha = Mathf.Clamp01(targetAlpha);
duration = Mathf.Max(0f, duration);
var currentAlpha = _canvasGroup.alpha;
if (Mathf.Abs(currentAlpha - targetAlpha) <= ALPHA_EPSILON)
{
_canvasGroup.alpha = targetAlpha;
SetInteractionState(targetAlpha > ALPHA_EPSILON);
return;
}
StopCurrentSequence();
if (duration <= 0f)
{
_canvasGroup.alpha = targetAlpha;
SetInteractionState(targetAlpha > ALPHA_EPSILON);
_eventCoordinator?.PublishImmediate(new FadeCompletedEvent());
return;
}
_currentFadeSequence = Sequence.Create()
.Group(Tween.Alpha(_canvasGroup, targetAlpha, duration, Ease.InOutSine));
try
{
await _currentFadeSequence.ToUniTask(cancellationToken: token);
_canvasGroup.alpha = targetAlpha;
SetInteractionState(targetAlpha > ALPHA_EPSILON);
_eventCoordinator?.PublishImmediate(new FadeCompletedEvent());
}
catch (OperationCanceledException)
{
// Another fade interrupted this one. Intentionally ignore completion.
}
}
private void BeginNewFade()
{
CancelAndDisposeFadeToken();
StopCurrentSequence();
_fadeCts = new CancellationTokenSource();
}
private void CancelAndDisposeFadeToken()
{
if (_fadeCts == null) return;
if (!_fadeCts.IsCancellationRequested) _fadeCts.Cancel();
_fadeCts.Dispose();
_fadeCts = null;
}
private void StopCurrentSequence()
{
if (_currentFadeSequence.isAlive) _currentFadeSequence.Stop();
}
private void SetInteractionState(bool isBlocking)
{
_canvasGroup.interactable = isBlocking;
_canvasGroup.blocksRaycasts = isBlocking;
}
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 3af562a2d3fb4b4bb261dc75d159b993
timeCreated: 1769712302