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();
Debug.Log("Ports: " + string.Join(", ", ports));
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
}