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; #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 от плат // Очередь для обработки 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) { try { int write1 = ComNativeWrapper.Serial_WriteStringUTF8(handle1, message, 1); //w-количество записаных символов в порт. можно обработать int write2 = ComNativeWrapper.Serial_WriteStringUTF8(handle2, message, 1); //w-количество записаных символов в порт. можно обработать //Debug.Log("Wrote: " + w); } catch { Debug.Log("Ошибка отправки строкового сигнала."); } } /// /// Отправляет команду управления подсветкой на периферийную плату. /// /// Режим подсветки из перечисления LightMode /// /// Формирует команду вида "80XXX" где XXX - код режима подсветки. /// Для добавления новых режимов необходимо обновить вложенный метод getLightString + добавить режим в перечисление. /// public static void Send(LightMode lightMode) { try { string signal = LIGHTSIGNAL_PREFIX + getLightString(lightMode); Send(signal); //ВОТЭТОТ МЕТОД ПОД СВОИ ПОДСВЕТКИ НАДО ПОДГОНЯТЬ string getLightString(LightMode lightMode) => lightMode switch { LightMode.Idle => "106", LightMode.Orange => "107", LightMode.Blue => "108", LightMode.Win => "109", LightMode.Lose => "110", LightMode.Prize => "111", _ => "106" }; } catch { Debug.Log("Ошибка отправки светового сигнала."); } } /// /// Выполняет списание платы за запуск игры. /// Проверяет наличие доступных кредитов или режим бесплатной игры. /// При успешном списании увеличивает счетчики запусков. /// /// /// 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() { // Обрабатываем 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}"); } } private void OnDisable() { StopAsyncPolling(); ClosePort(); } private void OnApplicationQuit() { StopAsyncPolling(); ClosePort(); } private void StopAsyncPolling() { pollCancellationTokenSource?.Cancel(); pollCancellationTokenSource?.Dispose(); pollCancellationTokenSource = null; } private async void StartAsyncPolling() { try { Debug.Log("Запуск асинхронного опроса портов"); while (pollCancellationTokenSource != null && !pollCancellationTokenSource.Token.IsCancellationRequested) { await Task.Delay(10, pollCancellationTokenSource.Token); // Опрос каждые 10ms string comInput = await ReadBufferLineAsync(pollCancellationTokenSource.Token); if (!string.IsNullOrEmpty(comInput)) { ProcessComInputImmediate(comInput); } } } catch (OperationCanceledException) { Debug.Log("Асинхронный опрос портов остановлен"); } catch (Exception e) { Debug.LogError($"Ошибка асинхронного опроса: {e.Message}"); } } private void ProcessComInputImmediate(string comInput) { Debug.Log(comInput); // INPUT обработка if (comInput.StartsWith("INPUT")) { inputToggle = 1; // Обновляем флаг для keep-alive onInputRecieved?.Invoke(comInput.Substring(5)); } } 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() { 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; } 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() { try { Debug.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) { Debug.LogWarning("Платы не отвечают! Попытка переподключения..."); await Task.Run(() => { ClosePort(); initPort(); }, pollCancellationTokenSource.Token); } await Task.Delay(7000, pollCancellationTokenSource.Token); // Итого ~10 секунд между проверками } } catch (OperationCanceledException) { Debug.Log("Асинхронная проверка соединения остановлена"); } catch (Exception e) { Debug.LogError($"Ошибка проверки соединения: {e.Message}"); } } #endregion } public enum LightMode { Idle, Orange, Blue, Win, Lose, Prize, }