using System; using System.Collections.Generic; using UnityEngine; using UnityEngine.EventSystems; using UnityEngine.UI; namespace RR.Common.UI { /// /// Центральный менеджер модальных окон. /// Управляет стеком открытых модальных окон и фокусом между ними. /// public class ModalManager : MonoBehaviour { private static ModalManager _instance; /// /// Singleton-инстанс менеджера модальных окон. /// Создаётся автоматически при первом обращении. /// public static ModalManager Instance { get { if (_instance) return _instance; // Ищем существующий в сцене _instance = FindAnyObjectByType(); if (_instance) return _instance; // Создаём новый GameObject go = new GameObject("[ModalManager]"); _instance = go.AddComponent(); DontDestroyOnLoad(go); return _instance; } } /// /// Информация о состоянии UI до открытия модального окна. /// private class ModalStackEntry { public Modal Modal; public GameObject PreviousSelected; public List DisabledGroups = new(); } private readonly Stack _stack = new(); /// /// Событие, вызываемое при открытии любого модального окна. /// public event Action OnModalOpened; /// /// Событие, вызываемое при закрытии любого модального окна. /// public event Action OnModalClosed; /// /// Текущее активное модальное окно (на вершине стека). /// public Modal CurrentModal => _stack.Count > 0 ? _stack.Peek().Modal : null; /// /// Есть ли открытые модальные окна. /// public bool HasOpenModals => _stack.Count > 0; /// /// Количество открытых модальных окон. /// public int OpenModalCount => _stack.Count; private void Awake() { if (_instance && _instance != this) { Destroy(gameObject); return; } _instance = this; DontDestroyOnLoad(gameObject); } private void Update() { // Следим за тем, чтобы фокус оставался внутри текущего модального окна if (_stack.Count == 0) return; ModalStackEntry top = _stack.Peek(); if (!top.Modal || !top.Modal.gameObject.activeInHierarchy) return; if (!top.Modal.EnforceFocus) return; EnsureFocusInModal(top.Modal); } /// /// Регистрирует модальное окно в стеке (вызывается из Modal.OnEnable). /// internal void Push(Modal modal) { if (modal == null) return; // Проверяем, не зарегистрировано ли уже это окно foreach (ModalStackEntry entry in _stack) { if (entry.Modal == modal) return; } ModalStackEntry newEntry = new ModalStackEntry { Modal = modal, PreviousSelected = EventSystem.current ? EventSystem.current.currentSelectedGameObject : null }; // Блокируем интерактивность всего, что не принадлежит этому модальному окну if (modal.BlockBackground) { BlockBackgroundInteraction(modal, newEntry); } _stack.Push(newEntry); // Устанавливаем фокус на первый элемент модального окна SetInitialFocus(modal); OnModalOpened?.Invoke(modal); } /// /// Удаляет модальное окно из стека (вызывается из Modal.OnDisable). /// internal void Pop(Modal modal) { if (modal == null) return; if (_stack.Count == 0) return; // Ищем и удаляем модальное окно из стека Stack temp = new Stack(); ModalStackEntry foundEntry = null; while (_stack.Count > 0) { ModalStackEntry entry = _stack.Pop(); if (entry.Modal == modal) { foundEntry = entry; break; } temp.Push(entry); } // Возвращаем остальные в стек while (temp.Count > 0) { _stack.Push(temp.Pop()); } if (foundEntry == null) return; // Восстанавливаем интерактивность заблокированных групп RestoreBackgroundInteraction(foundEntry); // Восстанавливаем фокус RestoreFocus(foundEntry); OnModalClosed?.Invoke(modal); } /// /// Закрывает все открытые модальные окна. /// public void CloseAll() { while (_stack.Count > 0) { ModalStackEntry entry = _stack.Peek(); if (entry.Modal != null) { entry.Modal.Close(); } else { _stack.Pop(); } } } /// /// Закрывает текущее (верхнее) модальное окно. /// public void CloseCurrent() { if (_stack.Count == 0) return; Modal current = _stack.Peek().Modal; if (current != null) { current.Close(); } } private void BlockBackgroundInteraction(Modal modal, ModalStackEntry entry) { // Находим все CanvasGroup, которые не принадлежат модальному окну CanvasGroup[] allGroups = FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); foreach (CanvasGroup group in allGroups) { if (!group || !group.interactable) continue; if (IsUnderModal(group.transform, modal)) continue; // Не блокируем родительские CanvasGroup модального окна if (IsParentOfModal(group.transform, modal)) continue; // Не блокируем CanvasGroup, если модальное окно является его потомком if (modal.transform.IsChildOf(group.transform)) continue; // Проверяем, не принадлежит ли эта группа другому модальному окну в стеке bool belongsToOtherModal = false; foreach (ModalStackEntry stackEntry in _stack) { if (stackEntry.Modal && IsUnderModal(group.transform, stackEntry.Modal)) { belongsToOtherModal = true; break; } } if (belongsToOtherModal) continue; group.interactable = false; entry.DisabledGroups.Add(group); } // Если у модального окна нет CanvasGroup-родителя, блокируем все Selectable напрямую if (entry.DisabledGroups.Count == 0) { BlockSelectablesOutsideModal(modal, entry); } } private void BlockSelectablesOutsideModal(Modal modal, ModalStackEntry entry) { Selectable[] all = FindObjectsByType(FindObjectsInactive.Exclude, FindObjectsSortMode.None); foreach (Selectable s in all) { if (!s || !s.interactable) continue; if (IsUnderModal(s.transform, modal)) continue; // Добавляем временную CanvasGroup для блокировки CanvasGroup cg = s.GetComponent(); if (!cg) { cg = s.gameObject.AddComponent(); } if (cg.interactable) { cg.interactable = false; entry.DisabledGroups.Add(cg); } } } private void RestoreBackgroundInteraction(ModalStackEntry entry) { foreach (CanvasGroup group in entry.DisabledGroups) { if (group) { group.interactable = true; } } entry.DisabledGroups.Clear(); } private void SetInitialFocus(Modal modal) { if (!EventSystem.current) return; GameObject toSelect = null; if (modal.FirstSelected && modal.FirstSelected.gameObject.activeInHierarchy) { toSelect = modal.FirstSelected.gameObject; } else { Selectable any = modal.GetComponentInChildren(includeInactive: false); if (any) toSelect = any.gameObject; } if (!toSelect) return; // Сбрасываем и устанавливаем фокус EventSystem.current.SetSelectedGameObject(null); EventSystem.current.SetSelectedGameObject(toSelect); } private void RestoreFocus(ModalStackEntry entry) { if (!EventSystem.current) return; // Если есть другие модальные окна в стеке, фокусируемся на верхнем if (_stack.Count > 0) { Modal topModal = _stack.Peek().Modal; if (topModal != null) { SetInitialFocus(topModal); return; } } // Иначе восстанавливаем предыдущий фокус GameObject toSelect = null; if (entry.PreviousSelected && entry.PreviousSelected.activeInHierarchy) { Selectable selectable = entry.PreviousSelected.GetComponent(); if (selectable && selectable.interactable) { toSelect = entry.PreviousSelected; } } // Если предыдущий элемент недоступен, ищем любой доступный if (!toSelect) { Selectable anySelectable = FindAnyObjectByType(FindObjectsInactive.Exclude); if (anySelectable && anySelectable.interactable) { toSelect = anySelectable.gameObject; } } EventSystem.current.SetSelectedGameObject(toSelect); } private void EnsureFocusInModal(Modal modal) { if (!EventSystem.current) return; GameObject current = EventSystem.current.currentSelectedGameObject; // Если текущий выделенный элемент находится внутри модального окна, всё ок if (current && IsUnderModal(current.transform, modal)) { return; } // Иначе возвращаем фокус внутрь модального окна SetInitialFocus(modal); } private bool IsUnderModal(Transform t, Modal modal) { if (!modal) return false; Transform root = modal.transform; for (Transform cur = t; cur; cur = cur.parent) { if (cur == root) return true; } return false; } /// /// Проверяет, является ли указанный Transform родителем модального окна. /// private bool IsParentOfModal(Transform t, Modal modal) { if (!modal || !t) return false; Transform modalTransform = modal.transform; for (Transform cur = modalTransform.parent; cur; cur = cur.parent) { if (cur == t) return true; } return false; } } }