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
423 lines
16 KiB
C#
423 lines
16 KiB
C#
using UnityEngine;
|
||
using UnityEngine.UI;
|
||
using UnityEngine.InputSystem;
|
||
using UnityEngine.EventSystems;
|
||
using RR.Game2D.Core.Managers;
|
||
using RR.Common.Settings;
|
||
using TMPro;
|
||
|
||
namespace RR.Common.UI
|
||
{
|
||
/// <summary>
|
||
/// Модальное окно настроек игры.
|
||
/// Управляет отображением и сохранением настроек через новый SettingsManager.
|
||
/// </summary>
|
||
[RequireComponent(typeof(CanvasGroup))]
|
||
public class SettingsUI : Modal
|
||
{
|
||
[Header("Дополнительное управление")]
|
||
[SerializeField] private InputActionReference triggerKey;
|
||
[SerializeField] private Button saveButton;
|
||
|
||
[Header("Контент настроек")]
|
||
[SerializeField] private Transform content;
|
||
[SerializeField] private SettingsUIGenerator settingsGenerator;
|
||
|
||
[Header("Навигация")]
|
||
[Tooltip("Первый элемент для фокуса при открытии. Если не задан - ищется автоматически.")]
|
||
[SerializeField] private Selectable firstSelectable;
|
||
|
||
[Header("Описание")]
|
||
[Tooltip("Текстовый элемент, в который показывается описание выбранной настройки.")]
|
||
[SerializeField] private TMP_Text focusedSettingDescriptionText;
|
||
|
||
private SettingsManager settingsManager;
|
||
|
||
protected override void Awake()
|
||
{
|
||
base.Awake();
|
||
|
||
settingsManager = SettingsManager.Instance;
|
||
|
||
if (triggerKey != null)
|
||
triggerKey.action.started += OnTriggerKey;
|
||
|
||
if (settingsManager == null)
|
||
{
|
||
GameManager.Logger?.LogError(name, "SettingsManager is null!");
|
||
// Не выходим - подпишемся позже когда SettingsManager появится
|
||
}
|
||
else
|
||
{
|
||
settingsManager.OnSettingsLoaded += OnSettingsLoaded;
|
||
settingsManager.OnSettingsSaved += OnSettingsSaved;
|
||
}
|
||
|
||
if (saveButton)
|
||
saveButton.onClick.AddListener(SaveAndClose);
|
||
|
||
// Подписываемся на события settingsGenerator сразу (даже если неактивен)
|
||
SubscribeToGenerator();
|
||
}
|
||
|
||
private bool _subscribedToGenerator;
|
||
|
||
private void SubscribeToGenerator()
|
||
{
|
||
if (_subscribedToGenerator) return;
|
||
|
||
// Если не назначен - пробуем найти в детях
|
||
if (settingsGenerator == null)
|
||
{
|
||
settingsGenerator = GetComponentInChildren<SettingsUIGenerator>(true);
|
||
}
|
||
|
||
if (settingsGenerator != null)
|
||
{
|
||
settingsGenerator.OnBuildComplete += OnGeneratorBuildComplete;
|
||
settingsGenerator.OnGroupExpandedChanged += OnGroupExpandedChanged;
|
||
_subscribedToGenerator = true;
|
||
}
|
||
else
|
||
{
|
||
Debug.LogWarning("[SettingsUI] settingsGenerator is NULL - cannot subscribe to events! Make sure to assign it in Inspector or add SettingsUIGenerator as a child.");
|
||
}
|
||
}
|
||
|
||
protected override void OnEnable()
|
||
{
|
||
base.OnEnable();
|
||
|
||
// Пробуем подписаться снова если не удалось в Awake
|
||
SubscribeToGenerator();
|
||
}
|
||
|
||
private void OnDestroy()
|
||
{
|
||
if (settingsManager != null)
|
||
{
|
||
settingsManager.OnSettingsLoaded -= OnSettingsLoaded;
|
||
settingsManager.OnSettingsSaved -= OnSettingsSaved;
|
||
}
|
||
|
||
if (_subscribedToGenerator && settingsGenerator != null)
|
||
{
|
||
settingsGenerator.OnBuildComplete -= OnGeneratorBuildComplete;
|
||
settingsGenerator.OnGroupExpandedChanged -= OnGroupExpandedChanged;
|
||
}
|
||
|
||
if (triggerKey != null)
|
||
triggerKey.action.started -= OnTriggerKey;
|
||
|
||
if (saveButton)
|
||
saveButton.onClick.RemoveListener(SaveAndClose);
|
||
}
|
||
|
||
private void OnGeneratorBuildComplete()
|
||
{
|
||
BindDescriptionTargets();
|
||
|
||
// Настраиваем навигацию после построения
|
||
SetupExplicitNavigation();
|
||
|
||
// Выбираем первый элемент
|
||
if (settingsGenerator != null && settingsGenerator.FirstSelectable != null)
|
||
{
|
||
if (EventSystem.current != null)
|
||
{
|
||
EventSystem.current.SetSelectedGameObject(settingsGenerator.FirstSelectable.gameObject);
|
||
}
|
||
}
|
||
}
|
||
|
||
private void OnGroupExpandedChanged()
|
||
{
|
||
// При сворачивании/разворачивании группы - перестраиваем навигацию
|
||
StartCoroutine(RebuildNavigationOnGroupChange());
|
||
}
|
||
|
||
private System.Collections.IEnumerator RebuildNavigationOnGroupChange()
|
||
{
|
||
// Запоминаем текущий выбранный объект
|
||
GameObject currentSelected = EventSystem.current?.currentSelectedGameObject;
|
||
|
||
// Ждём кадр чтобы layout обновился
|
||
yield return null;
|
||
|
||
// Перестраиваем навигацию
|
||
SetupExplicitNavigation();
|
||
|
||
// Проверяем, активен ли текущий выбранный объект
|
||
if (currentSelected == null || !currentSelected.activeInHierarchy)
|
||
{
|
||
// Текущий элемент стал неактивен (свёрнут) - выбираем первый активный
|
||
var allSelectables = new System.Collections.Generic.List<Selectable>();
|
||
Transform searchRoot = settingsGenerator != null && settingsGenerator.ContentRoot != null
|
||
? settingsGenerator.ContentRoot
|
||
: content;
|
||
|
||
if (searchRoot != null)
|
||
{
|
||
CollectActiveSelectables(searchRoot, allSelectables);
|
||
|
||
if (allSelectables.Count > 0 && EventSystem.current != null)
|
||
{
|
||
EventSystem.current.SetSelectedGameObject(allSelectables[0].gameObject);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Настраивает явную (Explicit) навигацию между всеми элементами настроек и кнопкой сохранения
|
||
/// </summary>
|
||
private void SetupExplicitNavigation()
|
||
{
|
||
// Собираем все активные Selectable элементы
|
||
var allSelectables = new System.Collections.Generic.List<Selectable>();
|
||
|
||
// Ищем в ContentRoot из settingsGenerator или в content
|
||
Transform searchRoot = null;
|
||
|
||
if (settingsGenerator != null && settingsGenerator.ContentRoot != null)
|
||
{
|
||
searchRoot = settingsGenerator.ContentRoot;
|
||
}
|
||
else if (content != null)
|
||
{
|
||
searchRoot = content;
|
||
}
|
||
|
||
if (searchRoot == null)
|
||
{
|
||
Debug.LogWarning("[SettingsUI] No content root found for navigation setup");
|
||
return;
|
||
}
|
||
|
||
// Получаем все Selectable в порядке иерархии (только активные)
|
||
CollectActiveSelectables(searchRoot, allSelectables);
|
||
|
||
if (allSelectables.Count == 0)
|
||
{
|
||
Debug.LogWarning("[SettingsUI] No active selectables found for navigation setup");
|
||
return;
|
||
}
|
||
|
||
// Настраиваем вертикальную навигацию для каждого элемента
|
||
for (int i = 0; i < allSelectables.Count; i++)
|
||
{
|
||
var current = allSelectables[i];
|
||
var nav = new Navigation();
|
||
nav.mode = Navigation.Mode.Explicit;
|
||
|
||
// Вверх - предыдущий элемент или saveButton (для первого)
|
||
if (i == 0)
|
||
{
|
||
nav.selectOnUp = saveButton;
|
||
}
|
||
else
|
||
{
|
||
nav.selectOnUp = allSelectables[i - 1];
|
||
}
|
||
|
||
// Вниз - следующий элемент или saveButton (для последнего)
|
||
if (i == allSelectables.Count - 1)
|
||
{
|
||
nav.selectOnDown = saveButton;
|
||
}
|
||
else
|
||
{
|
||
nav.selectOnDown = allSelectables[i + 1];
|
||
}
|
||
|
||
// Горизонтальная навигация - оставляем null (не переходим влево/вправо на другие элементы)
|
||
nav.selectOnLeft = null;
|
||
nav.selectOnRight = null;
|
||
|
||
current.navigation = nav;
|
||
}
|
||
|
||
// Настраиваем кнопку сохранения
|
||
if (saveButton != null)
|
||
{
|
||
var saveNav = new Navigation();
|
||
saveNav.mode = Navigation.Mode.Explicit;
|
||
saveNav.selectOnUp = allSelectables[allSelectables.Count - 1]; // Последний элемент
|
||
saveNav.selectOnDown = allSelectables[0]; // Первый элемент (циклическая)
|
||
saveNav.selectOnLeft = null;
|
||
saveNav.selectOnRight = null;
|
||
saveButton.navigation = saveNav;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Рекурсивно собирает все активные Selectable элементы в порядке иерархии
|
||
/// </summary>
|
||
private void CollectActiveSelectables(Transform parent, System.Collections.Generic.List<Selectable> result)
|
||
{
|
||
for (int i = 0; i < parent.childCount; i++)
|
||
{
|
||
var child = parent.GetChild(i);
|
||
|
||
// Пропускаем неактивные объекты
|
||
if (!child.gameObject.activeInHierarchy) continue;
|
||
|
||
// Проверяем, есть ли Selectable на этом объекте
|
||
var selectable = child.GetComponent<Selectable>();
|
||
if (selectable != null && selectable.interactable)
|
||
{
|
||
result.Add(selectable);
|
||
}
|
||
|
||
// Рекурсивно ищем в детях
|
||
CollectActiveSelectables(child, result);
|
||
}
|
||
}
|
||
|
||
private void OnTriggerKey(InputAction.CallbackContext ctx)
|
||
{
|
||
if (IsOpen)
|
||
{
|
||
SaveAndClose();
|
||
}
|
||
else
|
||
{
|
||
Open();
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Переопределяем Open для обновления настроек при открытии.
|
||
/// </summary>
|
||
public override void Open()
|
||
{
|
||
base.Open();
|
||
RefreshSettings();
|
||
ClearFocusedDescription();
|
||
|
||
// Выбираем первый элемент для навигации
|
||
SelectFirstElement();
|
||
}
|
||
|
||
private void SelectFirstElement()
|
||
{
|
||
// Запускаем корутину с задержкой, чтобы UI успел построиться
|
||
StartCoroutine(SelectFirstElementDelayed());
|
||
}
|
||
|
||
private System.Collections.IEnumerator SelectFirstElementDelayed()
|
||
{
|
||
// Ждём несколько кадров чтобы SettingsUIGenerator успел построить элементы
|
||
yield return null;
|
||
yield return null;
|
||
yield return new WaitForSecondsRealtime(0.1f);
|
||
|
||
// Настраиваем явную навигацию
|
||
SetupExplicitNavigation();
|
||
|
||
Selectable target = firstSelectable;
|
||
|
||
// Если не задан явно - ищем первый интерактивный элемент в content
|
||
if (target == null && content != null)
|
||
{
|
||
target = content.GetComponentInChildren<Selectable>();
|
||
}
|
||
|
||
// Если всё ещё не нашли - ищем в settingsGenerator
|
||
if (target == null && settingsGenerator != null)
|
||
{
|
||
target = settingsGenerator.GetComponentInChildren<Selectable>();
|
||
}
|
||
|
||
// Последняя попытка - ищем во всём объекте
|
||
if (target == null)
|
||
{
|
||
target = GetComponentInChildren<Selectable>();
|
||
}
|
||
|
||
if (target != null && EventSystem.current != null)
|
||
{
|
||
EventSystem.current.SetSelectedGameObject(target.gameObject);
|
||
}
|
||
else
|
||
{
|
||
Debug.LogWarning("[SettingsUI] Could not find any selectable element for focus");
|
||
}
|
||
}
|
||
|
||
private void RefreshSettings()
|
||
{
|
||
// Если используется новый генератор
|
||
if (settingsGenerator != null)
|
||
{
|
||
settingsGenerator.RefreshValues();
|
||
}
|
||
}
|
||
|
||
private void Save()
|
||
{
|
||
if (settingsManager != null)
|
||
{
|
||
settingsManager.SaveSettings();
|
||
}
|
||
}
|
||
|
||
public void SaveAndClose()
|
||
{
|
||
Save();
|
||
Close();
|
||
}
|
||
|
||
public override void Close()
|
||
{
|
||
ClearFocusedDescription();
|
||
base.Close();
|
||
}
|
||
|
||
private void OnSettingsLoaded()
|
||
{
|
||
RefreshSettings();
|
||
}
|
||
|
||
private void OnSettingsSaved()
|
||
{
|
||
GameManager.Logger?.Log(name, "Saved settings");
|
||
}
|
||
|
||
private void BindDescriptionTargets()
|
||
{
|
||
if (focusedSettingDescriptionText == null) return;
|
||
|
||
var items = GetComponentsInChildren<SettingUIItem>(true);
|
||
foreach (var item in items)
|
||
{
|
||
if (item != null)
|
||
item.BindExternalDescriptionTarget(focusedSettingDescriptionText);
|
||
}
|
||
|
||
var groups = GetComponentsInChildren<SettingsGroupPanel>(true);
|
||
foreach (var group in groups)
|
||
{
|
||
if (group != null)
|
||
group.BindExternalDescriptionTarget(focusedSettingDescriptionText);
|
||
}
|
||
|
||
if (saveButton != null)
|
||
{
|
||
var descriptionTarget = saveButton.GetComponent<SelectableDescriptionTarget>();
|
||
if (descriptionTarget == null)
|
||
descriptionTarget = saveButton.gameObject.AddComponent<SelectableDescriptionTarget>();
|
||
|
||
descriptionTarget.Configure(focusedSettingDescriptionText, string.Empty);
|
||
}
|
||
}
|
||
|
||
private void ClearFocusedDescription()
|
||
{
|
||
if (focusedSettingDescriptionText != null)
|
||
focusedSettingDescriptionText.text = string.Empty;
|
||
}
|
||
}
|
||
}
|
||
|