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
544 lines
20 KiB
C#
544 lines
20 KiB
C#
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;
|
||
}
|
||
}
|
||
}
|