Danro/Assets/Scripts/Controls/Inputs/ComSystem/PrizeStandController.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

371 lines
12 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.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
namespace RR.Common.Controls
{
// Зависит от ComHub и обрабатывает контроллеры витрины по ROLE.
public sealed class PrizeStandController : IComControllerHandler
{
public static readonly string[] SupportedRoles = { "prize_stand", "prize_stand_controller", "vitrina" };
/// <summary>
/// По завершении операции открытия.
/// bool - успех/неуспех.
/// string - сообщение в случае неуспеха
/// </summary>
public event Action<bool, string> OpeningFinished;
private const string RequestStandStatus = "25";
private const string OpenBoxPrefix = "2501";
private const float StatusRequestTimeout = 3f;
private const float WaitOpenStatusTimeout = 5f;
private static readonly IReadOnlyList<BoxStatus> ReadyToOpenStatuses =
new[] { BoxStatus.OK, BoxStatus.OKplusCard };
private readonly string _deviceId;
private readonly Func<string, string, bool> _sendToDevice;
private readonly Action<string> _log;
private readonly SemaphoreSlim _lock = new(1, 1);
private readonly ConcurrentQueue<BoxStatusEntry> _statusQueue = new();
private int _boxesCount;
public PrizeStandController(
string deviceId,
Func<string, string, bool> sendToDevice,
Action<string> log,
int boxesCount = 0)
{
_deviceId = deviceId;
_sendToDevice = sendToDevice;
_log = log;
_boxesCount = Math.Max(0, boxesCount);
}
public void SetBoxesCount(int boxesCount)
{
_boxesCount = Math.Max(0, boxesCount);
}
public int GetBoxesCount()
{
return _boxesCount;
}
public async Task<Dictionary<int, BoxStatus>> GetStatusesAsync(float timeoutSeconds = StatusRequestTimeout)
{
if (_boxesCount <= 0)
{
return new Dictionary<int, BoxStatus>();
}
ClearStatusQueue();
SendCommand(RequestStandStatus);
Dictionary<int, BoxStatus> statuses = new();
Stopwatch sw = Stopwatch.StartNew();
while (sw.Elapsed.TotalSeconds < timeoutSeconds && statuses.Count < _boxesCount)
{
while (_statusQueue.TryDequeue(out BoxStatusEntry entry))
{
statuses[entry.BoxNumber] = entry.Status;
if (statuses.Count >= _boxesCount)
{
break;
}
}
if (statuses.Count >= _boxesCount)
{
break;
}
await Task.Delay(10);
}
if (statuses.Count < _boxesCount)
{
_log?.Invoke("[Модуль витрины. Не дождался всех статусов ячеек]\n" +
$"Получено статусов: {statuses.Count}/{_boxesCount}");
}
return statuses;
}
public async Task<int[]> GetReadyBoxesAsync(float timeoutSeconds = StatusRequestTimeout)
{
Dictionary<int, BoxStatus> allStatuses = await GetStatusesAsync(timeoutSeconds);
return allStatuses
.Where(kvp => ReadyToOpenStatuses.Contains(kvp.Value))
.Select(kvp => kvp.Key)
.OrderBy(boxNumber => boxNumber)
.ToArray();
}
public int[] GetReadyBoxes(int timeoutMs = 1000)
{
try
{
Task<int[]> task = Task.Run(() => GetReadyBoxesAsync(timeoutMs / 1000f));
return task.Wait(timeoutMs) ? task.Result : Array.Empty<int>();
}
catch (Exception ex)
{
_log?.Invoke($"[Модуль витрины] Ошибка при получении готовых ячеек: {ex.Message}");
return Array.Empty<int>();
}
}
public async Task OpenBoxAsync(int boxNumber)
{
if (!await _lock.WaitAsync(0))
{
OpeningFinished?.Invoke(false, "Выдача уже в процессе");
return;
}
try
{
var validationResult = await CanExecuteOpenAsync(boxNumber);
if (!validationResult.OK)
{
OpeningFinished?.Invoke(false, validationResult.Message);
return;
}
ClearStatusQueue();
SendCommand($"{OpenBoxPrefix}{boxNumber:D2}");
BoxStatus openStatus = await ReadBoxStatusAsync(boxNumber, 1, WaitOpenStatusTimeout);
if (openStatus == BoxStatus.OPENED)
{
OpeningFinished?.Invoke(true, $"Ячейка {boxNumber} успешно открыта");
}
else
{
_log?.Invoke($"[Модуль витрины] Ошибка при открытии ячейки {boxNumber}");
OpeningFinished?.Invoke(false, $"Ошибка при открытии ячейки номер {boxNumber}");
}
}
catch (Exception ex)
{
_log?.Invoke($"[Модуль витрины] Ошибка при открытии ячейки {boxNumber}: {ex.Message}");
OpeningFinished?.Invoke(false, ex.Message);
}
finally
{
_lock.Release();
}
}
public void HandleCoinAccepted()
{
// Для витрины монеты не обрабатываются.
}
public void HandleInputReceived(string inputBits)
{
// Для витрины INPUT-пакеты не используются.
}
public void HandleRawLine(string line)
{
var parseResult = ParseBoxStatus(line);
if (parseResult.BoxNumber < 0)
{
return;
}
_statusQueue.Enqueue(new BoxStatusEntry(parseResult.BoxNumber, parseResult.Status));
}
public void Dispose()
{
_lock.Dispose();
ClearStatusQueue();
}
public static bool IsSupportedRole(string role)
{
if (string.IsNullOrWhiteSpace(role))
{
return false;
}
return SupportedRoles.Any(r => string.Equals(r, role, StringComparison.OrdinalIgnoreCase));
}
private async Task<(bool OK, string Message)> CanExecuteOpenAsync(int boxNumber)
{
bool isOk = true;
StringBuilder sb = new();
sb.AppendLine("[Модуль витрины. Проверка перед открытием]");
if (boxNumber <= 0)
{
isOk = false;
sb.AppendLine("- Номер коробки должен быть больше 0.");
}
if (_boxesCount > 0 && boxNumber > _boxesCount)
{
isOk = false;
sb.AppendLine($"- Номер коробки={boxNumber} невалидный. Требуется от 1 до {_boxesCount}.");
}
if (isOk)
{
ClearStatusQueue();
SendCommand(RequestStandStatus);
int expectedEntriesCount = _boxesCount > 0 ? _boxesCount : 1;
BoxStatus boxStatus = await ReadBoxStatusAsync(boxNumber, expectedEntriesCount, StatusRequestTimeout);
if (!ReadyToOpenStatuses.Contains(boxStatus))
{
isOk = false;
sb.AppendLine($"- Статус коробки {boxNumber}: {boxStatus} не позволяет провести открытие.");
}
}
else
{
sb.AppendLine($"- Невозможно проверить статус коробки {boxNumber} из-за ошибок выше.");
}
return (isOk, sb.ToString());
}
private async Task<BoxStatus> ReadBoxStatusAsync(int expectedBoxNumber, int expectedEntriesCount, float timeout)
{
BoxStatus status = BoxStatus.NotFound;
int boxesReceived = 0;
Stopwatch sw = Stopwatch.StartNew();
while (sw.Elapsed.TotalSeconds < timeout && boxesReceived < expectedEntriesCount)
{
while (_statusQueue.TryDequeue(out BoxStatusEntry entry))
{
boxesReceived++;
if (entry.BoxNumber == expectedBoxNumber)
{
status = entry.Status;
}
if (boxesReceived >= expectedEntriesCount)
{
break;
}
}
if (boxesReceived >= expectedEntriesCount)
{
break;
}
await Task.Delay(10);
}
if (status == BoxStatus.NotFound)
{
_log?.Invoke("[Модуль витрины. Не дождался ответа по статусу ячейки]\n" +
$"Ожидаемый номер: {expectedBoxNumber}\n" +
$"Получено статусов ячеек: {boxesReceived}/{expectedEntriesCount}");
}
return status;
}
private void SendCommand(string command)
{
if (!_sendToDevice(_deviceId, command))
{
throw new InvalidOperationException($"Не удалось отправить команду '{command}' в устройство {_deviceId}");
}
}
private void ClearStatusQueue()
{
while (_statusQueue.TryDequeue(out _))
{
}
}
private static (int BoxNumber, BoxStatus Status) ParseBoxStatus(string entryString)
{
if (string.IsNullOrWhiteSpace(entryString) || !entryString.StartsWith("BOX", StringComparison.OrdinalIgnoreCase))
{
return (-1, BoxStatus.ParsingError);
}
string noPrefixEntry = entryString.Substring(3);
string[] parts = noPrefixEntry.Split('_');
if (parts.Length != 2)
{
return (-1, BoxStatus.ParsingError);
}
if (!int.TryParse(parts[0], out int parsedNumber))
{
return (-1, BoxStatus.ParsingError);
}
return (parsedNumber, MapStatusFromString(parts[1]));
}
private static BoxStatus MapStatusFromString(string statusString)
{
return statusString switch
{
"OK" => BoxStatus.OK,
"OK+CARD" => BoxStatus.OKplusCard,
"EMPTY" => BoxStatus.Empty,
"OPEN" => BoxStatus.Open,
"OPENED" => BoxStatus.OPENED,
"OPEN_ERROR" => BoxStatus.OPEN_ERROR,
"ERROR" => BoxStatus.Error,
_ => BoxStatus.ParsingError
};
}
private readonly struct BoxStatusEntry
{
public readonly int BoxNumber;
public readonly BoxStatus Status;
public BoxStatusEntry(int boxNumber, BoxStatus status)
{
BoxNumber = boxNumber;
Status = status;
}
}
}
public enum BoxStatus
{
// Статусы ДО открытия
OK,
OKplusCard,
Empty,
Open,
// Статусы ПОСЛЕ открытия
OPENED,
OPEN_ERROR,
// Служебные статусы
ParsingError,
NotFound,
Error
}
}