Danro/Assets/Scripts/UI/SettingsUI.cs
Danil Iarosh 78d7679e2b
Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Has been cancelled
feat(prizes, ui): complete Mar 24-Apr 2 sprint ahead of schedule
- завершен спринт 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
2026-04-01 12:47:17 +03:00

423 lines
16 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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;
}
}
}