Danil Iarosh cefb2fd345 Build initial vertical platformer foundation
Подробные изменения:
- Добавлены сцены Bootstrap, MainMenu и Game с текущей вертикальной 9:16 структурой проекта.
- Добавлены runtime-скрипты gameplay-архитектуры: DI через Extenject, спавнер платформ, прогрессия этапов, one-way платформы, игрок, камера и оборудование JumpPad.
- Добавлены ScriptableObject-этапы сложности, weighted-настройки платформ, оборудования, противников и collectibles.
- Добавлены UI Toolkit ресурсы главного меню, preview-материалы, шрифты, спрайты, анимации и игровые prefab-ассеты.
- Перенесена и оформлена новая COM-система для работы с аппаратными портами и StreamingAssets.
- Обновлены проектные гайды, AGENTS.md, правила Git/Git LFS, .gitignore, Unity Packages и ProjectSettings.
- Удалён локальный skill start-required-mcp-servers из репозитория.
2026-05-11 07:41:14 +03:00

1778 lines
60 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;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Events;
using Random = UnityEngine.Random;
namespace DanroJump.Hardware.Com
{
[DefaultExecutionOrder(-9000)]
[DisallowMultipleComponent]
[AddComponentMenu("Hardware/COM System")]
public sealed class ComSystem : MonoBehaviour, IComSystem
{
private const string DefaultInputBits = "11111111111111111111111111";
private const string RequestInputCommand = "91";
private const string RequestStandStatusCommand = "25";
private const string OpenBoxCommandPrefix = "2501";
private const string ForceOpenBoxCommandPrefix = "2511";
private const string PrizeStandIdleLightingCommand = "8020";
private const string PrizeStandHighlightBoxCommandPrefix = "8021";
private const string BoardEffectsCommandPrefix = "86";
private const string BoardEffectsOffCommand = "85";
private static readonly string[] DefaultPrizeStandRoles =
{
"prize_stand",
"prize_stand_controller",
"vitrina"
};
[Header("Lifecycle")]
[SerializeField] private bool startOnEnable = true;
[SerializeField] private bool dontDestroyOnLoad = true;
[SerializeField] private bool stopPortsOnDisable;
[SerializeField] private bool sendShutdownCommandsOnQuit = true;
[Header("Port scan")]
[SerializeField] private bool useAllDetectedPorts = true;
[SerializeField] private string[] explicitPorts = Array.Empty<string>();
[SerializeField] private string preferredMainPort = "";
[Header("Serial settings")]
[SerializeField] private int baudRate = 115200;
[SerializeField] private int dataBits = 8;
[SerializeField] private SerialParity parity = SerialParity.None;
[SerializeField] private SerialStopBits stopBits = SerialStopBits.One;
[SerializeField] private int readTimeoutMs = 50;
[SerializeField] private int writeTimeoutMs = 100;
[Header("Authorization")]
[SerializeField] private bool useEncryptedHandshake = true;
[SerializeField] private int handshakeTimeoutMs = 5000;
[SerializeField] private string roleRequestCommand = "97";
[SerializeField] private string roleResponsePrefix = "ROLE";
[SerializeField] private int roleRequestTimeoutMs = 1500;
[SerializeField] private string mainControllerRole = "main_controller";
[Header("Keep alive")]
[SerializeField] private bool keepAlive = true;
[SerializeField] private float keepAliveIntervalSeconds = 8f;
[SerializeField] private int missedInputResponsesBeforeReconnect = 3;
[Header("Credits")]
[SerializeField] private bool saveCreditsBetweenSessions;
[SerializeField] private string creditsPlayerPrefsKey = "Com.Credits";
[Header("Prize showcase")]
[SerializeField] private string[] prizeStandRoles = DefaultPrizeStandRoles;
[SerializeField] [Min(0)] private int prizeStandBoxesCount = 24;
[SerializeField] private float prizeStatusTimeoutSeconds = 3f;
[SerializeField] private float prizeOpenTimeoutSeconds = 5f;
[Header("Lighting")]
[SerializeField] private string hatOnCommand = "2131";
[SerializeField] private string hatOffCommand = "2130";
[SerializeField] [Range(0.05f, 5f)] private float hatBlinkIntervalSeconds = 0.5f;
[SerializeField] [Range(0f, 2f)] private float hatBlinkVarianceSeconds;
[SerializeField] private LightMode defaultLightMode = LightMode.Rainbow;
[SerializeField] private float defaultLightCycles = 1f;
[Header("Events")]
[SerializeField] private UnityEvent<int> creditAdded = new();
[SerializeField] private UnityEvent<int> creditRemoved = new();
[SerializeField] private UnityEvent<int> creditsChanged = new();
[SerializeField] private UnityEvent<string> mainInputReceived = new();
[SerializeField] private UnityEvent<string> deviceAuthorized = new();
[SerializeField] private UnityEvent<string> deviceDisconnected = new();
[SerializeField] private UnityEvent<int> prizeBoxOpened = new();
[SerializeField] private UnityEvent hopperSensorTriggered = new();
private readonly Dictionary<string, ComDeviceConnection> devices = new(StringComparer.OrdinalIgnoreCase);
private readonly object devicesLock = new();
private readonly ConcurrentQueue<Action> mainThreadQueue = new();
private readonly ConcurrentQueue<string> coinQueue = new();
private readonly ConcurrentQueue<ComInputMessage> inputQueue = new();
private readonly ConcurrentQueue<PrizeBoxStatusEntry> prizeStatusQueue = new();
private readonly SemaphoreSlim prizeOperationLock = new(1, 1);
private IComPortProvider portProvider;
private IComTransportFactory transportFactory;
private CancellationTokenSource systemCts;
private Coroutine hatBlinkCoroutine;
private Coroutine lightSequenceCoroutine;
private LightSequenceState pausedLightSequence;
private bool hatIsOn;
private bool hatBlinkWasPaused;
private string mainDeviceId = "";
private string prizeStandDeviceId = "";
private int credits;
public static ComSystem Instance { get; private set; }
public UnityEvent<int> CreditAdded => creditAdded;
public UnityEvent<int> CreditRemoved => creditRemoved;
public UnityEvent<int> CreditsChanged => creditsChanged;
public UnityEvent<string> MainInputReceived => mainInputReceived;
public UnityEvent<string> DeviceAuthorized => deviceAuthorized;
public UnityEvent<string> DeviceDisconnected => deviceDisconnected;
public UnityEvent<int> PrizeBoxOpened => prizeBoxOpened;
public UnityEvent HopperSensorTriggered => hopperSensorTriggered;
public bool IsRunning => systemCts != null && !systemCts.IsCancellationRequested;
public int Credits => credits;
public string MainDeviceId => mainDeviceId;
public string PrizeStandDeviceId => prizeStandDeviceId;
public string LastMainInputBits => GetLastInputBits(mainDeviceId);
public IReadOnlyCollection<string> ConnectedDeviceIds
{
get
{
lock (devicesLock)
{
return devices.Keys.ToArray();
}
}
}
private void Awake()
{
if (Instance != null && Instance != this)
{
UnityEngine.Debug.LogWarning("[ComSystem] Another instance already exists. Keeping the newest scene instance active.", this);
}
Instance = this;
portProvider ??= new ReflectionComPortProvider();
transportFactory ??= new ReflectionSerialComTransportFactory();
if (dontDestroyOnLoad)
{
DontDestroyOnLoad(gameObject);
}
credits = saveCreditsBetweenSessions ? PlayerPrefs.GetInt(creditsPlayerPrefsKey, 0) : 0;
if (!saveCreditsBetweenSessions)
{
PlayerPrefs.SetInt(creditsPlayerPrefsKey, 0);
}
}
public void ConfigureInfrastructure(IComPortProvider customPortProvider, IComTransportFactory customTransportFactory)
{
if (IsRunning)
{
UnityEngine.Debug.LogWarning("[ComSystem] Infrastructure cannot be changed while the COM system is running.", this);
return;
}
portProvider = customPortProvider ?? new ReflectionComPortProvider();
transportFactory = customTransportFactory ?? new ReflectionSerialComTransportFactory();
}
private void OnEnable()
{
if (startOnEnable)
{
StartSystem();
}
}
private void Update()
{
while (mainThreadQueue.TryDequeue(out Action action))
{
action?.Invoke();
}
while (coinQueue.TryDequeue(out _))
{
AddCredit(1);
}
while (inputQueue.TryDequeue(out ComInputMessage message))
{
if (message.DeviceId == mainDeviceId)
{
mainInputReceived.Invoke(message.InputBits);
}
}
}
private void OnDisable()
{
if (stopPortsOnDisable)
{
StopSystem(false);
}
}
private void OnApplicationQuit()
{
StopSystem(sendShutdownCommandsOnQuit);
}
private void OnDestroy()
{
if (Instance == this)
{
Instance = null;
}
StopSystem(false);
prizeOperationLock.Dispose();
}
public void StartSystem()
{
if (IsRunning)
{
return;
}
systemCts = new CancellationTokenSource();
mainDeviceId = "";
prizeStandDeviceId = "";
ClearQueues();
string[] ports = SelectPorts(portProvider.GetPortNames());
if (ports.Length == 0)
{
UnityEngine.Debug.LogWarning("[ComSystem] COM ports were not found.");
return;
}
foreach (string portName in ports)
{
TryStartDevice(portName);
}
}
public void StopSystem(bool sendShutdownCommands)
{
if (systemCts == null)
{
return;
}
try
{
systemCts.Cancel();
}
catch
{
// ignored
}
ComDeviceConnection[] deviceSnapshot;
lock (devicesLock)
{
deviceSnapshot = devices.Values.ToArray();
devices.Clear();
}
foreach (ComDeviceConnection device in deviceSnapshot)
{
Unsubscribe(device);
device.Dispose(sendShutdownCommands);
}
systemCts.Dispose();
systemCts = null;
mainDeviceId = "";
prizeStandDeviceId = "";
StopBoardLightSequence();
StopHatBlink(false);
ClearQueues();
}
public bool SendToMain(string command)
{
return SendToDevice(mainDeviceId, command);
}
public bool SendToPrizeStand(string command)
{
return SendToDevice(prizeStandDeviceId, command);
}
public bool SendToDevice(string deviceId, string command)
{
if (string.IsNullOrWhiteSpace(deviceId) || string.IsNullOrWhiteSpace(command))
{
return false;
}
lock (devicesLock)
{
return devices.TryGetValue(deviceId, out ComDeviceConnection device) && device.Send(command);
}
}
public void SendToAll(string command)
{
if (string.IsNullOrWhiteSpace(command))
{
return;
}
ComDeviceConnection[] deviceSnapshot;
lock (devicesLock)
{
deviceSnapshot = devices.Values.ToArray();
}
foreach (ComDeviceConnection device in deviceSnapshot)
{
device.Send(command);
}
}
public string GetLastInputBits(string deviceId)
{
lock (devicesLock)
{
return !string.IsNullOrWhiteSpace(deviceId) && devices.TryGetValue(deviceId, out ComDeviceConnection device)
? device.LastInputBits
: DefaultInputBits;
}
}
public string GetDeviceRole(string deviceId)
{
lock (devicesLock)
{
return !string.IsNullOrWhiteSpace(deviceId) && devices.TryGetValue(deviceId, out ComDeviceConnection device)
? device.Role
: "";
}
}
public string GetDeviceMac(string deviceId)
{
lock (devicesLock)
{
return !string.IsNullOrWhiteSpace(deviceId) && devices.TryGetValue(deviceId, out ComDeviceConnection device)
? device.MacAddress
: "";
}
}
public void AddCredit(int amount = 1)
{
if (amount <= 0)
{
return;
}
credits += amount;
SaveCredits();
creditAdded.Invoke(credits);
creditsChanged.Invoke(credits);
}
public bool TrySpendCredits(int amount = 1)
{
if (amount <= 0)
{
return true;
}
if (credits < amount)
{
return false;
}
credits -= amount;
SaveCredits();
creditRemoved.Invoke(credits);
creditsChanged.Invoke(credits);
return true;
}
public void SetCredits(int value)
{
int oldCredits = credits;
credits = Mathf.Max(0, value);
SaveCredits();
if (credits > oldCredits)
{
creditAdded.Invoke(credits);
}
else if (credits < oldCredits)
{
creditRemoved.Invoke(credits);
}
creditsChanged.Invoke(credits);
}
public async Task<IReadOnlyDictionary<int, PrizeBoxStatus>> GetPrizeBoxStatusesAsync(CancellationToken cancellationToken = default)
{
if (prizeStandBoxesCount <= 0 || string.IsNullOrWhiteSpace(prizeStandDeviceId))
{
return new Dictionary<int, PrizeBoxStatus>();
}
ClearPrizeStatusQueue();
if (!SendToPrizeStand(RequestStandStatusCommand))
{
return new Dictionary<int, PrizeBoxStatus>();
}
Dictionary<int, PrizeBoxStatus> statuses = new();
Stopwatch stopwatch = Stopwatch.StartNew();
while (stopwatch.Elapsed.TotalSeconds < prizeStatusTimeoutSeconds && statuses.Count < prizeStandBoxesCount)
{
cancellationToken.ThrowIfCancellationRequested();
while (prizeStatusQueue.TryDequeue(out PrizeBoxStatusEntry entry))
{
statuses[entry.BoxNumber] = entry.Status;
if (statuses.Count >= prizeStandBoxesCount)
{
break;
}
}
await Task.Delay(10, cancellationToken);
}
return statuses;
}
public async Task<int[]> GetReadyPrizeBoxesAsync(CancellationToken cancellationToken = default)
{
IReadOnlyDictionary<int, PrizeBoxStatus> statuses = await GetPrizeBoxStatusesAsync(cancellationToken);
return statuses
.Where(pair => IsReadyToOpen(pair.Value))
.Select(pair => pair.Key)
.OrderBy(boxNumber => boxNumber)
.ToArray();
}
public async Task<PrizeOpenResult> OpenPrizeBoxAsync(int boxNumber, CancellationToken cancellationToken = default)
{
return await OpenPrizeBoxInternalAsync(boxNumber, false, cancellationToken);
}
public async Task<PrizeOpenResult> ForceOpenPrizeBoxAsync(int boxNumber, CancellationToken cancellationToken = default)
{
return await OpenPrizeBoxInternalAsync(boxNumber, true, cancellationToken);
}
public bool SetPrizeStandIdleLighting()
{
return SendToPrizeStand(PrizeStandIdleLightingCommand);
}
public bool HighlightPrizeBox(int boxNumber)
{
return boxNumber > 0 && SendToPrizeStand($"{PrizeStandHighlightBoxCommandPrefix}{boxNumber:D2}");
}
public void PlayBoardLight(LightMode mode, float cycles = -1f)
{
StopBoardLightSequence();
float resolvedCycles = cycles > 0f ? cycles : defaultLightCycles;
lightSequenceCoroutine = StartCoroutine(BoardLightSequenceCoroutine(new[] { new LightStep(mode, resolvedCycles) }, false));
}
public void PlayBoardLightSequence(IReadOnlyList<LightStep> steps, bool loop)
{
if (steps == null || steps.Count == 0)
{
return;
}
StopBoardLightSequence();
lightSequenceCoroutine = StartCoroutine(BoardLightSequenceCoroutine(steps, loop));
}
public void PlayDefaultBoardLight()
{
PlayBoardLight(defaultLightMode, defaultLightCycles);
}
public void StopBoardLightSequence()
{
if (lightSequenceCoroutine != null)
{
StopCoroutine(lightSequenceCoroutine);
lightSequenceCoroutine = null;
}
pausedLightSequence = default;
}
public void StopAllBoardEffects()
{
StopBoardLightSequence();
SendToMain(BoardEffectsOffCommand);
}
public void BlinkHat()
{
BlinkHat(hatBlinkIntervalSeconds);
}
public void BlinkHat(float intervalSeconds)
{
StopHatBlink(false);
hatIsOn = true;
hatBlinkCoroutine = StartCoroutine(HatBlinkCoroutine(Mathf.Max(0.05f, intervalSeconds)));
}
public void StopHatBlink(bool leaveHatEnabled = true)
{
if (hatBlinkCoroutine != null)
{
StopCoroutine(hatBlinkCoroutine);
hatBlinkCoroutine = null;
}
hatIsOn = leaveHatEnabled;
SendToMain(leaveHatEnabled ? hatOnCommand : hatOffCommand);
}
public void PauseLighting()
{
if (hatBlinkCoroutine != null)
{
hatBlinkWasPaused = true;
StopCoroutine(hatBlinkCoroutine);
hatBlinkCoroutine = null;
}
else
{
hatBlinkWasPaused = false;
}
if (lightSequenceCoroutine != null)
{
StopCoroutine(lightSequenceCoroutine);
lightSequenceCoroutine = null;
pausedLightSequence = new LightSequenceState(true);
}
}
public void ResumeLighting()
{
if (hatBlinkWasPaused)
{
hatBlinkWasPaused = false;
BlinkHat(hatBlinkIntervalSeconds);
}
else if (hatIsOn)
{
SendToMain(hatOnCommand);
}
if (pausedLightSequence.HasPausedSequence)
{
PlayDefaultBoardLight();
}
}
private async Task<PrizeOpenResult> OpenPrizeBoxInternalAsync(
int boxNumber,
bool force,
CancellationToken cancellationToken)
{
if (!await prizeOperationLock.WaitAsync(0, cancellationToken))
{
return PrizeOpenResult.Fail(boxNumber, "Выдача уже выполняется.");
}
try
{
if (boxNumber <= 0)
{
return PrizeOpenResult.Fail(boxNumber, "Номер ячейки должен быть больше 0.");
}
if (prizeStandBoxesCount > 0 && boxNumber > prizeStandBoxesCount)
{
return PrizeOpenResult.Fail(boxNumber, $"Номер ячейки должен быть от 1 до {prizeStandBoxesCount}.");
}
if (string.IsNullOrWhiteSpace(prizeStandDeviceId))
{
return PrizeOpenResult.Fail(boxNumber, "Призовая витрина не подключена.");
}
if (!force)
{
PrizeBoxStatus status = await ReadPrizeBoxStatusAsync(boxNumber, prizeStatusTimeoutSeconds, cancellationToken);
if (!IsReadyToOpen(status))
{
return PrizeOpenResult.Fail(boxNumber, $"Ячейка {boxNumber} не готова к выдаче. Статус: {status}.");
}
}
ClearPrizeStatusQueue();
string commandPrefix = force ? ForceOpenBoxCommandPrefix : OpenBoxCommandPrefix;
if (!SendToPrizeStand($"{commandPrefix}{boxNumber:D2}"))
{
return PrizeOpenResult.Fail(boxNumber, "Не удалось отправить команду открытия.");
}
PrizeBoxStatus openedStatus = await WaitPrizeBoxStatusAsync(
boxNumber,
PrizeBoxStatus.Opened,
prizeOpenTimeoutSeconds,
cancellationToken);
if (openedStatus == PrizeBoxStatus.Opened)
{
mainThreadQueue.Enqueue(() => prizeBoxOpened.Invoke(boxNumber));
return PrizeOpenResult.Success(boxNumber);
}
return PrizeOpenResult.Fail(boxNumber, $"Плата не подтвердила открытие. Последний статус: {openedStatus}.");
}
finally
{
prizeOperationLock.Release();
}
}
private async Task<PrizeBoxStatus> ReadPrizeBoxStatusAsync(
int boxNumber,
float timeoutSeconds,
CancellationToken cancellationToken)
{
ClearPrizeStatusQueue();
if (!SendToPrizeStand(RequestStandStatusCommand))
{
return PrizeBoxStatus.NotFound;
}
return await WaitPrizeBoxStatusAsync(boxNumber, null, timeoutSeconds, cancellationToken);
}
private async Task<PrizeBoxStatus> WaitPrizeBoxStatusAsync(
int boxNumber,
PrizeBoxStatus? expectedStatus,
float timeoutSeconds,
CancellationToken cancellationToken)
{
PrizeBoxStatus lastStatus = PrizeBoxStatus.NotFound;
Stopwatch stopwatch = Stopwatch.StartNew();
while (stopwatch.Elapsed.TotalSeconds < timeoutSeconds)
{
cancellationToken.ThrowIfCancellationRequested();
while (prizeStatusQueue.TryDequeue(out PrizeBoxStatusEntry entry))
{
if (entry.BoxNumber != boxNumber)
{
continue;
}
lastStatus = entry.Status;
if (!expectedStatus.HasValue || entry.Status == expectedStatus.Value)
{
return entry.Status;
}
}
await Task.Delay(10, cancellationToken);
}
return lastStatus;
}
private void TryStartDevice(string portName)
{
var device = new ComDeviceConnection(
transportFactory,
portName,
baudRate,
dataBits,
parity,
stopBits,
readTimeoutMs,
writeTimeoutMs,
useEncryptedHandshake,
handshakeTimeoutMs,
keepAlive,
keepAliveIntervalSeconds,
missedInputResponsesBeforeReconnect,
roleRequestCommand,
roleResponsePrefix,
roleRequestTimeoutMs);
Subscribe(device);
if (!device.Start(systemCts.Token))
{
Unsubscribe(device);
device.Dispose(false);
return;
}
lock (devicesLock)
{
devices[device.DeviceId] = device;
}
}
private void Subscribe(ComDeviceConnection device)
{
device.Authorized += OnDeviceAuthorized;
device.Disconnected += OnDeviceDisconnected;
device.CoinAccepted += OnCoinAccepted;
device.InputReceived += OnInputReceived;
device.RawLineReceived += OnRawLineReceived;
device.HopperSensorTriggered += OnHopperSensorTriggered;
}
private void Unsubscribe(ComDeviceConnection device)
{
device.Authorized -= OnDeviceAuthorized;
device.Disconnected -= OnDeviceDisconnected;
device.CoinAccepted -= OnCoinAccepted;
device.InputReceived -= OnInputReceived;
device.RawLineReceived -= OnRawLineReceived;
device.HopperSensorTriggered -= OnHopperSensorTriggered;
}
private void OnDeviceAuthorized(ComDeviceConnection device)
{
if (string.IsNullOrWhiteSpace(mainDeviceId) ||
device.PortName == preferredMainPort ||
IsRole(device.Role, mainControllerRole))
{
mainDeviceId = device.DeviceId;
}
if (IsPrizeStandRole(device.Role))
{
prizeStandDeviceId = device.DeviceId;
}
mainThreadQueue.Enqueue(() => deviceAuthorized.Invoke(device.DeviceId));
}
private void OnDeviceDisconnected(ComDeviceConnection device)
{
Unsubscribe(device);
lock (devicesLock)
{
devices.Remove(device.DeviceId);
if (device.DeviceId == mainDeviceId)
{
mainDeviceId = devices.Keys.FirstOrDefault() ?? "";
}
}
if (device.DeviceId == prizeStandDeviceId)
{
prizeStandDeviceId = "";
}
device.Dispose(false);
mainThreadQueue.Enqueue(() => deviceDisconnected.Invoke(device.DeviceId));
}
private void OnCoinAccepted(string deviceId)
{
if (deviceId == prizeStandDeviceId)
{
return;
}
coinQueue.Enqueue(deviceId);
}
private void OnInputReceived(ComInputMessage message)
{
if (message.DeviceId == prizeStandDeviceId)
{
return;
}
inputQueue.Enqueue(message);
}
private void OnRawLineReceived(string deviceId, string line)
{
if (deviceId == prizeStandDeviceId && TryParsePrizeBoxStatus(line, out PrizeBoxStatusEntry status))
{
prizeStatusQueue.Enqueue(status);
}
}
private void OnHopperSensorTriggered(string deviceId)
{
if (deviceId == mainDeviceId)
{
mainThreadQueue.Enqueue(() => hopperSensorTriggered.Invoke());
}
}
private string[] SelectPorts(string[] availablePorts)
{
if (availablePorts == null || availablePorts.Length == 0)
{
return Array.Empty<string>();
}
if (useAllDetectedPorts)
{
return availablePorts.OrderBy(port => port, StringComparer.OrdinalIgnoreCase).ToArray();
}
HashSet<string> explicitSet = new(
explicitPorts.Where(port => !string.IsNullOrWhiteSpace(port)),
StringComparer.OrdinalIgnoreCase);
return availablePorts.Where(explicitSet.Contains).ToArray();
}
private bool IsPrizeStandRole(string role)
{
if (string.IsNullOrWhiteSpace(role))
{
return false;
}
string[] roles = prizeStandRoles == null || prizeStandRoles.Length == 0 ? DefaultPrizeStandRoles : prizeStandRoles;
return roles.Any(candidate => IsRole(role, candidate));
}
private static bool IsRole(string role, string expected)
{
return !string.IsNullOrWhiteSpace(role) &&
!string.IsNullOrWhiteSpace(expected) &&
string.Equals(role.Trim(), expected.Trim(), StringComparison.OrdinalIgnoreCase);
}
private static bool IsReadyToOpen(PrizeBoxStatus status)
{
return status == PrizeBoxStatus.Ok || status == PrizeBoxStatus.OkWithCard;
}
private static bool TryParsePrizeBoxStatus(string line, out PrizeBoxStatusEntry status)
{
status = default;
if (string.IsNullOrWhiteSpace(line) || !line.StartsWith("BOX", StringComparison.OrdinalIgnoreCase))
{
return false;
}
string[] parts = line.Substring(3).Split('_');
if (parts.Length != 2 || !int.TryParse(parts[0], out int boxNumber))
{
return false;
}
status = new PrizeBoxStatusEntry(boxNumber, MapPrizeStatus(parts[1]));
return true;
}
private static PrizeBoxStatus MapPrizeStatus(string status)
{
return status switch
{
"OK" => PrizeBoxStatus.Ok,
"OK+CARD" => PrizeBoxStatus.OkWithCard,
"EMPTY" => PrizeBoxStatus.Empty,
"OPEN" => PrizeBoxStatus.Open,
"OPENED" => PrizeBoxStatus.Opened,
"OPEN_ERROR" => PrizeBoxStatus.OpenError,
"ERROR" => PrizeBoxStatus.Error,
_ => PrizeBoxStatus.ParsingError
};
}
private void SaveCredits()
{
if (!saveCreditsBetweenSessions)
{
return;
}
PlayerPrefs.SetInt(creditsPlayerPrefsKey, credits);
}
private void ClearQueues()
{
while (mainThreadQueue.TryDequeue(out _)) { }
while (coinQueue.TryDequeue(out _)) { }
while (inputQueue.TryDequeue(out _)) { }
ClearPrizeStatusQueue();
}
private void ClearPrizeStatusQueue()
{
while (prizeStatusQueue.TryDequeue(out _)) { }
}
private IEnumerator HatBlinkCoroutine(float intervalSeconds)
{
while (true)
{
SendToMain(Random.Range(0f, 1f) < 0.5f ? hatOnCommand : hatOffCommand);
float wait = hatBlinkVarianceSeconds > 0f
? Mathf.Max(0.05f, intervalSeconds + Random.Range(-hatBlinkVarianceSeconds, hatBlinkVarianceSeconds))
: intervalSeconds;
yield return new WaitForSecondsRealtime(wait);
}
}
private IEnumerator BoardLightSequenceCoroutine(IReadOnlyList<LightStep> steps, bool loop)
{
do
{
foreach (LightStep step in steps)
{
SendToMain(BuildBoardLightCommand(step.Mode, step.Cycles));
float waitSeconds = LightTimings.GetCycleSeconds(step.Mode) * step.Cycles;
if (step.Mode == LightMode.Off || waitSeconds <= 0f)
{
continue;
}
float elapsed = 0f;
while (elapsed < waitSeconds)
{
float chunk = Mathf.Min(2f, waitSeconds - elapsed);
yield return new WaitForSecondsRealtime(chunk);
elapsed += chunk;
if (elapsed < waitSeconds)
{
int remainSeconds = Mathf.CeilToInt(waitSeconds - elapsed);
SendToMain(BuildBoardLightCommand(step.Mode, remainSeconds));
}
}
}
}
while (loop);
lightSequenceCoroutine = null;
}
private static string BuildBoardLightCommand(LightMode mode, float cycles)
{
if (mode == LightMode.Off)
{
return BoardEffectsOffCommand;
}
int seconds = LightTimings.CyclesToCommandSeconds(mode, cycles);
return BuildBoardLightCommand(mode, seconds);
}
private static string BuildBoardLightCommand(LightMode mode, int durationSeconds)
{
if (mode == LightMode.Off)
{
return BoardEffectsOffCommand;
}
return $"{BoardEffectsCommandPrefix}{(int)mode:D2}{Mathf.Clamp(durationSeconds, 0, 99):D2}";
}
private sealed class ComDeviceConnection
{
private const long CoinDebounceMs = 250;
private static readonly byte[] AesEncryptedKey =
{
0x69, 0x40, 0xC7, 0x0E, 0xF2, 0xC7, 0x45, 0x70,
0x72, 0xCD, 0x28, 0x58, 0x99, 0x8F, 0x73, 0xA8,
0x5F, 0x5F, 0xED, 0xB4, 0xC1, 0x62, 0xB4, 0x24,
0xAC, 0xD7, 0xA3, 0xD6, 0xD8, 0x0B, 0x50, 0x37
};
private readonly object ioLock = new();
private readonly IComTransportFactory transportFactory;
private readonly int baudRate;
private readonly int dataBits;
private readonly SerialParity parity;
private readonly SerialStopBits stopBits;
private readonly int readTimeoutMs;
private readonly int writeTimeoutMs;
private readonly bool useEncryptedHandshake;
private readonly int handshakeTimeoutMs;
private readonly bool keepAlive;
private readonly float keepAliveIntervalSeconds;
private readonly int missedInputResponsesBeforeReconnect;
private readonly string roleRequestCommand;
private readonly string roleResponsePrefix;
private readonly int roleRequestTimeoutMs;
private readonly byte[] aesKey;
private IComTransport serialPort;
private CancellationTokenSource localCts;
private CancellationToken parentToken;
private Task readTask;
private Task keepAliveTask;
private TaskCompletionSource<bool> handshakeCompletion;
private TaskCompletionSource<bool> chalCompletion;
private TaskCompletionSource<string> roleCompletion;
private TaskCompletionSource<string> macCompletion;
private HandshakeStage handshakeStage = HandshakeStage.None;
private string requestHex = "";
private string lastHandshakeResponse = "";
private long lastCoinTime;
private int inputReceivedInCycle;
private int missedInputResponses;
private bool isOperational;
private bool disposed;
public ComDeviceConnection(
IComTransportFactory transportFactory,
string portName,
int baudRate,
int dataBits,
SerialParity parity,
SerialStopBits stopBits,
int readTimeoutMs,
int writeTimeoutMs,
bool useEncryptedHandshake,
int handshakeTimeoutMs,
bool keepAlive,
float keepAliveIntervalSeconds,
int missedInputResponsesBeforeReconnect,
string roleRequestCommand,
string roleResponsePrefix,
int roleRequestTimeoutMs)
{
this.transportFactory = transportFactory ?? throw new ArgumentNullException(nameof(transportFactory));
PortName = portName;
DeviceId = portName;
this.baudRate = baudRate;
this.dataBits = dataBits;
this.parity = parity;
this.stopBits = stopBits;
this.readTimeoutMs = Mathf.Max(1, readTimeoutMs);
this.writeTimeoutMs = Mathf.Max(1, writeTimeoutMs);
this.useEncryptedHandshake = useEncryptedHandshake;
this.handshakeTimeoutMs = Mathf.Max(500, handshakeTimeoutMs);
this.keepAlive = keepAlive;
this.keepAliveIntervalSeconds = Mathf.Max(1f, keepAliveIntervalSeconds);
this.missedInputResponsesBeforeReconnect = Mathf.Max(1, missedInputResponsesBeforeReconnect);
this.roleRequestCommand = roleRequestCommand?.Trim() ?? "";
this.roleResponsePrefix = string.IsNullOrWhiteSpace(roleResponsePrefix) ? "ROLE" : roleResponsePrefix.Trim();
this.roleRequestTimeoutMs = Mathf.Max(200, roleRequestTimeoutMs);
aesKey = BuildAesKey();
}
public string DeviceId { get; }
public string PortName { get; }
public string Role { get; private set; } = "";
public string MacAddress { get; private set; } = "";
public string LastInputBits { get; private set; } = DefaultInputBits;
public event Action<ComDeviceConnection> Authorized;
public event Action<ComDeviceConnection> Disconnected;
public event Action<string> CoinAccepted;
public event Action<ComInputMessage> InputReceived;
public event Action<string, string> RawLineReceived;
public event Action<string> HopperSensorTriggered;
public bool Start(CancellationToken token)
{
parentToken = token;
localCts = CancellationTokenSource.CreateLinkedTokenSource(token);
if (!OpenPort())
{
return false;
}
readTask = Task.Run(() => ReadLoopAsync(localCts.Token), localCts.Token);
if (useEncryptedHandshake)
{
_ = Task.Run(() => RunHandshakeAsync(localCts.Token), localCts.Token);
}
else
{
MarkOperational();
}
return true;
}
public bool Send(string command)
{
return Send(command, false);
}
public void Dispose(bool sendShutdownCommands)
{
if (disposed)
{
return;
}
disposed = true;
try
{
localCts?.Cancel();
}
catch
{
// ignored
}
if (sendShutdownCommands)
{
Send("2170", true);
Send("2130", true);
}
ClosePort();
localCts?.Dispose();
localCts = null;
}
private bool OpenPort()
{
try
{
lock (ioLock)
{
serialPort = transportFactory.Create(new SerialSettings(
PortName,
baudRate,
dataBits,
parity,
stopBits,
readTimeoutMs,
writeTimeoutMs));
serialPort.Open();
serialPort.DiscardInBuffer();
serialPort.DiscardOutBuffer();
}
lastCoinTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
return true;
}
catch (Exception exception)
{
UnityEngine.Debug.LogWarning($"[ComSystem] Failed to open {PortName}: {exception.Message}");
return false;
}
}
private async Task ReadLoopAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
string line;
lock (ioLock)
{
line = serialPort != null && serialPort.IsOpen ? serialPort.ReadLine() : "";
}
ProcessLine(line);
}
catch (TimeoutException)
{
await Task.Delay(1, token);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception exception) when (exception is IOException || exception is InvalidOperationException)
{
UnityEngine.Debug.LogWarning($"[ComSystem] {PortName} disconnected: {exception.Message}");
NotifyDisconnected();
break;
}
catch (Exception exception)
{
UnityEngine.Debug.LogWarning($"[ComSystem] {PortName} read error: {exception.Message}");
}
}
}
private void ProcessLine(string line)
{
line = (line ?? "").Trim();
if (string.IsNullOrEmpty(line))
{
return;
}
if (handshakeStage == HandshakeStage.WaitingChal || handshakeStage == HandshakeStage.WaitingRaes)
{
ProcessHandshakeLine(line);
return;
}
if (!isOperational)
{
return;
}
if (TryCompleteRole(line) || TryCompleteMac(line))
{
return;
}
if (string.Equals(line, "COIN", StringComparison.OrdinalIgnoreCase))
{
HandleCoin();
return;
}
if (line.StartsWith("INPUT", StringComparison.Ordinal))
{
HandleInput(line);
return;
}
RawLineReceived?.Invoke(DeviceId, line);
}
private async Task RunHandshakeAsync(CancellationToken token)
{
handshakeStage = HandshakeStage.WaitingChal;
handshakeCompletion = NewCompletion<bool>();
chalCompletion = NewCompletion<bool>();
requestHex = GenerateRandomBlock16Hex();
Send("07" + requestHex, true);
Task chalTimeout = Task.Delay(Mathf.Min(1200, Mathf.Max(250, handshakeTimeoutMs / 4)), token);
Task chalFinished = await Task.WhenAny(chalCompletion.Task, chalTimeout);
if (chalFinished != chalCompletion.Task)
{
FailHandshake("No CHAL response.");
return;
}
Task timeout = Task.Delay(handshakeTimeoutMs, token);
Task finished = await Task.WhenAny(handshakeCompletion.Task, timeout);
if (finished == handshakeCompletion.Task && handshakeCompletion.Task.Result)
{
MarkOperational();
return;
}
FailHandshake($"Handshake timeout. Last response: {lastHandshakeResponse}");
}
private void ProcessHandshakeLine(string line)
{
lastHandshakeResponse = line;
if (handshakeStage == HandshakeStage.WaitingChal && line.StartsWith("CHAL", StringComparison.Ordinal))
{
HandleChal(line);
return;
}
if (handshakeStage == HandshakeStage.WaitingRaes && line.StartsWith("RAES", StringComparison.Ordinal))
{
HandleRaes(line);
}
}
private void HandleChal(string line)
{
if (line.Length < 36)
{
FailHandshake("Invalid CHAL length.");
return;
}
string hex = line.Substring(4, 32);
if (!IsHexString(hex, 32))
{
FailHandshake("Invalid CHAL hex.");
return;
}
chalCompletion?.TrySetResult(true);
Send("08" + EncryptHexBlock16(hex), true);
handshakeStage = HandshakeStage.WaitingRaes;
}
private void HandleRaes(string line)
{
if (line.Length < 36)
{
FailHandshake("Invalid RAES length.");
return;
}
string hex = line.Substring(4, 32);
if (!IsHexString(hex, 32))
{
FailHandshake("Invalid RAES hex.");
return;
}
string decrypted = DecryptHexBlock16(hex);
if (!string.Equals(decrypted, requestHex, StringComparison.OrdinalIgnoreCase))
{
FailHandshake("RAES does not match request.");
return;
}
handshakeStage = HandshakeStage.Completed;
handshakeCompletion?.TrySetResult(true);
}
private void FailHandshake(string message)
{
UnityEngine.Debug.LogWarning($"[ComSystem] {PortName} authorization failed: {message}");
handshakeStage = HandshakeStage.Failed;
handshakeCompletion?.TrySetResult(false);
NotifyDisconnected();
}
private void MarkOperational()
{
isOperational = true;
handshakeStage = HandshakeStage.Completed;
missedInputResponses = 0;
Send("2171", true);
Send("890003", true);
Send("8600", true);
if (keepAlive)
{
keepAliveTask = Task.Run(() => KeepAliveLoopAsync(localCts.Token), localCts.Token);
}
_ = Task.Run(() => ResolveIdentityAsync(localCts.Token), localCts.Token);
}
private async Task ResolveIdentityAsync(CancellationToken token)
{
try
{
if (!string.IsNullOrWhiteSpace(roleRequestCommand))
{
roleCompletion = NewCompletion<string>();
Send(roleRequestCommand);
Task timeout = Task.Delay(roleRequestTimeoutMs, token);
Task finished = await Task.WhenAny(roleCompletion.Task, timeout);
if (finished == roleCompletion.Task && roleCompletion.Task.IsCompletedSuccessfully)
{
Role = roleCompletion.Task.Result;
}
}
macCompletion = NewCompletion<string>();
Send("06");
Task macTimeout = Task.Delay(roleRequestTimeoutMs, token);
Task macFinished = await Task.WhenAny(macCompletion.Task, macTimeout);
if (macFinished == macCompletion.Task && macCompletion.Task.IsCompletedSuccessfully)
{
MacAddress = macCompletion.Task.Result;
}
}
catch (OperationCanceledException)
{
return;
}
finally
{
Authorized?.Invoke(this);
}
}
private async Task KeepAliveLoopAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
await Task.Delay(TimeSpan.FromSeconds(keepAliveIntervalSeconds), token);
inputReceivedInCycle = 0;
Send(RequestInputCommand);
await Task.Delay(1000, token);
if (inputReceivedInCycle == 0)
{
missedInputResponses++;
if (missedInputResponses >= missedInputResponsesBeforeReconnect)
{
await ReconnectAsync(token);
}
}
else
{
missedInputResponses = 0;
}
}
catch (OperationCanceledException)
{
break;
}
catch (Exception exception)
{
UnityEngine.Debug.LogWarning($"[ComSystem] {PortName} keep alive error: {exception.Message}");
}
}
}
private async Task ReconnectAsync(CancellationToken token)
{
isOperational = false;
Role = "";
MacAddress = "";
LastInputBits = DefaultInputBits;
ClosePort();
await Task.Delay(500, token);
if (!OpenPort())
{
NotifyDisconnected();
return;
}
if (useEncryptedHandshake)
{
await RunHandshakeAsync(token);
}
else
{
MarkOperational();
}
}
private void HandleCoin()
{
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
if (now - lastCoinTime < CoinDebounceMs)
{
return;
}
lastCoinTime = now;
Send("10", true);
CoinAccepted?.Invoke(DeviceId);
}
private void HandleInput(string line)
{
inputReceivedInCycle = 1;
if (line.Length <= 5)
{
return;
}
string rawData = line.Substring(5);
if (string.IsNullOrWhiteSpace(rawData))
{
return;
}
if (long.TryParse(rawData, out long rawValue))
{
string rawBinary = Convert.ToString(rawValue, 2);
if (rawBinary.Length >= 2 && rawBinary[1] == '0')
{
HopperSensorTriggered?.Invoke(DeviceId);
}
}
LastInputBits = DecimalToReversedBinary(rawData);
InputReceived?.Invoke(new ComInputMessage(DeviceId, LastInputBits));
}
private bool Send(string command, bool bypassOperational)
{
if (string.IsNullOrWhiteSpace(command))
{
return false;
}
if (!bypassOperational && !isOperational)
{
return false;
}
try
{
lock (ioLock)
{
if (serialPort == null || !serialPort.IsOpen)
{
return false;
}
serialPort.WriteLine(command);
return true;
}
}
catch (Exception exception)
{
UnityEngine.Debug.LogWarning($"[ComSystem] Failed to write '{command}' to {PortName}: {exception.Message}");
return false;
}
}
private bool TryCompleteRole(string line)
{
if (roleCompletion == null || roleCompletion.Task.IsCompleted)
{
return false;
}
string trimmed = line.Trim();
if (!trimmed.StartsWith(roleResponsePrefix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
string role = trimmed.Substring(roleResponsePrefix.Length).Trim();
if (role.StartsWith(":", StringComparison.Ordinal) || role.StartsWith("=", StringComparison.Ordinal))
{
role = role.Substring(1).Trim();
}
if (string.IsNullOrWhiteSpace(role))
{
return false;
}
Role = role;
roleCompletion.TrySetResult(role);
return true;
}
private bool TryCompleteMac(string line)
{
if (macCompletion == null || macCompletion.Task.IsCompleted)
{
return false;
}
string trimmed = line.Trim();
if (trimmed.Length != 12 || !IsHexString(trimmed))
{
return false;
}
MacAddress = trimmed.ToUpperInvariant();
macCompletion.TrySetResult(MacAddress);
return true;
}
private void NotifyDisconnected()
{
if (disposed)
{
return;
}
isOperational = false;
localCts?.Cancel();
ClosePort();
Disconnected?.Invoke(this);
}
private void ClosePort()
{
lock (ioLock)
{
if (serialPort == null)
{
return;
}
try
{
if (serialPort.IsOpen)
{
serialPort.Close();
}
}
catch
{
// ignored
}
serialPort.Dispose();
serialPort = null;
}
}
private string EncryptHexBlock16(string plainHex)
{
byte[] plain = HexToBytes(plainHex);
using Aes aes = Aes.Create();
aes.Key = aesKey;
aes.Mode = CipherMode.ECB;
aes.Padding = PaddingMode.None;
using ICryptoTransform encryptor = aes.CreateEncryptor();
return BytesToHex(encryptor.TransformFinalBlock(plain, 0, plain.Length));
}
private string DecryptHexBlock16(string cipherHex)
{
byte[] cipher = HexToBytes(cipherHex);
using Aes aes = Aes.Create();
aes.Key = aesKey;
aes.Mode = CipherMode.ECB;
aes.Padding = PaddingMode.None;
using ICryptoTransform decryptor = aes.CreateDecryptor();
return BytesToHex(decryptor.TransformFinalBlock(cipher, 0, cipher.Length));
}
private static TaskCompletionSource<T> NewCompletion<T>()
{
return new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
}
private static byte[] BuildAesKey()
{
byte[] key = new byte[32];
for (int i = 0; i < key.Length; i++)
{
key[i] = (byte)(AesEncryptedKey[i] ^ 0x37);
}
return key;
}
private static string DecimalToReversedBinary(string decimalString)
{
if (!long.TryParse(decimalString, out long value))
{
return DefaultInputBits;
}
string binary = Convert.ToString(value, 2);
char[] chars = binary.ToCharArray();
Array.Reverse(chars);
string reversed = new string(chars);
return reversed.Length >= DefaultInputBits.Length
? reversed
: reversed.PadRight(DefaultInputBits.Length, '0');
}
private static string GenerateRandomBlock16Hex()
{
byte[] buffer = new byte[16];
RandomNumberGenerator.Fill(buffer);
return BytesToHex(buffer);
}
private static byte[] HexToBytes(string hex)
{
if (hex == null)
{
throw new ArgumentNullException(nameof(hex));
}
hex = hex.Trim();
if (hex.Length % 2 != 0)
{
throw new InvalidOperationException("Hex string length must be even.");
}
byte[] result = new byte[hex.Length / 2];
for (int i = 0; i < result.Length; i++)
{
result[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16);
}
return result;
}
private static string BytesToHex(byte[] bytes)
{
StringBuilder builder = new(bytes.Length * 2);
for (int i = 0; i < bytes.Length; i++)
{
builder.AppendFormat("{0:X2}", bytes[i]);
}
return builder.ToString();
}
private static bool IsHexString(string value, int expectedLength)
{
return !string.IsNullOrWhiteSpace(value) && value.Length == expectedLength && IsHexString(value);
}
private static bool IsHexString(string value)
{
for (int i = 0; i < value.Length; i++)
{
if (!Uri.IsHexDigit(value[i]))
{
return false;
}
}
return true;
}
private enum HandshakeStage
{
None,
WaitingChal,
WaitingRaes,
Completed,
Failed
}
}
private readonly struct PrizeBoxStatusEntry
{
public readonly int BoxNumber;
public readonly PrizeBoxStatus Status;
public PrizeBoxStatusEntry(int boxNumber, PrizeBoxStatus status)
{
BoxNumber = boxNumber;
Status = status;
}
}
private readonly struct LightSequenceState
{
public readonly bool HasPausedSequence;
public LightSequenceState(bool hasPausedSequence)
{
HasPausedSequence = hasPausedSequence;
}
}
}
}