refactor: Refactor work with COM to ComSystemManager

- Добавлен ComSystemManager как новый публичный компонент COM-системы для поэтапной замены ComWrapper
- Реализовано получение роли контроллера только после успешного handshake в ComDeviceClient
- Добавлено публичное поле ControllerRole в ComDeviceClient
- Добавлен запрос роли по команде 97 и разбор ответа формата ROLE : <value> (с пробелами)
- В ComMultiManager добавлена логика выбора DefaultDeviceId по роли main_controller
- Добавлен доступ к роли устройства через GetDeviceRole(deviceId) в менеджере и фасаде
- В ComWrapper добавлены настройки роли: roleRequestCommand, roleResponsePrefix, roleRequestTimeoutMs, mainControllerRole
- Дефолтная команда запроса роли изменена на 97
- Выполнена кодовая миграция зависимостей с ComWrapper на ComSystemManager в GameManager, GlobalsManager, PaymentManager, BalanceManager, InactivityManager, COMInput, LightController, PrizeSystem
- В GlobalsManager исправлена инициализация ссылки после AddComponent<ComSystemManager>(), чтобы исключить NullReference
This commit is contained in:
Данил Ярош 2026-02-20 14:57:20 +03:00
parent 6b02c76ef3
commit 32cfa79578
23 changed files with 1644 additions and 671 deletions

0
.ai/mcp/mcp.json Normal file
View File

View File

@ -12,6 +12,7 @@ GameObject:
- component: {fileID: -6830167632558745604}
- component: {fileID: 7290507550622501242}
- component: {fileID: 4577859410310799261}
- component: {fileID: -3401184227323537348}
- component: {fileID: -6245296037342915518}
- component: {fileID: 6566017136146807207}
- component: {fileID: 5633451267958679388}
@ -80,6 +81,39 @@ MonoBehaviour:
saveMoneyBetweenSessions: 0
enablePrizeChecking: 0
keepAlive: 1
enableEncryptedHandshake: 1
handshakeTimeoutMs: 5000
roleRequestCommand: 97
roleResponsePrefix: ROLE
roleRequestTimeoutMs: 1500
mainControllerRole: main_controller
useAllDetectedPorts: 1
explicitPorts: []
defaultDevicePort:
--- !u!114 &-3401184227323537348
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 4763496076470024150}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 1c4ba28ebcf4f5e42bddbc16592fe81c, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::RR.Common.Controls.ComSystemManager
saveMoneyBetweenSessions: 0
enablePrizeChecking: 0
keepAlive: 1
enableEncryptedHandshake: 1
handshakeTimeoutMs: 5000
roleRequestCommand: 97
roleResponsePrefix: ROLE
roleRequestTimeoutMs: 1500
mainControllerRole: main_controller
useAllDetectedPorts: 1
explicitPorts: []
defaultDevicePort:
--- !u!114 &-6245296037342915518
MonoBehaviour:
m_ObjectHideFlags: 0

View File

@ -1,4 +1,4 @@
using System;
using System;
using RR.Game2D.Core.Managers;
using UnityEngine;
@ -31,7 +31,7 @@ namespace RR.Common.Controls
gm = GameManager.Instance;
//ОБЯЗАТЕЛЬНЫЙ БЛОК
ComWrapper.OnInputReceived.AddListener(UpdateInputState);
ComSystemManager.OnInputReceived.AddListener(UpdateInputState);
}
catch (Exception e)
@ -261,4 +261,4 @@ namespace RR.Common.Controls
return result;
}
}
}
}

View File

@ -0,0 +1,723 @@
using System;
using System.Security.Cryptography;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using RR.Game2D.Core.Managers;
namespace RR.Common.Controls
{
internal sealed class ComDeviceClient
{
private const string RequestInput = "91";
private const string DefaultInputBits = "11111111111111111111111111";
private const long CoinDebounceMs = 250;
private static readonly byte[] AesEncryptedKey =
{
0x69, 0x40, 0xC7, 0x0E, 0xF2, 0xC7, 0x45, 0x70,
0x72, 0xCD, 0x28, 0x58, 0x99, 0x8F, 0x73, 0xA8,
0x5F, 0x5F, 0xED, 0xB4, 0xC1, 0x62, 0xB4, 0x24,
0xAC, 0xD7, 0xA3, 0xD6, 0xD8, 0x0B, 0x50, 0x37
};
private readonly object _ioLock = new object();
private readonly bool _enableEncryptedHandshake;
private readonly int _handshakeTimeoutMs;
private readonly bool _keepAlive;
private readonly string _roleRequestCommand;
private readonly string _roleResponsePrefix;
private readonly int _roleRequestTimeoutMs;
private CancellationTokenSource _lifecycleCts;
private Task _pollTask;
private Task _keepAliveTask;
private int _handle;
private long _lastCoinTime;
private int _inputToggle;
private bool _isAuthorized;
private bool _isOperational;
private bool _handshakeInProgress;
private string _requestHex = string.Empty;
private string _lastInputBits = DefaultInputBits;
private byte[] _aesKey32;
private HandshakeStage _handshakeStage = HandshakeStage.None;
private TaskCompletionSource<bool> _handshakeCompletion;
private TaskCompletionSource<string> _roleCompletion;
private enum HandshakeStage
{
None,
WaitingRaes,
WaitingChal,
WaitingReady,
Completed,
Failed
}
public string DeviceId { get; }
public string PortName { get; }
public bool IsOperational => _isOperational;
public string LastInputBits => _lastInputBits;
public string ControllerRole { get; private set; } = string.Empty;
public event Action<string> CoinAccepted;
public event Action<string, string> InputReceived;
public event Action<string> Authorized;
public event Action<string> Disconnected;
public ComDeviceClient(
string portName,
bool enableEncryptedHandshake,
int handshakeTimeoutMs,
bool keepAlive,
string roleRequestCommand,
string roleResponsePrefix,
int roleRequestTimeoutMs)
{
PortName = portName;
DeviceId = portName;
_enableEncryptedHandshake = enableEncryptedHandshake;
_handshakeTimeoutMs = Math.Max(500, handshakeTimeoutMs);
_keepAlive = keepAlive;
_roleRequestCommand = roleRequestCommand?.Trim() ?? string.Empty;
_roleResponsePrefix = string.IsNullOrWhiteSpace(roleResponsePrefix) ? "ROLE" : roleResponsePrefix.Trim();
_roleRequestTimeoutMs = Math.Max(200, roleRequestTimeoutMs);
if (_enableEncryptedHandshake)
{
_aesKey32 = BuildAesKey();
}
}
public bool Start()
{
if (!OpenPort()) return false;
_lifecycleCts = new CancellationTokenSource();
_pollTask = Task.Run(() => PollLoopAsync(_lifecycleCts.Token), _lifecycleCts.Token);
if (_enableEncryptedHandshake)
{
_ = RunHandshakeAsync(_lifecycleCts.Token);
}
else
{
MarkOperational();
}
return true;
}
public void Stop(bool sendShutdownCommands)
{
try
{
_lifecycleCts?.Cancel();
}
catch
{
// ignored
}
if (sendShutdownCommands)
{
SendRaw("2170", true);
SendRaw("2130", true);
}
lock (_ioLock)
{
if (_handle > 0)
{
ComNativeWrapper.Serial_Close(_handle);
_handle = 0;
}
}
_isOperational = false;
_isAuthorized = false;
_handshakeInProgress = false;
_handshakeStage = HandshakeStage.None;
_handshakeCompletion?.TrySetCanceled();
_roleCompletion?.TrySetCanceled();
ControllerRole = string.Empty;
Disconnected?.Invoke(DeviceId);
_lifecycleCts?.Dispose();
_lifecycleCts = null;
}
public void Send(string message)
{
SendRaw(message, false);
}
private bool OpenPort()
{
int handle = ComNativeWrapper.Serial_Open(PortName, 115200, 8, 0, 1, 5, 10);
if (handle <= 0)
{
GameManager.Logger?.LogError("ComDeviceClient", $"[{DeviceId}] Open failed: {ComNativeWrapper.GetLastError()}");
return false;
}
lock (_ioLock)
{
_handle = handle;
}
_lastCoinTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
FlushPortBuffer();
return true;
}
private async Task PollLoopAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
await Task.Delay(1, token);
string line = ReadBufferLine();
if (string.IsNullOrEmpty(line)) continue;
ProcessLine(line);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception e)
{
GameManager.Logger?.LogError("ComDeviceClient", $"[{DeviceId}] Poll error: {e.Message}");
}
}
}
private void ProcessLine(string line)
{
line = line.Trim();
if (string.IsNullOrEmpty(line)) return;
if (_handshakeInProgress)
{
TryProcessHandshakeLine(line);
return;
}
if (!_isOperational) return;
if (TryHandleRoleLine(line))
{
return;
}
if (line.StartsWith("INPUT", StringComparison.Ordinal))
{
_inputToggle = 1;
if (line.Length <= 5) return;
string rawData = line.Substring(5);
if (string.IsNullOrWhiteSpace(rawData)) return;
string bits = DecimalToBinary(rawData);
_lastInputBits = bits;
InputReceived?.Invoke(DeviceId, bits);
}
}
private async Task RunHandshakeAsync(CancellationToken token)
{
_handshakeInProgress = true;
_isAuthorized = false;
_isOperational = false;
_handshakeStage = HandshakeStage.WaitingRaes;
_handshakeCompletion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
SendRaw("00", true);
await Task.Delay(1000, token);
_requestHex = GenerateRandomBlock16Hex();
SendRaw("07" + _requestHex, true);
Task timeoutTask = Task.Delay(_handshakeTimeoutMs, token);
Task finished = await Task.WhenAny(_handshakeCompletion.Task, timeoutTask);
if (finished == _handshakeCompletion.Task && _handshakeCompletion.Task.Result)
{
MarkOperational();
return;
}
FailHandshake("Таймаут рукопожатия");
}
private void TryProcessHandshakeLine(string line)
{
if (line.Equals("READY", StringComparison.OrdinalIgnoreCase))
{
if (_handshakeStage != HandshakeStage.WaitingReady)
{
FailHandshake($"READY в неверной фазе: {_handshakeStage}");
return;
}
_handshakeStage = HandshakeStage.Completed;
_isAuthorized = true;
_handshakeCompletion?.TrySetResult(true);
return;
}
if (line.StartsWith("RAES", StringComparison.Ordinal))
{
HandleRaes(line);
return;
}
if (line.StartsWith("CHAL", StringComparison.Ordinal))
{
HandleChal(line);
}
}
private void HandleRaes(string line)
{
try
{
if (_handshakeStage != HandshakeStage.WaitingRaes)
{
FailHandshake($"RAES в неверной фазе: {_handshakeStage}");
return;
}
if (line.Length < 36)
{
FailHandshake("Некорректная длина RAES");
return;
}
string hex = line.Substring(4, 32);
if (!IsHexString(hex, 32))
{
FailHandshake("RAES содержит некорректный HEX");
return;
}
string decrypted = DecryptHexBlock16(hex);
if (!string.Equals(decrypted, _requestHex, StringComparison.OrdinalIgnoreCase))
{
FailHandshake("RAES не совпадает с запросом 07");
return;
}
_handshakeStage = HandshakeStage.WaitingChal;
}
catch (Exception e)
{
FailHandshake($"Ошибка RAES: {e.Message}");
}
}
private void HandleChal(string line)
{
try
{
if (_handshakeStage != HandshakeStage.WaitingChal)
{
FailHandshake($"CHAL в неверной фазе: {_handshakeStage}");
return;
}
if (line.Length < 36)
{
FailHandshake("Некорректная длина CHAL");
return;
}
string hex = line.Substring(4, 32);
if (!IsHexString(hex, 32))
{
FailHandshake("CHAL содержит некорректный HEX");
return;
}
string encrypted = EncryptHexBlock16(hex);
SendRaw("08" + encrypted, true);
_handshakeStage = HandshakeStage.WaitingReady;
}
catch (Exception e)
{
FailHandshake($"Ошибка CHAL: {e.Message}");
}
}
private void MarkOperational()
{
_handshakeInProgress = false;
_isAuthorized = true;
_isOperational = true;
_handshakeStage = HandshakeStage.Completed;
ControllerRole = string.Empty;
SendRaw("2171", true);
if (_keepAlive)
{
_keepAliveTask = Task.Run(() => KeepAliveLoopAsync(_lifecycleCts.Token), _lifecycleCts.Token);
}
_ = Task.Run(() => ResolveRoleAfterAuthorizationAsync(_lifecycleCts.Token), _lifecycleCts.Token);
}
private void FailHandshake(string message)
{
_isAuthorized = false;
_isOperational = false;
_handshakeInProgress = false;
_handshakeStage = HandshakeStage.Failed;
_handshakeCompletion?.TrySetResult(false);
GameManager.Logger?.LogError("ComDeviceClient", $"[{DeviceId}] {message}");
}
private async Task KeepAliveLoopAsync(CancellationToken token)
{
while (!token.IsCancellationRequested)
{
try
{
await Task.Delay(2000, token);
if (!_isOperational)
{
await Task.Delay(8000, token);
continue;
}
SendRaw(RequestInput, false);
_inputToggle = 0;
await Task.Delay(1000, token);
if (_inputToggle == 0)
{
await ReconnectAsync(token);
}
await Task.Delay(7000, token);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception e)
{
GameManager.Logger?.LogError("ComDeviceClient", $"[{DeviceId}] KeepAlive error: {e.Message}");
}
}
}
private async Task ReconnectAsync(CancellationToken token)
{
_isOperational = false;
_isAuthorized = false;
_handshakeInProgress = false;
_handshakeStage = HandshakeStage.None;
_lastInputBits = DefaultInputBits;
_inputToggle = 0;
ControllerRole = string.Empty;
lock (_ioLock)
{
if (_handle > 0)
{
ComNativeWrapper.Serial_Close(_handle);
_handle = 0;
}
}
if (!OpenPort())
{
return;
}
if (_enableEncryptedHandshake)
{
await RunHandshakeAsync(token);
}
else
{
MarkOperational();
}
}
private void FlushPortBuffer()
{
lock (_ioLock)
{
if (_handle <= 0) return;
byte[] localBuf = new byte[1024];
int flushedCount = 0;
int read;
while ((read = ComNativeWrapper.Serial_ReadLineUTF8_bytes(_handle, localBuf, localBuf.Length)) > 0)
{
flushedCount++;
if (flushedCount > 100) break;
}
}
}
private string ReadBufferLine()
{
lock (_ioLock)
{
if (_handle <= 0) return string.Empty;
byte[] localBuf = new byte[1024];
int read = ComNativeWrapper.Serial_ReadLineUTF8_bytes(_handle, localBuf, localBuf.Length);
if (read <= 0) return string.Empty;
if (read > localBuf.Length) read = localBuf.Length;
string line = Encoding.UTF8.GetString(localBuf, 0, read).Trim();
if (line != "COIN") return line;
long now = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
if (now - _lastCoinTime < CoinDebounceMs) return string.Empty;
_lastCoinTime = now;
if (_isOperational)
{
ComNativeWrapper.Serial_WriteStringUTF8(_handle, "10", 1);
CoinAccepted?.Invoke(DeviceId);
}
return string.Empty;
}
}
private void SendRaw(string message, bool bypassAuthorization)
{
if (string.IsNullOrWhiteSpace(message)) return;
if (!bypassAuthorization && !_isOperational) return;
lock (_ioLock)
{
if (_handle <= 0) return;
ComNativeWrapper.Serial_WriteStringUTF8(_handle, message, 1);
}
}
private bool TryHandleRoleLine(string line)
{
if (_roleCompletion == null) return false;
if (!_roleCompletion.Task.IsCompleted && TryExtractRole(line, out string role))
{
ControllerRole = role;
_roleCompletion.TrySetResult(role);
return true;
}
return false;
}
private async Task ResolveRoleAfterAuthorizationAsync(CancellationToken token)
{
// Роль считываем только после завершенной авторизации рукопожатия.
if (!_enableEncryptedHandshake || !_isAuthorized || string.IsNullOrWhiteSpace(_roleRequestCommand))
{
Authorized?.Invoke(DeviceId);
return;
}
try
{
_roleCompletion = new TaskCompletionSource<string>(TaskCreationOptions.RunContinuationsAsynchronously);
SendRaw(_roleRequestCommand, false);
Task timeoutTask = Task.Delay(_roleRequestTimeoutMs, token);
Task finished = await Task.WhenAny(_roleCompletion.Task, timeoutTask);
if (finished == _roleCompletion.Task &&
_roleCompletion.Task.IsCompletedSuccessfully &&
!string.IsNullOrWhiteSpace(_roleCompletion.Task.Result))
{
ControllerRole = _roleCompletion.Task.Result;
}
else
{
GameManager.Logger?.LogWarning("ComDeviceClient",
$"[{DeviceId}] Не удалось получить роль контроллера за {_roleRequestTimeoutMs}ms");
}
}
catch (OperationCanceledException)
{
// ignore
}
catch (Exception e)
{
GameManager.Logger?.LogError("ComDeviceClient", $"[{DeviceId}] Role resolve error: {e.Message}");
}
finally
{
Authorized?.Invoke(DeviceId);
}
}
private bool TryExtractRole(string line, out string role)
{
role = string.Empty;
if (string.IsNullOrWhiteSpace(line)) return false;
string trimmed = line.Trim();
if (!trimmed.StartsWith(_roleResponsePrefix, StringComparison.OrdinalIgnoreCase))
{
return false;
}
string tail = trimmed.Substring(_roleResponsePrefix.Length).Trim();
if (tail.StartsWith(":") || tail.StartsWith("="))
{
tail = tail.Substring(1).Trim();
}
if (string.IsNullOrWhiteSpace(tail)) return false;
role = tail;
return true;
}
private static string DecimalToBinary(string decimalString)
{
try
{
if (!long.TryParse(decimalString, out long value)) return DefaultInputBits;
string binary = Convert.ToString(value, 2);
char[] chars = binary.ToCharArray();
Array.Reverse(chars);
return new string(chars);
}
catch
{
return DefaultInputBits;
}
}
private static byte[] BuildAesKey()
{
byte[] key = new byte[32];
for (int i = 0; i < key.Length; i++)
{
key[i] = (byte)(AesEncryptedKey[i] ^ 0x37);
}
return key;
}
private string EncryptHexBlock16(string plainHex)
{
byte[] plain = HexToBytes(plainHex);
if (plain.Length != 16) throw new InvalidOperationException("Plain must be 16 bytes.");
return BytesToHex(Aes256EcbEncrypt(plain));
}
private string DecryptHexBlock16(string cipherHex)
{
byte[] cipher = HexToBytes(cipherHex);
if (cipher.Length != 16) throw new InvalidOperationException("Cipher must be 16 bytes.");
return BytesToHex(Aes256EcbDecrypt(cipher));
}
private byte[] Aes256EcbEncrypt(byte[] data)
{
using (Aes aes = Aes.Create())
{
aes.Key = _aesKey32;
aes.Mode = CipherMode.ECB;
aes.Padding = PaddingMode.None;
using (ICryptoTransform encryptor = aes.CreateEncryptor())
{
return encryptor.TransformFinalBlock(data, 0, data.Length);
}
}
}
private byte[] Aes256EcbDecrypt(byte[] data)
{
using (Aes aes = Aes.Create())
{
aes.Key = _aesKey32;
aes.Mode = CipherMode.ECB;
aes.Padding = PaddingMode.None;
using (ICryptoTransform decryptor = aes.CreateDecryptor())
{
return decryptor.TransformFinalBlock(data, 0, data.Length);
}
}
}
private static string GenerateRandomBlock16Hex()
{
byte[] buffer = new byte[16];
RandomNumberGenerator.Fill(buffer);
return BytesToHex(buffer);
}
private static byte[] HexToBytes(string hex)
{
if (hex == null) throw new ArgumentNullException(nameof(hex));
hex = hex.Trim();
if (hex.Length % 2 != 0)
{
throw new InvalidOperationException("Hex string length must be even.");
}
byte[] result = new byte[hex.Length / 2];
for (int i = 0; i < result.Length; i++)
{
result[i] = Convert.ToByte(hex.Substring(i * 2, 2), 16);
}
return result;
}
private static string BytesToHex(byte[] bytes)
{
StringBuilder builder = new StringBuilder(bytes.Length * 2);
for (int i = 0; i < bytes.Length; i++)
{
builder.AppendFormat("{0:X2}", bytes[i]);
}
return builder.ToString();
}
private static bool IsHexString(string value, int expectedLength)
{
if (string.IsNullOrWhiteSpace(value) || value.Length != expectedLength)
{
return false;
}
for (int i = 0; i < value.Length; i++)
{
if (!Uri.IsHexDigit(value[i]))
{
return false;
}
}
return true;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: a37a338f0b42c7d45b7fae0e3702ea38

View File

@ -0,0 +1,244 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using RR.Game2D.Core.Managers;
namespace RR.Common.Controls
{
internal sealed class ComMultiManager
{
private readonly Dictionary<string, ComDeviceClient> _devices = new();
private readonly ConcurrentQueue<string> _coinQueue = new();
private readonly ConcurrentQueue<DeviceInputMessage> _inputQueue = new();
private readonly ConcurrentQueue<string> _authorizedQueue = new();
private readonly bool _useAllDetectedPorts;
private readonly string[] _explicitPorts;
private readonly string _defaultDevicePort;
private readonly bool _enableEncryptedHandshake;
private readonly int _handshakeTimeoutMs;
private readonly bool _keepAlive;
private readonly string _roleRequestCommand;
private readonly string _roleResponsePrefix;
private readonly int _roleRequestTimeoutMs;
private readonly string _mainControllerRole;
private string _defaultDeviceId = string.Empty;
public ComMultiManager(
bool useAllDetectedPorts,
string[] explicitPorts,
string defaultDevicePort,
bool enableEncryptedHandshake,
int handshakeTimeoutMs,
bool keepAlive,
string roleRequestCommand,
string roleResponsePrefix,
int roleRequestTimeoutMs,
string mainControllerRole)
{
_useAllDetectedPorts = useAllDetectedPorts;
_explicitPorts = explicitPorts ?? Array.Empty<string>();
_defaultDevicePort = defaultDevicePort ?? string.Empty;
_enableEncryptedHandshake = enableEncryptedHandshake;
_handshakeTimeoutMs = handshakeTimeoutMs;
_keepAlive = keepAlive;
_roleRequestCommand = roleRequestCommand ?? string.Empty;
_roleResponsePrefix = roleResponsePrefix ?? "ROLE";
_roleRequestTimeoutMs = roleRequestTimeoutMs;
_mainControllerRole = string.IsNullOrWhiteSpace(mainControllerRole) ? "main_controller" : mainControllerRole;
}
public string DefaultDeviceId => _defaultDeviceId;
public string[] ConnectedDeviceIds => _devices.Keys.ToArray();
public void InitializeDevices()
{
ComNativeWrapper.Serial_CloseAll();
string[] allPorts = ComNativeWrapper.ListPorts();
if (allPorts.Length == 0)
{
GameManager.Logger?.LogWarning("ComMultiManager", "Не найдено COM-портов");
return;
}
string[] selectedPorts = SelectPorts(allPorts);
foreach (string port in selectedPorts)
{
TryCreateDevice(port);
}
ResolveDefaultDeviceId();
}
public void ShutdownAll(bool sendShutdownCommands)
{
foreach (ComDeviceClient client in _devices.Values)
{
UnsubscribeDevice(client);
client.Stop(sendShutdownCommands);
}
_devices.Clear();
_defaultDeviceId = string.Empty;
while (_coinQueue.TryDequeue(out _)) { }
while (_inputQueue.TryDequeue(out _)) { }
while (_authorizedQueue.TryDequeue(out _)) { }
}
public bool SendToDefault(string message)
{
if (string.IsNullOrEmpty(_defaultDeviceId)) return false;
return SendToDevice(_defaultDeviceId, message);
}
public bool SendToDevice(string deviceId, string message)
{
if (string.IsNullOrWhiteSpace(deviceId)) return false;
if (!_devices.TryGetValue(deviceId, out ComDeviceClient client)) return false;
client.Send(message);
return true;
}
public void SendToAll(string message)
{
foreach (ComDeviceClient client in _devices.Values)
{
client.Send(message);
}
}
public string GetLastInputState(string deviceId)
{
if (string.IsNullOrWhiteSpace(deviceId)) return "11111111111111111111111111";
return _devices.TryGetValue(deviceId, out ComDeviceClient client)
? client.LastInputBits
: "11111111111111111111111111";
}
public bool TryDequeueCoin(out string deviceId) => _coinQueue.TryDequeue(out deviceId);
public bool TryDequeueInput(out DeviceInputMessage message) => _inputQueue.TryDequeue(out message);
public bool TryDequeueAuthorized(out string deviceId) => _authorizedQueue.TryDequeue(out deviceId);
public string GetDeviceRole(string deviceId)
{
if (string.IsNullOrWhiteSpace(deviceId)) return string.Empty;
return _devices.TryGetValue(deviceId, out ComDeviceClient client)
? client.ControllerRole
: string.Empty;
}
private string[] SelectPorts(string[] allPorts)
{
if (_useAllDetectedPorts) return allPorts;
if (_explicitPorts.Length == 0) return Array.Empty<string>();
HashSet<string> explicitSet = new HashSet<string>(_explicitPorts.Where(p => !string.IsNullOrWhiteSpace(p)));
return allPorts.Where(explicitSet.Contains).ToArray();
}
private void ResolveDefaultDeviceId()
{
if (!string.IsNullOrWhiteSpace(_defaultDevicePort) && _devices.ContainsKey(_defaultDevicePort))
{
_defaultDeviceId = _defaultDevicePort;
return;
}
_defaultDeviceId = _devices.Keys.FirstOrDefault() ?? string.Empty;
}
private void TryCreateDevice(string port)
{
ComDeviceClient client = new ComDeviceClient(
port,
_enableEncryptedHandshake,
_handshakeTimeoutMs,
_keepAlive,
_roleRequestCommand,
_roleResponsePrefix,
_roleRequestTimeoutMs);
SubscribeDevice(client);
if (!client.Start())
{
UnsubscribeDevice(client);
return;
}
_devices[client.DeviceId] = client;
}
private void SubscribeDevice(ComDeviceClient client)
{
client.CoinAccepted += OnDeviceCoinAccepted;
client.InputReceived += OnDeviceInputReceived;
client.Authorized += OnDeviceAuthorized;
client.Disconnected += OnDeviceDisconnected;
}
private void UnsubscribeDevice(ComDeviceClient client)
{
client.CoinAccepted -= OnDeviceCoinAccepted;
client.InputReceived -= OnDeviceInputReceived;
client.Authorized -= OnDeviceAuthorized;
client.Disconnected -= OnDeviceDisconnected;
}
private void OnDeviceCoinAccepted(string deviceId)
{
_coinQueue.Enqueue(deviceId);
}
private void OnDeviceInputReceived(string deviceId, string inputBits)
{
_inputQueue.Enqueue(new DeviceInputMessage(deviceId, inputBits));
}
private void OnDeviceAuthorized(string deviceId)
{
if (_devices.TryGetValue(deviceId, out ComDeviceClient client) &&
string.Equals(client.ControllerRole, _mainControllerRole, StringComparison.OrdinalIgnoreCase))
{
_defaultDeviceId = deviceId;
}
else if (string.IsNullOrWhiteSpace(_defaultDeviceId))
{
_defaultDeviceId = deviceId;
}
_authorizedQueue.Enqueue(deviceId);
}
private void OnDeviceDisconnected(string deviceId)
{
if (!_devices.TryGetValue(deviceId, out ComDeviceClient client)) return;
UnsubscribeDevice(client);
_devices.Remove(deviceId);
if (_defaultDeviceId == deviceId)
{
_defaultDeviceId = _devices.Keys.FirstOrDefault() ?? string.Empty;
}
}
}
internal readonly struct DeviceInputMessage
{
public readonly string DeviceId;
public readonly string InputBits;
public DeviceInputMessage(string deviceId, string inputBits)
{
DeviceId = deviceId;
InputBits = inputBits;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: edad4940535f5fb46bb83766432da396

View File

@ -0,0 +1,13 @@
using UnityEngine;
namespace RR.Common.Controls
{
/// <summary>
/// Новый публичный компонент COM-системы.
/// Наследует реализацию от <see cref="ComWrapper"/> для плавной миграции.
/// </summary>
[AddComponentMenu("RR/Controls/Com System Manager")]
public class ComSystemManager : ComWrapper
{
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 1c4ba28ebcf4f5e42bddbc16592fe81c

View File

@ -1,7 +1,4 @@
using System;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using RR.Game2D.Core.Managers;
using UnityEngine;
using UnityEngine.Events;
@ -9,607 +6,226 @@ using UnityEngine.Events;
namespace RR.Common.Controls
{
/// <summary>
/// Основной класс-обертка для работы с COM-портом и периферийной платой.
/// Управляет всей коммуникацией с аппаратным обеспечением: прием монет, чтение кнопок, управление подсветкой, отправка сигналов.
/// Совместимый фасад над мульти-устройственной COM-системой.
/// Старый API сохранен для текущего проекта.
/// </summary>
public class ComWrapper : MonoBehaviour
{
//Настраиваем из инспектора
[Header("Основные настройки")]
[SerializeField] private bool saveMoneyBetweenSessions;
[SerializeField] private bool enablePrizeChecking = false; // Если хопер с проверкой наличия приза ставим true
[SerializeField] private bool enablePrizeChecking;
[SerializeField] private bool keepAlive = true;
[Header("Авторизация")]
[SerializeField] private bool enableEncryptedHandshake = true;
[SerializeField] private int handshakeTimeoutMs = 5000;
[Header("Роль контроллера (после handshake)")]
[SerializeField] private string roleRequestCommand = "97";
[SerializeField] private string roleResponsePrefix = "ROLE";
[SerializeField] private int roleRequestTimeoutMs = 1500;
[SerializeField] private string mainControllerRole = "main_controller";
[Header("Мульти-устройства")]
[SerializeField] private bool useAllDetectedPorts = true;
[SerializeField] private string[] explicitPorts = Array.Empty<string>();
[SerializeField] private string defaultDevicePort = "";
public static UnityEvent<int> OnCreditAdded;
public static UnityEvent<int> OnCreditRemoved;
public static UnityEvent<string> OnInputReceived;
public static Action OnInitialize;
#region Приватные поля
private const string REQUEST_INPUT = "91";
public static UnityEvent<string, string> OnDeviceInputReceived;
public static UnityEvent<string> OnDeviceAuthorized;
private static int h; // идентификатор хэндла
private static readonly object bufferLock = new object(); // Блокировка для синхронизации доступа к COM-порту
private static ComWrapper _instance;
private static int _creditsCount;
private static int CreditsCount;
// Асинхронная работа
private CancellationTokenSource pollCancellationTokenSource;
private int inputToggle; // Флаг для Проверки получения INPUT от платы
// Очередь для обработки COIN в главном потоке
private static readonly System.Collections.Concurrent.ConcurrentQueue<int> coinQueue = new System.Collections.Concurrent.ConcurrentQueue<int>();
// Защита от спама 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 бит по умолчанию
// Контроллер подсветки (добавляется автоматически вместе с этим компонентом)
private ComMultiManager _manager;
private LightController _light;
/// <summary>Доступ к контроллеру подсветки.</summary>
private bool _initializeInvoked;
public LightController Light => _light;
#endregion
//===========================start: PUBLIC API===========================//
/// <summary>
/// Инициализирует систему работы с COM-портом.
/// Открывает соединение, настраивает события и запускает асинхронные запросы состояния.
/// </summary>
/// <remarks>
/// Обязателен к вызову при старте приложения. Выполняет:
/// - Создание UnityEvent'ов для уведомлений
/// - Загрузку количества кредитов из PlayerPrefs
/// - Открытие COM-порта
/// - Запуск асинхронного опроса пинов (если включена проверка призов)
/// </remarks>
public void Initialize()
{
//Создаем ивенты
_instance = this;
OnCreditAdded = new UnityEvent<int>();
OnCreditRemoved = new UnityEvent<int>();
OnInputReceived = new UnityEvent<string>();
OnDeviceInputReceived = new UnityEvent<string, string>();
OnDeviceAuthorized = new UnityEvent<string>();
//Начальная настройка
if (!saveMoneyBetweenSessions) PlayerPrefs.SetInt("CoinsCount", 0);
CreditsCount = PlayerPrefs.GetInt("CoinsCount", 0);
_creditsCount = PlayerPrefs.GetInt("CoinsCount", 0);
// Получаем или создаём LightController
_light = GetComponent<LightController>() ?? gameObject.AddComponent<LightController>();
initPort();
_manager = new ComMultiManager(
useAllDetectedPorts,
explicitPorts,
defaultDevicePort,
enableEncryptedHandshake,
handshakeTimeoutMs,
keepAlive || enablePrizeChecking,
roleRequestCommand,
roleResponsePrefix,
roleRequestTimeoutMs,
mainControllerRole);
// Отправляем сигнал запуска игры
Send("2171");
_light.Blink();
// Запускаем асинхронный опрос порта
pollCancellationTokenSource = new CancellationTokenSource();
StartAsyncPolling();
// Запускаем keep-alive механизм если включен
if (keepAlive || enablePrizeChecking)
{
StartKeepAliveProcess();
}
OnInitialize?.Invoke();
_manager.InitializeDevices();
}
/// <summary>
/// Добавляет один кредит в систему и сохраняет обновленное количество.
/// Вызывается автоматически событием при получении сигнала "COIN" из COM-порта.
/// </summary>
/// <remarks>
/// Увеличивает счетчик кредитов и вызывает событие onCoinAdded для уведомления подписчиков.
/// Автоматически сохраняет состояние в PlayerPrefs.
/// </remarks>
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);
_creditsCount++;
PlayerPrefs.SetInt("CoinsCount", _creditsCount);
OnCreditAdded?.Invoke(_creditsCount);
}
/// <summary>
/// Отправляет произвольную строковую команду на периферийную плату через COM-порт.
/// </summary>
/// <param name="message">Команда для отправки</param>
/// <remarks>
/// Используется для отправки пользовательских команд на плату.
/// Команда должна быть в формате, ожидаемом прошивкой платы.
/// </remarks>
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}");
}
}
/// <summary>
/// Выполняет списание платы за запуск игры.
/// Проверяет наличие доступных кредитов или режим бесплатной игры.
/// При успешном списании увеличивает счетчики запусков.
/// </summary>
/// <returns>
/// true - если списание прошло успешно (бесплатный режим или есть кредиты),
/// false - если кредитов недостаточно и платный режим
/// </returns>
/// <remarks>
/// В случае успеха:
/// - Увеличивает общие счетчики запусков (key_starts и key_startsNul)
/// - При платном режиме уменьшает количество кредитов на 1
/// - Вызывает событие onCoinRemoved при списании кредита
/// </remarks>
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)
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;
}
return false;
_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;
}
/// <summary>
/// Запускает мигание подсветки. Делегируется в <see cref="LightController"/>.
/// </summary>
public void Blink() => _light.Blink();
public static int GetCredits() => _creditsCount;
/// <summary>
/// Останавливает мигание подсветки. Делегируется в <see cref="LightController"/>.
/// </summary>
public void StopBlink(bool enableHat = true) => _light.StopBlink(enableHat);
/// <summary>
/// Получаем текущий баланс кредитов
/// </summary>
public static int GetCredits() => CreditsCount;
/// <summary>
/// Устанавливает количество кредитов напрямую (для синхронизации с балансом)
/// </summary>
public static void SetCredits(int amount)
{
int oldCredits = CreditsCount;
CreditsCount = Mathf.Max(0, amount);
PlayerPrefs.SetInt("CoinsCount", CreditsCount);
int oldCredits = _creditsCount;
_creditsCount = Mathf.Max(0, amount);
PlayerPrefs.SetInt("CoinsCount", _creditsCount);
if (CreditsCount > oldCredits)
if (_creditsCount > oldCredits)
{
OnCreditAdded?.Invoke(CreditsCount);
OnCreditAdded?.Invoke(_creditsCount);
}
else if (CreditsCount < oldCredits)
else if (_creditsCount < oldCredits)
{
OnCreditRemoved?.Invoke(CreditsCount);
OnCreditRemoved?.Invoke(_creditsCount);
}
}
//===========================end: PUBLIC API===========================//
public static void Send(string message)
{
if (_instance == null || string.IsNullOrWhiteSpace(message)) return;
_instance._manager?.SendToDefault(message);
}
public static void SendToDevice(string deviceId, string message)
{
if (_instance == null || string.IsNullOrWhiteSpace(message)) return;
_instance._manager?.SendToDevice(deviceId, message);
}
public static void SendToAll(string message)
{
if (_instance == null || string.IsNullOrWhiteSpace(message)) return;
_instance._manager?.SendToAll(message);
}
public static string GetLastInputState()
{
if (_instance?._manager == null) return "11111111111111111111111111";
string defaultId = _instance._manager.DefaultDeviceId;
return string.IsNullOrWhiteSpace(defaultId)
? "11111111111111111111111111"
: _instance._manager.GetLastInputState(defaultId);
}
public static string GetLastInputState(string deviceId)
{
return _instance?._manager?.GetLastInputState(deviceId) ?? "11111111111111111111111111";
}
public static string[] GetConnectedDeviceIds()
{
return _instance?._manager?.ConnectedDeviceIds ?? Array.Empty<string>();
}
public static string GetDefaultDeviceId()
{
return _instance?._manager?.DefaultDeviceId ?? string.Empty;
}
public static string GetDeviceRole(string deviceId)
{
return _instance?._manager?.GetDeviceRole(deviceId) ?? string.Empty;
}
public void Blink() => _light.Blink();
public void StopBlink(bool enableHat = true) => _light.StopBlink(enableHat);
private void Update()
{
// Обрабатываем COIN из очереди в главном потоке
if (_manager == null) return;
int coinsProcessed = 0;
while (coinQueue.TryDequeue(out _))
while (_manager.TryDequeueCoin(out _))
{
AddCredit();
coinsProcessed++;
}
while (_manager.TryDequeueInput(out DeviceInputMessage msg))
{
OnDeviceInputReceived?.Invoke(msg.DeviceId, msg.InputBits);
if (msg.DeviceId == _manager.DefaultDeviceId)
{
OnInputReceived?.Invoke(msg.InputBits);
}
}
while (_manager.TryDequeueAuthorized(out string deviceId))
{
OnDeviceAuthorized?.Invoke(deviceId);
if (!_initializeInvoked)
{
_initializeInvoked = true;
OnInitialize?.Invoke();
}
if (deviceId == _manager.DefaultDeviceId)
{
_light.Blink();
}
}
if (coinsProcessed > 0)
{
GameManager.Logger.Log($"Обработано монет: {coinsProcessed}");
GameManager.Logger?.Log("ComWrapper", $"Обработано монет: {coinsProcessed}");
}
}
private void OnDisable()
{
StopAsyncPolling();
ClosePort();
_manager?.ShutdownAll(false);
_manager = null;
_initializeInvoked = false;
}
private void OnApplicationQuit()
{
Send("2170"); // Выключаем кнопки
Send("2130"); // Выключаем шапку
StopAsyncPolling();
ClosePort();
_manager?.ShutdownAll(true);
_manager = null;
_initializeInvoked = false;
}
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(1, pollCancellationTokenSource.Token); // Опрос каждые 1ms для уменьшения задержки
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)
{
// Debug.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;
// Debug.Log($"Decimal: {rawData} → Binary: {binaryValue}");
OnInputReceived?.Invoke(binaryValue);
}
}
/// <summary>
/// Преобразует строку с десятичным числом в двоичное представление.
/// Младшие биты слева, старшие справа (перевёрнутый порядок для соответствия индексам пинов).
/// </summary>
/// <param name="decimalString">Строка с десятичным числом</param>
/// <returns>Строка с двоичным представлением</returns>
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);
}
Debug.Log($"Не удалось распарсить значение INPUT: {decimalString}");
return "11111111111111111111111111"; // 26 единиц по умолчанию
}
catch (Exception e)
{
Debug.Log($"Ошибка преобразования в двоичную систему: {e.Message}");
return "11111111111111111111111111"; // 26 единиц по умолчанию
}
}
/// <summary>
/// Получает последнее кэшированное состояние INPUT.
/// Полезно когда нужно узнать состояние кнопок без ожидания нового события.
/// </summary>
/// <returns>Строка с двоичным представлением состояния кнопок</returns>
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 активирован");
}
/// <summary>
/// Очищает буфер порта от накопившихся старых данных
/// </summary>
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 "";
}
}
}
/// <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)
{
GameManager.Logger.Log($"Ошибка асинхронного чтения порта: {e.Message}");
return string.Empty;
}
}
//просим прислать состояние цифровых входов. Приходит что то вроде "INPUT111111101"
private static void requestInputs() => Send(REQUEST_INPUT);
/// <summary>
/// Асинхронный keep-alive процесс для проверки соединения с платой
/// </summary>
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
}
}

View File

@ -1,5 +1,4 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using Random = UnityEngine.Random;
@ -68,7 +67,7 @@ namespace RR.Common.Controls
/// <para>Если вызвать без <c>.Then()</c> / <c>.Loop()</c> — эффект запустится немедленно как одиночный.</para>
/// </summary>
/// <param name="mode">Эффект подсветки</param>
/// <param name="durationSeconds">Время эффекта (0–99 секунд)</param>
/// <param name="durationSeconds">Время эффекта (0–99 секунд, либо <c>-1</c> для команды без <c>YY</c>)</param>
public LightSequence SetLight(LightMode mode, int durationSeconds)
{
StopSequence();
@ -92,7 +91,7 @@ namespace RR.Common.Controls
public void StopAllEffects()
{
StopSequence();
ComWrapper.Send(EffectPrefix);
ComSystemManager.Send(EffectPrefix);
}
/// <summary>
@ -130,7 +129,7 @@ namespace RR.Common.Controls
_blinkingCoroutine = null;
}
ComWrapper.Send(enableHat ? hatOnCommand : hatOffCommand);
ComSystemManager.Send(enableHat ? hatOnCommand : hatOffCommand);
}
/// <summary>Возвращает <c>true</c>, если мигание шляпы сейчас активно.</summary>
@ -147,7 +146,7 @@ namespace RR.Common.Controls
/// </summary>
public void SendCommand(LightMode mode, int durationSeconds)
{
ComWrapper.Send(BuildCommand(mode, durationSeconds));
ComSystemManager.Send(BuildCommand(mode, durationSeconds));
}
// ReSharper disable once IteratorNeverReturns — корутина намеренно бесконечная
@ -155,7 +154,7 @@ namespace RR.Common.Controls
{
while (true)
{
ComWrapper.Send(Random.Range(0f, 1f) < 0.5f ? hatOnCommand : hatOffCommand);
ComSystemManager.Send(Random.Range(0f, 1f) < 0.5f ? hatOnCommand : hatOffCommand);
float wait = blinkIntervalVariance > 0f
? Mathf.Max(0.05f, interval + Random.Range(-blinkIntervalVariance, blinkIntervalVariance))
@ -171,95 +170,12 @@ namespace RR.Common.Controls
return EffectPrefix;
int xx = (int)mode;
if (durationSeconds == -1)
return $"{EffectPrefix}{xx:D2}";
int yy = Mathf.Clamp(durationSeconds, 0, 99);
return $"{EffectPrefix}{xx:D2}{yy:D2}";
}
/// <summary>
/// Строитель последовательности световых эффектов.
/// Создаётся через <see cref="LightController.SetLight(LightMode,int)"/>.
/// <example><code>
/// SetLight(LightMode.VerticalWave, 3).Then(LightMode.DiagonalWave, 3).Loop();
/// SetLight(LightMode.VerticalWave, 3).Then(LightMode.Off);
/// SetLight(LightMode.Rainbow, 5);
/// </code></example>
/// </summary>
public class LightSequence
{
private readonly struct Step
{
public readonly LightMode Mode;
public readonly int DurationSeconds;
public Step(LightMode mode, int durationSeconds) { Mode = mode; DurationSeconds = durationSeconds; }
}
private readonly List<Step> _steps = new();
private readonly LightController _controller;
private bool _looping;
private Coroutine _coroutine;
internal LightSequence(LightController controller, LightMode firstMode, int firstDuration)
{
_controller = controller;
_steps.Add(new Step(firstMode, firstDuration));
}
/// <summary>Добавляет следующий эффект с временем по умолчанию.</summary>
public LightSequence Then(LightMode mode) => Then(mode, _controller.DefaultDuration);
/// <summary>Добавляет следующий эффект с указанным временем.</summary>
public LightSequence Then(LightMode mode, int durationSeconds)
{
_steps.Add(new Step(mode, durationSeconds));
return this;
}
/// <summary>
/// Зацикливает последовательность. Для остановки — <see cref="LightController.StopSequence"/>.
/// </summary>
public LightSequence Loop()
{
_looping = true;
Play();
return this;
}
/// <summary>Запускает последовательность один раз.</summary>
public LightSequence Play()
{
Stop();
_coroutine = _controller.StartCoroutine(RunSequence());
return this;
}
/// <summary>Останавливает выполнение последовательности.</summary>
public void Stop()
{
if (_coroutine == null) return;
_controller.StopCoroutine(_coroutine);
_coroutine = null;
}
/// <summary>Возвращает <c>true</c>, если последовательность сейчас выполняется.</summary>
public bool IsPlaying => _coroutine != null;
// ReSharper disable once IteratorNeverReturns — цикл намеренно бесконечный при _looping=true
private IEnumerator RunSequence()
{
do
{
foreach (Step step in _steps)
{
_controller.SendCommand(step.Mode, step.DurationSeconds);
if (step.Mode != LightMode.Off && step.DurationSeconds > 0)
yield return new WaitForSeconds(step.DurationSeconds);
}
}
while (_looping);
_coroutine = null;
}
}
}
}

View File

@ -0,0 +1,93 @@
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
namespace RR.Common.Controls
{
/// <summary>
/// Строитель последовательности световых эффектов.
/// Создаётся через <see cref="LightController.SetLight(LightMode,int)"/>.
/// <example><code>
/// SetLight(LightMode.VerticalWave, 3).Then(LightMode.DiagonalWave, 3).Loop();
/// SetLight(LightMode.VerticalWave, 3).Then(LightMode.Off);
/// SetLight(LightMode.Rainbow, 5);
/// </code></example>
/// </summary>
public class LightSequence
{
private readonly struct Step
{
public readonly LightMode Mode;
public readonly int DurationSeconds;
public Step(LightMode mode, int durationSeconds) { Mode = mode; DurationSeconds = durationSeconds; }
}
private readonly List<Step> _steps = new();
private readonly LightController _controller;
private bool _looping;
private Coroutine _coroutine;
internal LightSequence(LightController controller, LightMode firstMode, int firstDuration)
{
_controller = controller;
_steps.Add(new Step(firstMode, firstDuration));
}
/// <summary>Добавляет следующий эффект с временем по умолчанию.</summary>
public LightSequence Then(LightMode mode) => Then(mode, _controller.DefaultDuration);
/// <summary>Добавляет следующий эффект с указанным временем.</summary>
public LightSequence Then(LightMode mode, int durationSeconds)
{
_steps.Add(new Step(mode, durationSeconds));
return this;
}
/// <summary>
/// Зацикливает последовательность. Для остановки — <see cref="LightController.StopSequence"/>.
/// </summary>
public LightSequence Loop()
{
_looping = true;
Play();
return this;
}
/// <summary>Запускает последовательность один раз.</summary>
public LightSequence Play()
{
Stop();
_coroutine = _controller.StartCoroutine(RunSequence());
return this;
}
/// <summary>Останавливает выполнение последовательности.</summary>
public void Stop()
{
if (_coroutine == null) return;
_controller.StopCoroutine(_coroutine);
_coroutine = null;
}
/// <summary>Возвращает <c>true</c>, если последовательность сейчас выполняется.</summary>
public bool IsPlaying => _coroutine != null;
// ReSharper disable once IteratorNeverReturns — цикл намеренно бесконечный при _looping=true
private IEnumerator RunSequence()
{
do
{
foreach (Step step in _steps)
{
_controller.SendCommand(step.Mode, step.DurationSeconds);
if (step.Mode != LightMode.Off && step.DurationSeconds > 0)
yield return new WaitForSeconds(step.DurationSeconds);
}
}
while (_looping);
_coroutine = null;
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f2022e7f65aa9734ba4d9349e2fc4bdb

View File

@ -0,0 +1,315 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using RR.Common.Controls;
using UnityEngine;
//Зависит от ComNativeWrapper и одноименного плагина
//рекомендуется соединение с таймаутом не меньше 10мс. Иначе вероятно busy loop и много алокаций.
public static class PrizeStandController
{
/// <summary>
/// По завершении инициализации.
/// bool - успех/неуспех.
/// string - сообщение в случае неуспеха
/// </summary>
public static event Action<bool, string> InitializationComplete;
/// <summary>
/// По завершении операции открытия.
/// bool - успех/неуспех.
/// string - сообщение в случае неуспеха
/// </summary>
public static event Action<bool, string> OpeningFinished;
private const string REQUEST_STAND_STATUS = "25";
private const string OPEN_BOX_PREFIX = "2501";
private const float STATUS_REQUEST_TIMEOUT = 3;
private const float WAIT_OPEN_STATUS_TIMEOUT = 5;
private static readonly IReadOnlyList<BoxStatus> READY_TO_OPEN_STATUSES = new[] { BoxStatus.OK, BoxStatus.OKplusCard };
private static int handle = -1;
private static int boxesCount;
private static readonly SemaphoreSlim _lock = new(1, 1);
private static bool initialized = false;
private static ILogger _logger;
#region PUBLIC API
/// <summary>
/// Инициализировать модуль витрины.
/// </summary>
/// <param name="handleId">Id хэндла полученное после ComNativeWrapper.Serial_Open</param>
/// <param name="countOfBoxes">Количество физических коробок витрины</param>
/// <param name="logger">(опционально) Логгер</param>
public static void Initialize(int handleId, int countOfBoxes, ILogger logger)
{
bool hasError = false;
StringBuilder sb = new StringBuilder();
//Проверка хэндла. h<=0 - ошибка открытия порта
if (handleId <= 0)
{
hasError = true;
sb.AppendLine($"[Модуль витрины] Переданный handle={handleId}. Ожидалось значение>0.");
}
else handle = handleId;
//Проверка количества коробок
if (countOfBoxes <= 0)
{
hasError = true;
sb.AppendLine($"[Модуль витрины] Попытка задать количество коробок={countOfBoxes}. Ожидалось значение>0.");
}
else boxesCount = countOfBoxes;
//Установка логгера
_logger = logger;
if (_logger == null)
sb.AppendLine($"[Модуль витрины] Получен логгер NULL. Если нужно логгирование, передайте дествующий логгер.");
//Завершение
if (hasError)
{
initialized = false;
handle = -1;
boxesCount = 0;
InitializationComplete?.Invoke(false, sb.ToString());
}
else
{
initialized = true;
InitializationComplete?.Invoke(true, sb.ToString());
}
}
/// <summary>
/// Открыть коробку по номеру
/// </summary>
/// <param name="boxNumber">Номер коробки (считаем с 1)</param>
public static async Task OpenBoxAsync(int boxNumber)
{
if(!await _lock.WaitAsync(0))
{
OpeningFinished?.Invoke(false, "Выдача уже в процессе");
return;
}
try
{
//Валидация
var validationResult = await CanExecuteOpenAsync(boxNumber);
if (!validationResult.OK)
{
OpeningFinished?.Invoke(false, validationResult.Message);
return;
}
SendOpenCommand(boxNumber);
//Обработка результатов
BoxStatus openStatus = await ReadBoxStatus(boxNumber, 1, WAIT_OPEN_STATUS_TIMEOUT);
if (openStatus == BoxStatus.OPENED)
{
OpeningFinished?.Invoke(true, $"Ячейка {boxNumber} успешно открыта");
}
else
{
_logger?.Log($"[Модуль витрины] Ошибка при открытии ячейки {boxNumber}");
OpeningFinished?.Invoke(false, $"Ошибка при открытии ячейки номер {boxNumber}");
}
}
catch (Exception ex)
{
_logger?.Log($"[Модуль витрины] Ошибка при открытии ячейки {boxNumber}: {ex.Message}");
OpeningFinished?.Invoke(false, ex.Message);
}
finally
{
_lock.Release();
}
}
#endregion
#region HELPERS
private static void SendOpenCommand(int boxNumber)
=> ComNativeWrapper.Serial_WriteStringUTF8(handleId: handle,
utf8: $"{OPEN_BOX_PREFIX}{boxNumber:D2}", //D2 приводит в формат XX (2 знака, ноль впереди)
appendNewline: 1);
private static void SendRequestStatusCommand()
=> ComNativeWrapper.Serial_WriteStringUTF8(handle, REQUEST_STAND_STATUS, 1);
/// <summary>
/// Проверка может ли модуль произвести выдачу
/// </summary>
private static async Task<(bool OK, string Message)> CanExecuteOpenAsync(int boxNumber)
{
bool isOk = true;
StringBuilder sb = new StringBuilder();
sb.AppendLine($"[Модуль витрины. Проверка перед открытием]");
//Проверка инициализации
if (!initialized)
{
isOk = false;
sb.AppendLine("- Модуль не инициализирован.");
}
bool boxNumberIsValid = boxNumber > 0 && boxNumber <= boxesCount;
if (!boxNumberIsValid)
{
isOk = false;
sb.AppendLine($"- Номер коробки={boxNumber} невалидный. Требуется от 1 до {boxesCount}.");
}
//Не лезем в порт с запросом статуса коробки, если то, что выше дало НеОК
if (isOk)
{
SendRequestStatusCommand();
BoxStatus boxStatus = await ReadBoxStatus(boxNumber, boxesCount, STATUS_REQUEST_TIMEOUT);
if (!READY_TO_OPEN_STATUSES.Contains(boxStatus))
{
isOk = false;
sb.AppendLine($"- Статус коробки {boxNumber}: {boxStatus} не позволяет провести открытие.");
}
}
else
{
sb.AppendLine($"- Невозможно проверить статус коробки {boxNumber} из-за ошибок выше.");
}
return (isOk, sb.ToString());
}
/// <summary>
/// Читать статус заданной ячейки витрины. Выход когда прочитали заданное количество вхождений, или по таймауту.
/// </summary>
/// <param name="expectedBoxNumber">Номер ячейки которую мы ждем</param>
/// <param name="expectedEntriesCount">Сколько ячеек мы ждем</param>
/// <param name="timeout">Таймаут операции(сек.)</param>
/// <returns></returns>
private static async Task<BoxStatus> ReadBoxStatus(int expectedBoxNumber, int expectedEntriesCount, float timeout)
{
byte[] buffer = new byte[1024];
//Изначально предполагаем что статус нужной коробки не пришел
BoxStatus status = BoxStatus.NotFound;
int boxesRecieved = 0;
var sw = Stopwatch.StartNew();
while (sw.Elapsed.TotalSeconds < timeout && boxesRecieved < expectedEntriesCount)
{
//ждать пока всё не считаем
int entryLength = await Task.Run(() => ComNativeWrapper.Serial_ReadLineUTF8_bytes(handle, buffer, buffer.Length));
if (entryLength == 0) //ничего не пришло
{
continue;
}
else if (entryLength < 0) //ошибка
{
_logger?.Log($"Ошибка при считывании статуса ячеек={entryLength} :: {ComNativeWrapper.GetLastError()}");
continue;
}
string entry = Encoding.UTF8.GetString(buffer, 0, entryLength).Trim();
var ParseResult = ParseBoxStatus(entry);
if(ParseResult.BoxNumber == -1)
{
_logger?.Log($"[Модуль витрины. Ошибка получения статуса ячейки]\n{ParseResult.Message}");
}
else
{
boxesRecieved++;
if (ParseResult.BoxNumber == expectedBoxNumber)
status = ParseResult.Status;
}
}
if (status == BoxStatus.NotFound)
_logger?.Log($"[Модуль витрины. Не дождался ответа по статусу ячейки]\n" +
$"Ожидаемый номер: {expectedBoxNumber}\n" +
$"Получено статусов ячеек: {boxesRecieved}/{expectedEntriesCount}");
return status;
}
private static (int BoxNumber, BoxStatus Status, string Message) ParseBoxStatus(string entryString)
{
int number = -1;
BoxStatus status = BoxStatus.ParsingError;
string message = "";
if (entryString.StartsWith("BOX"))
{
var noPrefixEntry = entryString.Substring(3); //Убираем "BOX"
string[] parts = noPrefixEntry.Split('_'); //ожидаем массив [<номер_коробки>, <статус_коробки>]
if (parts.Length == 2)
{
//Парсим номер коробки
bool boxNumberParsed = (int.TryParse(parts[0], out int parsedNumber));
if (boxNumberParsed)
{
number = parsedNumber;
status = MapStatusFromString(parts[1]);
}
else message = $"Не удалось спарсить номер коробки: {entryString}";
}
else
{
message = $"Ошибка парсинга. Часть строки не пришла: {entryString}";
}
}
else message = $"Неожиданный префикс при парсинге ячеек: {entryString}";
return (number, status, message);
}
private static BoxStatus MapStatusFromString(string statusString) => statusString switch
{
"OK" => BoxStatus.OK,
"OK+CARD" => BoxStatus.OKplusCard,
"EMPTY" => BoxStatus.Empty,
"OPEN" => BoxStatus.Open,
"OPENED" => BoxStatus.OPENED,
"OPEN_ERROR" => BoxStatus.OPEN_ERROR,
"ERROR" => BoxStatus.Error,
_ => BoxStatus.ParsingError
};
#endregion
}
enum BoxStatus
{
//Статусы ДО открытия
OK, //Готов к выдаче
OKplusCard, //Готов к выдаче + готов выдать по карте
Empty, //Пусто, уже выдали
Open, //Дверка УЖЕ открыта. Приз предположительно пустой
//Статусы ПОСЛЕ открытия
OPENED, //Всё хорошо, открылся
OPEN_ERROR, //Ошибка открытия
//Служебные статусы
ParsingError, //При парсинге что то пошло не так, или пришла непредусмотренная фигня
NotFound, //Коробка с требуемым номером не найдена
Error //Общая ошибка (например дали команду на открытие пустого ящика или сломан замок)
}

View File

@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: ce33b35c8b4042a5b85c0263727d588b
timeCreated: 1771575375

View File

@ -1,4 +1,4 @@
using System.Collections;
using System.Collections;
using RR.Common.Managers;
using RR.Game2D.Core.Managers;
using UnityEngine;
@ -102,7 +102,7 @@ namespace RR.Common.Controls
const float timeout = 10; //сек
ComWrapper.Send("301");//включаем мотор
ComSystemManager.Send("301");//включаем мотор
float startTime = Time.time;
bool success = false;
@ -121,7 +121,7 @@ namespace RR.Common.Controls
}
//Выключаем мотор
ComWrapper.Send("300");
ComSystemManager.Send("300");
// DebugWindow.Message("Закончил. Успех: " + success);
_lastPrizeSuccess = success;
@ -146,4 +146,4 @@ namespace RR.Common.Controls
}
#endregion
}
}
}

View File

@ -285,7 +285,7 @@ namespace RR.Common.Managers
}
/// <summary>
/// Синхронизирует кредиты в ComWrapper с текущим балансом
/// Синхронизирует кредиты в ComSystemManager с текущим балансом
/// по формуле: кредиты = баланс / стоимость_импульса (округление вниз)
/// </summary>
private void SyncCreditsWithBalance()
@ -297,13 +297,13 @@ namespace RR.Common.Managers
}
int targetCredits = currentBalance / _impulseCost;
int currentCredits = ComWrapper.GetCredits();
int currentCredits = ComSystemManager.GetCredits();
if (targetCredits == currentCredits)
return; // Кредиты уже синхронизированы
// Устанавливаем кредиты напрямую через SetCredits
ComWrapper.SetCredits(targetCredits);
ComSystemManager.SetCredits(targetCredits);
}
/// <summary>

View File

@ -1,4 +1,4 @@
using System;
using System;
using System.IO;
using RR.Common.Controls;
using UnityEngine;
@ -32,7 +32,7 @@ namespace RR.Game2D.Core.Managers
public InactivityManager Inactivity {get; private set;}
// Plate control for game
public ComWrapper Plate { get; private set; }
public ComSystemManager Plate { get; private set; }
// Light control for game
public LightController Light => Plate != null ? Plate.Light : null;
@ -160,7 +160,7 @@ namespace RR.Game2D.Core.Managers
// Apply loaded settings (new system applies automatically in Awake)
if (Plate == null) Plate = globals.GetComponent<ComWrapper>();
if (Plate == null) Plate = globals.GetComponent<ComSystemManager>();
Payment.OnCreditAdded.AddListener(OnCreditAdded);
@ -175,7 +175,7 @@ namespace RR.Game2D.Core.Managers
private void OnDestroy()
{
if (Instance == this) Instance = null;
Plate.StopBlink(false);
Plate?.StopBlink(false);
}
private void OnApplicationQuit()

View File

@ -8,7 +8,7 @@ namespace RR.Common.Managers
public class GlobalsManager : MonoBehaviour
{
private SettingsManager _settingsManager;
private ComWrapper _comWrapper;
private ComSystemManager _comSystemManager;
private In _in;
private PrizeSystem _prizeSystem;
private PrizeSystemManager _prizeSystemManager;
@ -19,26 +19,26 @@ namespace RR.Common.Managers
{
GameManager.Logger.Log(name ,"Initializing global objects...");
_settingsManager = GetComponent<SettingsManager>();
_comWrapper = GetComponent<ComWrapper>();
_comSystemManager = GetComponent<ComSystemManager>();
_in = GetComponent<In>();
_prizeSystem = GetComponent<PrizeSystem>();
_prizeSystemManager = GetComponent<PrizeSystemManager>();
_paymentManager = GetComponent<PaymentManager>();
_adapter = GetComponent<ComInputToInputSystemAdapter>();
if (_settingsManager == null) _settingsManager = gameObject.AddComponent<SettingsManager>();
if (_comWrapper == null) gameObject.AddComponent<ComWrapper>();
if (_in == null) gameObject.AddComponent<In>();
if (_prizeSystem == null) gameObject.AddComponent<PrizeSystem>();
if (_prizeSystemManager == null) gameObject.AddComponent<PrizeSystemManager>();
if (_paymentManager == null) gameObject.AddComponent<PaymentManager>();
if (_adapter == null) gameObject.AddComponent<ComInputToInputSystemAdapter>();
if (_settingsManager == null) _settingsManager = gameObject.AddComponent<SettingsManager>();
if (_comSystemManager == null) _comSystemManager = gameObject.AddComponent<ComSystemManager>();
if (_in == null) _in = gameObject.AddComponent<In>();
if (_prizeSystem == null) _prizeSystem = gameObject.AddComponent<PrizeSystem>();
if (_prizeSystemManager == null) _prizeSystemManager = gameObject.AddComponent<PrizeSystemManager>();
if (_paymentManager == null) _paymentManager = gameObject.AddComponent<PaymentManager>();
if (_adapter == null) _adapter = gameObject.AddComponent<ComInputToInputSystemAdapter>();
_settingsManager.Initialize();
GameManager.Logger.Log(name, "SettingsManager successfully initialized.");
_comWrapper.Initialize();
GameManager.Logger.Log(name, "ComWrapper successfully initialized.");
_comSystemManager.Initialize();
GameManager.Logger.Log(name, "ComSystemManager successfully initialized.");
_in.Initialize();
GameManager.Logger.Log(name, "In.cs successfully initialized.");
@ -56,4 +56,4 @@ namespace RR.Common.Managers
GameManager.Logger.Log(name, "InputAdapter successfully initialized.");
}
}
}
}

View File

@ -100,10 +100,10 @@ namespace RR.Common.Managers
private void SubscribeToInputEvents()
{
// Подписываемся на события ComWrapper (кнопки автомата)
if (ComWrapper.OnInputReceived != null)
// Подписываемся на события ComSystemManager (кнопки автомата)
if (ComSystemManager.OnInputReceived != null)
{
ComWrapper.OnInputReceived.AddListener(OnComInput);
ComSystemManager.OnInputReceived.AddListener(OnComInput);
}
// InputSystem.onAnyButtonPress - это событие, которое срабатывает каждый раз
@ -112,9 +112,9 @@ namespace RR.Common.Managers
private void UnsubscribeFromInputEvents()
{
if (ComWrapper.OnInputReceived != null)
if (ComSystemManager.OnInputReceived != null)
{
ComWrapper.OnInputReceived.RemoveListener(OnComInput);
ComSystemManager.OnInputReceived.RemoveListener(OnComInput);
}
}

View File

@ -7,7 +7,7 @@ using UnityEngine.InputSystem;
namespace RR.Common.Managers
{
/// <summary>
/// Менеджер системы оплаты. Управляет кредитами и интегрируется с ComWrapper.
/// Менеджер системы оплаты. Управляет кредитами и интегрируется с ComSystemManager.
/// </summary>
public class PaymentManager : MonoBehaviour
{
@ -54,9 +54,9 @@ namespace RR.Common.Managers
CreditsCount = PlayerPrefs.GetInt("CoinsCount", 0);
// Подписка на события ComWrapper
ComWrapper.OnCreditAdded?.AddListener(HandleCreditAdded);
ComWrapper.OnCreditRemoved?.AddListener(HandleCreditRemoved);
// Подписка на события ComSystemManager
ComSystemManager.OnCreditAdded?.AddListener(HandleCreditAdded);
ComSystemManager.OnCreditRemoved?.AddListener(HandleCreditRemoved);
// Привязка действия добавления кредита (для тестирования)
if (addCreditAction != null)
@ -83,9 +83,9 @@ namespace RR.Common.Managers
private void OnDestroy()
{
// Отписка от событий ComWrapper
ComWrapper.OnCreditAdded?.RemoveListener(HandleCreditAdded);
ComWrapper.OnCreditRemoved?.RemoveListener(HandleCreditRemoved);
// Отписка от событий ComSystemManager
ComSystemManager.OnCreditAdded?.RemoveListener(HandleCreditAdded);
ComSystemManager.OnCreditRemoved?.RemoveListener(HandleCreditRemoved);
// Отключение действия добавления кредита
if (addCreditAction == null) return;
@ -103,7 +103,7 @@ namespace RR.Common.Managers
#region Private Methods
/// <summary>
/// Обработчик добавления кредита из ComWrapper
/// Обработчик добавления кредита из ComSystemManager
/// </summary>
/// <param name="newCreditsCount">Новое количество кредитов</param>
private void HandleCreditAdded(int newCreditsCount)
@ -115,7 +115,7 @@ namespace RR.Common.Managers
}
/// <summary>
/// Обработчик удаления кредита из ComWrapper
/// Обработчик удаления кредита из ComSystemManager
/// </summary>
/// <param name="newCreditsCount">Новое количество кредитов</param>
private void HandleCreditRemoved(int newCreditsCount)
@ -155,7 +155,7 @@ namespace RR.Common.Managers
}
/// <summary>
/// Выполняет списание платы за запуск игры через ComWrapper.Pay()
/// Выполняет списание платы за запуск игры через ComSystemManager.Pay()
/// </summary>
/// <returns>true если оплата прошла успешно</returns>
public bool ProcessPayment(int amount = 1)
@ -166,7 +166,7 @@ namespace RR.Common.Managers
return true;
}
bool paymentSuccess = ComWrapper.Pay(amount);
bool paymentSuccess = ComSystemManager.Pay(amount);
Debug.Log(paymentSuccess ? "Оплата успешна" : "Оплата не прошла - недостаточно кредитов", this);
@ -181,7 +181,7 @@ namespace RR.Common.Managers
{
for (int i = 0; i < amount; i++)
{
ComWrapper.AddCredit();
ComSystemManager.AddCredit();
}
}

View File

@ -1,6 +1,7 @@
{
"dependencies": {
"com.github-glitchenzo.nugetforunity": "https://github.com/GlitchEnzo/NuGetForUnity.git?path=/src/NuGetForUnity",
"com.quaza.unitymcp": "https://github.com/quazaai/UnityMCPIntegration.git",
"com.unity.ai.inference": "2.5.0",
"com.unity.cinemachine": "3.1.5",
"com.unity.collab-proxy": "2.11.3",

View File

@ -7,6 +7,13 @@
"dependencies": {},
"hash": "34432703a177c97631a4eb1d908f086037c67969"
},
"com.quaza.unitymcp": {
"version": "https://github.com/quazaai/UnityMCPIntegration.git",
"depth": 0,
"source": "git",
"dependencies": {},
"hash": "d1515b326e2fc14b39de5a08534159ba29963c7d"
},
"com.unity.2d.animation": {
"version": "13.0.4",
"depth": 1,