using System; using System.Text; using System.Threading; using System.Threading.Tasks; using UnityEngine; using UnityEngine.Events; /// /// Основной класс-обертка для работы с COM-портом и периферийной платой. /// Управляет всей коммуникацией с аппаратным обеспечением: прием монет, чтение кнопок, управление подсветкой, отправка сигналов. /// public class ComWrapper : MonoBehaviour { //Настраиваем из инспектора [SerializeField] bool saveMoneyBetweenSessions; [SerializeField] bool enablePrizeChecking = false; // Если хопер с проверкой наличия приза ставим true [SerializeField] bool keepAlive = true; // Периодическая проверка соединения с платами public static UnityEvent onCoinAdded; public static UnityEvent onCoinRemoved; public static UnityEvent onInputRecieved; public static System.Action OnInitialize; // Событие, вызываемое непосредственно перед закрытием портов — слушатели могут послать команды выключения public static System.Action OnBeforeClose; // Очередь исходящих сообщений — все отправки пойдут через неё и будут обработаны в Update() или при завершении private struct OutgoingMessage { public int playerId; public string message; } private static readonly System.Collections.Concurrent.ConcurrentQueue outgoingQueue = new System.Collections.Concurrent.ConcurrentQueue(); #region Приватные поля private const string REQUEST_INPUT = "91"; private const string LIGHTSIGNAL_PREFIX = "80"; private static int handle1; //идентификатор хэндла платы 1 private static int handle2; //идентификатор хэндла платы 2 // Асинхронная работа private CancellationTokenSource pollCancellationTokenSource; private int inputToggle = 0; // Флаг для проверки получения INPUT от плат // Флаг для безопасного запроса переподключения, выставляется из фонового потока и обрабатывается в Update() на главном потоке private volatile bool needReinit = false; // Очередь для обработки COIN в главном потоке private static readonly System.Collections.Concurrent.ConcurrentQueue coinQueue = new System.Collections.Concurrent.ConcurrentQueue(); // Защита от спама COIN: отслеживание времени последней монеты для каждой платы private static long lastCoinTimePlate1 = 0; // в миллисекундах private static long lastCoinTimePlate2 = 0; // в миллисекундах private const long COIN_DEBOUNCE_MS = 250; // Минимальный интервал между монетами (250ms, больше чем 200ms спам от платы) private static byte[] buf = new byte[1024]; //Байт-буфер куда будут приходить данные с порта // StringBuilder для эффективного формирования строки INPUT без лишних аллокаций private static System.Text.StringBuilder inputBuilder = new System.Text.StringBuilder(32); // Кэш последних состояний INPUT для каждой платы (26 бит от каждой платы) // ВАЖНО: 0 = кнопка нажата, 1 = кнопка отпущена, поэтому по умолчанию все единицы // Каждая плата присылает 26 бит, из них используются только 10: // - первые 8 бит = кнопки (биты 0-7) // - последние 2 бита = хопер (биты 24-25) // Итоговая строка: 8 кнопок платы1 + 8 кнопок платы2 + 2 хопера платы1 + 2 хопера платы2 = 20 бит private static string lastInputBits1 = "11111111111111111111111111"; // последние 26 бит от платы 1 (все отпущены) private static string lastInputBits2 = "11111111111111111111111111"; // последние 26 бит от платы 2 (все отпущены) private static int CreditsPlayer1; private static int CreditsPlayer2; #endregion //===========================start: PUBLIC API===========================// /// /// Инициализирует систему работы с COM-портом. /// Открывает соединение, настраивает события и запускает асинхронные запросы состояния. /// public void Initialize() { //Создаем ивенты onCoinAdded = new UnityEvent(); onCoinRemoved = new UnityEvent(); onInputRecieved = new(); //Начальная настройка if (!saveMoneyBetweenSessions) { PlayerPrefs.SetInt("Player1_Coins", 0); PlayerPrefs.SetInt("Player2_Coins", 0); } CreditsPlayer1 = PlayerPrefs.GetInt("Player1_Coins", 0); CreditsPlayer2 = PlayerPrefs.GetInt("Player2_Coins", 0); initPort(); //открываем порты // Запускаем асинхронный опрос портов pollCancellationTokenSource = new CancellationTokenSource(); StartAsyncPolling(); // Запускаем keep-alive механизм если включен if (keepAlive || enablePrizeChecking) { StartKeepAliveProcess(); } OnInitialize?.Invoke(); } /// /// Добавляет один кредит в систему и сохраняет обновленное количество. /// Вызывается автоматически событием при получении сигнала "COIN" из COM-порта. /// /// /// Увеличивает счетчик кредитов и вызывает событие onCoinAdded для уведомления подписчиков. /// Автоматически сохраняет состояние в PlayerPrefs. /// public static void AddCredit(int coins = 1, int playerId = 1) { if (playerId == 1) { CreditsPlayer1 += coins; PlayerPrefs.SetInt("Player1_Coins", CreditsPlayer1); onCoinAdded?.Invoke(playerId, CreditsPlayer1); // передаём номер игрока! } else if (playerId == 2) { CreditsPlayer2 += coins; PlayerPrefs.SetInt("Player2_Coins", CreditsPlayer2); onCoinAdded?.Invoke(playerId, CreditsPlayer2); // передаём номер игрока! } PlayerPrefs.SetInt(PrizeSystem.key_starts, PlayerPrefs.GetInt(PrizeSystem.key_starts) + 1); PlayerPrefs.SetInt(PrizeSystem.key_startsNul, PlayerPrefs.GetInt(PrizeSystem.key_startsNul) + 1); } /// /// Отправляет произвольную строковую команду на периферийную плату через COM-порт. /// /// Команда для отправки /// /// Используется для отправки пользовательских команд на плату. /// Команда должна быть в формате, ожидаемом прошивкой платы. /// В случае ошибки отправки выводит предупреждение в DebugWindow. /// public static void Send(string message, int playerId) { // Публикуем в очередь — реальные записи будут выполнены в Update() (на главном потоке) if (playerId != 1 && playerId != 2) { Debug.LogWarning("ComWrapper.Send: неверный playerId"); return; } outgoingQueue.Enqueue(new OutgoingMessage { playerId = playerId, message = message }); } /// /// Отправляет строковую команду на все подключенные платы (broadcast). /// Простая перегрузка, минимальное и безопасное изменение. /// public static void Send(string message) { // Broadcast: ставим в очередь для обеих плат outgoingQueue.Enqueue(new OutgoingMessage { playerId = 1, message = message }); outgoingQueue.Enqueue(new OutgoingMessage { playerId = 2, message = message }); } /// /// Выполняет списание платы за запуск игры. /// Проверяет наличие доступных кредитов или режим бесплатной игры. /// При успешном списании увеличивает счетчики запусков. /// /// /// true - если списание прошло успешно (бесплатный режим или есть кредиты), /// false - если кредитов недостаточно и платный режим /// /// /// В случае успеха: /// - Увеличивает общие счетчики запусков (key_starts и key_startsNul) /// - При платном режиме уменьшает количество кредитов на 1 /// - Вызывает событие onCoinRemoved при списании кредита /// public static bool Pay(int coins = 1, int playerId = 1) { if (SettingsManager.instance.GetSettingValue("Бесплатная игра") == "Вкл") return true; ref int credits = ref (playerId == 1 ? ref CreditsPlayer1 : ref CreditsPlayer2); string key = playerId == 1 ? "Player1_Coins" : "Player2_Coins"; if (credits > 0) { credits -= coins; PlayerPrefs.SetInt(key, credits); onCoinRemoved?.Invoke(playerId, credits); return true; } return false; } /// /// Получаем баланс игрока по id /// /// public static int GetPlayerCredits(int playerId = 1) { return playerId == 1 ? CreditsPlayer1 : CreditsPlayer2; } //===========================end: PUBLIC API===========================// private void Update() { // Выполняем переподключение на главном потоке, если было запрошено из фонового if (needReinit) { needReinit = false; Debug.LogWarning("ComWrapper: выполняю переподключение портов на главном потоке..."); ClosePort(); initPort(); } // Обрабатываем COIN из очереди в главном потоке int coinsPlayer1 = 0; int coinsPlayer2 = 0; while (coinQueue.TryDequeue(out int playerId)) { AddCredit(1, playerId); if (playerId == 1) coinsPlayer1++; else coinsPlayer2++; } if (coinsPlayer1 > 0 || coinsPlayer2 > 0) { Debug.Log($"✓ Обработано монет: Игрок1={coinsPlayer1}, Игрок2={coinsPlayer2}, Итого={coinsPlayer1 + coinsPlayer2}"); } // Отправляем накопленные исходящие сообщения (выходящие команды на платы) ProcessOutgoingQueueOnce(); } // Отправляет все сообщения, которые есть в очереди в данный момент (синхронно, на главном потоке) private void ProcessOutgoingQueueOnce() { while (outgoingQueue.TryDequeue(out OutgoingMessage msg)) { try { if (msg.playerId == 1 && handle1 > 0) ComNativeWrapper.Serial_WriteStringUTF8(handle1, msg.message, 1); else if (msg.playerId == 2 && handle2 > 0) ComNativeWrapper.Serial_WriteStringUTF8(handle2, msg.message, 1); } catch (Exception ex) { Debug.LogWarning($"ComWrapper: ошибка отправки исходящего сообщения: {ex.Message}"); } } } private void OnDisable() { try { OnBeforeClose?.Invoke(); } catch { } // Даем шанс отправить накопленные команды (например, TurnAllOff). Ждем короткий таймаут. try { var sw = System.Diagnostics.Stopwatch.StartNew(); const int maxWaitMs = 200; // ждем до 200 ms while (!outgoingQueue.IsEmpty && sw.ElapsedMilliseconds < maxWaitMs) { // Обрабатываем очередь синхронно ProcessOutgoingQueueOnce(); System.Threading.Thread.Sleep(10); } } catch (Exception) { } StopAsyncPolling(); ClosePort(); } private void OnApplicationQuit() { try { OnBeforeClose?.Invoke(); } catch { } try { var sw = System.Diagnostics.Stopwatch.StartNew(); const int maxWaitMs = 200; while (!outgoingQueue.IsEmpty && sw.ElapsedMilliseconds < maxWaitMs) { ProcessOutgoingQueueOnce(); System.Threading.Thread.Sleep(10); } } catch (Exception) { } StopAsyncPolling(); ClosePort(); } private void StopAsyncPolling() { // Только отменяем токен; не вызываем Dispose здесь — StartAsyncPolling() выполнит очистку. try { pollCancellationTokenSource?.Cancel(); } catch (Exception ex) { Debug.LogWarning($"StopAsyncPolling: ошибка при Cancel: {ex.Message}"); } } private async void StartAsyncPolling() { Debug.Log("Запуск асинхронного опроса портов"); // Локальная ссылка на CTS var cts = pollCancellationTokenSource; try { while (true) { // Всегда берём свежую ссылку в начале итерации cts = pollCancellationTokenSource; if (cts == null) break; // Безопасно получаем токен: доступ к cts.Token может выбросить ObjectDisposedException CancellationToken token; try { token = cts.Token; } catch (ObjectDisposedException) { Debug.LogWarning("StartAsyncPolling: CTS disposed при попытке получить Token — выходим из цикла."); break; } if (token.IsCancellationRequested) break; try { // Используем локальный токен для задержек и чтения await Task.Delay(10, token); // Опрос каждые 10ms string comInput = await ReadBufferLineAsync(token); if (!string.IsNullOrEmpty(comInput)) { try { ProcessComInputImmediate(comInput); } catch (Exception ex) { Debug.LogError($"Ошибка обработки COM-входа: {ex}\nInput=[{comInput}]"); } } } catch (OperationCanceledException) { // токен отменён — корректный выход из цикла break; } catch (ObjectDisposedException odex) { Debug.LogWarning($"StartAsyncPolling: CancellationTokenSource был disposed во время ожидания: {odex.Message}"); break; } catch (Exception ex) { // Если источник был disposed — не считать это критической ошибкой if (ex is ObjectDisposedException) { Debug.LogWarning($"StartAsyncPolling: ObjectDisposedException (обработано): {ex.Message}"); break; } Debug.LogError($"Внутренняя ошибка опроса порта: {ex}"); } } } finally { // Убедимся, что мы корректно освободим CTS и обнулим поле только если оно всё ещё ссылается на тот объект try { var tmp = pollCancellationTokenSource; if (tmp != null) { try { tmp.Cancel(); } catch { } try { tmp.Dispose(); } catch { } } } finally { pollCancellationTokenSource = null; Debug.Log("StartAsyncPolling: завершён и pollCancellationTokenSource очищен"); } } } private void ProcessComInputImmediate(string comInput) { if (string.IsNullOrEmpty(comInput)) return; Debug.Log(comInput); // INPUT обработка if (comInput.StartsWith("INPUT")) { System.Threading.Interlocked.Exchange(ref inputToggle, 1); // потокобезопасно выставляем флаг для keep-alive if (comInput.Length > 5) onInputRecieved?.Invoke(comInput.Substring(5)); else onInputRecieved?.Invoke(string.Empty); } } private void ToggleInputReceived(int value) => inputToggle = value; private int GetInputToggle() => inputToggle; #region Приватные методы //Ну тут по дебаг логам все должно быть понятно //Если интересно, то h - это хэндл. Что то на плюсовом, типа ID соединения или чтото. Если 0 то нету соед. если меньше то ошибка, больше 0 - норм. private void initPort() { Debug.Log("Старт программы"); ComNativeWrapper.Serial_CloseAll(); var ports = ComNativeWrapper.ListPorts(); Debug.Log("Ports: " + string.Join(", ", ports)); if (ports.Length == 0) { Debug.Log("Нет портов, завершаю"); return; } // НОВАЯ ПРОВЕРКА: нужно минимум 2 порта if (ports.Length < 2) { Debug.Log($"ОШИБКА: Найден только {ports.Length} порт, а нужно минимум 2 для работы с двумя платами!"); return; } string port1 = ports[^1]; string port2 = ports[^2]; Debug.Log("Открываю первый с конца порт: " + port1); Debug.Log("Открываю второй с конца порт: " + port2); //вот эти настройки лучше не ковырять, но если очень хочется можно поменять readTimeoutMs и writeTimeoutMs //Если read поставить слишком маленьким оно проебует кредиты когда они идут большой пачкой например при оплате через вендисту. //Там в порт спамит "COIN" в больших количествах и процентов 10 из этих строк не успевают прочитаться при таймауте 1мс, соотв. монеты проебуются. //Слишком большой таймаут будет тормозить игру потому что оно все выполняется синхронно и каждый кадр забирает столько мс сколько в переменной указано. handle1 = ComNativeWrapper.Serial_Open(port1, 115200, 8, 0, 1, 5, 10); handle2 = ComNativeWrapper.Serial_Open(port2, 115200, 8, 0, 1, 5, 10); Debug.Log($"handle: {handle1}"); if (handle1 <= 0) { Debug.Log("Open failed: " + ComNativeWrapper.GetLastError()); return; } Debug.Log($"handle: {handle2}"); if (handle2 <= 0) { Debug.Log("Open failed: " + ComNativeWrapper.GetLastError()); return; } // ВАЖНО: Очищаем буферы портов от старых данных Debug.Log("Очистка буферов портов от старых данных..."); FlushPortBuffers(); // Инициализируем lastCoinTime текущим временем чтобы не обработать старые COIN long currentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); lastCoinTimePlate1 = currentTime; lastCoinTimePlate2 = currentTime; Debug.Log($"Порты инициализированы, debounce активирован с текущего времени: {currentTime}"); } /// /// Очищает буферы обоих портов от накопившихся старых данных /// private static void FlushPortBuffers() { int flushedCount1 = 0; int flushedCount2 = 0; // Читаем все накопившиеся данные из порта 1 while (ComNativeWrapper.Serial_ReadLineUTF8_bytes(handle1, buf, buf.Length) > 0) { flushedCount1++; if (flushedCount1 > 100) break; // Защита от бесконечного цикла } // Читаем все накопившиеся данные из порта 2 while (ComNativeWrapper.Serial_ReadLineUTF8_bytes(handle2, buf, buf.Length) > 0) { flushedCount2++; if (flushedCount2 > 100) break; // Защита от бесконечного цикла } Debug.Log($"Очищено: Плата1={flushedCount1} сообщений, Плата2={flushedCount2} сообщений"); } private static void ClosePort() { if (handle1 > 0) { ComNativeWrapper.Serial_Close(handle1); handle1 = 0; } if (handle2 > 0) { ComNativeWrapper.Serial_Close(handle2); handle2 = 0; } } /// /// Конвертирует десятичное число в двоичную строку фиксированной длины (26 бит) /// private static string DecimalToBinary(string decimalString) { if (int.TryParse(decimalString, out int decimalValue)) { // Конвертируем в двоичную строку и дополняем нулями слева до 26 символов string binary = Convert.ToString(decimalValue, 2).PadLeft(26, '0'); // Переворачиваем строку (младшие биты слева, старшие справа) char[] chars = binary.ToCharArray(); Array.Reverse(chars); string reversed = new string(chars); return reversed; } // Если не удалось распарсить - возвращаем строку как есть (для обратной совместимости) Debug.LogWarning($"Не удалось преобразовать '{decimalString}' в число. Используем как есть."); return decimalString.PadLeft(26, '1'); // По умолчанию все кнопки отпущены } //Чтение с ДВУХ плат и объединение битов INPUT с использованием кэша //COIN обрабатывается сразу, чтобы не потерять монеты с обеих плат private static string ReadBufferLine() { try { string result1 = ""; string result2 = ""; bool hasNewInput1 = false; bool hasNewInput2 = false; // Читаем с первой платы (8 кнопок + 2 хопера) int read1 = ComNativeWrapper.Serial_ReadLineUTF8_bytes(handle1, buf, buf.Length); if (read1 > 0) { result1 = Encoding.UTF8.GetString(buf, 0, read1); // Обрабатываем COIN сразу, но добавляем в очередь для обработки в главном потоке if (result1 == "COIN") { long currentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); long timeSinceLastCoin = currentTime - lastCoinTimePlate1; // Проверяем debounce: если прошло меньше 250ms - это спам от той же монеты if (timeSinceLastCoin < COIN_DEBOUNCE_MS) { Debug.Log($"COM1: COIN проигнорирован (спам через {timeSinceLastCoin}ms < 250ms) - подтверждение НЕ отправлено"); // НЕ отправляем подтверждение для спама! // НЕ обновляем lastCoinTime для спама - это позволит следующей реальной монете пройти } else { // Это РЕАЛЬНАЯ новая монета - обрабатываем lastCoinTimePlate1 = currentTime; // ВАЖНО: Отправляем подтверждение ТОЛЬКО для реальных монет! int confirmSent = ComNativeWrapper.Serial_WriteStringUTF8(handle1, "10", 1); Debug.Log($"COM1: COIN принят → Подтверждение '10' отправлено (bytes: {confirmSent})"); // Добавляем в очередь для обработки в главном потоке coinQueue.Enqueue(1); } } // Если пришёл INPUT - обновляем кэш else if (result1.StartsWith("INPUT")) { string rawData = result1.Substring(5); // Конвертируем десятичное число в двоичную строку lastInputBits1 = DecimalToBinary(rawData); hasNewInput1 = true; } } // Читаем со второй платы (8 кнопок + 2 хопера) int read2 = ComNativeWrapper.Serial_ReadLineUTF8_bytes(handle2, buf, buf.Length); if (read2 > 0) { result2 = Encoding.UTF8.GetString(buf, 0, read2); // Обрабатываем COIN сразу, но добавляем в очередь для обработки в главном потоке if (result2 == "COIN") { long currentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); long timeSinceLastCoin = currentTime - lastCoinTimePlate2; // Проверяем debounce: если прошло меньше 250ms - это спам от той же монеты if (timeSinceLastCoin < COIN_DEBOUNCE_MS) { Debug.Log($"COM2: COIN проигнорирован (спам через {timeSinceLastCoin}ms < 250ms) - подтверждение НЕ отправлено"); // НЕ отправляем подтверждение для спама! // НЕ обновляем lastCoinTime для спама - это позволит следующей реальной монете пройти } else { // Это РЕАЛЬНАЯ новая монета - обрабатываем lastCoinTimePlate2 = currentTime; // ВАЖНО: Отправляем подтверждение ТОЛЬКО для реальных монет! int confirmSent = ComNativeWrapper.Serial_WriteStringUTF8(handle2, "10", 1); Debug.Log($"COM2: COIN принят → Подтверждение '10' отправлено (bytes: {confirmSent})"); // Добавляем в очередь для обработки в главном потоке coinQueue.Enqueue(2); } } // Если пришёл INPUT - обновляем кэш else if (result2.StartsWith("INPUT")) { string rawData = result2.Substring(5); // Конвертируем десятичное число в двоичную строку lastInputBits2 = DecimalToBinary(rawData); hasNewInput2 = true; } } // Если хотя бы с одной платы пришёл INPUT - объединяем с кэшем if (hasNewInput1 || hasNewInput2) { // Берём нужные биты из каждой платы (из 26 бит): // - от платы 1: первые 8 бит (кнопки 0-7) // - от платы 2: первые 8 бит (кнопки 8-15) // - от платы 1: последние 2 бита (хопер 16-17, биты 24-25) // - от платы 2: последние 2 бита (хопер 18-19, биты 24-25) // Итого: 20 бит (8+8+2+2) string buttons1 = lastInputBits1.Substring(0, 8); // биты 0-7: кнопки платы 1 string buttons2 = lastInputBits2.Substring(0, 8); // биты 8-15: кнопки платы 2 string hopper1 = lastInputBits1.Substring(24, 2); // биты 16-17: хопер платы 1 (последние 2 из 26) string hopper2 = lastInputBits2.Substring(24, 2); // биты 18-19: хопер платы 2 (последние 2 из 26) // Формируем строку эффективно через StringBuilder inputBuilder.Clear(); inputBuilder.Append("INPUT"); inputBuilder.Append(buttons1); inputBuilder.Append(buttons2); inputBuilder.Append(hopper1); inputBuilder.Append(hopper2); string combined = inputBuilder.ToString(); return combined; } } catch (Exception ex) { Debug.LogError($"Ошибка в ReadBufferLine: {ex}"); } 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) { Debug.LogError($"Ошибка асинхронного чтения портов: {e.Message}"); return string.Empty; } } //просим прислать состояние цифровых входов. Приходит что то вроде "INPUT111111101" private void requestInputs() => Send(REQUEST_INPUT); /// /// Асинхронный keep-alive процесс для проверки соединения с платами /// private async void StartKeepAliveProcess() { Debug.Log("Запуск асинхронной проверки соединения"); var cts = pollCancellationTokenSource; try { while (true) { // Всегда берем свежую ссылку cts = pollCancellationTokenSource; if (cts == null) break; CancellationToken token; try { token = cts.Token; } catch (ObjectDisposedException) { Debug.LogWarning("StartKeepAliveProcess: CTS disposed — выхожу."); break; } if (token.IsCancellationRequested) break; try { await Task.Delay(2000, token); // Ждем 2 секунды // Запрашиваем INPUT и сбрасываем флаг ожидания requestInputs(); System.Threading.Interlocked.Exchange(ref inputToggle, 0); // Ждём ответа (1 сек) await Task.Delay(1000, token); // Проверяем, пришёл ли ответ (потокобезопасно) if (System.Threading.Volatile.Read(ref inputToggle) == 0) { Debug.LogWarning("ComWrapper: платы не отвечают — устанавливаю запрос на переподключение (будет выполнено в Update)."); needReinit = true; } await Task.Delay(7000, token); // Итого ~10 секунд между проверками } catch (OperationCanceledException) { break; } catch (ObjectDisposedException) { Debug.LogWarning("StartKeepAliveProcess: CancellationTokenSource disposed — выхожу."); break; } catch (Exception e) { Debug.LogError($"Ошибка проверки соединения: {e.Message}"); } } } finally { // ничего не диспозим здесь — StopAsyncPolling/StartAsyncPolling управляют жизненным циклом CTS } } #endregion }