Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Has been cancelled
- доработан 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'ы и добавлены сопутствующие проектные артефакты, которые сейчас используются обновлённой конфигурацией проекта
455 lines
15 KiB
C#
455 lines
15 KiB
C#
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
|
||
}
|
||
}
|