using System.Text; using UnityEngine; using UnityEngine.Events; /// /// Основной класс-обертка для работы с COM-портом и периферийной платой. /// Управляет всей коммуникацией с аппаратным обеспечением: прием монет, чтение кнопок, управление подсветкой, отправка сигналов. /// public class ComWrapper : MonoBehaviour { //Настраиваем из инспектора [SerializeField] bool saveMoneyBetweenSessions; [SerializeField] bool enablePrizeChecking = false; // Если хопер с проверкой наличия приза ставим 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 static byte[] buf = new byte[1024]; //Байт-буфер куда будут приходить данные с порта // StringBuilder для эффективного формирования строки INPUT без лишних аллокаций private static System.Text.StringBuilder inputBuilder = new System.Text.StringBuilder(32); // Кэш последних состояний INPUT для каждой платы (16 бит от каждой платы) // ВАЖНО: 0 = кнопка нажата, 1 = кнопка отпущена, поэтому по умолчанию все единицы // Каждая плата присылает 16 бит, но мы берём: // - от платы 1: первые 8 бит (биты 0-7) // - от платы 2: последние 8 бит (биты 8-15) private static string lastInputBits1 = "1111111111111111"; // последние 16 бит от платы 1 (все отпущены) private static string lastInputBits2 = "1111111111111111"; // последние 16 бит от платы 2 (все отпущены) private static int CreditsPlayer1; private static int CreditsPlayer2; #endregion //===========================start: PUBLIC API===========================// /// /// Инициализирует систему работы с COM-портом. /// Открывает соединение, настраивает события и запускает периодические запросы состояния. /// /// /// Обязателен к вызову при старте приложения. Выполняет: /// - Создание UnityEvent'ов для уведомлений /// - Загрузку количества кредитов из PlayerPrefs /// - Открытие 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(); //вот это важный метод который собственно порт открывает. //Каждые 5 сек запрашиваем инпуты у платы. Надо потому что при заходе в игру с пинов еще не пришла инфа if (enablePrizeChecking) InvokeRepeating("requestInputs", 0, 5); 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() => pollPort(); private void OnDisable() => ClosePort(); private void OnApplicationQuit() => ClosePort(); #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; } } private static void ClosePort() { if (handle1 > 0) { ComNativeWrapper.Serial_Close(handle1); handle1 = 0; } if (handle2 > 0) { ComNativeWrapper.Serial_Close(handle2); handle2 = 0; } } //Чтение с ДВУХ плат и объединение битов INPUT с использованием кэша //COIN обрабатывается сразу, чтобы не потерять монеты с обеих плат private static string ReadBufferLine() { string result1 = ""; string result2 = ""; bool hasNewInput1 = false; bool hasNewInput2 = false; // Читаем с первой платы (биты 0-7) int read1 = ComNativeWrapper.Serial_ReadLineUTF8_bytes(handle1, buf, buf.Length); if (read1 > 0) { result1 = Encoding.UTF8.GetString(buf, 0, read1); // Обрабатываем COIN сразу, чтобы не потерять if (result1 == "COIN") { AddCredit(1, 1); // 1 монета, игрок 1 Debug.Log("COM1: COIN (Игрок 1)"); } // Если пришёл INPUT - обновляем кэш else if (result1.StartsWith("INPUT")) { lastInputBits1 = result1.Substring(5); // сохраняем биты 0-7 hasNewInput1 = true; } } // Читаем со второй платы (биты 8-15) int read2 = ComNativeWrapper.Serial_ReadLineUTF8_bytes(handle2, buf, buf.Length); if (read2 > 0) { result2 = Encoding.UTF8.GetString(buf, 0, read2); // Обрабатываем COIN сразу, чтобы не потерять if (result2 == "COIN") { AddCredit(1, 2); // 1 монета, игрок 2 Debug.Log("COM2: COIN (Игрок 2)"); } // Если пришёл INPUT - обновляем кэш else if (result2.StartsWith("INPUT")) { lastInputBits2 = result2.Substring(5); // сохраняем биты 8-15 hasNewInput2 = true; } } // Если хотя бы с одной платы пришёл INPUT - объединяем с кэшем if (hasNewInput1 || hasNewInput2) { // Берём нужные биты из каждой платы: // - от платы 1: первые 8 символов (биты 0-7) // - от платы 2: последние 8 символов (биты 8-15) string bits0_7 = lastInputBits1.Substring(0, 8); // первые 8 бит от платы 1 string bits8_15 = lastInputBits2.Substring(8, 8); // последние 8 бит от платы 2 // Формируем строку эффективно через StringBuilder inputBuilder.Clear(); inputBuilder.Append("INPUT"); inputBuilder.Append(bits0_7); inputBuilder.Append(bits8_15); string combined = inputBuilder.ToString(); if (hasNewInput1 && hasNewInput2) { Debug.Log($"Обе платы ответили: [{bits0_7}]+[{bits8_15}]=[{combined}]"); } else if (hasNewInput1) { Debug.Log($"Плата1 новая, плата2 кэш: [{bits0_7}]+[{bits8_15}]=[{combined}]"); } else { Debug.Log($"Плата1 кэш, плата2 новая: [{bits0_7}]+[{bits8_15}]=[{combined}]"); } return combined; } return ""; } //просим прислать состояние цифровых входов. Приходит что то вроде "INPUT111111101" private void requestInputs() => Send(REQUEST_INPUT); private void pollPort() { //Тут все просто, читаем что нам приходит с порта и сравниваем. string comInput = ReadBufferLine(); if (comInput != "") { Debug.Log("COM: " + comInput); // COIN уже обработан в ReadBufferLine(), здесь только INPUT if (comInput.StartsWith("INPUT")) onInputRecieved?.Invoke(comInput.Substring(5)); //инвокаю сразу без префикса "INPUT" } } #endregion } public enum LightMode { Idle, Orange, Blue, Win, Lose, Prize, }