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

448 lines
17 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 RR.Game2D.Core.Managers;
using UnityEngine;
using UnityEngine.Events;
namespace RR.Common.Controls
{
/// <summary>
/// Публичный компонент COM-системы.
/// </summary>
[AddComponentMenu("RR/Controls/Com System Manager")]
public class ComSystemManager : MonoBehaviour
{
private enum ControllerHandlerType
{
Default = 0,
PrizeStand = 1
}
[Serializable]
private sealed class RoleHandlerBinding
{
public string role;
public ControllerHandlerType handlerType = ControllerHandlerType.Default;
[Min(0)] public int prizeStandBoxesCount;
}
[Header("Основные настройки")]
[SerializeField] private bool saveMoneyBetweenSessions;
[SerializeField] private bool enablePrizeChecking;
[SerializeField] private bool keepAlive = true;
[Header("Авторизация")]
[SerializeField] private bool enableEncryptedHandshake = true;
[SerializeField] private int handshakeTimeoutMs = 5000;
[Header("Роль контроллера (после handshake)")]
[SerializeField] private string roleRequestCommand = "97";
[SerializeField] private string roleResponsePrefix = "ROLE";
[SerializeField] private int roleRequestTimeoutMs = 1500;
[SerializeField] private string mainControllerRole = "main_controller";
[Header("Мульти-устройства")]
[SerializeField] private bool useAllDetectedPorts = true;
[SerializeField] private string[] explicitPorts = Array.Empty<string>();
[SerializeField] private string defaultDevicePort = "";
[Header("Фабрика обработчиков по ROLE")]
[SerializeField] private RoleHandlerBinding[] roleHandlerBindings =
{
new RoleHandlerBinding { role = "prize_stand", handlerType = ControllerHandlerType.PrizeStand, prizeStandBoxesCount = 24 },
new RoleHandlerBinding { role = "prize_stand_controller", handlerType = ControllerHandlerType.PrizeStand, prizeStandBoxesCount = 24 },
new RoleHandlerBinding { role = "vitrina", handlerType = ControllerHandlerType.PrizeStand, prizeStandBoxesCount = 24 }
};
public static UnityEvent<int> OnCreditAdded;
public static UnityEvent<int> OnCreditRemoved;
public static UnityEvent<string> OnInputReceived;
public static Action OnInitialize;
public static UnityEvent<string, string> OnDeviceInputReceived;
public static UnityEvent<string> OnDeviceAuthorized;
public static UnityEvent<string> OnDeviceDisconnected;
private static ComSystemManager _instance;
private static int _creditsCount;
private ComHub _manager;
private LightController _light;
private bool _initializeInvoked;
private string _lastDefaultDeviceId = string.Empty;
private string _lastDefaultRole = string.Empty;
private string _lastDefaultMac = string.Empty;
private PrizeStandController _currentPrizeStand;
private string _prizeStandDeviceId = string.Empty;
public LightController Light => _light;
public void Initialize()
{
_instance = this;
OnCreditAdded = new UnityEvent<int>();
OnCreditRemoved = new UnityEvent<int>();
OnInputReceived = new UnityEvent<string>();
OnDeviceInputReceived = new UnityEvent<string, string>();
OnDeviceAuthorized = new UnityEvent<string>();
OnDeviceDisconnected = new UnityEvent<string>();
if (!saveMoneyBetweenSessions) PlayerPrefs.SetInt("CoinsCount", 0);
_creditsCount = PlayerPrefs.GetInt("CoinsCount", 0);
_light = GetComponent<LightController>() ?? gameObject.AddComponent<LightController>();
_manager = new ComHub(
useAllDetectedPorts,
explicitPorts,
defaultDevicePort,
enableEncryptedHandshake,
handshakeTimeoutMs,
keepAlive || enablePrizeChecking,
roleRequestCommand,
roleResponsePrefix,
roleRequestTimeoutMs,
mainControllerRole,
CreateRoleHandler);
_manager.InitializeDevices();
// Если плата изначально не подключена — ставим подсветку на паузу
if (string.IsNullOrWhiteSpace(_manager.DefaultDeviceId))
{
_light.PauseAll();
GameManager.Logger?.LogWarning("ComSystemManager",
"Плата не обнаружена при инициализации — подсветка приостановлена");
}
}
public static void AddCredit()
{
_creditsCount++;
PlayerPrefs.SetInt("CoinsCount", _creditsCount);
OnCreditAdded?.Invoke(_creditsCount);
}
public static bool Pay(int amount = 1)
{
if (_creditsCount < amount)
{
return false;
}
_creditsCount -= amount;
PlayerPrefs.SetInt("CoinsCount", _creditsCount);
PlayerPrefs.SetInt(PrizeSystem.key_starts, PlayerPrefs.GetInt(PrizeSystem.key_starts) + 1);
PlayerPrefs.SetInt(PrizeSystem.key_startsNul, PlayerPrefs.GetInt(PrizeSystem.key_startsNul) + 1);
OnCreditRemoved?.Invoke(_creditsCount);
return true;
}
public static int GetCredits() => _creditsCount;
public static void SetCredits(int amount)
{
int oldCredits = _creditsCount;
_creditsCount = Mathf.Max(0, amount);
PlayerPrefs.SetInt("CoinsCount", _creditsCount);
if (_creditsCount > oldCredits)
{
OnCreditAdded?.Invoke(_creditsCount);
}
else if (_creditsCount < oldCredits)
{
OnCreditRemoved?.Invoke(_creditsCount);
}
}
public static void Send(string message)
{
if (_instance == null || string.IsNullOrWhiteSpace(message)) return;
_instance._manager?.SendToDefault(message);
}
public static void SendToDevice(string deviceId, string message)
{
if (_instance == null || string.IsNullOrWhiteSpace(message)) return;
_instance._manager?.SendToDevice(deviceId, message);
}
public static void SendToAll(string message)
{
if (_instance == null || string.IsNullOrWhiteSpace(message)) return;
_instance._manager?.SendToAll(message);
}
public static string GetLastInputState()
{
if (_instance?._manager == null) return "11111111111111111111111111";
string defaultId = _instance._manager.DefaultDeviceId;
return string.IsNullOrWhiteSpace(defaultId)
? "11111111111111111111111111"
: _instance._manager.GetLastInputState(defaultId);
}
public static string GetLastInputState(string deviceId)
{
return _instance?._manager?.GetLastInputState(deviceId) ?? "11111111111111111111111111";
}
public static string[] GetConnectedDeviceIds()
{
return _instance?._manager?.ConnectedDeviceIds ?? Array.Empty<string>();
}
public static string GetDefaultDeviceId()
{
return _instance?._manager?.DefaultDeviceId ?? string.Empty;
}
public static string GetDeviceRole(string deviceId)
{
return _instance?._manager?.GetDeviceRole(deviceId) ?? string.Empty;
}
public static string GetDeviceMac(string deviceId)
{
return _instance?._manager?.GetDeviceMac(deviceId) ?? string.Empty;
}
public static PrizeStandController GetPrizeStandController(string deviceId = null)
{
if (_instance == null)
return null;
// Если указан конкретный deviceId и он совпадает с витриной
if (!string.IsNullOrWhiteSpace(deviceId))
{
return deviceId == _instance._prizeStandDeviceId ? _instance._currentPrizeStand : null;
}
// Иначе возвращаем текущую витрину
return _instance._currentPrizeStand;
}
private IComControllerHandler CreateRoleHandler(string deviceId, string role)
{
if (string.IsNullOrWhiteSpace(role) || roleHandlerBindings == null || roleHandlerBindings.Length == 0)
{
return null;
}
RoleHandlerBinding binding = FindRoleBinding(role);
if (binding == null)
{
return null;
}
if (binding.handlerType == ControllerHandlerType.PrizeStand)
{
PrizeStandController controller = new PrizeStandController(
deviceId,
(id, message) => _manager != null && _manager.SendToDevice(id, message),
message => GameManager.Logger?.Log("PrizeStandController", $"[{deviceId}] {message}"),
binding.prizeStandBoxesCount);
// Сохраняем витрину в ComSystemManager
_currentPrizeStand = controller;
_prizeStandDeviceId = deviceId;
return controller;
}
return null;
}
private RoleHandlerBinding FindRoleBinding(string role)
{
foreach (RoleHandlerBinding binding in roleHandlerBindings)
{
if (binding == null || string.IsNullOrWhiteSpace(binding.role))
{
continue;
}
if (string.Equals(binding.role.Trim(), role.Trim(), StringComparison.OrdinalIgnoreCase))
{
return binding;
}
}
return null;
}
public void Blink() => _light.Blink();
public void StopBlink(bool enableHat = true) => _light.StopBlink(enableHat);
private void Update()
{
if (_manager == null) return;
int coinsProcessed = 0;
while (_manager.TryDequeueCoin(out _))
{
AddCredit();
coinsProcessed++;
}
while (_manager.TryDequeueInput(out DeviceInputMessage msg))
{
OnDeviceInputReceived?.Invoke(msg.DeviceId, msg.InputBits);
if (msg.DeviceId == _manager.DefaultDeviceId)
{
OnInputReceived?.Invoke(msg.InputBits);
}
}
while (_manager.TryDequeueDisconnected(out DeviceDisconnectMessage dcMsg))
{
// Очищаем витрину если она отключилась
if (dcMsg.DeviceId == _prizeStandDeviceId)
{
_currentPrizeStand = null;
_prizeStandDeviceId = string.Empty;
}
// Запоминаем идентификатор отключившегося устройства по умолчанию
if (dcMsg.DeviceId == _lastDefaultDeviceId || string.IsNullOrWhiteSpace(_manager.DefaultDeviceId))
{
_lastDefaultRole = dcMsg.Role;
_lastDefaultMac = dcMsg.Mac;
GameManager.Logger?.Log("ComSystemManager",
$"Устройство [{dcMsg.DeviceId}] отключилось (role={_lastDefaultRole}, mac={_lastDefaultMac})");
_light.PauseAll();
}
OnDeviceDisconnected?.Invoke(dcMsg.DeviceId);
}
while (_manager.TryDequeueAuthorized(out string deviceId))
{
OnDeviceAuthorized?.Invoke(deviceId);
if (!_initializeInvoked)
{
_initializeInvoked = true;
OnInitialize?.Invoke();
}
if (deviceId == _manager.DefaultDeviceId)
{
string newRole = _manager.GetDeviceRole(deviceId);
string newMac = _manager.GetDeviceMac(deviceId);
bool isSameDevice = IsSameDevice(_lastDefaultRole, _lastDefaultMac, newRole, newMac);
GameManager.Logger?.Log("ComSystemManager",
$"Устройство [{deviceId}] авторизовано (role={newRole}, mac={newMac}, " +
$"sameDevice={isSameDevice})");
_lastDefaultDeviceId = deviceId;
_lastDefaultRole = newRole;
_lastDefaultMac = newMac;
// Определяем текущую сцену
string sceneName = UnityEngine.SceneManagement.SceneManager.GetActiveScene().name;
bool isMainMenu = sceneName == "MainMenu";
if (isMainMenu)
{
// В главном меню — гарантированно запускаем мигание + эффекты
GameManager.Instance?.StartIdleEffects();
}
else if (_light.IsSequencePaused || _light.IsBlinkPaused)
{
// На уровне с приостановленными эффектами
if (isSameDevice)
{
_light.ResumeAll();
}
else
{
// Другая плата — перезапускаем эффекты уровня
GameManager.Instance?.StartIdleEffects();
}
}
else
{
// На уровне без приостановленных эффектов
if (_light.IsBlinking)
{
// Мигание уже идёт — корутина сама отправит команды
}
else if (_light.IsHatOn)
{
_light.ResendHatState();
}
if (!_light.IsSequencePlaying)
{
// Эффекты не играют — пробуем запустить idle
GameManager.Instance?.StartIdleEffects();
}
}
}
}
if (coinsProcessed > 0)
{
GameManager.Logger?.Log("ComSystemManager", $"Обработано монет: {coinsProcessed}");
}
}
private void OnDisable()
{
_manager?.ShutdownAll(false);
_manager = null;
_initializeInvoked = false;
_currentPrizeStand = null;
_prizeStandDeviceId = string.Empty;
}
private void OnApplicationQuit()
{
_manager?.ShutdownAll(true);
_manager = null;
_initializeInvoked = false;
_currentPrizeStand = null;
_prizeStandDeviceId = string.Empty;
}
/// <summary>
/// Сравнивает идентификатор устройства по роли и MAC-адресу.
/// Если MAC известен у обоих — сравниваем по MAC (самый надёжный).
/// Если MAC неизвестен — сравниваем по роли.
/// Если ничего нет — считаем что это то же устройство (reconnect на том же порту).
/// </summary>
private static bool IsSameDevice(string oldRole, string oldMac, string newRole, string newMac)
{
bool hasOldMac = !string.IsNullOrWhiteSpace(oldMac);
bool hasNewMac = !string.IsNullOrWhiteSpace(newMac);
// Если оба MAC-адреса известны — сравниваем по MAC
if (hasOldMac && hasNewMac)
{
return string.Equals(oldMac, newMac, StringComparison.OrdinalIgnoreCase);
}
bool hasOldRole = !string.IsNullOrWhiteSpace(oldRole);
bool hasNewRole = !string.IsNullOrWhiteSpace(newRole);
// Если обе роли известны — сравниваем по роли
if (hasOldRole && hasNewRole)
{
return string.Equals(oldRole, newRole, StringComparison.OrdinalIgnoreCase);
}
// Нет данных для сравнения — считаем что это то же устройство (reconnect)
return true;
}
}
}