Danro/Assets/Scripts/Managers/PrizeSystemManager.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

544 lines
20 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 UnityEngine;
using RR.Common.Prizes;
using RR.Common.Settings;
using RR.Common.UI;
using RR.Common.Utility;
using RR.Game2D.Core.Managers;
using RR.Common.Controls;
namespace RR.Common.Managers
{
/// <summary>
/// Центральный менеджер системы призов.
/// Не знает конкретики о призах — читает конфигурацию из JSON (<see cref="PrizeDatabase"/>)
/// и делегирует выдачу зарегистрированным исполнителям (<see cref="PrizeBase"/>).
/// </summary>
public class PrizeSystemManager : MonoBehaviour
{
public static PrizeSystemManager Instance { get; private set; }
[Header("QR-коды (глобальная конфигурация)")]
[SerializeField] private List<QRCodeInfo> qrCodes = new();
[SerializeField] private string phoneNumberMask = "+7XXXXXXXXXX";
/// <summary>Хранилище призов за уровни (JSON).</summary>
public PrizeDatabase Database { get; private set; }
/// <summary>
/// Вызывается после выдачи любого приза. Параметры: тип приза, успех.
/// </summary>
public event Action<PrizeType, bool> OnAnyPrizeResult;
// ── Исполнители ──
private readonly Dictionary<PrizeType, PrizeBase> _executors = new();
// ── QR-коды ──
private readonly List<GeneratedQRCodeEntry> _generatedQRCodes = new();
private readonly Dictionary<QRTier, Texture2D[]> _qrCodesCacheByTier = new();
private List<QRCodeInfo> _defaultQRCodeConfigs = new();
private string _phoneNumber = string.Empty;
// ────────────── Инициализация ──────────────
public void Initialize()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
DontDestroyOnLoad(gameObject);
_defaultQRCodeConfigs = qrCodes != null ? new List<QRCodeInfo>(qrCodes) : new List<QRCodeInfo>();
// Загружаем базу призов из JSON
Database = new PrizeDatabase();
Database.Load();
LoadQRCodeConfigs();
// QR-коды (глобальные)
RefreshPhoneNumber();
GenerateQRCodes();
SubscribeToSettings();
// Регистрируем исполнителей — модальные окна регистрируют себя сами через static Current
RegisterExecutor(new NonePrize());
RegisterExecutor(new QRCodePrize(() => QRCodeModal.Current, GetQRCodes));
RegisterExecutor(new HopperPrize());
RegisterExecutor(new PrizeStandPrize(() => PrizeStandModal.Current));
Debug.Log("[PrizeSystemManager] Инициализирован. " +
$"Исполнители: {_executors.Count}, Уровней в базе: {Database.GetAll().Count}");
}
private void OnDestroy()
{
UnsubscribeFromSettings();
if (Instance == this) Instance = null;
}
// ────────────── Публичный API ──────────────
/// <summary>
/// Выдаёт приз за текущий уровень (World-Stage).
/// Конфигурация берётся из <see cref="PrizeDatabase"/>.
/// </summary>
public void GivePrizeForCurrentLevel()
{
GivePrizeForLevel(GetCurrentLevelName());
}
/// <summary>
/// Возвращает конфигурацию приза для текущего уровня.
/// Если база недоступна или конфиг не найден, возвращает конфиг без приза.
/// </summary>
public LevelPrizeConfig GetPrizeConfigForCurrentLevel()
{
return GetPrizeConfigForLevel(GetCurrentLevelName());
}
/// <summary>
/// Возвращает конфигурацию приза для указанного уровня.
/// Если база недоступна или конфиг не найден, возвращает конфиг без приза.
/// </summary>
public LevelPrizeConfig GetPrizeConfigForLevel(string levelName)
{
if (Database == null || string.IsNullOrWhiteSpace(levelName))
return new LevelPrizeConfig();
return Database.GetConfig(levelName);
}
/// <summary>
/// Выдаёт приз за указанный уровень.
/// </summary>
public void GivePrizeForLevel(string levelName)
{
LevelPrizeConfig config = GetPrizeConfigForLevel(levelName);
if (config.type == PrizeType.None)
{
Debug.Log($"[PrizeSystemManager] Уровень «{levelName}»: приз не назначен.");
OnAnyPrizeResult?.Invoke(PrizeType.None, true);
return;
}
if (!_executors.TryGetValue(config.type, out PrizeBase executor))
{
Debug.LogWarning($"[PrizeSystemManager] Нет исполнителя для типа «{config.type}».");
return;
}
if (executor.IsGiving)
{
Debug.LogWarning($"[PrizeSystemManager] Исполнитель «{config.type}» занят.");
return;
}
Debug.Log($"[PrizeSystemManager] Уровень «{levelName}» → приз «{config.type}».");
executor.Execute(this, config);
}
/// <summary>
/// Обратная совместимость: выдаёт приз хоппером (если включён в настройках).
/// </summary>
public void GivePrize()
{
bool hopperEnabled = SettingsManager.Instance != null
&& SettingsManager.Instance.GetBool("prizes.giveSmallPrize");
if (hopperEnabled && _executors.TryGetValue(PrizeType.Hopper, out PrizeBase executor))
executor.Execute(this, new LevelPrizeConfig { type = PrizeType.Hopper });
else
Debug.Log("[PrizeSystemManager] Хоппер отключён в настройках.");
}
/// <summary>
/// Выдаёт приз указанного типа с конфигурацией по умолчанию.
/// </summary>
public void GivePrize(PrizeType type)
{
if (!_executors.TryGetValue(type, out PrizeBase executor))
{
Debug.LogWarning($"[PrizeSystemManager] Нет исполнителя для «{type}».");
return;
}
executor.Execute(this, new LevelPrizeConfig { type = type });
}
/// <summary>Возвращает QR-коды выбранного ранга.</summary>
public Texture2D[] GetQRCodes(QRTier tier)
{
if (_qrCodesCacheByTier.TryGetValue(tier, out Texture2D[] cached))
{
return cached;
}
List<Texture2D> textures = new();
foreach (GeneratedQRCodeEntry entry in _generatedQRCodes)
{
if (entry.Texture == null)
continue;
if (tier == QRTier.None || entry.Tier == tier)
textures.Add(entry.Texture);
}
if (textures.Count == 0 && tier != QRTier.None)
{
foreach (GeneratedQRCodeEntry entry in _generatedQRCodes)
{
if (entry.Texture != null && entry.Tier == QRTier.None)
textures.Add(entry.Texture);
}
}
Texture2D[] resolved = textures.ToArray();
_qrCodesCacheByTier[tier] = resolved;
return resolved;
}
public List<QRCodeInfo> GetQRCodeConfigs()
{
return qrCodes != null ? new List<QRCodeInfo>(qrCodes) : new List<QRCodeInfo>();
}
public void SetQRCodeConfigs(List<QRCodeInfo> configs)
{
qrCodes = MergeQRCodeConfigs(configs, _defaultQRCodeConfigs);
Database?.SetQRCodes(qrCodes);
GenerateQRCodes();
}
/// <summary>Возвращает конфигурацию глобальной витрины.</summary>
public PrizeStandSettings GetPrizeStandSettings()
{
return Database?.GetPrizeStandSettings() ?? new PrizeStandSettings();
}
/// <summary>Сохраняет конфигурацию глобальной витрины.</summary>
public void SetPrizeStandSettings(PrizeStandSettings settings)
{
Database?.SetPrizeStandSettings(settings);
}
public PrizeStandConnectionInfo GetPrizeStandConnectionInfo()
{
PrizeStandController controller = ComSystemManager.GetPrizeStandController();
if (controller == null)
return new PrizeStandConnectionInfo(false, 0);
return new PrizeStandConnectionInfo(true, controller.GetBoxesCount());
}
/// <summary>
/// Возвращает первый непустой короткий текст QR-приза по указанному приоритету рангов.
/// По умолчанию ищет в порядке Gold → Silver → Bronze.
/// </summary>
public string GetPreferredQRCodePrizeText(params QRTier[] tierPriority)
{
QRTier[] tiers = tierPriority != null && tierPriority.Length > 0
? tierPriority
: new[] { QRTier.Gold, QRTier.Silver, QRTier.Bronze };
for (int tierIndex = 0; tierIndex < tiers.Length; tierIndex++)
{
QRTier tier = tiers[tierIndex];
for (int i = 0; i < qrCodes.Count; i++)
{
if (qrCodes[i].tier != tier)
continue;
string prizeText = qrCodes[i].prizeText;
if (!string.IsNullOrWhiteSpace(prizeText))
return prizeText.Trim();
}
}
return string.Empty;
}
public string GetQRCodeModalPrizeText(QRTier tier)
{
string directMatch = GetQRCodeModalPrizeTextForTier(tier);
if (!string.IsNullOrWhiteSpace(directMatch))
{
return directMatch;
}
if (tier != QRTier.None)
{
string fallback = GetQRCodeModalPrizeTextForTier(QRTier.None);
if (!string.IsNullOrWhiteSpace(fallback))
{
return fallback;
}
}
return string.Empty;
}
/// <summary>Идёт ли выдача какого-либо приза.</summary>
public bool IsAnyPrizeGiving
{
get
{
foreach (PrizeBase ex in _executors.Values)
if (ex.IsGiving) return true;
return false;
}
}
// ────────────── Регистрация исполнителей ──────────────
/// <summary>Регистрирует исполнитель приза. Заменяет предыдущий для того же типа.</summary>
public void RegisterExecutor(PrizeBase executor)
{
_executors[executor.Type] = executor;
executor.OnPrizeResult += success => OnAnyPrizeResult?.Invoke(executor.Type, success);
}
// ────────────── QR-коды (глобальная генерация) ──────────────
private void RefreshPhoneNumber()
{
var settings = SettingsManager.Instance;
_phoneNumber = settings?.GetString("prizes.phoneNumber") ?? string.Empty;
_phoneNumber = PhoneNumberUtils.ValidatePhoneNumber(_phoneNumber);
_phoneNumber = PhoneNumberUtils.ApplyPhoneMask(_phoneNumber, phoneNumberMask);
}
private void LoadQRCodeConfigs()
{
if (Database == null)
{
return;
}
if (Database.HasStoredQRCodes)
{
List<QRCodeInfo> storedConfigs = Database.GetQRCodes();
List<QRCodeInfo> mergedConfigs = MergeQRCodeConfigs(storedConfigs, _defaultQRCodeConfigs);
qrCodes = mergedConfigs;
if (AreQRCodeConfigsDifferent(storedConfigs, mergedConfigs))
{
Database.SetQRCodes(mergedConfigs);
}
return;
}
if (qrCodes != null && qrCodes.Count > 0)
{
Database.SetQRCodes(qrCodes);
}
}
private string GetQRCodeModalPrizeTextForTier(QRTier tier)
{
for (int i = 0; i < qrCodes.Count; i++)
{
if (qrCodes[i].tier != tier)
continue;
string modalPrizeText = qrCodes[i].prizeText;
if (!string.IsNullOrWhiteSpace(modalPrizeText))
{
return modalPrizeText.Trim();
}
}
return string.Empty;
}
private static List<QRCodeInfo> MergeQRCodeConfigs(List<QRCodeInfo> storedConfigs, List<QRCodeInfo> defaultConfigs)
{
List<QRCodeInfo> merged = new();
List<QRCodeInfo> stored = storedConfigs ?? new List<QRCodeInfo>();
List<QRCodeInfo> defaults = defaultConfigs ?? new List<QRCodeInfo>();
if (stored.Count == 0)
{
return new List<QRCodeInfo>(defaults);
}
Dictionary<QRTier, int> tierUsage = new();
for (int i = 0; i < stored.Count; i++)
{
QRCodeInfo info = stored[i];
int sameTierIndex = tierUsage.TryGetValue(info.tier, out int currentIndex) ? currentIndex : 0;
tierUsage[info.tier] = sameTierIndex + 1;
QRCodeInfo fallback = ResolveFallbackConfig(defaults, info.tier, sameTierIndex);
merged.Add(MergeQRCodeInfo(info, fallback));
}
return merged;
}
private static QRCodeInfo ResolveFallbackConfig(List<QRCodeInfo> defaults, QRTier tier, int sameTierIndex)
{
if (defaults == null || defaults.Count == 0)
{
return default;
}
int exactTierSeen = 0;
for (int i = 0; i < defaults.Count; i++)
{
if (defaults[i].tier != tier)
continue;
if (exactTierSeen == sameTierIndex)
return defaults[i];
exactTierSeen++;
}
int noneTierSeen = 0;
for (int i = 0; i < defaults.Count; i++)
{
if (defaults[i].tier != QRTier.None)
continue;
if (noneTierSeen == sameTierIndex)
return defaults[i];
noneTierSeen++;
}
return defaults[0];
}
private static QRCodeInfo MergeQRCodeInfo(QRCodeInfo stored, QRCodeInfo fallback)
{
QRCodeInfo merged = stored;
if (string.IsNullOrWhiteSpace(merged.linkPrefix))
{
merged.linkPrefix = fallback.linkPrefix;
}
if (string.IsNullOrWhiteSpace(merged.messageFormat) ||
(merged.messageFormat == "{0}" && !string.IsNullOrWhiteSpace(fallback.messageFormat) && fallback.messageFormat != "{0}"))
{
merged.messageFormat = fallback.messageFormat;
}
return merged;
}
private static bool AreQRCodeConfigsDifferent(List<QRCodeInfo> left, List<QRCodeInfo> right)
{
if (ReferenceEquals(left, right))
return false;
int leftCount = left != null ? left.Count : 0;
int rightCount = right != null ? right.Count : 0;
if (leftCount != rightCount)
return true;
for (int i = 0; i < leftCount; i++)
{
QRCodeInfo a = left[i];
QRCodeInfo b = right[i];
if (a.generateQRCode != b.generateQRCode ||
a.tier != b.tier ||
a.linkPrefix != b.linkPrefix ||
a.messageFormat != b.messageFormat ||
a.prizeText != b.prizeText ||
a.darkColor != b.darkColor ||
a.lightColor != b.lightColor)
{
return true;
}
}
return false;
}
private void GenerateQRCodes()
{
_generatedQRCodes.Clear();
_qrCodesCacheByTier.Clear();
foreach (QRCodeInfo data in qrCodes)
{
if (!data.generateQRCode) continue;
string fullLink = data.GetFullLink(_phoneNumber);
Texture2D tex = QRMaker.MakeTexture(
fullLink, 8, true,
QRCodeInfo.GetColor(data.darkColor),
QRCodeInfo.GetColor(data.lightColor));
_generatedQRCodes.Add(new GeneratedQRCodeEntry(data.tier, tex));
}
}
// ────────────── Настройки ──────────────
private void SubscribeToSettings()
{
var settings = SettingsManager.Instance;
if (settings != null) settings.OnSettingChanged += OnSettingChanged;
}
private void UnsubscribeFromSettings()
{
var settings = SettingsManager.Instance;
if (settings != null) settings.OnSettingChanged -= OnSettingChanged;
}
private void OnSettingChanged(string key, SettingValue value)
{
if (key == "prizes.phoneNumber")
{
RefreshPhoneNumber();
GenerateQRCodes();
Debug.Log("[PrizeSystemManager] QR-коды перегенерированы.");
}
}
private static string GetCurrentLevelName()
{
if (GameManager.Instance == null)
return string.Empty;
return $"{GameManager.Instance.World}-{GameManager.Instance.Stage}";
}
private readonly struct GeneratedQRCodeEntry
{
public readonly QRTier Tier;
public readonly Texture2D Texture;
public GeneratedQRCodeEntry(QRTier tier, Texture2D texture)
{
Tier = tier;
Texture = texture;
}
}
}
public readonly struct PrizeStandConnectionInfo
{
public readonly bool IsConnected;
public readonly int ConnectedBoxesCount;
public PrizeStandConnectionInfo(bool isConnected, int connectedBoxesCount)
{
IsConnected = isConnected;
ConnectedBoxesCount = connectedBoxesCount;
}
}
}