990 lines
47 KiB
C#
990 lines
47 KiB
C#
using System;
|
||
using System.Text;
|
||
using System.Threading;
|
||
using System.Threading.Tasks;
|
||
using UnityEngine;
|
||
using UnityEngine.Events;
|
||
using Inputs.ComSystem;
|
||
|
||
/// <summary>
|
||
/// Основной класс-обертка для работы с COM-портом и периферийной платой.
|
||
/// Управляет всей коммуникацией с аппаратным обеспечением: прием монет, чтение кнопок, управление подсветкой, отправка сигналов.
|
||
/// </summary>
|
||
public class ComWrapper : MonoBehaviour
|
||
{
|
||
//Настраиваем из инспектора
|
||
[SerializeField] bool enablePrizeChecking = false; // Если хопер с проверкой наличия приза ставим true
|
||
[SerializeField] bool keepAlive = true; // Периодическая проверка соединения с платами
|
||
[SerializeField] bool requireHandshake = true; // Двойное рукопожатие AES
|
||
|
||
// Монеты и кредиты — см. CreditSystem.cs (работает в любом режиме ввода)
|
||
public static UnityEvent<string> onInputRecieved;
|
||
public static System.Action OnInitialize;
|
||
// Событие, вызываемое непосредственно перед закрытием портов — слушатели могут послать команды выключения
|
||
public static System.Action OnBeforeClose;
|
||
|
||
// Очередь исходящих сообщений — все отправки пойдут через неё и будут обработаны в Update() или при завершении
|
||
private struct OutgoingMessage { public int playerId; public string message; }
|
||
private static readonly System.Collections.Concurrent.ConcurrentQueue<OutgoingMessage> outgoingQueue = new System.Collections.Concurrent.ConcurrentQueue<OutgoingMessage>();
|
||
|
||
#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 int handle3; //идентификатор хэндла платы 3 (опциональная витрина)
|
||
|
||
// Роль-ориентированные хэндлы (заполняются в AssignRoles после инициализации портов)
|
||
private static int handleLeft = 0; // левая игровая плата (player 1)
|
||
private static int handleRight = 0; // правая игровая плата (player 2)
|
||
private static int handleVitrine = 0; // витрина / showcase (опционально)
|
||
|
||
// Константы ролей — должны совпадать с тем, что плата возвращает на команду 97
|
||
// Ответ платы: "ROLE : <название>" (до 20 символов, регистр игнорируется)
|
||
public const string ROLE_LEFT = "left";
|
||
public const string ROLE_RIGHT = "right";
|
||
public const string ROLE_VITRINE = "vitrina";
|
||
private const string CMD_GET_ROLE = "97"; // команда запроса роли платы
|
||
|
||
// Асинхронная работа
|
||
private CancellationTokenSource pollCancellationTokenSource;
|
||
private int inputToggle = 0; // Флаг для проверки получения INPUT от плат
|
||
// Флаг для безопасного запроса переподключения, выставляется из фонового потока и обрабатывается в Update() на главном потоке
|
||
private volatile bool needReinit = false;
|
||
|
||
// Очередь для обработки 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 (все отпущены)
|
||
|
||
|
||
#endregion
|
||
|
||
//===========================start: PUBLIC API===========================//
|
||
|
||
/// <summary>
|
||
/// Инициализирует систему работы с COM-портом.
|
||
/// Открывает соединение, настраивает события и запускает асинхронные запросы состояния.
|
||
/// </summary>
|
||
public void Initialize()
|
||
{
|
||
//Создаем ивенты
|
||
onInputRecieved = new();
|
||
|
||
initPort(); //открываем порты
|
||
|
||
// Запускаем асинхронный опрос портов
|
||
pollCancellationTokenSource = new CancellationTokenSource();
|
||
StartAsyncPolling();
|
||
|
||
// Запускаем keep-alive механизм если включен
|
||
if (keepAlive || enablePrizeChecking)
|
||
{
|
||
StartKeepAliveProcess();
|
||
}
|
||
|
||
OnInitialize?.Invoke();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Добавляет один кредит в систему и сохраняет обновленное количество.
|
||
/// Вызывается автоматически событием при получении сигнала "COIN" из COM-порта.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// Вся логика кредитов перенесена в CreditSystem.cs.
|
||
/// Оставлено только для совместимости — удалено.
|
||
/// </remarks>
|
||
|
||
/// <summary>
|
||
/// Отправляет произвольную строковую команду на периферийную плату через COM-порт.
|
||
/// </summary>
|
||
public static void Send(string message, int playerId)
|
||
{
|
||
// Публикуем в очередь — реальные записи будут выполнены в Update() (на главном потоке)
|
||
if (playerId != 1 && playerId != 2)
|
||
{
|
||
Debug.LogWarning("ComWrapper.Send: неверный playerId");
|
||
return;
|
||
}
|
||
outgoingQueue.Enqueue(new OutgoingMessage { playerId = playerId, message = message });
|
||
}
|
||
|
||
/// <summary>
|
||
/// Отправляет строковую команду на все подключенные платы (broadcast).
|
||
/// </summary>
|
||
public static void Send(string message)
|
||
{
|
||
outgoingQueue.Enqueue(new OutgoingMessage { playerId = 1, message = message });
|
||
outgoingQueue.Enqueue(new OutgoingMessage { playerId = 2, message = message });
|
||
}
|
||
|
||
/// <summary>
|
||
/// Возвращает true, если витрина (showcase-плата) была обнаружена и подключена.
|
||
/// </summary>
|
||
public static bool HasVitrine => handleVitrine > 0;
|
||
|
||
/// <summary>
|
||
/// Отправляет команду на витрину (showcase-плату).
|
||
/// Если витрина не обнаружена — выводит предупреждение и ничего не делает.
|
||
/// </summary>
|
||
public static void SendToVitrine(string message)
|
||
{
|
||
if (handleVitrine > 0)
|
||
outgoingQueue.Enqueue(new OutgoingMessage { playerId = 3, message = message });
|
||
else
|
||
Debug.LogWarning("ComWrapper.SendToVitrine: витрина не подключена или роль не определена.");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Возвращает хэндл платы по её строковой роли (используй ROLE_LEFT / ROLE_RIGHT / ROLE_VITRINE).
|
||
/// Возвращает 0, если плата с такой ролью не найдена.
|
||
/// </summary>
|
||
public static int GetHandleByRole(string role)
|
||
{
|
||
return role switch
|
||
{
|
||
ROLE_LEFT => handleLeft,
|
||
ROLE_RIGHT => handleRight,
|
||
ROLE_VITRINE => handleVitrine,
|
||
_ => 0
|
||
};
|
||
}
|
||
|
||
//===========================end: PUBLIC API===========================//
|
||
|
||
private void Update()
|
||
{
|
||
// Выполняем переподключение на главном потоке, если было запрошено из фонового
|
||
if (needReinit)
|
||
{
|
||
needReinit = false;
|
||
Debug.LogWarning("ComWrapper: выполняю переподключение портов на главном потоке...");
|
||
ClosePort();
|
||
initPort();
|
||
}
|
||
|
||
// Обрабатываем COIN из очереди — делегируем в CreditSystem
|
||
int coinsPlayer1 = 0;
|
||
int coinsPlayer2 = 0;
|
||
|
||
while (coinQueue.TryDequeue(out int playerId))
|
||
{
|
||
CreditSystem.AddImpulse(playerId);
|
||
if (playerId == 1) coinsPlayer1++;
|
||
else coinsPlayer2++;
|
||
}
|
||
|
||
if (coinsPlayer1 > 0 || coinsPlayer2 > 0)
|
||
{
|
||
Debug.Log($"<color=green>✓ Обработано монет: Игрок1={coinsPlayer1}, Игрок2={coinsPlayer2}, Итого={coinsPlayer1 + coinsPlayer2}</color>");
|
||
}
|
||
|
||
// Отправляем накопленные исходящие сообщения (выходящие команды на платы)
|
||
ProcessOutgoingQueueOnce();
|
||
}
|
||
|
||
// Отправляет все сообщения, которые есть в очереди в данный момент (синхронно, на главном потоке)
|
||
private void ProcessOutgoingQueueOnce()
|
||
{
|
||
while (outgoingQueue.TryDequeue(out OutgoingMessage msg))
|
||
{
|
||
try
|
||
{
|
||
// Используем роль-алиасы; если ещё не назначены — fallback на raw handle1/handle2
|
||
int targetHandle = msg.playerId switch
|
||
{
|
||
1 => handleLeft > 0 ? handleLeft : handle1, // левая / player1
|
||
2 => handleRight > 0 ? handleRight : handle2, // правая / player2
|
||
3 => handleVitrine, // витрина (только через SendToVitrine)
|
||
_ => 0
|
||
};
|
||
if (targetHandle > 0)
|
||
ComNativeWrapper.Serial_WriteStringUTF8(targetHandle, msg.message, 1);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogWarning($"ComWrapper: ошибка отправки исходящего сообщения: {ex.Message}");
|
||
}
|
||
}
|
||
}
|
||
|
||
private void OnDisable()
|
||
{
|
||
try { OnBeforeClose?.Invoke(); } catch { }
|
||
|
||
// Даем шанс отправить накопленные команды (например, TurnAllOff). Ждем короткий таймаут.
|
||
try
|
||
{
|
||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||
const int maxWaitMs = 200; // ждем до 200 ms
|
||
while (!outgoingQueue.IsEmpty && sw.ElapsedMilliseconds < maxWaitMs)
|
||
{
|
||
// Обрабатываем очередь синхронно
|
||
ProcessOutgoingQueueOnce();
|
||
System.Threading.Thread.Sleep(10);
|
||
}
|
||
}
|
||
catch (Exception) { }
|
||
|
||
StopAsyncPolling();
|
||
ClosePort();
|
||
}
|
||
|
||
private void OnApplicationQuit()
|
||
{
|
||
try { OnBeforeClose?.Invoke(); } catch { }
|
||
|
||
try
|
||
{
|
||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||
const int maxWaitMs = 200;
|
||
while (!outgoingQueue.IsEmpty && sw.ElapsedMilliseconds < maxWaitMs)
|
||
{
|
||
ProcessOutgoingQueueOnce();
|
||
System.Threading.Thread.Sleep(10);
|
||
}
|
||
}
|
||
catch (Exception) { }
|
||
|
||
StopAsyncPolling();
|
||
ClosePort();
|
||
}
|
||
|
||
private void StopAsyncPolling()
|
||
{
|
||
// Только отменяем токен; не вызываем Dispose здесь — StartAsyncPolling() выполнит очистку.
|
||
try
|
||
{
|
||
pollCancellationTokenSource?.Cancel();
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogWarning($"StopAsyncPolling: ошибка при Cancel: {ex.Message}");
|
||
}
|
||
}
|
||
|
||
private async void StartAsyncPolling()
|
||
{
|
||
Debug.Log("Запуск асинхронного опроса портов");
|
||
// Локальная ссылка на CTS
|
||
var cts = pollCancellationTokenSource;
|
||
try
|
||
{
|
||
while (true)
|
||
{
|
||
// Всегда берём свежую ссылку в начале итерации
|
||
cts = pollCancellationTokenSource;
|
||
if (cts == null) break;
|
||
|
||
// Безопасно получаем токен: доступ к cts.Token может выбросить ObjectDisposedException
|
||
CancellationToken token;
|
||
try
|
||
{
|
||
token = cts.Token;
|
||
}
|
||
catch (ObjectDisposedException)
|
||
{
|
||
Debug.LogWarning("StartAsyncPolling: CTS disposed при попытке получить Token — выходим из цикла.");
|
||
break;
|
||
}
|
||
|
||
if (token.IsCancellationRequested) break;
|
||
|
||
try
|
||
{
|
||
// Используем локальный токен для задержек и чтения
|
||
await Task.Delay(10, token); // Опрос каждые 10ms
|
||
|
||
string comInput = await ReadBufferLineAsync(token);
|
||
if (!string.IsNullOrEmpty(comInput))
|
||
{
|
||
try
|
||
{
|
||
ProcessComInputImmediate(comInput);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError($"Ошибка обработки COM-входа: {ex}\nInput=[{comInput}]");
|
||
}
|
||
}
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
// токен отменён — корректный выход из цикла
|
||
break;
|
||
}
|
||
catch (ObjectDisposedException odex)
|
||
{
|
||
Debug.LogWarning($"StartAsyncPolling: CancellationTokenSource был disposed во время ожидания: {odex.Message}");
|
||
break;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Если источник был disposed — не считать это критической ошибкой
|
||
if (ex is ObjectDisposedException)
|
||
{
|
||
Debug.LogWarning($"StartAsyncPolling: ObjectDisposedException (обработано): {ex.Message}");
|
||
break;
|
||
}
|
||
|
||
Debug.LogError($"Внутренняя ошибка опроса порта: {ex}");
|
||
}
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
// Убедимся, что мы корректно освободим CTS и обнулим поле только если оно всё ещё ссылается на тот объект
|
||
try
|
||
{
|
||
var tmp = pollCancellationTokenSource;
|
||
if (tmp != null)
|
||
{
|
||
try { tmp.Cancel(); } catch { }
|
||
try { tmp.Dispose(); } catch { }
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
pollCancellationTokenSource = null;
|
||
Debug.Log("StartAsyncPolling: завершён и pollCancellationTokenSource очищен");
|
||
}
|
||
}
|
||
}
|
||
|
||
private void ProcessComInputImmediate(string comInput)
|
||
{
|
||
if (string.IsNullOrEmpty(comInput)) return;
|
||
Debug.Log(comInput);
|
||
|
||
// INPUT обработка
|
||
if (comInput.StartsWith("INPUT"))
|
||
{
|
||
System.Threading.Interlocked.Exchange(ref inputToggle, 1); // потокобезопасно выставляем флаг для keep-alive
|
||
if (comInput.Length > 5)
|
||
onInputRecieved?.Invoke(comInput.Substring(5));
|
||
else
|
||
onInputRecieved?.Invoke(string.Empty);
|
||
}
|
||
}
|
||
|
||
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;
|
||
}
|
||
|
||
// Опционально открываем 3-й порт (возможная витрина)
|
||
handle3 = 0;
|
||
if (ports.Length >= 3)
|
||
{
|
||
string port3 = ports[^3];
|
||
Debug.Log($"<color=cyan>Найден 3-й порт, открываю (возможная витрина): {port3}</color>");
|
||
handle3 = ComNativeWrapper.Serial_Open(port3, 115200, 8, 0, 1, 5, 10);
|
||
if (handle3 <= 0)
|
||
{
|
||
Debug.LogWarning($"Не удалось открыть порт {port3} (handle3={handle3}), витрина недоступна.");
|
||
handle3 = 0;
|
||
}
|
||
else
|
||
{
|
||
Debug.Log($"<color=cyan>handle3 (возможная витрина): {handle3}</color>");
|
||
}
|
||
}
|
||
|
||
// ВАЖНО: Очищаем буферы портов от старых данных
|
||
Debug.Log("<color=yellow>Очистка буферов портов от старых данных...</color>");
|
||
FlushPortBuffers();
|
||
|
||
// Инициализируем lastCoinTime текущим временем чтобы не обработать старые COIN
|
||
long currentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||
lastCoinTimePlate1 = currentTime;
|
||
lastCoinTimePlate2 = currentTime;
|
||
Debug.Log($"<color=green>Порты инициализированы, debounce активирован с текущего времени: {currentTime}</color>");
|
||
|
||
// Выполняем handshake если требуется
|
||
if (requireHandshake)
|
||
{
|
||
PerformHandshakeForBothBoards(port1, port2);
|
||
}
|
||
|
||
// Определяем роли плат через команду 97 (левая / правая / витрина)
|
||
AssignRoles();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Очищает буферы обоих портов от накопившихся старых данных
|
||
/// </summary>
|
||
private static void FlushPortBuffers()
|
||
{
|
||
int flushedCount1 = 0;
|
||
int flushedCount2 = 0;
|
||
int flushedCount3 = 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; // Защита от бесконечного цикла
|
||
}
|
||
|
||
// Читаем накопившиеся данные из порта 3 (витрина), если открыт
|
||
if (handle3 > 0)
|
||
{
|
||
while (ComNativeWrapper.Serial_ReadLineUTF8_bytes(handle3, buf, buf.Length) > 0)
|
||
{
|
||
flushedCount3++;
|
||
if (flushedCount3 > 100) break;
|
||
}
|
||
}
|
||
|
||
Debug.Log($"<color=cyan>Очищено: Плата1={flushedCount1}, Плата2={flushedCount2}, Плата3={flushedCount3} сообщений</color>");
|
||
}
|
||
|
||
private static void ClosePort()
|
||
{
|
||
if (handle1 > 0) { ComNativeWrapper.Serial_Close(handle1); handle1 = 0; }
|
||
if (handle2 > 0) { ComNativeWrapper.Serial_Close(handle2); handle2 = 0; }
|
||
if (handle3 > 0) { ComNativeWrapper.Serial_Close(handle3); handle3 = 0; }
|
||
// Сбрасываем роль-ориентированные алиасы
|
||
handleLeft = 0; handleRight = 0; handleVitrine = 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()
|
||
{
|
||
try
|
||
{
|
||
string result1 = "";
|
||
string result2 = "";
|
||
bool hasNewInput1 = false;
|
||
bool hasNewInput2 = false;
|
||
|
||
// Используем роль-алиасы, если уже назначены (иначе — raw handle1/handle2)
|
||
int hLeft = handleLeft > 0 ? handleLeft : handle1;
|
||
int hRight = handleRight > 0 ? handleRight : handle2;
|
||
|
||
// Читаем с левой платы (8 кнопок + 2 хопера)
|
||
int read1 = ComNativeWrapper.Serial_ReadLineUTF8_bytes(hLeft, 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>COM-ЛЕВАЯ: COIN проигнорирован (спам через {timeSinceLastCoin}ms < 250ms) - подтверждение НЕ отправлено</color>");
|
||
// НЕ отправляем подтверждение для спама!
|
||
// НЕ обновляем lastCoinTime для спама - это позволит следующей реальной монете пройти
|
||
}
|
||
else
|
||
{
|
||
// Это РЕАЛЬНАЯ новая монета - обрабатываем
|
||
lastCoinTimePlate1 = currentTime;
|
||
|
||
// ВАЖНО: Отправляем подтверждение ТОЛЬКО для реальных монет!
|
||
int confirmSent = ComNativeWrapper.Serial_WriteStringUTF8(hLeft, "10", 1);
|
||
Debug.Log($"<color=cyan>COM-ЛЕВАЯ: 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(hRight, 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>COM-ПРАВАЯ: COIN проигнорирован (спам через {timeSinceLastCoin}ms < 250ms) - подтверждение НЕ отправлено</color>");
|
||
// НЕ отправляем подтверждение для спама!
|
||
// НЕ обновляем lastCoinTime для спама - это позволит следующей реальной монете пройти
|
||
}
|
||
else
|
||
{
|
||
// Это РЕАЛЬНАЯ новая монета - обрабатываем
|
||
lastCoinTimePlate2 = currentTime;
|
||
|
||
// ВАЖНО: Отправляем подтверждение ТОЛЬКО для реальных монет!
|
||
int confirmSent = ComNativeWrapper.Serial_WriteStringUTF8(hRight, "10", 1);
|
||
Debug.Log($"<color=cyan>COM-ПРАВАЯ: 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;
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError($"Ошибка в ReadBufferLine: {ex}");
|
||
}
|
||
|
||
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()
|
||
{
|
||
Debug.Log("Запуск асинхронной проверки соединения");
|
||
var cts = pollCancellationTokenSource;
|
||
try
|
||
{
|
||
while (true)
|
||
{
|
||
// Всегда берем свежую ссылку
|
||
cts = pollCancellationTokenSource;
|
||
if (cts == null) break;
|
||
|
||
CancellationToken token;
|
||
try { token = cts.Token; }
|
||
catch (ObjectDisposedException) { Debug.LogWarning("StartKeepAliveProcess: CTS disposed — выхожу."); break; }
|
||
|
||
if (token.IsCancellationRequested) break;
|
||
|
||
try
|
||
{
|
||
await Task.Delay(2000, token); // Ждем 2 секунды
|
||
|
||
// Запрашиваем INPUT и сбрасываем флаг ожидания
|
||
requestInputs();
|
||
System.Threading.Interlocked.Exchange(ref inputToggle, 0);
|
||
|
||
// Ждём ответа (1 сек)
|
||
await Task.Delay(1000, token);
|
||
|
||
// Проверяем, пришёл ли ответ (потокобезопасно)
|
||
if (System.Threading.Volatile.Read(ref inputToggle) == 0)
|
||
{
|
||
Debug.LogWarning("ComWrapper: платы не отвечают — устанавливаю запрос на переподключение (будет выполнено в Update).");
|
||
needReinit = true;
|
||
}
|
||
|
||
await Task.Delay(7000, token); // Итого ~10 секунд между проверками
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
break;
|
||
}
|
||
catch (ObjectDisposedException)
|
||
{
|
||
Debug.LogWarning("StartKeepAliveProcess: CancellationTokenSource disposed — выхожу.");
|
||
break;
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Debug.LogError($"Ошибка проверки соединения: {e.Message}");
|
||
}
|
||
}
|
||
}
|
||
finally
|
||
{
|
||
// ничего не диспозим здесь — StopAsyncPolling/StartAsyncPolling управляют жизненным циклом CTS
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Выполняет двойное рукопожатие для обеих плат через ComNativeWrapper
|
||
/// </summary>
|
||
private void PerformHandshakeForBothBoards(string port1Name, string port2Name)
|
||
{
|
||
Debug.Log("=== НАЧАЛО ДВОЙНОГО РУКОПОЖАТИЯ ===");
|
||
|
||
bool success1 = PerformHandshakeSingle(handle1, 1);
|
||
bool success2 = PerformHandshakeSingle(handle2, 2);
|
||
bool success3 = handle3 > 0 && PerformHandshakeSingle(handle3, 3);
|
||
|
||
if (success1 && success2)
|
||
{
|
||
Debug.Log("<color=green>=== ОБЕ ИГРОВЫЕ ПЛАТЫ АВТОРИЗОВАНЫ ===</color>");
|
||
}
|
||
else
|
||
{
|
||
Debug.LogError($"<color=red>=== HANDSHAKE ПРОВАЛЕН: Плата1={success1}, Плата2={success2} ===</color>");
|
||
}
|
||
|
||
if (handle3 > 0)
|
||
{
|
||
if (success3) Debug.Log("<color=cyan>=== ВИТРИНА АВТОРИЗОВАНА ===</color>");
|
||
else Debug.LogWarning("<color=yellow>=== HANDSHAKE ВИТРИНЫ ПРОВАЛЕН (продолжаем без витрины) ===</color>");
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Выполняет handshake для одной платы (07→CHAL→08→RAES)
|
||
/// </summary>
|
||
private bool PerformHandshakeSingle(int handle, int plateId)
|
||
{
|
||
if (handle <= 0)
|
||
{
|
||
Debug.LogError($"Handshake [{plateId}]: невалидный handle={handle}");
|
||
return false;
|
||
}
|
||
|
||
try
|
||
{
|
||
// 1. Генерируем случайный блок 16 байт
|
||
string randomHex = AESCrypto.GenerateRandomBlock16Hex();
|
||
string cmd1 = "07" + randomHex + "\r\n";
|
||
|
||
Debug.Log($"Handshake [{plateId}]: → 07+random...");
|
||
ComNativeWrapper.Serial_WriteStringUTF8(handle, cmd1, 1);
|
||
|
||
// 2. Ждем ответ CHAL
|
||
string chalResponse = WaitForResponse(handle, "CHAL", 3000);
|
||
if (string.IsNullOrEmpty(chalResponse) || chalResponse.Length < 36)
|
||
{
|
||
Debug.LogWarning($"Handshake [{plateId}]: нет ответа CHAL");
|
||
return false;
|
||
}
|
||
|
||
string serverChalHex = chalResponse.Substring(4, 32);
|
||
Debug.Log($"Handshake [{plateId}]: ← CHAL={serverChalHex.Substring(0, 8)}...");
|
||
|
||
// 3. Шифруем и отправляем 08+encrypted
|
||
string encryptedChal = AESCrypto.EncryptHexBlock16(serverChalHex);
|
||
string cmd2 = "08" + encryptedChal + "\r\n";
|
||
|
||
Debug.Log($"Handshake [{plateId}]: → 08+encrypted...");
|
||
ComNativeWrapper.Serial_WriteStringUTF8(handle, cmd2, 1);
|
||
|
||
// 4. Ждем ответ RAES
|
||
string raesResponse = WaitForResponse(handle, "RAES", 3000);
|
||
if (string.IsNullOrEmpty(raesResponse) || raesResponse.Length < 36)
|
||
{
|
||
Debug.LogWarning($"Handshake [{plateId}]: нет ответа RAES");
|
||
return false;
|
||
}
|
||
|
||
string raesHex = raesResponse.Substring(4, 32);
|
||
Debug.Log($"Handshake [{plateId}]: ← RAES={raesHex.Substring(0, 8)}...");
|
||
|
||
// 5. Расшифровываем и проверяем
|
||
string decryptedRaes = AESCrypto.DecryptHexBlock16(raesHex);
|
||
|
||
if (string.Equals(decryptedRaes, randomHex, StringComparison.OrdinalIgnoreCase))
|
||
{
|
||
Debug.Log($"<color=green>Handshake [{plateId}]: ✓ УСПЕХ</color>");
|
||
return true;
|
||
}
|
||
else
|
||
{
|
||
Debug.LogError($"Handshake [{plateId}]: ✗ Проверка провалена! Expected={randomHex.Substring(0, 8)}..., Got={decryptedRaes.Substring(0, 8)}...");
|
||
return false;
|
||
}
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError($"Handshake [{plateId}]: Exception: {ex.Message}");
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Ожидает ответ с определенным префиксом от платы
|
||
/// </summary>
|
||
private string WaitForResponse(int handle, string prefix, int timeoutMs)
|
||
{
|
||
float elapsed = 0;
|
||
byte[] localBuf = new byte[1024];
|
||
|
||
while (elapsed < timeoutMs)
|
||
{
|
||
int read = ComNativeWrapper.Serial_ReadLineUTF8_bytes(handle, localBuf, localBuf.Length);
|
||
if (read > 0)
|
||
{
|
||
string line = Encoding.UTF8.GetString(localBuf, 0, read).Trim();
|
||
if (line.StartsWith(prefix))
|
||
{
|
||
return line;
|
||
}
|
||
}
|
||
|
||
System.Threading.Thread.Sleep(10);
|
||
elapsed += 10;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Запрашивает роль платы командой 97 и возвращает её (в верхнем регистре, обрезанную).
|
||
/// Ответ платы ожидается в формате: "ROLE : ЛЕВАЯ" / "ROLE : ПРАВАЯ" / "ROLE : ВИТРИНА"
|
||
/// </summary>
|
||
private string RequestBoardRole(int handle)
|
||
{
|
||
if (handle <= 0) return string.Empty;
|
||
|
||
try
|
||
{
|
||
// Отправляем команду запроса роли
|
||
ComNativeWrapper.Serial_WriteStringUTF8(handle, CMD_GET_ROLE, 1);
|
||
|
||
// Ждём ответ с префиксом "ROLE" до 2 секунд
|
||
string response = WaitForResponse(handle, "ROLE", 2000);
|
||
if (string.IsNullOrEmpty(response))
|
||
{
|
||
Debug.LogWarning($"RequestBoardRole (handle={handle}): нет ответа на команду {CMD_GET_ROLE}");
|
||
return string.Empty;
|
||
}
|
||
|
||
// Парсим "ROLE : <название>" → ищем двоеточие
|
||
int colonIdx = response.IndexOf(':');
|
||
if (colonIdx >= 0 && colonIdx < response.Length - 1)
|
||
{
|
||
string role = response.Substring(colonIdx + 1).Trim().ToUpper();
|
||
return role;
|
||
}
|
||
|
||
Debug.LogWarning($"RequestBoardRole (handle={handle}): неожиданный формат ответа: '{response}'");
|
||
return string.Empty;
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
Debug.LogError($"RequestBoardRole (handle={handle}): Exception: {ex.Message}");
|
||
return string.Empty;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Определяет роли плат (ЛЕВАЯ / ПРАВАЯ / ВИТРИНА) через команду 97.
|
||
/// Заполняет handleLeft, handleRight, handleVitrine.
|
||
/// Если роль платы не определена — выводит предупреждение, fallback на порядок портов.
|
||
/// </summary>
|
||
private void AssignRoles()
|
||
{
|
||
Debug.Log("<color=yellow>=== ОПРЕДЕЛЕНИЕ РОЛЕЙ ПЛАТ (команда 97) ===</color>");
|
||
|
||
handleLeft = 0;
|
||
handleRight = 0;
|
||
handleVitrine = 0;
|
||
|
||
// Перебираем все открытые хэндлы
|
||
int[] allHandles = { handle1, handle2, handle3 };
|
||
foreach (int h in allHandles)
|
||
{
|
||
if (h <= 0) continue;
|
||
|
||
string role = RequestBoardRole(h);
|
||
Debug.Log($"<color=cyan>Плата handle={h}: роль = '{role}'</color>");
|
||
|
||
switch (role)
|
||
{
|
||
case ROLE_LEFT:
|
||
handleLeft = h;
|
||
Debug.Log($"<color=green>→ handle={h} назначена как ЛЕВАЯ (player 1)</color>");
|
||
break;
|
||
case ROLE_RIGHT:
|
||
handleRight = h;
|
||
Debug.Log($"<color=green>→ handle={h} назначена как ПРАВАЯ (player 2)</color>");
|
||
break;
|
||
case ROLE_VITRINE:
|
||
handleVitrine = h;
|
||
Debug.Log($"<color=cyan>→ handle={h} назначена как ВИТРИНА (showcase)</color>");
|
||
break;
|
||
default:
|
||
Debug.LogWarning($"Плата handle={h}: неизвестная или пустая роль '{role}' — будет использован fallback");
|
||
break;
|
||
}
|
||
}
|
||
|
||
// Fallback: если игровые платы не ответили на запрос роли — используем порядок портов
|
||
if (handleLeft == 0 && handle1 > 0)
|
||
{
|
||
handleLeft = handle1;
|
||
Debug.LogWarning($"<color=orange>ЛЕВАЯ роль не определена — используем handle1={handle1} как fallback</color>");
|
||
}
|
||
if (handleRight == 0 && handle2 > 0)
|
||
{
|
||
handleRight = handle2;
|
||
Debug.LogWarning($"<color=orange>ПРАВАЯ роль не определена — используем handle2={handle2} как fallback</color>");
|
||
}
|
||
|
||
Debug.Log($"<color=green>=== РОЛИ: Левая={handleLeft}, Правая={handleRight}, Витрина={handleVitrine} ===</color>");
|
||
}
|
||
|
||
#endregion
|
||
}
|