Danro/Assets/Scripts/UI/Modal/PrizeStandModal.cs
Danil Iarosh cb2e83a596
Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Has been cancelled
feat(ui, prizes, spawners, editor): expand prize flow and add spawning/editor tooling
- доработан flow призовой витрины и связанного UI:
  добавлены `PrizeStandButtonView` и `RulesPrizeListPage`, обновлены
  `PrizeStandController`, `PrizeStandPrize`, `PrizeStandModal`,
  `PrizeStandSettingsModal`, `LevelPrizeSettingsModal`, `QRCodeSettingsModal`,
  `BalanceDisplay`, `ButtonTextFocusColor` и связанные prefab'ы,
  чтобы улучшить настройку, навигацию, отображение кнопок и prize flow
- расширена система настроек и сохранения значений:
  `OptionPicker` получил отдельный формат `ValueSelector`,
  обновлены `SettingsDatabase`, `SettingEntryDrawer`, `SettingUIItem`,
  `SettingsUIGenerator`, `ValueSelectorRow` и `MainSettingsDatabase.asset`,
  а сохранение option picker-значений, включая `gameplay.gameDifficulty`,
  исправлено и синхронизировано с остальными настройками
- добавлены универсальные спавнеры сущностей:
  реализованы `EntitySpawner` и `EntitySpawnerManager` с поддержкой
  весов префабов, точек спавна, заполнения по проценту, ограничений
  на количество и автоспавна при инициализации
- доработана логика игрового спавна и сложности:
  процент инициализационного спавна может зависеть от настройки
  `gameplay.gameDifficulty` с градациями 25/50/75/100,
  в одном цикле массового спавна повторно не используется та же точка,
  а gizmo спавнера теперь показывает только сами точки без линий
- упрощена работа в редакторе Unity и добавлены editor-инструменты:
  в `GameManager` лицензия пропускается при запуске в `UNITY_EDITOR`,
  а `HierarchySelectionCounter`, `HierarchySelectionCountOverlay`
  и `HierarchyBulkRenamerWindow` добавляют подсчет выделения,
  realtime overlay в `SceneView` и массовое переименование объектов
  по правилам нумерации из Unity Project Settings
- синхронизированы сцены, ресурсы и UI-ассеты под новый flow:
  обновлены `MainMenu`, `Level UI`, уровни `1-1` и `1-2`,
  строки настроек, кнопки, TMP/font assets, изображения UI,
  `prizes.json` и recovery-данные после правок интерфейса и логики
- очищены и переразложены часть внешних и служебных ресурсов:
  удалён пакет `Lava_Flowing_Shader`, переименованы/заменены
  отдельные UI-prefab'ы и добавлены сопутствующие проектные артефакты,
  которые сейчас используются обновлённой конфигурацией проекта
2026-04-28 14:05:05 +03:00

303 lines
10 KiB
C#
Raw 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 System;
using System.Collections.Generic;
using TMPro;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.InputSystem;
using UnityEngine.UI;
using RR.Common.Controls;
using RR.Common.Managers;
using RR.Game2D.Core.Managers;
using ControlsBoxStatus = RR.Common.Controls.BoxStatus;
namespace RR.Common.UI
{
/// <summary>
/// Модальное окно витрины с выбором ячейки/полки для выдачи приза.
/// Отображает сетку кнопок-ячеек. Навигация — через Input System (Navigate).
/// </summary>
public class PrizeStandModal : Modal
{
/// <summary>Текущий экземпляр на сцене. Регистрируется автоматически.</summary>
public static PrizeStandModal Current { get; private set; }
[Header("Контент")]
[Tooltip("Контейнер для кнопок ячеек (например, GridLayoutGroup).")]
[SerializeField] private Transform boxButtonsContainer;
[Tooltip("Префаб кнопки ячейки. Должен содержать Button и TMP_Text.")]
[SerializeField] private GameObject boxButtonPrefab;
[Header("UI")]
[Tooltip("Текст-заголовок окна (опционально).")]
[SerializeField] private TMP_Text titleText;
[Tooltip("Текст заголовка по умолчанию.")]
[SerializeField] private string defaultTitle = "Выберите ячейку";
[Tooltip("Текст с количеством очков игрока.")]
[SerializeField] private TMP_Text scoreText;
[Tooltip("Формат текста очков. Используйте {0} для подстановки значения.")]
[SerializeField] private string scoreTextFormat = "Очки за игру: {0}";
[Header("Навигация")]
[Tooltip("InputAction для навигации (Navigate из Input System).")]
[SerializeField] private InputActionReference navigateAction;
/// <summary>
/// Вызывается при выборе ячейки. Параметр — номер ячейки (1-based).
/// Если игрок закрыл окно без выбора, вызывается с 0.
/// </summary>
public event Action<int> OnBoxSelected;
private readonly List<GameObject> _createdButtons = new();
private bool _selectionMade;
private PrizeStandController _standController;
private int _lastHighlightedBoxNumber;
// ────────────── Lifecycle ──────────────
protected override void Awake()
{
Current = this;
base.Awake();
// При закрытии без выбора — уведомляем с 0
OnClosed += HandleClosedWithoutSelection;
}
private void OnDestroy()
{
if (Current == this) Current = null;
OnClosed -= HandleClosedWithoutSelection;
}
// ────────────── Публичный API ──────────────
/// <summary>
/// Показывает окно с указанным количеством ячеек.
/// </summary>
/// <param name="boxCount">Количество ячеек для отображения.</param>
public void Show(
IReadOnlyList<PrizeStandBoxAvailability> boxes,
bool isConnected,
int connectedBoxesCount,
float gameTime,
int finalScore,
bool showFinalScore)
{
_selectionMade = false;
_lastHighlightedBoxNumber = 0;
_standController = isConnected ? ComSystemManager.GetPrizeStandController() : null;
BuildButtons(boxes);
if (titleText != null)
{
titleText.text = defaultTitle;
}
if (scoreText != null)
{
scoreText.text = BuildScoreText(finalScore);
}
Open();
GameManager.Instance?.SetPause(true);
}
// ────────────── Внутреннее ──────────────
private void BuildButtons(IReadOnlyList<PrizeStandBoxAvailability> boxes)
{
ClearButtons();
if (boxButtonsContainer == null || boxButtonPrefab == null)
{
Debug.LogError("[PrizeStandModal] Контейнер или префаб кнопки не назначены.");
return;
}
if (boxes == null)
return;
for (int i = 0; i < boxes.Count; i++)
{
PrizeStandBoxAvailability box = boxes[i];
GameObject btnObj = Instantiate(boxButtonPrefab, boxButtonsContainer);
btnObj.name = $"BoxButton_{box.BoxNumber}";
PrizeStandButtonView buttonView = btnObj.GetComponent<PrizeStandButtonView>();
if (buttonView == null)
{
buttonView = btnObj.AddComponent<PrizeStandButtonView>();
}
buttonView.Bind(box.BoxNumber, box.requiredScore);
// Настраиваем кнопку
Button btn = btnObj.GetComponent<Button>();
if (btn != null)
{
int boxNumber = box.BoxNumber;
btn.interactable = box.IsAvailable;
btn.onClick.AddListener(() => OnBoxButtonClicked(boxNumber));
PrizeStandButtonSelectionRelay relay = btnObj.GetComponent<PrizeStandButtonSelectionRelay>();
if (relay == null)
{
relay = btnObj.AddComponent<PrizeStandButtonSelectionRelay>();
}
relay.Initialize(boxNumber, OnBoxButtonFocused);
}
_createdButtons.Add(btnObj);
}
// Устанавливаем первый элемент для фокуса
for (int i = 0; i < _createdButtons.Count; i++)
{
Button firstBtn = _createdButtons[i].GetComponent<Button>();
if (firstBtn != null && firstBtn.interactable)
{
SetFirstSelected(firstBtn);
OnBoxButtonFocused(boxes[i].BoxNumber);
break;
}
}
}
private void OnBoxButtonClicked(int boxNumber)
{
_selectionMade = true;
Debug.Log($"[PrizeStandModal] Выбрана ячейка #{boxNumber}.");
Close();
GameManager.Instance?.SetPause(false);
OnBoxSelected?.Invoke(boxNumber);
}
private void HandleClosedWithoutSelection()
{
if (!_selectionMade)
{
Debug.Log("[PrizeStandModal] Окно закрыто без выбора.");
GameManager.Instance?.SetPause(false);
OnBoxSelected?.Invoke(0);
}
}
private void ClearButtons()
{
foreach (GameObject obj in _createdButtons)
{
if (obj != null) Destroy(obj);
}
_createdButtons.Clear();
}
private void OnBoxButtonFocused(int boxNumber)
{
if (_standController == null || boxNumber <= 0 || _lastHighlightedBoxNumber == boxNumber)
{
return;
}
if (_standController.HighlightBoxLighting(boxNumber))
{
_lastHighlightedBoxNumber = boxNumber;
}
}
private void RestoreIdleLighting()
{
if (_standController == null)
{
return;
}
_standController.SetIdleLighting();
_lastHighlightedBoxNumber = 0;
}
protected override void OnDisable()
{
base.OnDisable();
RestoreIdleLighting();
ClearButtons();
if (scoreText != null)
scoreText.text = string.Empty;
GameManager.Instance?.SetPause(false);
_standController = null;
}
private string BuildScoreText(int finalScore)
{
if (string.IsNullOrWhiteSpace(scoreTextFormat))
{
return finalScore.ToString();
}
try
{
return string.Format(scoreTextFormat, finalScore);
}
catch (FormatException)
{
return $"{scoreTextFormat} {finalScore}";
}
}
}
[DisallowMultipleComponent]
public sealed class PrizeStandButtonSelectionRelay : MonoBehaviour, ISelectHandler, IPointerEnterHandler
{
private Action<int> _onFocused;
private int _boxNumber;
public void Initialize(int boxNumber, Action<int> onFocused)
{
_boxNumber = boxNumber;
_onFocused = onFocused;
}
public void OnSelect(BaseEventData eventData)
{
_onFocused?.Invoke(_boxNumber);
}
public void OnPointerEnter(PointerEventData eventData)
{
_onFocused?.Invoke(_boxNumber);
}
}
public readonly struct PrizeStandBoxAvailability
{
public readonly int BoxNumber;
public readonly int requiredScore;
public readonly bool IsUnlocked;
public readonly bool IsConnected;
public readonly ControlsBoxStatus Status;
public bool IsReadyToOpen => PrizeStandController.IsReadyToOpenStatus(Status);
public bool IsAvailable => IsUnlocked && IsConnected && IsReadyToOpen;
public bool IsOpened => Status == ControlsBoxStatus.Open || Status == ControlsBoxStatus.OPENED;
public bool IsEmpty => Status == ControlsBoxStatus.Empty;
public bool HasError => Status == ControlsBoxStatus.OPEN_ERROR || Status == ControlsBoxStatus.Error || Status == ControlsBoxStatus.ParsingError;
public PrizeStandBoxAvailability(int boxNumber, int requiredScore, bool isUnlocked, bool isConnected, ControlsBoxStatus status)
{
BoxNumber = boxNumber;
this.requiredScore = requiredScore;
IsUnlocked = isUnlocked;
IsConnected = isConnected;
Status = status;
}
}
}