Danro/Assets/Scripts/UI/Modal/LevelPrizeSettingsModal.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

590 lines
22 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.Managers;
using RR.Common.Prizes;
namespace RR.Common.UI
{
/// <summary>
/// Модальное окно настройки приза за конкретный уровень.
/// Для QR настраивает ранг на уровне, для витрины редактирует общую игровую конфигурацию.
/// </summary>
public class LevelPrizeSettingsModal : Modal
{
[Header("UI — Общее")]
[SerializeField] private TMP_Text titleText;
[SerializeField] private TMP_Text prizeTypeLabel;
[SerializeField] private TMP_Text detailsText;
[Header("UI — QR")]
[SerializeField] private GameObject qrParamsGroup;
[SerializeField] private TMP_Text qrTierLabel;
[Header("UI — Параметры хоппера")]
[SerializeField] private GameObject hopperParamsGroup;
[SerializeField] private TMP_Text hopperCapsuleCountLabel;
[Header("UI — Параметры витрины")]
[SerializeField] private GameObject prizeStandParamsGroup;
[SerializeField] private TMP_Text prizeStandStatusLabel;
[SerializeField] private TMP_Text prizeStandBoxCountLabel;
[SerializeField] private TMP_Text prizeStandSelectedBoxLabel;
[SerializeField] private TMP_Text prizeStandThresholdLabel;
[Header("Строки (RectTransform)")]
[SerializeField] private RectTransform prizeTypeRow;
[SerializeField] private RectTransform qrTierRow;
[SerializeField] private RectTransform hopperParamRow;
[SerializeField] private RectTransform prizeStandBoxCountRow;
[SerializeField] private RectTransform prizeStandSelectedBoxRow;
[SerializeField] private RectTransform prizeStandThresholdRow;
[Header("Указатель")]
[SerializeField] private UIRowPointer rowPointer;
[Header("Кнопка закрытия")]
[SerializeField] private Button closeModalButton;
[Header("Навигация (Input System)")]
[SerializeField] private InputActionReference navigateAction;
[SerializeField] private float deadZone = 0.5f;
[Header("Лимиты")]
[SerializeField] private int maxCapsules = 10;
[SerializeField] private int maxBoxes = 48;
[SerializeField] private int defaultPrizeStandBoxesCount = 5;
[SerializeField] private int scoreStep = 50;
private const int ROW_TYPE = 0;
private const int ROW_QR_TIER = 1;
private const int ROW_HOPPER_PARAM = 2;
private const int ROW_STAND_BOX_COUNT = 3;
private const int ROW_STAND_SELECTED_BOX = 4;
private const int ROW_STAND_THRESHOLD = 5;
private const int ROW_CLOSE = 6;
private const int ROW_COUNT = 7;
private static readonly PrizeType[] PrizeTypes = (PrizeType[])Enum.GetValues(typeof(PrizeType));
private static readonly QRTier[] QRTiers = { QRTier.Bronze, QRTier.Silver, QRTier.Gold };
private string _levelName;
private LevelPrizeConfig _config;
private PrizeStandSettings _prizeStandSettings;
private int _currentTypeIndex;
private int _currentTierIndex;
private int _activeRow;
private int _selectedPrizeStandBoxIndex;
private bool _axisHeldX;
private bool _axisHeldY;
public event Action<string> OnApplied;
protected override void OnEnable()
{
base.OnEnable();
if (navigateAction != null && navigateAction.action != null)
{
navigateAction.action.Enable();
navigateAction.action.performed += OnNavigatePerformed;
navigateAction.action.canceled += OnNavigateCanceled;
}
}
protected override void OnDisable()
{
if (navigateAction != null && navigateAction.action != null)
{
navigateAction.action.performed -= OnNavigatePerformed;
navigateAction.action.canceled -= OnNavigateCanceled;
}
_axisHeldX = false;
_axisHeldY = false;
base.OnDisable();
}
protected override void OnReturnedToTop()
{
_axisHeldX = false;
_axisHeldY = false;
RefreshRowHighlights();
}
private void OnNavigatePerformed(InputAction.CallbackContext ctx)
{
if (!_isOpen) return;
Vector2 v = ctx.ReadValue<Vector2>();
if (Mathf.Abs(v.y) >= deadZone)
{
if (!_axisHeldY)
{
_axisHeldY = true;
MoveRow(v.y > 0f ? -1 : 1);
}
}
else
{
_axisHeldY = false;
}
if (Mathf.Abs(v.x) >= deadZone)
{
if (!_axisHeldX)
{
_axisHeldX = true;
HandleLeftRight(v.x > 0f ? 1 : -1);
}
}
else
{
_axisHeldX = false;
}
}
private void OnNavigateCanceled(InputAction.CallbackContext ctx)
{
_axisHeldX = false;
_axisHeldY = false;
}
private void MoveRow(int delta)
{
int next = _activeRow;
for (int i = 0; i < ROW_COUNT; i++)
{
next = (next + delta + ROW_COUNT) % ROW_COUNT;
if (IsRowVisible(next))
{
_activeRow = next;
break;
}
}
RefreshRowHighlights();
}
private bool IsRowVisible(int row)
{
return row switch
{
ROW_QR_TIER => _config.type == PrizeType.QRCode,
ROW_HOPPER_PARAM => _config.type == PrizeType.Hopper,
ROW_STAND_BOX_COUNT => _config.type == PrizeType.PrizeStand,
ROW_STAND_SELECTED_BOX => _config.type == PrizeType.PrizeStand && GetPrizeStandBoxes().Count > 0,
ROW_STAND_THRESHOLD => _config.type == PrizeType.PrizeStand && GetPrizeStandBoxes().Count > 0,
_ => true
};
}
private void HandleLeftRight(int direction)
{
switch (_activeRow)
{
case ROW_TYPE:
CyclePrizeType(direction);
break;
case ROW_QR_TIER:
AdjustQrTier(direction);
break;
case ROW_HOPPER_PARAM:
AdjustCapsules(direction);
break;
case ROW_STAND_BOX_COUNT:
AdjustPrizeStandBoxCount(direction);
break;
case ROW_STAND_SELECTED_BOX:
AdjustSelectedPrizeStandBox(direction);
break;
case ROW_STAND_THRESHOLD:
AdjustPrizeStandThreshold(direction);
break;
}
}
public void OpenForLevel(string levelName)
{
_levelName = levelName;
PrizeDatabase db = PrizeSystemManager.Instance?.Database;
_config = db != null
? CopyConfig(db.GetConfig(levelName))
: new LevelPrizeConfig();
_prizeStandSettings = db != null
? CopyPrizeStandSettings(db.GetPrizeStandSettings())
: CreateDefaultPrizeStandSettings();
EnsurePrizeStandSettingsInitialized();
_currentTypeIndex = Array.IndexOf(PrizeTypes, _config.type);
if (_currentTypeIndex < 0)
_currentTypeIndex = 0;
_currentTierIndex = GetTierIndex(_config.qrTier);
_selectedPrizeStandBoxIndex = Mathf.Clamp(_selectedPrizeStandBoxIndex, 0, Mathf.Max(0, GetPrizeStandBoxes().Count - 1));
_activeRow = ROW_TYPE;
AutoResolveRows();
RefreshUI();
RefreshRowHighlights();
Open();
}
public override void Close()
{
Save();
base.Close();
}
private void CyclePrizeType(int direction)
{
_currentTypeIndex = (_currentTypeIndex + direction + PrizeTypes.Length) % PrizeTypes.Length;
_config.type = PrizeTypes[_currentTypeIndex];
if (_config.type == PrizeType.QRCode && _config.qrTier == QRTier.None)
_config.qrTier = QRTiers[0];
EnsurePrizeStandSettingsInitialized();
if (!IsRowVisible(_activeRow))
_activeRow = ROW_TYPE;
RefreshUI();
RefreshRowHighlights();
}
private void AdjustQrTier(int direction)
{
_currentTierIndex = (_currentTierIndex + direction + QRTiers.Length) % QRTiers.Length;
_config.qrTier = QRTiers[_currentTierIndex];
RefreshUI();
}
private void AdjustCapsules(int delta)
{
_config.hopperCapsuleCount = Mathf.Clamp(_config.hopperCapsuleCount + delta, 1, maxCapsules);
RefreshUI();
}
private void AdjustPrizeStandBoxCount(int delta)
{
List<PrizeStandBoxConfig> boxes = GetPrizeStandBoxes();
int currentCount = boxes.Count;
int nextCount = Mathf.Clamp(currentCount + delta, 1, maxBoxes);
if (nextCount == currentCount)
return;
if (nextCount > currentCount)
{
for (int i = currentCount + 1; i <= nextCount; i++)
boxes.Add(new PrizeStandBoxConfig { boxNumber = i, requiredScore = 0 });
}
else
{
boxes.RemoveRange(nextCount, currentCount - nextCount);
}
NormalizePrizeStandBoxes();
_selectedPrizeStandBoxIndex = Mathf.Clamp(_selectedPrizeStandBoxIndex, 0, boxes.Count - 1);
RefreshUI();
RefreshRowHighlights();
}
private void AdjustSelectedPrizeStandBox(int delta)
{
List<PrizeStandBoxConfig> boxes = GetPrizeStandBoxes();
if (boxes.Count == 0)
return;
_selectedPrizeStandBoxIndex = (_selectedPrizeStandBoxIndex + delta + boxes.Count) % boxes.Count;
RefreshUI();
}
private void AdjustPrizeStandThreshold(int direction)
{
PrizeStandBoxConfig selectedBox = GetSelectedPrizeStandBox();
if (selectedBox == null)
return;
selectedBox.requiredScore = Mathf.Max(0, selectedBox.requiredScore + direction * scoreStep);
RefreshUI();
}
private void Save()
{
if (string.IsNullOrEmpty(_levelName) || _config == null)
return;
PrizeDatabase db = PrizeSystemManager.Instance?.Database;
PrizeSystemManager manager = PrizeSystemManager.Instance;
if (db == null || manager == null)
{
Debug.LogError("[LevelPrizeSettingsModal] PrizeDatabase недоступна.");
return;
}
db.SetConfig(_levelName, _config);
manager.SetPrizeStandSettings(_prizeStandSettings);
Debug.Log($"[LevelPrizeSettingsModal] Уровень «{_levelName}» → приз «{_config.type}» сохранён.");
OnApplied?.Invoke(_levelName);
}
private void RefreshUI()
{
EnsurePrizeStandSettingsInitialized();
if (titleText)
titleText.text = $"Приз за уровень {_levelName}";
if (prizeTypeLabel)
prizeTypeLabel.text = GetPrizeTypeName(_config.type);
if (qrParamsGroup) qrParamsGroup.SetActive(_config.type == PrizeType.QRCode);
if (hopperParamsGroup) hopperParamsGroup.SetActive(_config.type == PrizeType.Hopper);
if (prizeStandParamsGroup) prizeStandParamsGroup.SetActive(_config.type == PrizeType.PrizeStand);
if (qrTierLabel)
qrTierLabel.text = GetTierName(_config.qrTier);
if (hopperCapsuleCountLabel)
hopperCapsuleCountLabel.text = _config.hopperCapsuleCount.ToString();
PrizeStandConnectionInfo connectionInfo = PrizeSystemManager.Instance != null
? PrizeSystemManager.Instance.GetPrizeStandConnectionInfo()
: new PrizeStandConnectionInfo(false, 0);
List<PrizeStandBoxConfig> boxes = GetPrizeStandBoxes();
PrizeStandBoxConfig selectedBox = GetSelectedPrizeStandBox();
if (prizeStandStatusLabel)
prizeStandStatusLabel.text = connectionInfo.IsConnected
? $"Подключена ({connectionInfo.ConnectedBoxesCount} ячеек)"
: "Не подключена";
if (prizeStandBoxCountLabel)
prizeStandBoxCountLabel.text = boxes.Count.ToString();
if (prizeStandSelectedBoxLabel)
prizeStandSelectedBoxLabel.text = selectedBox != null ? selectedBox.boxNumber.ToString() : "-";
if (prizeStandThresholdLabel)
prizeStandThresholdLabel.text = selectedBox != null ? selectedBox.requiredScore.ToString() : "-";
if (detailsText)
detailsText.text = BuildDetailsText(connectionInfo, boxes, selectedBox);
}
private string BuildDetailsText(PrizeStandConnectionInfo connectionInfo, List<PrizeStandBoxConfig> boxes, PrizeStandBoxConfig selectedBox)
{
return _config.type switch
{
PrizeType.QRCode => $"Ранг QR-приза: {GetTierName(_config.qrTier)}",
PrizeType.Hopper => $"Капсул за выдачу: {_config.hopperCapsuleCount}",
PrizeType.PrizeStand => BuildPrizeStandDetails(connectionInfo, boxes, selectedBox),
_ => "Для уровня приз не назначен."
};
}
private string BuildPrizeStandDetails(PrizeStandConnectionInfo connectionInfo, List<PrizeStandBoxConfig> boxes, PrizeStandBoxConfig selectedBox)
{
string connectionText = connectionInfo.IsConnected
? $"Витрина подключена, доступно на устройстве: {connectionInfo.ConnectedBoxesCount} ячеек."
: "Витрина не подключена. Редактируется сохранённая конфигурация.";
string selectedText = selectedBox != null
? $"Выбрана ячейка {selectedBox.boxNumber}, порог: {selectedBox.requiredScore} очков."
: "Ячейки не настроены.";
return $"{connectionText}\nСконфигурировано ячеек: {boxes.Count}\n{selectedText}";
}
private void RefreshRowHighlights()
{
if (_activeRow == ROW_CLOSE)
{
if (rowPointer) rowPointer.Hide();
if (closeModalButton && EventSystem.current)
EventSystem.current.SetSelectedGameObject(closeModalButton.gameObject);
}
else
{
if (rowPointer)
{
RectTransform target = GetActiveRowTransform();
if (target) rowPointer.SetTarget(target);
}
}
}
private RectTransform GetActiveRowTransform()
{
return _activeRow switch
{
ROW_TYPE => prizeTypeRow,
ROW_QR_TIER => qrTierRow != null ? qrTierRow : prizeTypeRow,
ROW_HOPPER_PARAM => hopperParamRow != null ? hopperParamRow : prizeTypeRow,
ROW_STAND_BOX_COUNT => prizeStandBoxCountRow != null ? prizeStandBoxCountRow : prizeTypeRow,
ROW_STAND_SELECTED_BOX => prizeStandSelectedBoxRow != null ? prizeStandSelectedBoxRow : prizeTypeRow,
ROW_STAND_THRESHOLD => prizeStandThresholdRow != null ? prizeStandThresholdRow : prizeTypeRow,
_ => prizeTypeRow
};
}
private void AutoResolveRows()
{
if (prizeTypeRow == null && prizeTypeLabel != null)
prizeTypeRow = prizeTypeLabel.transform.parent as RectTransform;
if (qrTierRow == null && qrTierLabel != null)
qrTierRow = qrTierLabel.transform.parent as RectTransform;
if (hopperParamRow == null && hopperCapsuleCountLabel != null)
hopperParamRow = hopperCapsuleCountLabel.transform.parent as RectTransform;
if (prizeStandBoxCountRow == null && prizeStandBoxCountLabel != null)
prizeStandBoxCountRow = prizeStandBoxCountLabel.transform.parent as RectTransform;
if (prizeStandSelectedBoxRow == null && prizeStandSelectedBoxLabel != null)
prizeStandSelectedBoxRow = prizeStandSelectedBoxLabel.transform.parent as RectTransform;
if (prizeStandThresholdRow == null && prizeStandThresholdLabel != null)
prizeStandThresholdRow = prizeStandThresholdLabel.transform.parent as RectTransform;
}
private void EnsurePrizeStandSettingsInitialized()
{
_prizeStandSettings ??= CreateDefaultPrizeStandSettings();
_prizeStandSettings.boxes ??= new List<PrizeStandBoxConfig>();
if (_prizeStandSettings.boxes.Count == 0)
{
int initialCount = defaultPrizeStandBoxesCount;
PrizeStandConnectionInfo connectionInfo = PrizeSystemManager.Instance != null
? PrizeSystemManager.Instance.GetPrizeStandConnectionInfo()
: new PrizeStandConnectionInfo(false, 0);
if (connectionInfo.IsConnected && connectionInfo.ConnectedBoxesCount > 0)
initialCount = Mathf.Clamp(connectionInfo.ConnectedBoxesCount, 1, maxBoxes);
for (int i = 1; i <= initialCount; i++)
_prizeStandSettings.boxes.Add(new PrizeStandBoxConfig { boxNumber = i, requiredScore = 0 });
}
NormalizePrizeStandBoxes();
}
private void NormalizePrizeStandBoxes()
{
List<PrizeStandBoxConfig> boxes = _prizeStandSettings.boxes;
boxes.Sort((a, b) => a.boxNumber.CompareTo(b.boxNumber));
for (int i = 0; i < boxes.Count; i++)
{
if (boxes[i] == null)
boxes[i] = new PrizeStandBoxConfig();
boxes[i].boxNumber = i + 1;
boxes[i].requiredScore = Mathf.Max(0, boxes[i].requiredScore);
}
}
private List<PrizeStandBoxConfig> GetPrizeStandBoxes()
{
EnsurePrizeStandSettingsInitialized();
return _prizeStandSettings.boxes;
}
private PrizeStandBoxConfig GetSelectedPrizeStandBox()
{
List<PrizeStandBoxConfig> boxes = GetPrizeStandBoxes();
if (boxes.Count == 0)
return null;
_selectedPrizeStandBoxIndex = Mathf.Clamp(_selectedPrizeStandBoxIndex, 0, boxes.Count - 1);
return boxes[_selectedPrizeStandBoxIndex];
}
private static string GetPrizeTypeName(PrizeType type)
{
return type switch
{
PrizeType.None => "Нет приза",
PrizeType.QRCode => "QR-код",
PrizeType.Hopper => "Хоппер",
PrizeType.PrizeStand => "Витрина",
_ => type.ToString()
};
}
private static string GetTierName(QRTier tier)
{
return tier switch
{
QRTier.Bronze => "Бронзовый",
QRTier.Silver => "Серебряный",
QRTier.Gold => "Золотой",
_ => "Не задан"
};
}
private static int GetTierIndex(QRTier tier)
{
int index = Array.IndexOf(QRTiers, tier);
return index >= 0 ? index : 0;
}
private static LevelPrizeConfig CopyConfig(LevelPrizeConfig src)
{
return new LevelPrizeConfig
{
type = src.type,
qrTier = src.qrTier,
hopperCapsuleCount = src.hopperCapsuleCount,
hopperDelayBetweenCapsules = src.hopperDelayBetweenCapsules
};
}
private static PrizeStandSettings CopyPrizeStandSettings(PrizeStandSettings src)
{
PrizeStandSettings copy = new PrizeStandSettings();
if (src?.boxes == null)
return copy;
for (int i = 0; i < src.boxes.Count; i++)
{
PrizeStandBoxConfig box = src.boxes[i];
if (box == null)
continue;
copy.boxes.Add(new PrizeStandBoxConfig
{
boxNumber = box.boxNumber,
requiredScore = box.requiredScore
});
}
return copy;
}
private PrizeStandSettings CreateDefaultPrizeStandSettings()
{
PrizeStandSettings settings = new PrizeStandSettings();
for (int i = 1; i <= defaultPrizeStandBoxesCount; i++)
{
settings.boxes.Add(new PrizeStandBoxConfig
{
boxNumber = i,
requiredScore = 0
});
}
return settings;
}
}
}