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

455 lines
15 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 string ForceOpenBoxPrefix = "2511";
private const string IdleLightingCommand = "8020";
private const string HighlightBoxLightingPrefix = "8021";
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 static bool IsReadyToOpenStatus(BoxStatus status)
{
return ReadyToOpenStatuses.Contains(status);
}
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 => IsReadyToOpenStatus(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 async Task OpenBoxForcedAsync(int boxNumber)
{
if (!await _lock.WaitAsync(0))
{
OpeningFinished?.Invoke(false, "Выдача уже в процессе");
return;
}
try
{
if (boxNumber <= 0)
{
OpeningFinished?.Invoke(false, "Номер коробки должен быть больше 0");
return;
}
if (_boxesCount > 0 && boxNumber > _boxesCount)
{
OpeningFinished?.Invoke(false, $"Номер коробки={boxNumber} невалидный. Требуется от 1 до {_boxesCount}.");
return;
}
ClearStatusQueue();
SendCommand($"{ForceOpenBoxPrefix}{boxNumber:D2}");
BoxStatus openStatus = await ReadBoxStatusAsync(boxNumber, 1, WaitOpenStatusTimeout);
if (openStatus == BoxStatus.OPENED)
{
OpeningFinished?.Invoke(true, $"Ячейка {boxNumber} успешно открыта");
}
else
{
_log?.Invoke($"[Модуль витрины] Ошибка при принудительном открытии ячейки {boxNumber}. Статус: {openStatus}");
OpeningFinished?.Invoke(false, $"Ошибка при принудительном открытии ячейки номер {boxNumber}");
}
}
catch (Exception ex)
{
_log?.Invoke($"[Модуль витрины] Ошибка при принудительном открытии ячейки {boxNumber}: {ex.Message}");
OpeningFinished?.Invoke(false, ex.Message);
}
finally
{
_lock.Release();
}
}
public bool SetIdleLighting()
{
return TrySendCommand(IdleLightingCommand);
}
public bool HighlightBoxLighting(int boxNumber)
{
if (boxNumber <= 0)
{
return false;
}
return TrySendCommand($"{HighlightBoxLightingPrefix}{boxNumber:D2}");
}
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 (!IsReadyToOpenStatus(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 bool TrySendCommand(string command)
{
try
{
SendCommand(command);
return true;
}
catch (Exception ex)
{
_log?.Invoke($"[Модуль витрины] Ошибка при отправке команды '{command}': {ex.Message}");
return false;
}
}
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
}
}