using System;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using RR.Game2D.Core.Managers;
using UnityEngine;
using UnityEngine.Events;
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 бит по умолчанию
#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();
// Запускаем асинхронный опрос порта
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 static int GetCredits() => 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()
{
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(10, pollCancellationTokenSource.Token); // Опрос каждые 10ms
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)
{
GameManager.Logger.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;
GameManager.Logger.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);
}
GameManager.Logger.Log($"Не удалось распарсить значение INPUT: {decimalString}");
return "11111111111111111111111111"; // 26 единиц по умолчанию
}
catch (Exception e)
{
GameManager.Logger.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}");
}
}
#endregion
}
public enum LightMode
{
Idle1,
Idle2,
Orange,
Blue,
Win,
Lose,
Prize,
Tama,
Dog,
Sam,
Mistress
}
}