Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Has been cancelled
- завершен спринт 24 марта - 2 апреля с опережением плана и закрытием задач раньше срока - расширена и стабилизирована система призов: добавлены и доработаны QR-tier конфиги, новые типы и модели данных, сохранение `qrCodes` в `prizes.json`, fallback-логика и синхронизация через `PrizeSystemManager`, `PrizeDatabase` и `LevelPrizeConfig` - переработан flow настройки QR-призов: добавлены `QRCodeSettingsModal` и связанный UI-код, разнесены короткий текст приза и полный текст сообщения, добавлены шаблоны форматирования, корректная подстановка текста в QR и в UI-модалках, а также сохранение конфигурации между запусками - доработан flow суперприза и текстовых настроек: добавлены fallback-правила показа, корректная обработка пустых строк и строк из одних пробелов, обновлены binders и показ текста в меню - реализована и обновлена отдельная настройка витрины: добавлена `PrizeStandSettingsModal`, обновлена навигация строк, логика выбора ячейки и порогов очков, циклический выбор ячеек, ускорение изменения значений и исправления для keyboard/gamepad UX - доработана игровая модалка витрины `PrizeStandModal`: добавлен вывод очков игрока, форматируемый текст счета, улучшено отображение ячеек и связанная логика выбора приза - исправлен подсчет очков и интеграция с prize flow: `ScoreSystem` переведен на событийное обновление текущего счета, добавлены `CurrentScore`, `CurrentScoreChanged` и корректный display score, витрина и связанные UI-элементы теперь получают актуальные очки, а не нулевые или несвоевременные preview-значения - переработаны модальные окна и навигация UI: улучшены `Modal`, `ModalManager`, `UIRowPointer`, `RulesModal`, `QRCodeModal`, `PrizeStandModal`, `LevelPrizeSettingsModal` и др., чтобы фокус, указатель и submit работали консистентно без мыши - обновлен win/prize UI: доработаны `WinBannerController`, `GameEndUIController`, `MainMenu`, `QRCodeUIController`, `TopPanelTextController` и настройки UI, улучшены тексты баннеров, отображение призов и итогового счета - обновлены сцены и ассеты под новый flow: `MainMenu`, `Level UI`, prefab'ы строк настроек и кнопок, глобальные префабы, шрифты TMP, иконки, recovery-данные и ресурсы UI - добавлены вспомогательные UI-компоненты и служебные скрипты: `ButtonTextFocusColor`, `SelectableDescriptionTarget`, `QRTier` и другие изменения, поддерживающие новый конфигурируемый prize UI
397 lines
14 KiB
C#
397 lines
14 KiB
C#
using System;
|
||
using System.Collections;
|
||
using UnityEngine;
|
||
using UnityEngine.UI;
|
||
using UnityEngine.InputSystem;
|
||
|
||
namespace RR.Common.UI
|
||
{
|
||
/// <summary>
|
||
/// Базовый компонент модального окна.
|
||
/// Автоматически регистрируется в ModalManager при активации.
|
||
/// </summary>
|
||
[DisallowMultipleComponent]
|
||
public class Modal : MonoBehaviour
|
||
{
|
||
[Header("Настройки модального окна")]
|
||
[Tooltip("Первый элемент, который получит фокус при открытии окна.")]
|
||
[SerializeField] private Selectable firstSelected;
|
||
|
||
[Tooltip("Блокировать интерактивность элементов за пределами модального окна.")]
|
||
[SerializeField] private bool blockBackground = true;
|
||
|
||
[Tooltip("Автоматически возвращать фокус внутрь окна, если он уходит наружу.")]
|
||
[SerializeField] private bool enforceFocus = true;
|
||
|
||
[Header("Анимация")]
|
||
[Tooltip("CanvasGroup для анимации появления/исчезновения.")]
|
||
[SerializeField] private CanvasGroup canvasGroup;
|
||
|
||
[Tooltip("Длительность анимации появления/исчезновения.")]
|
||
[SerializeField] private float fadeDuration = 0.15f;
|
||
|
||
[Header("Закрытие")]
|
||
[Tooltip("InputAction для закрытия модального окна (например, Escape).")]
|
||
[SerializeField] private InputActionReference closeAction;
|
||
|
||
[Tooltip("Кнопка закрытия модального окна.")]
|
||
[SerializeField] private Button closeButton;
|
||
|
||
/// <summary>
|
||
/// Первый элемент для фокуса при открытии.
|
||
/// </summary>
|
||
public Selectable FirstSelected => firstSelected;
|
||
|
||
/// <summary>
|
||
/// Блокировать ли фон при открытии.
|
||
/// </summary>
|
||
public bool BlockBackground => blockBackground;
|
||
|
||
/// <summary>
|
||
/// Следить ли за фокусом и возвращать его внутрь окна.
|
||
/// </summary>
|
||
public bool EnforceFocus => enforceFocus;
|
||
|
||
/// <summary>
|
||
/// Событие, вызываемое перед открытием окна.
|
||
/// </summary>
|
||
public event Action OnOpening;
|
||
|
||
/// <summary>
|
||
/// Событие, вызываемое после открытия окна.
|
||
/// </summary>
|
||
public event Action OnOpened;
|
||
|
||
/// <summary>
|
||
/// Событие, вызываемое перед закрытием окна.
|
||
/// </summary>
|
||
public event Action OnClosing;
|
||
|
||
/// <summary>
|
||
/// Событие, вызываемое после закрытия окна.
|
||
/// </summary>
|
||
public event Action OnClosed;
|
||
|
||
protected bool _isOpen;
|
||
protected Coroutine _fadeCoroutine;
|
||
|
||
/// <summary>
|
||
/// Открыто ли модальное окно.
|
||
/// </summary>
|
||
public bool IsOpen => _isOpen;
|
||
|
||
protected virtual void Awake()
|
||
{
|
||
if (closeButton)
|
||
{
|
||
closeButton.onClick.AddListener(Close);
|
||
}
|
||
|
||
// Если canvasGroup не указан или указан на родительском объекте,
|
||
// создаем/получаем CanvasGroup на самом модальном окне
|
||
if (!canvasGroup || canvasGroup.gameObject != gameObject)
|
||
{
|
||
canvasGroup = GetComponent<CanvasGroup>();
|
||
if (!canvasGroup)
|
||
{
|
||
canvasGroup = gameObject.AddComponent<CanvasGroup>();
|
||
}
|
||
}
|
||
|
||
// Изначально скрыто
|
||
if (canvasGroup)
|
||
{
|
||
canvasGroup.alpha = 0f;
|
||
canvasGroup.interactable = false;
|
||
canvasGroup.blocksRaycasts = false;
|
||
}
|
||
|
||
gameObject.SetActive(false);
|
||
}
|
||
|
||
protected virtual void OnEnable()
|
||
{
|
||
// Регистрируемся в менеджере
|
||
ModalManager.Instance?.Push(this);
|
||
|
||
// Подписываемся на действие закрытия
|
||
if (closeAction)
|
||
{
|
||
closeAction.action.Enable();
|
||
closeAction.action.performed += OnCloseActionPerformed;
|
||
}
|
||
}
|
||
|
||
protected virtual void OnDisable()
|
||
{
|
||
// Отписываемся от действия закрытия
|
||
if (closeAction)
|
||
{
|
||
closeAction.action.performed -= OnCloseActionPerformed;
|
||
}
|
||
|
||
// Удаляемся из менеджера
|
||
ModalManager.Instance?.Pop(this);
|
||
}
|
||
|
||
private void OnCloseActionPerformed(InputAction.CallbackContext ctx)
|
||
{
|
||
Close();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Открывает модальное окно.
|
||
/// </summary>
|
||
public virtual void Open()
|
||
{
|
||
if (_isOpen) return;
|
||
|
||
OnOpening?.Invoke();
|
||
|
||
gameObject.SetActive(true);
|
||
_isOpen = true;
|
||
|
||
if (canvasGroup)
|
||
{
|
||
if (_fadeCoroutine != null)
|
||
{
|
||
StopCoroutine(_fadeCoroutine);
|
||
}
|
||
|
||
// Проверяем, активен ли объект в иерархии после SetActive(true)
|
||
// Если родитель неактивен, запускаем корутину через ModalManager
|
||
if (gameObject.activeInHierarchy)
|
||
{
|
||
_fadeCoroutine = StartCoroutine(FadeCoroutine(0f, 1f, fadeDuration, () =>
|
||
{
|
||
OnOpened?.Invoke();
|
||
}));
|
||
}
|
||
else
|
||
{
|
||
// Объект неактивен в иерархии, запускаем через ModalManager
|
||
var modalManager = ModalManager.Instance;
|
||
if (modalManager != null && modalManager.gameObject.activeInHierarchy)
|
||
{
|
||
_fadeCoroutine = modalManager.StartCoroutine(FadeCoroutine(0f, 1f, fadeDuration, () =>
|
||
{
|
||
OnOpened?.Invoke();
|
||
}));
|
||
}
|
||
else
|
||
{
|
||
// Если ModalManager тоже недоступен, просто вызываем OnOpened без анимации
|
||
Debug.LogWarning($"[Modal] Cannot start fade coroutine for '{gameObject.name}' - object is inactive and ModalManager is not available");
|
||
canvasGroup.alpha = 1f;
|
||
OnOpened?.Invoke();
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
OnOpened?.Invoke();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Закрывает модальное окно.
|
||
/// </summary>
|
||
public virtual void Close()
|
||
{
|
||
if (!_isOpen && !gameObject.activeSelf) return;
|
||
|
||
OnClosing?.Invoke();
|
||
|
||
if (canvasGroup && fadeDuration > 0f)
|
||
{
|
||
if (_fadeCoroutine != null)
|
||
{
|
||
StopCoroutine(_fadeCoroutine);
|
||
}
|
||
|
||
// Проверяем активность объекта в иерархии
|
||
if (gameObject.activeInHierarchy)
|
||
{
|
||
_fadeCoroutine = StartCoroutine(FadeCoroutine(canvasGroup.alpha, 0f, fadeDuration, () =>
|
||
{
|
||
gameObject.SetActive(false);
|
||
_isOpen = false;
|
||
OnClosed?.Invoke();
|
||
}));
|
||
}
|
||
else
|
||
{
|
||
// Если объект неактивен, запускаем через ModalManager
|
||
var modalManager = ModalManager.Instance;
|
||
if (modalManager != null && modalManager.gameObject.activeInHierarchy)
|
||
{
|
||
_fadeCoroutine = modalManager.StartCoroutine(FadeCoroutine(canvasGroup.alpha, 0f, fadeDuration, () =>
|
||
{
|
||
gameObject.SetActive(false);
|
||
_isOpen = false;
|
||
OnClosed?.Invoke();
|
||
}));
|
||
}
|
||
else
|
||
{
|
||
// Если ModalManager недоступен, просто закрываем без анимации
|
||
gameObject.SetActive(false);
|
||
_isOpen = false;
|
||
OnClosed?.Invoke();
|
||
}
|
||
}
|
||
}
|
||
else
|
||
{
|
||
if (canvasGroup)
|
||
{
|
||
canvasGroup.alpha = 0f;
|
||
canvasGroup.interactable = false;
|
||
canvasGroup.blocksRaycasts = false;
|
||
}
|
||
gameObject.SetActive(false);
|
||
_isOpen = false;
|
||
OnClosed?.Invoke();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Мгновенно открывает окно без анимации.
|
||
/// </summary>
|
||
public void OpenInstant()
|
||
{
|
||
if (_isOpen) return;
|
||
|
||
OnOpening?.Invoke();
|
||
|
||
gameObject.SetActive(true);
|
||
_isOpen = true;
|
||
|
||
if (canvasGroup)
|
||
{
|
||
canvasGroup.alpha = 1f;
|
||
canvasGroup.interactable = true;
|
||
canvasGroup.blocksRaycasts = true;
|
||
}
|
||
|
||
OnOpened?.Invoke();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Мгновенно закрывает окно без анимации.
|
||
/// </summary>
|
||
public void CloseInstant()
|
||
{
|
||
if (!_isOpen && !gameObject.activeSelf) return;
|
||
|
||
OnClosing?.Invoke();
|
||
|
||
if (canvasGroup)
|
||
{
|
||
canvasGroup.alpha = 0f;
|
||
canvasGroup.interactable = false;
|
||
canvasGroup.blocksRaycasts = false;
|
||
}
|
||
|
||
gameObject.SetActive(false);
|
||
_isOpen = false;
|
||
OnClosed?.Invoke();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Переключает состояние окна (открыто/закрыто).
|
||
/// </summary>
|
||
public void Toggle()
|
||
{
|
||
if (_isOpen)
|
||
Close();
|
||
else
|
||
Open();
|
||
}
|
||
|
||
private IEnumerator FadeCoroutine(float from, float to, float duration, Action onComplete = null)
|
||
{
|
||
if (!canvasGroup)
|
||
{
|
||
onComplete?.Invoke();
|
||
yield break;
|
||
}
|
||
|
||
// При открытии сразу делаем интерактивным
|
||
if (to > from)
|
||
{
|
||
canvasGroup.interactable = true;
|
||
canvasGroup.blocksRaycasts = true;
|
||
}
|
||
|
||
float elapsed = 0f;
|
||
while (elapsed < duration)
|
||
{
|
||
elapsed += Time.unscaledDeltaTime;
|
||
float t = Mathf.Clamp01(elapsed / duration);
|
||
canvasGroup.alpha = Mathf.Lerp(from, to, t);
|
||
yield return null;
|
||
}
|
||
|
||
canvasGroup.alpha = to;
|
||
|
||
// При закрытии выключаем интерактивность
|
||
if (to < from)
|
||
{
|
||
canvasGroup.interactable = false;
|
||
canvasGroup.blocksRaycasts = false;
|
||
}
|
||
|
||
onComplete?.Invoke();
|
||
_fadeCoroutine = null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Устанавливает первый элемент для фокуса (можно менять динамически).
|
||
/// </summary>
|
||
public void SetFirstSelected(Selectable selectable)
|
||
{
|
||
firstSelected = selectable;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Вызывается, когда окно снова становится верхним после закрытия вложенной модалки.
|
||
/// </summary>
|
||
protected virtual void OnReturnedToTop()
|
||
{
|
||
}
|
||
|
||
internal void NotifyReturnedToTop()
|
||
{
|
||
OnReturnedToTop();
|
||
}
|
||
|
||
#if UNITY_EDITOR
|
||
protected virtual void Reset()
|
||
{
|
||
// Пытаемся автоматически найти CanvasGroup
|
||
canvasGroup = GetComponent<CanvasGroup>();
|
||
if (!canvasGroup)
|
||
{
|
||
canvasGroup = GetComponentInChildren<CanvasGroup>();
|
||
}
|
||
|
||
// Ищем кнопку закрытия
|
||
Button[] buttons = GetComponentsInChildren<Button>(true);
|
||
foreach (Button btn in buttons)
|
||
{
|
||
string btnName = btn.name.ToLower();
|
||
if (btnName.Contains("close") || btnName.Contains("back") || btnName.Contains("cancel"))
|
||
{
|
||
closeButton = btn;
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Ищем первый Selectable для фокуса
|
||
firstSelected = GetComponentInChildren<Selectable>(false);
|
||
}
|
||
#endif
|
||
}
|
||
}
|