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

547 lines
20 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 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);
System.Drawing.Color darkColor = data.tier == QRTier.None
? QRCodeInfo.GetColor(data.darkColor)
: QRCodeInfo.GetTierDarkColor(data.tier);
Texture2D tex = QRMaker.MakeTexture(
fullLink, 8, true,
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;
}
}
}