636 lines
30 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Events;
/// <summary>
/// Основной класс-обертка для работы с COM-портом и периферийной платой.
/// Управляет всей коммуникацией с аппаратным обеспечением: прием монет, чтение кнопок, управление подсветкой, отправка сигналов.
/// </summary>
public class ComWrapper : MonoBehaviour
{
//Настраиваем из инспектора
[SerializeField] bool saveMoneyBetweenSessions;
[SerializeField] bool enablePrizeChecking = false; // Если хопер с проверкой наличия приза ставим true
[SerializeField] bool keepAlive = true; // Периодическая проверка соединения с платами
public static UnityEvent<int,int> onCoinAdded;
public static UnityEvent<int,int> onCoinRemoved;
public static UnityEvent<string> 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<int> coinQueue = new System.Collections.Concurrent.ConcurrentQueue<int>();
// Защита от спама 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===========================//
/// <summary>
/// Инициализирует систему работы с COM-портом.
/// Открывает соединение, настраивает события и запускает асинхронные запросы состояния.
/// </summary>
public void Initialize()
{
//Создаем ивенты
onCoinAdded = new UnityEvent<int,int>();
onCoinRemoved = new UnityEvent<int,int>();
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();
}
/// <summary>
/// Добавляет один кредит в систему и сохраняет обновленное количество.
/// Вызывается автоматически событием при получении сигнала "COIN" из COM-порта.
/// </summary>
/// <remarks>
/// Увеличивает счетчик кредитов и вызывает событие onCoinAdded для уведомления подписчиков.
/// Автоматически сохраняет состояние в PlayerPrefs.
/// </remarks>
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);
}
/// <summary>
/// Отправляет произвольную строковую команду на периферийную плату через COM-порт.
/// </summary>
/// <param name="message">Команда для отправки</param>
/// <remarks>
/// Используется для отправки пользовательских команд на плату.
/// Команда должна быть в формате, ожидаемом прошивкой платы.
/// В случае ошибки отправки выводит предупреждение в DebugWindow.
/// </remarks>
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("Ошибка отправки строкового сигнала."); }
}
/// <summary>
/// Отправляет команду управления подсветкой на периферийную плату.
/// </summary>
/// <param name="lightMode">Режим подсветки из перечисления LightMode</param>
/// <remarks>
/// Формирует команду вида "80XXX" где XXX - код режима подсветки.
/// Для добавления новых режимов необходимо обновить вложенный метод getLightString + добавить режим в перечисление.
/// </remarks>
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("Ошибка отправки светового сигнала."); }
}
/// <summary>
/// Выполняет списание платы за запуск игры.
/// Проверяет наличие доступных кредитов или режим бесплатной игры.
/// При успешном списании увеличивает счетчики запусков.
/// </summary>
/// <returns>
/// true - если списание прошло успешно (бесплатный режим или есть кредиты),
/// false - если кредитов недостаточно и платный режим
/// </returns>
/// <remarks>
/// В случае успеха:
/// - Увеличивает общие счетчики запусков (key_starts и key_startsNul)
/// - При платном режиме уменьшает количество кредитов на 1
/// - Вызывает событие onCoinRemoved при списании кредита
/// </remarks>
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;
}
/// <summary>
/// Получаем баланс игрока по id
/// </summary>
///
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($"<color=green>✓ Обработано монет: Игрок1={coinsPlayer1}, Игрок2={coinsPlayer2}, Итого={coinsPlayer1 + coinsPlayer2}</color>");
}
}
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($"<color=red>ОШИБКА: Найден только {ports.Length} порт, а нужно минимум 2 для работы с двумя платами!</color>");
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("<color=yellow>Очистка буферов портов от старых данных...</color>");
FlushPortBuffers();
// Инициализируем lastCoinTime текущим временем чтобы не обработать старые COIN
long currentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
lastCoinTimePlate1 = currentTime;
lastCoinTimePlate2 = currentTime;
Debug.Log($"<color=green>Порты инициализированы, debounce активирован с текущего времени: {currentTime}</color>");
}
/// <summary>
/// Очищает буферы обоих портов от накопившихся старых данных
/// </summary>
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($"<color=cyan>Очищено: Плата1={flushedCount1} сообщений, Плата2={flushedCount2} сообщений</color>");
}
private static void ClosePort()
{
if (handle1 > 0)
{
ComNativeWrapper.Serial_Close(handle1);
handle1 = 0;
}
if (handle2 > 0)
{
ComNativeWrapper.Serial_Close(handle2);
handle2 = 0;
}
}
/// <summary>
/// Конвертирует десятичное число в двоичную строку фиксированной длины (26 бит)
/// </summary>
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($"<color=yellow>COM1: COIN проигнорирован (спам через {timeSinceLastCoin}ms < 250ms) - подтверждение НЕ отправлено</color>");
// НЕ отправляем подтверждение для спама!
// НЕ обновляем lastCoinTime для спама - это позволит следующей реальной монете пройти
}
else
{
// Это РЕАЛЬНАЯ новая монета - обрабатываем
lastCoinTimePlate1 = currentTime;
// ВАЖНО: Отправляем подтверждение ТОЛЬКО для реальных монет!
int confirmSent = ComNativeWrapper.Serial_WriteStringUTF8(handle1, "10", 1);
Debug.Log($"<color=cyan>COM1: COIN принят → Подтверждение '10' отправлено (bytes: {confirmSent})</color>");
// Добавляем в очередь для обработки в главном потоке
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($"<color=yellow>COM2: COIN проигнорирован (спам через {timeSinceLastCoin}ms < 250ms) - подтверждение НЕ отправлено</color>");
// НЕ отправляем подтверждение для спама!
// НЕ обновляем lastCoinTime для спама - это позволит следующей реальной монете пройти
}
else
{
// Это РЕАЛЬНАЯ новая монета - обрабатываем
lastCoinTimePlate2 = currentTime;
// ВАЖНО: Отправляем подтверждение ТОЛЬКО для реальных монет!
int confirmSent = ComNativeWrapper.Serial_WriteStringUTF8(handle2, "10", 1);
Debug.Log($"<color=cyan>COM2: COIN принят → Подтверждение '10' отправлено (bytes: {confirmSent})</color>");
// Добавляем в очередь для обработки в главном потоке
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 "";
}
/// <summary>
/// Асинхронное чтение с двух портов. Выполняется в фоновом потоке.
/// </summary>
private static async Task<string> 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);
/// <summary>
/// Асинхронный keep-alive процесс для проверки соединения с платами
/// </summary>
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,
}