using System; using System.Collections; using System.Text; using UnityEngine; using UnityEngine.Events; //async using System.Threading.Tasks; using System.Threading; /// /// Основной класс-обертка для работы с COM-портом и периферийной платой. /// Управляет всей коммуникацией с аппаратным обеспечением: прием монет, чтение кнопок, управление подсветкой, отправка сигналов. /// public class ComWrapper : MonoBehaviour { //Настраиваем из инспектора [SerializeField] bool saveMoneyBetweenSessions; [SerializeField] bool enablePrizeChecking = false; // Если хопер с проверкой наличия приза ставим true [SerializeField] bool keepAlive = true; public static UnityEvent onPulseRecieved; public static UnityEvent onCoinAdded; public static UnityEvent onCoinRemoved; public static UnityEvent onInputRecieved; #region Приватные поля private const string REQUEST_INPUT = "91"; private const string DEFAULT_INPUT = "INPUT1111111111111111"; private static int h;//идентификатор хэндла private static byte[] buf = new byte[1024]; //Байт-буфер куда будут приходить данные с порта public static int Balance; bool boardEncrypted = false; string encString; string confirmString; Coroutine keepAliveCoroutine; //async private CancellationTokenSource pollCancellationTokenSource; private bool isPolling = false; //сделать запрос инпутов. а если его нет то просим ребут? делаем если по другим факторам не пройдёт. //private string inputString = DEFAULT_INPUT; #endregion //===========================start: PUBLIC API===========================// /// /// Инициализирует систему работы с COM-портом. /// Открывает соединение, настраивает события и запускает периодические запросы состояния. /// /// /// Обязателен к вызову при старте приложения. Выполняет: /// - Создание UnityEvent'ов для уведомлений /// - Загрузку количества кредитов из PlayerPrefs /// - Открытие COM-порта /// - Запуск периодического опроса пинов (если включена проверка призов) /// public void Initialize() { //Создаем ивенты onCoinAdded = new UnityEvent(); onCoinRemoved = new UnityEvent(); onInputRecieved = new UnityEvent(); onPulseRecieved = new UnityEvent(); //Начальная настройка if (!saveMoneyBetweenSessions) PlayerPrefs.SetInt("CoinsCount", 0); Balance = PlayerPrefs.GetInt("CoinsCount", 0); onInputRecieved?.AddListener(InputRecievedCather); StartCoroutine(SetupBoard()); } /// /// Добавляет один кредит в систему и сохраняет обновленное количество. /// Вызывается автоматически событием при получении сигнала "COIN" из COM-порта. /// /// /// Увеличивает счетчик кредитов и вызывает событие onCoinAdded для уведомления подписчиков. /// Автоматически сохраняет состояние в PlayerPrefs. /// public static void AddCredit() { int recieved = PlayerPrefs.GetInt("ValutaPerImpulse", 10); Balance = Balance + recieved; PlayerPrefs.SetInt("CoinsCount", Balance); //Счетчики выручки PlayerPrefs.SetInt(PrizeSystem.key_starts, PlayerPrefs.GetInt(PrizeSystem.key_starts) + recieved); PlayerPrefs.SetInt(PrizeSystem.key_startsNul, PlayerPrefs.GetInt(PrizeSystem.key_startsNul) + recieved); if(SettingsManager.GetString("BaseBotDifficulty", "Фиксированная") == "Повышенная") { int nowPulses = PlayerPrefs.GetInt("PulsesCounter", 0) + 1; PlayerPrefs.SetInt("PulsesCounter", nowPulses); } onPulseRecieved?.Invoke(); onCoinAdded?.Invoke(Balance); } /// /// Отправляет произвольную строковую команду на периферийную плату через COM-порт. /// /// Команда для отправки /// /// Используется для отправки пользовательских команд на плату. /// Команда должна быть в формате, ожидаемом прошивкой платы. /// В случае ошибки отправки выводит предупреждение в DebugWindow. /// public static void Send(string message) { try { int w = ComNativeWrapper.Serial_WriteStringUTF8(h, message, 1); //w-количество записаных символов в порт. можно обработать //Debug.Log("Wrote: " + w); } catch { Debug.LogError("Ошибка отправки строкового сигнала."); } } /// /// Отправляет команду управления подсветкой на периферийную плату. /// /// Режим подсветки из перечисления LightMode /// /// Формирует команду вида "80XXX" где XXX - код режима подсветки. /// Для добавления новых режимов необходимо обновить вложенный метод getLightString + добавить режим в перечисление. /// public static void Send(LightMode lightMode) { try { string signal = LightUtility.GetLightString(lightMode); Send(signal); } catch { Debug.LogError("Ошибка отправки светового сигнала."); } } /// /// Выполняет списание платы за запуск игры. /// Проверяет наличие доступных кредитов или режим бесплатной игры. /// При успешном списании увеличивает счетчики запусков. /// /// /// true - если списание прошло успешно (бесплатный режим или есть кредиты), /// false - если кредитов недостаточно и платный режим /// /// /// В случае успеха: /// - Увеличивает общие счетчики запусков (key_starts и key_startsNul) /// - При платном режиме уменьшает количество кредитов на 1 /// - Вызывает событие onCoinRemoved при списании кредита /// public static bool Pay(int amount) { //блок проверки оплаты if (SettingsManager.GetBool("Freeplay", false)) { return true; } if (Balance >= amount) { Balance -= amount; PlayerPrefs.SetInt("CoinsCount", Balance); onCoinRemoved?.Invoke(Balance); return true; } return false; } //===========================end: PUBLIC API===========================// //private void Update() => pollPort(); //async пока выключил это private void OnDisable() { StopAsyncPolling();//async ClosePort(); } private void OnApplicationQuit() { StopAsyncPolling();//async ClosePort(); } private void StopAsyncPolling() { pollCancellationTokenSource?.Cancel(); pollCancellationTokenSource?.Dispose(); pollCancellationTokenSource = null; } private async void StartAsyncPolling() { try { Logger.Instance.Log("Запуск асинхронного опроса порта"); while (pollCancellationTokenSource != null && !pollCancellationTokenSource.Token.IsCancellationRequested) { await Task.Delay(10, pollCancellationTokenSource.Token); string comInput = await ReadBufferLineAsync(pollCancellationTokenSource.Token); if (!string.IsNullOrEmpty(comInput)) { // Прямой вызов - мы уже в главном потоке после await ProcessComInputImmediate(comInput); } } } catch (OperationCanceledException) { Logger.Instance.Log("Асинхронный опрос порта остановлен"); } catch (Exception e) { Logger.Instance.Log($"Ошибка асинхронного опроса: {e.Message}"); } } private void ProcessComInputImmediate(string comInput) { Debug.LogError("COM: " + comInput); if (comInput == "COIN") AddCredit(); if (comInput.StartsWith("INPUT")) onInputRecieved?.Invoke(comInput.Substring(5)); if (comInput.StartsWith("ENC:")) encString = comInput; if (comInput.StartsWith("ACTIVATED:")) confirmString = comInput; } private IEnumerator KeepPortAliveProcess() { Logger.Instance.Log("Подключаю проверку порта."); while (true) { requestInputs(); yield return new WaitForSeconds(2f); //нет инпута if (GetInputToggle() == 0) { Logger.Instance.Log("Плата отвалилась. Запускаю переподключение."); initPort(); } //есть инпут else { ToggleInputRecieved(0); } yield return new WaitForSeconds(10f); } } void ToggleInputRecieved(int value) => PlayerPrefs.SetInt("inputToggle", value); int GetInputToggle() => PlayerPrefs.GetInt("inputToggle", 0); void InputRecievedCather(string input) { if (!string.IsNullOrEmpty(input)) { //пришел инпут ToggleInputRecieved(1); } } #region Приватные методы //Ну тут по дебаг логам все должно быть понятно //Если интересно, то h - это хэндл. Что то на плюсовом, типа ID соединения или чтото. Если 0 то нету соед. если меньше то ошибка, больше 0 - норм. private void initPort() { Logger.Instance.Log("Инициализация порта"); ComNativeWrapper.Serial_CloseAll(); //ComNativeWrapper.Serial_Close(h); var ports = ComNativeWrapper.ListPorts(); if (ports.Length == 0) { Debug.LogError("Нет портов, завершаю"); return; } string port = ports[^1]; Logger.Instance.Log("Открываю порт: " + port); //вот эти настройки лучше не ковырять, но если очень хочется можно поменять readTimeoutMs и writeTimeoutMs //Если read поставить слишком маленьким оно проебует кредиты когда они идут большой пачкой например при оплате через вендисту. //Там в порт спамит "COIN" в больших количествах и процентов 10 из этих строк не успевают прочитаться при таймауте 1мс, соотв. монеты проебуются. //Слишком большой таймаут будет тормозить игру потому что оно все выполняется синхронно и каждый кадр забирает столько мс сколько в переменной указано. h = ComNativeWrapper.Serial_Open(port, 115200, 8, 0, 1, 4, 10); Logger.Instance.Log($"handle: {h}"); if (h <= 0) { Logger.Instance.Log("Open failed: " + ComNativeWrapper.GetLastError()); return; } else { Logger.Instance.Log($"Открыл порт: {port}"); } } private static void ClosePort() { Logger.Instance.Log($"Отпускаю порт handle:{h}"); if (h > 0) { ComNativeWrapper.Serial_Close(h); h = 0; } } //Тут чтение, не трогаем(опционально) private static string ReadBufferLine() { int read = ComNativeWrapper.Serial_ReadLineUTF8_bytes(h, buf, buf.Length); if (read > 0) { var s = Encoding.UTF8.GetString(buf, 0, read); //DebugWindow.Message($"ReadLine len={read} data=[{s}]"); return s; } else if (read < 0) { //DebugWindow.Message($"ReadLine err={read} :: {ComNativeWrapper.GetLastError()}"); } return ""; } //async private static async Task ReadBufferLineAsync(CancellationToken cancellationToken = default) { try { return await Task.Run(() => { // Проверяем отмену внутри Task.Run if (cancellationToken.IsCancellationRequested) return string.Empty; int read = ComNativeWrapper.Serial_ReadLineUTF8_bytes(h, buf, buf.Length); if (read > 0) { return Encoding.UTF8.GetString(buf, 0, read); } return string.Empty; }, cancellationToken); } catch (OperationCanceledException) { Logger.Instance.Log("Чтение порта отменено"); return string.Empty; } catch (Exception e) { Logger.Instance.Log($"Ошибка асинхронного чтения: {e.Message}"); return string.Empty; } } //просим прислать состояние цифровых входов. Приходит что то вроде "INPUT111111101" private void requestInputs() => Send(REQUEST_INPUT); private void pollPort() { //Тут все просто, читаем что нам приходит с порта и сравниваем. string comInput = ReadBufferLine(); if (comInput != "") { Debug.Log("COM: " + comInput); if (comInput == "COIN") AddCredit(); if (comInput.StartsWith("INPUT")) onInputRecieved?.Invoke(comInput.Substring(5)); //инвокаю сразу без префикса "INPUT" if (comInput.StartsWith("ENC:")) encString = comInput; if (comInput.StartsWith("ACTIVATED:")) confirmString = comInput; } } private IEnumerator SetupBoard() { if (keepAliveCoroutine != null) StopCoroutine(keepAliveCoroutine); initPort(); boardEncrypted = true; //предполагаем что зашифрована //пробуем получить инпуты. Вдруг плата осталась расшифрована ToggleInputRecieved(0); requestInputs(); float timer = 1; while (GetInputToggle() == 0 && timer > 0) { pollPort(); timer -= Time.deltaTime; yield return null; } //Есть инпут. незашифровано if (GetInputToggle() == 1) { boardEncrypted = false; } if (boardEncrypted) { Logger.Instance.Log("Шифрованная плата"); //запрос encString = ""; Send("666"); //ждем пока придет ENC: timer = 1; while (string.IsNullOrEmpty(encString) && timer > 0) { pollPort(); timer -= Time.deltaTime; yield return null; } //получили ENC: строку или вышли по таймеру //Строка пришла if (!string.IsNullOrEmpty(encString)) { Logger.Instance.Log($"Расшифровываю {encString}"); string decr = BoardUtility.DecryptSerial(ExtractData(encString)); //подтверждение confirmString = ""; Send("777" + decr); } else { Logger.Instance.Log($"Зашифрованная строка не пришла"); //отвал игры пускать (new GameObject()).AddComponent(typeof(VfxPrefab)); } //ждем подтверждение от платы timer = 2; while (string.IsNullOrEmpty(confirmString) && timer > 0) { pollPort(); timer -= Time.deltaTime; yield return null; } if (string.IsNullOrEmpty(confirmString)) { Application.Quit(); } else { if (confirmString.IndexOf("OK") != -1) { Logger.Instance.Log("Активировано."); } else { Application.Quit(); } } } else { Logger.Instance.Log("НЕшифрованная плата"); } //async //Каждые 5 сек запрашиваем инпуты у платы. Надо потому что при заходе в игру с пинов еще не пришла инфа /* if (keepAlive) keepAliveCoroutine = StartCoroutine(KeepPortAliveProcess());*/ //async pollCancellationTokenSource = new CancellationTokenSource(); StartAsyncPolling(); StartKeepAliveProcess(); } private async void StartKeepAliveProcess() { if (keepAlive) { await StartKeepAliveProcessAsync(pollCancellationTokenSource.Token); } } private async Task StartKeepAliveProcessAsync(CancellationToken cancellationToken) { try { Logger.Instance.Log("Запуск асинхронной проверки порта"); while (!cancellationToken.IsCancellationRequested) { await SendRequestInputsAsync(cancellationToken); await Task.Delay(2000, cancellationToken); // 2 секунды if (!await CheckPortConnectionAsync(cancellationToken)) { Logger.Instance.Log("Плата отвалилась. Запускаю переподключение."); await ReinitializePortAsync(cancellationToken); } else { ToggleInputRecieved(0); } await Task.Delay(10000, cancellationToken); // 10 секунд } } catch (OperationCanceledException) { Logger.Instance.Log("Асинхронная проверка порта остановлена"); } catch (Exception e) { Logger.Instance.Log($"Ошибка проверки порта: {e.Message}"); } } private async Task SendRequestInputsAsync(CancellationToken cancellationToken) { try { requestInputs(); await Task.CompletedTask; } catch (Exception e) { Logger.Instance.Log($"Ошибка отправки запроса инпутов: {e.Message}"); } } private async Task CheckPortConnectionAsync(CancellationToken cancellationToken) { // Простая проверка, не требует Task.Run return await Task.FromResult(GetInputToggle() != 0); } private async Task ReinitializePortAsync(CancellationToken cancellationToken) { await Task.Run(() => initPort(), cancellationToken); } public static string ExtractData(string input) { const string encPrefix = "ENC:"; int encIndex = input.IndexOf(encPrefix); if (encIndex == -1) { Logger.Instance.Log($"Неверный формат шифрованной строки {input}"); throw new ArgumentException("Invalid input format"); } int dataStart = encIndex + encPrefix.Length; return input.Substring(4); } #endregion } public enum LightMode { Idle1, Idle2, Orange, Blue, Win, Lose, Prize, Toaster, Tama, Dog, Sam, Mistress }