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

230 lines
10 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.Collections;
using UnityEngine;
using Random = UnityEngine.Random;
namespace RR.Common.Controls
{
/// <summary>
/// Управляет подсветкой периферийной платы по протоколу <c>85XXYY</c>:
/// <list type="bullet">
/// <item><c>XX</c> — код эффекта (<see cref="LightMode"/>)</item>
/// <item><c>YY</c> — время в секундах (00–99)</item>
/// </list>
/// Команда <c>85</c> (без параметров) отключает рендеринг и освобождает память.
/// <para>Поддерживает цепочки эффектов через <see cref="LightSequence"/>:</para>
/// <code>
/// SetLight(LightMode.VerticalWave, 3).Then(LightMode.DiagonalWave, 3).Loop();
/// </code>
/// </summary>
public class LightController : MonoBehaviour
{
// ───────────────────────── Инспектор ─────────────────────────
[Header("Команды шляпы")]
[Tooltip("COM-команда для включения шляпы")]
[SerializeField] private string hatOnCommand = "2131";
[Tooltip("COM-команда для выключения шляпы")]
[SerializeField] private string hatOffCommand = "2130";
[Header("Мигание")]
[Tooltip("Интервал между переключениями при мигании (секунды)")]
[SerializeField] [Range(0.05f, 5f)] private float blinkInterval = 0.5f;
[Tooltip("Случайный разброс интервала (±секунды). 0 = строго равномерно")]
[SerializeField] [Range(0f, 2f)] private float blinkIntervalVariance;
[Header("Световые эффекты")]
[Tooltip("Эффект по умолчанию при вызове SetLight без аргументов")]
[SerializeField] private LightMode defaultLightMode = LightMode.Rainbow;
[Tooltip("Количество циклов эффекта по умолчанию")]
[SerializeField] private float defaultCycles = 1f;
// ───────────────────────── Приватное состояние ─────────────────────────
private Coroutine _blinkingCoroutine;
private LightSequence _activeSequence;
private bool _blinkWasPaused;
private float _pausedBlinkInterval;
private const string EffectPrefix = "86";
// ───────────────────────── Свойства ─────────────────────────
/// <summary>Количество циклов по умолчанию (используется в <see cref="LightSequence.Then(LightMode)"/>).</summary>
public float DefaultCycles => defaultCycles;
// ===========================start: PUBLIC API===========================
/// <summary>
/// Включает эффект по умолчанию и возвращает <see cref="LightSequence"/> для построения цепочки.
/// </summary>
public LightSequence SetLight() => SetLight(defaultLightMode, defaultCycles);
/// <summary>
/// Включает эффект с количеством циклов по умолчанию и возвращает <see cref="LightSequence"/> для построения цепочки.
/// </summary>
public LightSequence SetLight(LightMode mode) => SetLight(mode, defaultCycles);
/// <summary>
/// Включает световой эффект на указанное количество циклов и возвращает <see cref="LightSequence"/> для построения цепочки.
/// <para>Если вызвать без <c>.Then()</c> / <c>.Loop()</c> — эффект запустится немедленно как одиночный.</para>
/// </summary>
/// <param name="mode">Эффект подсветки</param>
/// <param name="cycles">Количество циклов (дробное: 0.5 = половина цикла)</param>
public LightSequence SetLight(LightMode mode, float cycles)
{
StopSequence();
_activeSequence = new LightSequence(this, mode, cycles);
_activeSequence.Play();
return _activeSequence;
}
/// <summary>
/// Останавливает текущую активную последовательность эффектов и отключает рендеринг.
/// </summary>
public void StopSequence()
{
_activeSequence?.Stop();
_activeSequence = null;
}
/// <summary>
/// Останавливает все эффекты и отключает рендеринг платы (<c>85</c>).
/// </summary>
public void StopAllEffects()
{
StopSequence();
ComSystemManager.Send(EffectPrefix + "00");
}
/// <summary>
/// Запускает мигание шляпы с настроенным интервалом.
/// </summary>
public void Blink() => Blink(blinkInterval);
/// <summary>
/// Запускает мигание шляпы с явно указанным интервалом.
/// </summary>
public void Blink(float interval)
{
StopBlink(enableHat: false);
_blinkingCoroutine = StartCoroutine(BlinkingCoroutine(interval));
}
/// <summary>
/// Меняет интервал мигания на лету; перезапускает мигание, если оно активно.
/// </summary>
public void SetBlinkInterval(float interval)
{
blinkInterval = Mathf.Max(0.05f, interval);
if (_blinkingCoroutine != null) Blink(blinkInterval);
}
/// <summary>
/// Останавливает мигание шляпы.
/// </summary>
/// <param name="enableHat">Оставить шляпу включённой после остановки</param>
public void StopBlink(bool enableHat = true)
{
if (_blinkingCoroutine != null)
{
StopCoroutine(_blinkingCoroutine);
_blinkingCoroutine = null;
}
ComSystemManager.Send(enableHat ? hatOnCommand : hatOffCommand);
}
/// <summary>Возвращает <c>true</c>, если мигание шляпы сейчас активно.</summary>
public bool IsBlinking => _blinkingCoroutine != null;
/// <summary>Возвращает <c>true</c>, если последовательность эффектов сейчас активна.</summary>
public bool IsSequencePlaying => _activeSequence?.IsPlaying ?? false;
/// <summary>Возвращает <c>true</c>, если последовательность эффектов приостановлена.</summary>
public bool IsSequencePaused => _activeSequence?.IsPaused ?? false;
/// <summary>Возвращает <c>true</c>, если мигание было приостановлено.</summary>
public bool IsBlinkPaused => _blinkWasPaused;
/// <summary>
/// Приостанавливает все активные эффекты (подсветку и мигание).
/// Вызывается при отвале платы контроллера.
/// </summary>
public void PauseAll()
{
if (_blinkingCoroutine != null)
{
_blinkWasPaused = true;
_pausedBlinkInterval = blinkInterval;
StopCoroutine(_blinkingCoroutine);
_blinkingCoroutine = null;
}
else
{
_blinkWasPaused = false;
}
_activeSequence?.Pause();
}
/// <summary>
/// Возобновляет приостановленные эффекты (подсветку и мигание).
/// Вызывается при переподключении платы контроллера.
/// </summary>
public void ResumeAll()
{
if (_blinkWasPaused)
{
_blinkWasPaused = false;
_blinkingCoroutine = StartCoroutine(BlinkingCoroutine(_pausedBlinkInterval));
}
if (_activeSequence != null && _activeSequence.IsPaused)
{
_activeSequence.Resume();
}
}
// ===========================end: PUBLIC API===========================
/// <summary>
/// Отправляет COM-команду для одного шага последовательности.
/// Используется из <see cref="LightSequence"/>.
/// </summary>
public void SendCommand(LightMode mode, int durationSeconds)
{
ComSystemManager.Send(BuildCommand(mode, durationSeconds));
}
// ReSharper disable once IteratorNeverReturns — корутина намеренно бесконечная
private IEnumerator BlinkingCoroutine(float interval)
{
while (true)
{
ComSystemManager.Send(Random.Range(0f, 1f) < 0.5f ? hatOnCommand : hatOffCommand);
float wait = blinkIntervalVariance > 0f
? Mathf.Max(0.05f, interval + Random.Range(-blinkIntervalVariance, blinkIntervalVariance))
: interval;
yield return new WaitForSecondsRealtime(wait);
}
}
private static string BuildCommand(LightMode mode, int durationSeconds)
{
if (mode == LightMode.Off)
return EffectPrefix;
int xx = (int)mode;
if (durationSeconds == -1)
return $"{EffectPrefix}{xx:D2}";
int yy = Mathf.Clamp(durationSeconds, 0, 99);
return $"{EffectPrefix}{xx:D2}{yy:D2}";
}
}
}