Danil Iarosh 6b02c76ef3
Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Has been cancelled
refactor: LightController + LightSequence, протокол 85XXYY, namespace
LightController / LightMode:
- Новый LightMode.cs: enum эффектов под протокол 85XXYY (13 эффектов + Off)
  значения enum = коды команд XX, Off=-1 генерирует просто '85'
- Новый LightController.cs: управление подсветкой платы
  * SetLight(mode, duration) → строит команду 85XXYY, возвращает LightSequence
  * Blink() / StopBlink() / SetBlinkInterval() — мигание шляпы
  * StopSequence() / StopAllEffects() — остановка эффектов
  * Все параметры (hatOn/hatOff, blinkInterval, defaultMode/Duration) в инспекторе
- Вложенный класс LightSequence — builder цепочек эффектов:
  SetLight(A,3).Then(B,3).Then(C,3).Loop()
  SetLight(A,3).Then(LightMode.Off)
  SetLight(A,5) — одиночный эффект
GameManager:
- Добавлено свойство Light (→ Plate.Light)
- GameWin   → SetLight(FinalWave, 30)
- GameOver  → SetLight(BlueWave, 30)
- NewGame   → SetLight(Rainbow, 99)
- LoadScene MainMenu → SetLight(Off) + Blink()
namespace (добавлены в файлы без namespace):
- Editor/MouseWorldPositionOverlay.cs  → RR.Common.InspectorOverrides
- UI/StatsWindowUIController.cs        → RR.Common.UI
- Utilities/AdvancedLogging_Test.cs    → RR.Common.Utility
- Utilities/DebugSettings.cs           → RR.Common.Utility
2026-02-19 16:52:59 +03:00

616 lines
28 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.Text;
using System.Threading;
using System.Threading.Tasks;
using RR.Game2D.Core.Managers;
using UnityEngine;
using UnityEngine.Events;
namespace RR.Common.Controls
{
/// <summary>
/// Основной класс-обертка для работы с COM-портом и периферийной платой.
/// Управляет всей коммуникацией с аппаратным обеспечением: прием монет, чтение кнопок, управление подсветкой, отправка сигналов.
/// </summary>
public class ComWrapper : MonoBehaviour
{
//Настраиваем из инспектора
[SerializeField] private bool saveMoneyBetweenSessions;
[SerializeField] private bool enablePrizeChecking = false; // Если хопер с проверкой наличия приза ставим true
[SerializeField] private bool keepAlive = true;
public static UnityEvent<int> OnCreditAdded;
public static UnityEvent<int> OnCreditRemoved;
public static UnityEvent<string> OnInputReceived;
public static Action OnInitialize;
#region Приватные поля
private const string REQUEST_INPUT = "91";
private static int h; // идентификатор хэндла
private static readonly object bufferLock = new object(); // Блокировка для синхронизации доступа к COM-порту
private static int CreditsCount;
// Асинхронная работа
private CancellationTokenSource pollCancellationTokenSource;
private int inputToggle; // Флаг для Проверки получения INPUT от платы
// Очередь для обработки COIN в главном потоке
private static readonly System.Collections.Concurrent.ConcurrentQueue<int> coinQueue = new System.Collections.Concurrent.ConcurrentQueue<int>();
// Защита от спама COIN: отслеживание времени последней монеты
private static long lastCoinTime; // в миллисекундах
private const long COIN_DEBOUNCE_MS = 250; // Минимальный интервал между монетами (250ms)
// StringBuilder для эффективного формирования строки INPUT без лишних аллокаций
private static readonly StringBuilder inputBuilder = new StringBuilder(32);
// Кэш последнего состояния INPUT
// ВАЖНО: 0 = кнопка нажата, 1 = кнопка отпущена, поэтому по умолчанию все единицы
private static string lastInputBits = "11111111111111111111111111"; // 26 бит по умолчанию
// Контроллер подсветки (добавляется автоматически вместе с этим компонентом)
private LightController _light;
/// <summary>Доступ к контроллеру подсветки.</summary>
public LightController Light => _light;
#endregion
//===========================start: PUBLIC API===========================//
/// <summary>
/// Инициализирует систему работы с COM-портом.
/// Открывает соединение, настраивает события и запускает асинхронные запросы состояния.
/// </summary>
/// <remarks>
/// Обязателен к вызову при старте приложения. Выполняет:
/// - Создание UnityEvent'ов для уведомлений
/// - Загрузку количества кредитов из PlayerPrefs
/// - Открытие COM-порта
/// - Запуск асинхронного опроса пинов (если включена проверка призов)
/// </remarks>
public void Initialize()
{
//Создаем ивенты
OnCreditAdded = new UnityEvent<int>();
OnCreditRemoved = new UnityEvent<int>();
OnInputReceived = new UnityEvent<string>();
//Начальная настройка
if (!saveMoneyBetweenSessions) PlayerPrefs.SetInt("CoinsCount", 0);
CreditsCount = PlayerPrefs.GetInt("CoinsCount", 0);
// Получаем или создаём LightController
_light = GetComponent<LightController>() ?? gameObject.AddComponent<LightController>();
initPort();
// Отправляем сигнал запуска игры
Send("2171");
_light.Blink();
// Запускаем асинхронный опрос порта
pollCancellationTokenSource = new CancellationTokenSource();
StartAsyncPolling();
// Запускаем keep-alive механизм если включен
if (keepAlive || enablePrizeChecking)
{
StartKeepAliveProcess();
}
OnInitialize?.Invoke();
}
/// <summary>
/// Добавляет один кредит в систему и сохраняет обновленное количество.
/// Вызывается автоматически событием при получении сигнала "COIN" из COM-порта.
/// </summary>
/// <remarks>
/// Увеличивает счетчик кредитов и вызывает событие onCoinAdded для уведомления подписчиков.
/// Автоматически сохраняет состояние в PlayerPrefs.
/// </remarks>
public static void AddCredit()
{
CreditsCount++;
PlayerPrefs.SetInt("CoinsCount", CreditsCount);
OnCreditAdded?.Invoke(CreditsCount);
// PlayerPrefs.SetInt(PrizeSystem.key_starts, PlayerPrefs.GetInt(PrizeSystem.key_starts) + 1);
// PlayerPrefs.SetInt(PrizeSystem.key_startsNul, PlayerPrefs.GetInt(PrizeSystem.key_startsNul) + 1);
}
/// <summary>
/// Отправляет произвольную строковую команду на периферийную плату через COM-порт.
/// </summary>
/// <param name="message">Команда для отправки</param>
/// <remarks>
/// Используется для отправки пользовательских команд на плату.
/// Команда должна быть в формате, ожидаемом прошивкой платы.
/// </remarks>
public static void Send(string message)
{
try
{
if (h <= 0)
{
GameManager.Logger.Log("ComWrapper: Попытка отправки при закрытом порте");
return;
}
ComNativeWrapper.Serial_WriteStringUTF8(h, message, 1);
}
catch (Exception e)
{
GameManager.Logger.Log($"Ошибка отправки сигнала: {e.Message}");
}
}
/// <summary>
/// Выполняет списание платы за запуск игры.
/// Проверяет наличие доступных кредитов или режим бесплатной игры.
/// При успешном списании увеличивает счетчики запусков.
/// </summary>
/// <returns>
/// true - если списание прошло успешно (бесплатный режим или есть кредиты),
/// false - если кредитов недостаточно и платный режим
/// </returns>
/// <remarks>
/// В случае успеха:
/// - Увеличивает общие счетчики запусков (key_starts и key_startsNul)
/// - При платном режиме уменьшает количество кредитов на 1
/// - Вызывает событие onCoinRemoved при списании кредита
/// </remarks>
public static bool Pay(int amount = 1)
{
// блок проверки оплаты
// if (SettingsManager.instance.GetSettingValue("Бесплатная игра") == "Вкл")
// {
// //Счетчики пробега
// PlayerPrefs.SetInt(PrizeSystem.key_starts, PlayerPrefs.GetInt(PrizeSystem.key_starts) + 1);
// PlayerPrefs.SetInt(PrizeSystem.key_startsNul, PlayerPrefs.GetInt(PrizeSystem.key_startsNul) + 1);
//
// return true;
// }
if (CreditsCount >= amount)
{
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;
}
return false;
}
/// <summary>
/// Запускает мигание подсветки. Делегируется в <see cref="LightController"/>.
/// </summary>
public void Blink() => _light.Blink();
/// <summary>
/// Останавливает мигание подсветки. Делегируется в <see cref="LightController"/>.
/// </summary>
public void StopBlink(bool enableHat = true) => _light.StopBlink(enableHat);
/// <summary>
/// Получаем текущий баланс кредитов
/// </summary>
public static int GetCredits() => CreditsCount;
/// <summary>
/// Устанавливает количество кредитов напрямую (для синхронизации с балансом)
/// </summary>
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);
}
}
//===========================end: PUBLIC API===========================//
private void Update()
{
// Обрабатываем COIN из очереди в главном потоке
int coinsProcessed = 0;
while (coinQueue.TryDequeue(out _))
{
AddCredit();
coinsProcessed++;
}
if (coinsProcessed > 0)
{
GameManager.Logger.Log($"Обработано монет: {coinsProcessed}");
}
}
private void OnDisable()
{
StopAsyncPolling();
ClosePort();
}
private void OnApplicationQuit()
{
Send("2170"); // Выключаем кнопки
Send("2130"); // Выключаем шапку
StopAsyncPolling();
ClosePort();
}
private void StopAsyncPolling()
{
pollCancellationTokenSource?.Cancel();
pollCancellationTokenSource?.Dispose();
pollCancellationTokenSource = null;
}
private async void StartAsyncPolling()
{
try
{
GameManager.Logger.Log("Запуск асинхронного опроса порта");
while (pollCancellationTokenSource != null &&
!pollCancellationTokenSource.Token.IsCancellationRequested)
{
await Task.Delay(1, pollCancellationTokenSource.Token); // Опрос каждые 1ms для уменьшения задержки
string comInput = await ReadBufferLineAsync(pollCancellationTokenSource.Token);
if (!string.IsNullOrEmpty(comInput))
{
ProcessComInputImmediate(comInput);
}
}
}
catch (OperationCanceledException)
{
GameManager.Logger.Log("Асинхронный опрос порта остановлен");
}
catch (Exception e)
{
GameManager.Logger.Log($"Ошибка асинхронного опроса: {e.Message}");
}
}
private void ProcessComInputImmediate(string comInput)
{
// Debug.Log("COM: " + comInput);
// INPUT обработка
if (comInput.StartsWith("INPUT"))
{
inputToggle = 1; // Обновляем флаг для keep-alive
// Проверяем, что после "INPUT" есть данные
if (comInput.Length <= 5)
{
GameManager.Logger.Log("INPUT получен, но без данных");
return;
}
string rawData = comInput.Substring(5);
// Проверяем, что rawData не пустой
if (string.IsNullOrWhiteSpace(rawData))
{
GameManager.Logger.Log("INPUT получен с пустым значением");
return;
}
// Конвертируем десятичное число в двоичную строку
string binaryValue = DecimalToBinary(rawData);
// Обновляем кэш последнего состояния
lastInputBits = binaryValue;
// Debug.Log($"Decimal: {rawData} → Binary: {binaryValue}");
OnInputReceived?.Invoke(binaryValue);
}
}
/// <summary>
/// Преобразует строку с десятичным числом в двоичное представление.
/// Младшие биты слева, старшие справа (перевёрнутый порядок для соответствия индексам пинов).
/// </summary>
/// <param name="decimalString">Строка с десятичным числом</param>
/// <returns>Строка с двоичным представлением</returns>
private static string DecimalToBinary(string decimalString)
{
try
{
if (long.TryParse(decimalString, out long decimalValue))
{
// Стандартное преобразование: старшие биты слева
string binary = Convert.ToString(decimalValue, 2);
// Переворачиваем строку: младшие биты слева, старшие справа
char[] chars = binary.ToCharArray();
Array.Reverse(chars);
return new string(chars);
}
Debug.Log($"Не удалось распарсить значение INPUT: {decimalString}");
return "11111111111111111111111111"; // 26 единиц по умолчанию
}
catch (Exception e)
{
Debug.Log($"Ошибка преобразования в двоичную систему: {e.Message}");
return "11111111111111111111111111"; // 26 единиц по умолчанию
}
}
/// <summary>
/// Получает последнее кэшированное состояние INPUT.
/// Полезно когда нужно узнать состояние кнопок без ожидания нового события.
/// </summary>
/// <returns>Строка с двоичным представлением состояния кнопок</returns>
public static string GetLastInputState() => lastInputBits;
#region Приватные методы
//Ну тут по дебаг логам все должно быть понятно
//Если интересно, то h - это хэндл. Что-то на плюсовом, типа ID соединения или что-то. Если 0, то нету соед. если меньше то ошибка, больше 0 - норм.
private void initPort()
{
GameManager.Logger.Log("ComWrapper", "Старт программы");
ComNativeWrapper.Serial_CloseAll();
var ports = ComNativeWrapper.ListPorts();
GameManager.Logger.Log("ComWrapper", "Ports: " + string.Join(", ", ports));
if (ports.Length == 0) { GameManager.Logger.Log("Нет портов, завершаю"); return; }
string port = ports[^1];
GameManager.Logger.Log("ComWrapper", "Открываю порт: " + port);
//вот эти настройки лучше не ковырять, но если очень хочется можно поменять readTimeoutMs и writeTimeoutMs
//Если read поставить слишком маленьким оно проебует кредиты когда они идут большой пачкой например при оплате через вендисту.
//Там в порт спамит "COIN" в больших количествах и процентов 10 из этих строк не успевают прочитаться при таймауте 1мс, соотв. монеты проебуются.
//Слишком большой таймаут будет тормозить игру потому что оно все выполняется синхронно и каждый кадр забирает столько мс сколько в переменной указано.
h = ComNativeWrapper.Serial_Open(port, 115200, 8, 0, 1, 5, 10);
GameManager.Logger.Log("ComWrapper", $"handle: {h}");
if (h <= 0)
{
GameManager.Logger.Log("ComWrapper", "Open failed: " + ComNativeWrapper.GetLastError());
return;
}
else
{
GameManager.Logger.Log("ComWrapper", $"Открыл порт: {port}");
}
// Очищаем буфер порта от старых данных
GameManager.Logger.Log("Очистка буфера порта от старых данных...");
FlushPortBuffer();
// Инициализируем lastCoinTime текущим временем чтобы не обработать старые COIN
lastCoinTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
GameManager.Logger.Log($"Порт инициализирован, debounce активирован");
}
/// <summary>
/// Очищает буфер порта от накопившихся старых данных
/// </summary>
private static void FlushPortBuffer()
{
lock (bufferLock)
{
try
{
// Проверяем валидность handle
if (h <= 0)
{
return;
}
// Используем локальный буфер
byte[] localBuf = new byte[1024];
int flushedCount = 0;
// Читаем все накопившиеся данные из порта
int read;
while ((read = ComNativeWrapper.Serial_ReadLineUTF8_bytes(h, localBuf, localBuf.Length)) > 0)
{
flushedCount++;
if (flushedCount > 100) break; // Защита от бесконечного цикла
}
GameManager.Logger.Log($"Очищено: {flushedCount} сообщений");
}
catch (Exception e)
{
GameManager.Logger.Log($"FlushPortBuffer exception: {e.Message}");
}
}
}
private static void ClosePort()
{
Debug.Log("Закрываю все порты");
if (h > 0)
{
ComNativeWrapper.Serial_Close(h);
h = 0;
}
}
//Чтение с порта
private static string ReadBufferLine()
{
lock (bufferLock)
{
try
{
// Проверяем валидность handle перед вызовом нативной функции
if (h <= 0)
{
return "";
}
// Используем локальный буфер для полной изоляции
byte[] localBuf = new byte[1024];
int read = ComNativeWrapper.Serial_ReadLineUTF8_bytes(h, localBuf, localBuf.Length);
// Проверяем валидность значения read ПЕРЕД использованием
if (read <= 0)
{
if (read < 0)
{
GameManager.Logger.Log("ComWrapper", $"ReadLine err={read} :: {ComNativeWrapper.GetLastError()}");
}
return "";
}
// Защита от выхода за границы массива
if (read > localBuf.Length)
{
GameManager.Logger.Log($"ReadBufferLine: read ({read}) превышает размер буфера ({localBuf.Length}), обрезаем");
read = localBuf.Length;
}
string s = Encoding.UTF8.GetString(localBuf, 0, read);
// Обрабатываем COIN сразу, но добавляем в очередь для обработки в главном потоке
if (s == "COIN")
{
long currentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
long timeSinceLastCoin = currentTime - lastCoinTime;
// Проверяем debounce: если прошло меньше 250ms - это спам от той же монеты
if (timeSinceLastCoin < COIN_DEBOUNCE_MS)
{
GameManager.Logger.Log($"COIN проигнорирован (спам через {timeSinceLastCoin}ms < 250ms)");
// НЕ отправляем подтверждение для спама!
}
else
{
// Это РЕАЛЬНАЯ новая монета - обрабатываем
lastCoinTime = currentTime;
// Отправляем подтверждение
int confirmSent = ComNativeWrapper.Serial_WriteStringUTF8(h, "10", 1);
GameManager.Logger.Log($"COIN принят → Подтверждение '10' отправлено (bytes: {confirmSent})");
// Добавляем в очередь для обработки в главном потоке
coinQueue.Enqueue(1);
}
return "";
}
return s;
}
catch (Exception e)
{
GameManager.Logger.Log($"ReadBufferLine exception: {e.Message}");
return "";
}
}
}
/// <summary>
/// Асинхронное чтение с порта. Выполняется в фоновом потоке.
/// </summary>
private static async Task<string> ReadBufferLineAsync(CancellationToken cancellationToken = default)
{
try
{
return await Task.Run(() =>
{
if (cancellationToken.IsCancellationRequested)
return string.Empty;
return ReadBufferLine();
}, cancellationToken);
}
catch (OperationCanceledException)
{
return string.Empty;
}
catch (Exception e)
{
GameManager.Logger.Log($"Ошибка асинхронного чтения порта: {e.Message}");
return string.Empty;
}
}
//просим прислать состояние цифровых входов. Приходит что то вроде "INPUT111111101"
private static void requestInputs() => Send(REQUEST_INPUT);
/// <summary>
/// Асинхронный keep-alive процесс для проверки соединения с платой
/// </summary>
private async void StartKeepAliveProcess()
{
try
{
GameManager.Logger.Log("Запуск асинхронной проверки соединения");
while (pollCancellationTokenSource != null &&
!pollCancellationTokenSource.Token.IsCancellationRequested)
{
await Task.Delay(2000, pollCancellationTokenSource.Token); // Ждем 2 секунды
// Запрашиваем INPUT
requestInputs();
inputToggle = 0;
await Task.Delay(1000, pollCancellationTokenSource.Token); // Ждем ответа 1 секунду
// Проверяем, пришел ли ответ
if (inputToggle == 0)
{
GameManager.Logger.Log("Плата не отвечает! Попытка переподключения...");
await Task.Run(() =>
{
ClosePort();
initPort();
}, pollCancellationTokenSource.Token);
}
await Task.Delay(7000, pollCancellationTokenSource.Token); // Итого ~10 секунд между проверками
}
}
catch (OperationCanceledException)
{
GameManager.Logger.Log("Асинхронная проверка соединения остановлена");
}
catch (Exception e)
{
GameManager.Logger.Log($"Ошибка проверки соединения: {e.Message}");
}
}
#endregion
}
}