using System; using System.Collections; using System.Text; using System.Threading; using System.Threading.Tasks; using RR.Game2D.Core.Managers; using UnityEngine; using UnityEngine.Events; using Random = UnityEngine.Random; namespace RR.Common.Controls { /// /// Основной класс-обертка для работы с COM-портом и периферийной платой. /// Управляет всей коммуникацией с аппаратным обеспечением: прием монет, чтение кнопок, управление подсветкой, отправка сигналов. /// public class ComWrapper : MonoBehaviour { //Настраиваем из инспектора [SerializeField] private bool saveMoneyBetweenSessions; [SerializeField] private bool enablePrizeChecking = false; // Если хопер с проверкой наличия приза ставим true [SerializeField] private bool keepAlive = true; public static UnityEvent OnCreditAdded; public static UnityEvent OnCreditRemoved; public static UnityEvent OnInputReceived; public static Action OnInitialize; #region Приватные поля private const string REQUEST_INPUT = "91"; private const string LIGHTSIGNAL_PREFIX = "80"; 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 coinQueue = new System.Collections.Concurrent.ConcurrentQueue(); // Защита от спама 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 Coroutine blinkingCoroutine; #endregion //===========================start: PUBLIC API===========================// /// /// Инициализирует систему работы с COM-портом. /// Открывает соединение, настраивает события и запускает асинхронные запросы состояния. /// /// /// Обязателен к вызову при старте приложения. Выполняет: /// - Создание UnityEvent'ов для уведомлений /// - Загрузку количества кредитов из PlayerPrefs /// - Открытие COM-порта /// - Запуск асинхронного опроса пинов (если включена проверка призов) /// public void Initialize() { //Создаем ивенты OnCreditAdded = new UnityEvent(); OnCreditRemoved = new UnityEvent(); OnInputReceived = new UnityEvent(); //Начальная настройка if (!saveMoneyBetweenSessions) PlayerPrefs.SetInt("CoinsCount", 0); CreditsCount = PlayerPrefs.GetInt("CoinsCount", 0); initPort(); // Отправляем сигнал запуска игры Send("2171"); Blink(); // Запускаем асинхронный опрос порта pollCancellationTokenSource = new CancellationTokenSource(); StartAsyncPolling(); // Запускаем keep-alive механизм если включен if (keepAlive || enablePrizeChecking) { StartKeepAliveProcess(); } OnInitialize?.Invoke(); } /// /// Добавляет один кредит в систему и сохраняет обновленное количество. /// Вызывается автоматически событием при получении сигнала "COIN" из COM-порта. /// /// /// Увеличивает счетчик кредитов и вызывает событие onCoinAdded для уведомления подписчиков. /// Автоматически сохраняет состояние в PlayerPrefs. /// 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); } /// /// Отправляет произвольную строковую команду на периферийную плату через COM-порт. /// /// Команда для отправки /// /// Используется для отправки пользовательских команд на плату. /// Команда должна быть в формате, ожидаемом прошивкой платы. /// 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}"); } } /// /// Отправляет команду управления подсветкой на периферийную плату. /// /// Режим подсветки из перечисления LightMode /// /// Формирует команду вида "80XXX" где XXX - код режима подсветки. /// Для добавления новых режимов необходимо обновить вложенный метод getLightString + добавить режим в перечисление. /// public static void Send(LightMode lightMode) { try { string signal = LIGHTSIGNAL_PREFIX + getLightString(lightMode); Send(signal); //ВОТЭТОТ МЕТОД ПОД СВОИ ПОДСВЕТКИ НАДО ПОДГОНЯТЬ string getLightString(LightMode mode) => mode switch { LightMode.Idle1 => "500", LightMode.Idle2 => "507", LightMode.Orange => "501", LightMode.Blue => "502", LightMode.Win => "503", LightMode.Lose => "504", LightMode.Prize => "505", LightMode.Tama => "513", LightMode.Dog => "514", LightMode.Sam => "515", LightMode.Mistress => "516", _ => "500" }; } catch { // DebugWindow.Message("Ошибка отправки светового сигнала."); } } /// /// Выполняет списание платы за запуск игры. /// Проверяет наличие доступных кредитов или режим бесплатной игры. /// При успешном списании увеличивает счетчики запусков. /// /// /// true - если списание прошло успешно (бесплатный режим или есть кредиты), /// false - если кредитов недостаточно и платный режим /// /// /// В случае успеха: /// - Увеличивает общие счетчики запусков (key_starts и key_startsNul) /// - При платном режиме уменьшает количество кредитов на 1 /// - Вызывает событие onCoinRemoved при списании кредита /// 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; } public void Blink() { StopBlink(); blinkingCoroutine = StartCoroutine(BlinkingCoroutine()); } public void StopBlink(bool enableHat = true) { if (blinkingCoroutine != null) StopCoroutine(blinkingCoroutine); blinkingCoroutine = null; Send(enableHat ? "2131" : "2130"); // включаем или выключаем шляпу } /// /// Получаем текущий баланс кредитов /// 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); } } //===========================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); } } /// /// Преобразует строку с десятичным числом в двоичное представление. /// Младшие биты слева, старшие справа (перевёрнутый порядок для соответствия индексам пинов). /// /// Строка с десятичным числом /// Строка с двоичным представлением 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 единиц по умолчанию } } /// /// Получает последнее кэшированное состояние INPUT. /// Полезно когда нужно узнать состояние кнопок без ожидания нового события. /// /// Строка с двоичным представлением состояния кнопок 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 активирован"); } /// /// Очищает буфер порта от накопившихся старых данных /// 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 ""; } } } /// /// Асинхронное чтение с порта. Выполняется в фоновом потоке. /// private static async Task 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); /// /// Асинхронный keep-alive процесс для проверки соединения с платой /// 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}"); } } private static IEnumerator BlinkingCoroutine() { while (true) { float randomFloat = Random.Range(0f, 1f); Send(randomFloat < 0.5f ? "2130" : "2131"); yield return new WaitForSeconds(0.5f); } } #endregion } public enum LightMode { Idle1, Idle2, Orange, Blue, Win, Lose, Prize, Tama, Dog, Sam, Mistress } }