Danil Iarosh 79b66b2d17
Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Has been cancelled
feat: add controller disconnect/reconnect handling, MAC identification, lighting pause/resume system
- Рефакторинг системы подсветки: вместо длительности в секундах теперь
  используется количество циклов (float). Добавлена таблица длительности
  циклов эффектов `LightCycleDurations` для пересчёта циклов в секунды.
  Префикс команд подсветки изменён с `85` на `86`.
- `LightSequence`: добавлены `Pause()` / `Resume()` с запоминанием текущего
  шага последовательности; возобновление продолжает с сохранённой позиции.
- `LightController`: добавлены `PauseAll()` / `ResumeAll()` для
  приостановки и восстановления подсветки и мигания шляпы при
  отключении/переподключении платы. Свойства `IsSequencePaused`,
  `IsBlinkPaused`.
- `ComDeviceClient`: после успешной авторизации отправляются команды
  `890003` и `8600`. Добавлен запрос MAC-адреса через команду `06`
  (ответ — 12 hex-символов без префикса). Новое событие `ConnectionLost`
  при начале KeepAlive-реконнекта.
- `ComHub`: очередь `_disconnectedQueue` с типом `DeviceDisconnectMessage`
  (DeviceId + Role + Mac) — сохраняет идентификатор устройства до его
  удаления из словаря. Подписка на `ConnectionLost`.
  Добавлен `GetDeviceMac()`.
- `ComSystemManager`: при инициализации, если плата не обнаружена,
  подсветка сразу ставится на паузу. При отвале дефолтного устройства —
  `PauseAll()`, при переподключении — сравнение по MAC и роли через
  `IsSameDevice()`: та же плата → `ResumeAll()`, другая → `Blink()` с нуля.
  Добавлены событие `OnDeviceDisconnected` и метод `GetDeviceMac()`.
- `COMInput`: при событии `OnDeviceDisconnected` сбрасываются все состояния
  кнопок через `ResetAllButtons()`, генерируя `buttonUp` для зажатых кнопок.
  Устраняет баг залипания управления после переподключения платы.
- `GameManager`: расширена последовательность световых эффектов в меню
  (добавлены `DiagonalWave3`, `WaveUp`, `OrangeWave`); вызовы `SetLight`
  для game over/won переведены на float-циклы.
- Обновлены `ARCHITECTURE.md` и `README.md`: переписана документация
  на актуальную структуру проекта и текущие возможности игры.
2026-03-04 13:55:08 +03:00

393 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 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 }
};
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;
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)
{
return _instance?._manager?.GetPrizeStandController(deviceId);
}
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}"));
controller.SetBoxesCount(binding.prizeStandBoxesCount);
return controller;
}
return null;
}
private RoleHandlerBinding FindRoleBinding(string role)
{
for (int i = 0; i < roleHandlerBindings.Length; i++)
{
RoleHandlerBinding binding = roleHandlerBindings[i];
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 == _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;
if (isSameDevice && (_light.IsSequencePaused || _light.IsBlinkPaused))
{
// Та же плата переподключилась — восстанавливаем эффекты
_light.ResumeAll();
}
else
{
// Другая плата или первое подключение — запускаем мигание с нуля
_light.StopSequence();
_light.Blink();
}
}
}
if (coinsProcessed > 0)
{
GameManager.Logger?.Log("ComSystemManager", $"Обработано монет: {coinsProcessed}");
}
}
private void OnDisable()
{
_manager?.ShutdownAll(false);
_manager = null;
_initializeInvoked = false;
}
private void OnApplicationQuit()
{
_manager?.ShutdownAll(true);
_manager = null;
_initializeInvoked = false;
}
/// <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;
}
}
}