diff --git a/.ai/mcp/mcp.json b/.ai/mcp/mcp.json new file mode 100644 index 0000000..e69de29 diff --git a/Assets/Resources/Prefabs/Global/Globals.prefab b/Assets/Resources/Prefabs/Global/Globals.prefab index 6ad97b2..8e27430 100644 --- a/Assets/Resources/Prefabs/Global/Globals.prefab +++ b/Assets/Resources/Prefabs/Global/Globals.prefab @@ -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 diff --git a/Assets/Scripts/Controls/Inputs/COMInput.cs b/Assets/Scripts/Controls/Inputs/COMInput.cs index c1acfd4..fe69145 100644 --- a/Assets/Scripts/Controls/Inputs/COMInput.cs +++ b/Assets/Scripts/Controls/Inputs/COMInput.cs @@ -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; } } -} \ No newline at end of file +} diff --git a/Assets/Scripts/Controls/Inputs/ComSystem/ComDeviceClient.cs b/Assets/Scripts/Controls/Inputs/ComSystem/ComDeviceClient.cs new file mode 100644 index 0000000..4bbdb45 --- /dev/null +++ b/Assets/Scripts/Controls/Inputs/ComSystem/ComDeviceClient.cs @@ -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 _handshakeCompletion; + private TaskCompletionSource _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 CoinAccepted; + public event Action InputReceived; + public event Action Authorized; + public event Action 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(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(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; + } + } +} diff --git a/Assets/Scripts/Controls/Inputs/ComSystem/ComDeviceClient.cs.meta b/Assets/Scripts/Controls/Inputs/ComSystem/ComDeviceClient.cs.meta new file mode 100644 index 0000000..aac27f8 --- /dev/null +++ b/Assets/Scripts/Controls/Inputs/ComSystem/ComDeviceClient.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: a37a338f0b42c7d45b7fae0e3702ea38 \ No newline at end of file diff --git a/Assets/Scripts/Controls/Inputs/ComSystem/ComMultiManager.cs b/Assets/Scripts/Controls/Inputs/ComSystem/ComMultiManager.cs new file mode 100644 index 0000000..6f14445 --- /dev/null +++ b/Assets/Scripts/Controls/Inputs/ComSystem/ComMultiManager.cs @@ -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 _devices = new(); + private readonly ConcurrentQueue _coinQueue = new(); + private readonly ConcurrentQueue _inputQueue = new(); + private readonly ConcurrentQueue _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(); + _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(); + + HashSet explicitSet = new HashSet(_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; + } + } +} diff --git a/Assets/Scripts/Controls/Inputs/ComSystem/ComMultiManager.cs.meta b/Assets/Scripts/Controls/Inputs/ComSystem/ComMultiManager.cs.meta new file mode 100644 index 0000000..5b7c84d --- /dev/null +++ b/Assets/Scripts/Controls/Inputs/ComSystem/ComMultiManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: edad4940535f5fb46bb83766432da396 \ No newline at end of file diff --git a/Assets/Scripts/Controls/Inputs/ComSystem/ComSystemManager.cs b/Assets/Scripts/Controls/Inputs/ComSystem/ComSystemManager.cs new file mode 100644 index 0000000..b05150c --- /dev/null +++ b/Assets/Scripts/Controls/Inputs/ComSystem/ComSystemManager.cs @@ -0,0 +1,13 @@ +using UnityEngine; + +namespace RR.Common.Controls +{ + /// + /// Новый публичный компонент COM-системы. + /// Наследует реализацию от для плавной миграции. + /// + [AddComponentMenu("RR/Controls/Com System Manager")] + public class ComSystemManager : ComWrapper + { + } +} diff --git a/Assets/Scripts/Controls/Inputs/ComSystem/ComSystemManager.cs.meta b/Assets/Scripts/Controls/Inputs/ComSystem/ComSystemManager.cs.meta new file mode 100644 index 0000000..86789c0 --- /dev/null +++ b/Assets/Scripts/Controls/Inputs/ComSystem/ComSystemManager.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 1c4ba28ebcf4f5e42bddbc16592fe81c \ No newline at end of file diff --git a/Assets/Scripts/Controls/Inputs/ComSystem/ComWrapper.cs b/Assets/Scripts/Controls/Inputs/ComSystem/ComWrapper.cs index 0fea2c1..725a0d7 100644 --- a/Assets/Scripts/Controls/Inputs/ComSystem/ComWrapper.cs +++ b/Assets/Scripts/Controls/Inputs/ComSystem/ComWrapper.cs @@ -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 { /// - /// Основной класс-обертка для работы с COM-портом и периферийной платой. - /// Управляет всей коммуникацией с аппаратным обеспечением: прием монет, чтение кнопок, управление подсветкой, отправка сигналов. + /// Совместимый фасад над мульти-устройственной COM-системой. + /// Старый API сохранен для текущего проекта. /// 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(); + [SerializeField] private string defaultDevicePort = ""; public static UnityEvent OnCreditAdded; public static UnityEvent OnCreditRemoved; public static UnityEvent OnInputReceived; public static Action OnInitialize; -#region Приватные поля - - private const string REQUEST_INPUT = "91"; + public static UnityEvent OnDeviceInputReceived; + public static UnityEvent 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 coinQueue = new System.Collections.Concurrent.ConcurrentQueue(); - - // Защита от спама COIN: отслеживание времени последней монеты - private static long lastCoinTime; // в миллисекундах - private const long COIN_DEBOUNCE_MS = 250; // Минимальный интервал между монетами (250ms) - - // StringBuilder для эффективного формирования строки INPUT без лишних аллокаций - private static readonly StringBuilder inputBuilder = new StringBuilder(32); - - // Кэш последнего состояния INPUT - // ВАЖНО: 0 = кнопка нажата, 1 = кнопка отпущена, поэтому по умолчанию все единицы - private static string lastInputBits = "11111111111111111111111111"; // 26 бит по умолчанию - - // Контроллер подсветки (добавляется автоматически вместе с этим компонентом) + private ComMultiManager _manager; private LightController _light; - - /// Доступ к контроллеру подсветки. + private bool _initializeInvoked; + public LightController Light => _light; - -#endregion - //===========================start: PUBLIC API===========================// - - /// - /// Инициализирует систему работы с COM-портом. - /// Открывает соединение, настраивает события и запускает асинхронные запросы состояния. - /// - /// - /// Обязателен к вызову при старте приложения. Выполняет: - /// - Создание UnityEvent'ов для уведомлений - /// - Загрузку количества кредитов из PlayerPrefs - /// - Открытие COM-порта - /// - Запуск асинхронного опроса пинов (если включена проверка призов) - /// public void Initialize() { - //Создаем ивенты + _instance = this; + OnCreditAdded = new UnityEvent(); OnCreditRemoved = new UnityEvent(); OnInputReceived = new UnityEvent(); + OnDeviceInputReceived = new UnityEvent(); + OnDeviceAuthorized = new UnityEvent(); - //Начальная настройка if (!saveMoneyBetweenSessions) PlayerPrefs.SetInt("CoinsCount", 0); - CreditsCount = PlayerPrefs.GetInt("CoinsCount", 0); + _creditsCount = PlayerPrefs.GetInt("CoinsCount", 0); - // Получаем или создаём LightController _light = GetComponent() ?? gameObject.AddComponent(); - 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(); } - /// - /// Добавляет один кредит в систему и сохраняет обновленное количество. - /// Вызывается автоматически событием при получении сигнала "COIN" из COM-порта. - /// - /// - /// Увеличивает счетчик кредитов и вызывает событие onCoinAdded для уведомления подписчиков. - /// Автоматически сохраняет состояние в PlayerPrefs. - /// public static void AddCredit() { - CreditsCount++; - PlayerPrefs.SetInt("CoinsCount", CreditsCount); - OnCreditAdded?.Invoke(CreditsCount); - - // PlayerPrefs.SetInt(PrizeSystem.key_starts, PlayerPrefs.GetInt(PrizeSystem.key_starts) + 1); - // PlayerPrefs.SetInt(PrizeSystem.key_startsNul, PlayerPrefs.GetInt(PrizeSystem.key_startsNul) + 1); + _creditsCount++; + PlayerPrefs.SetInt("CoinsCount", _creditsCount); + OnCreditAdded?.Invoke(_creditsCount); } - /// - /// Отправляет произвольную строковую команду на периферийную плату через COM-порт. - /// - /// Команда для отправки - /// - /// Используется для отправки пользовательских команд на плату. - /// Команда должна быть в формате, ожидаемом прошивкой платы. - /// - public static void Send(string message) - { - try - { - if (h <= 0) - { - GameManager.Logger.Log("ComWrapper: Попытка отправки при закрытом порте"); - return; - } - - ComNativeWrapper.Serial_WriteStringUTF8(h, message, 1); - } - catch (Exception e) - { - GameManager.Logger.Log($"Ошибка отправки сигнала: {e.Message}"); - } - } - - - /// - /// Выполняет списание платы за запуск игры. - /// Проверяет наличие доступных кредитов или режим бесплатной игры. - /// При успешном списании увеличивает счетчики запусков. - /// - /// - /// true - если списание прошло успешно (бесплатный режим или есть кредиты), - /// false - если кредитов недостаточно и платный режим - /// - /// - /// В случае успеха: - /// - Увеличивает общие счетчики запусков (key_starts и key_startsNul) - /// - При платном режиме уменьшает количество кредитов на 1 - /// - Вызывает событие onCoinRemoved при списании кредита - /// public static bool Pay(int amount = 1) { - // блок проверки оплаты - // if (SettingsManager.instance.GetSettingValue("Бесплатная игра") == "Вкл") - // { - // //Счетчики пробега - // PlayerPrefs.SetInt(PrizeSystem.key_starts, PlayerPrefs.GetInt(PrizeSystem.key_starts) + 1); - // PlayerPrefs.SetInt(PrizeSystem.key_startsNul, PlayerPrefs.GetInt(PrizeSystem.key_startsNul) + 1); - // - // return true; - // } - - if (CreditsCount >= amount) + 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; } - /// - /// Запускает мигание подсветки. Делегируется в . - /// - public void Blink() => _light.Blink(); + public static int GetCredits() => _creditsCount; - /// - /// Останавливает мигание подсветки. Делегируется в . - /// - public void StopBlink(bool enableHat = true) => _light.StopBlink(enableHat); - - /// - /// Получаем текущий баланс кредитов - /// - public static int GetCredits() => CreditsCount; - - /// - /// Устанавливает количество кредитов напрямую (для синхронизации с балансом) - /// 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(); + } + + 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); - } - } - - /// - /// Преобразует строку с десятичным числом в двоичное представление. - /// Младшие биты слева, старшие справа (перевёрнутый порядок для соответствия индексам пинов). - /// - /// Строка с десятичным числом - /// Строка с двоичным представлением - 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 единиц по умолчанию - } - } - - /// - /// Получает последнее кэшированное состояние INPUT. - /// Полезно когда нужно узнать состояние кнопок без ожидания нового события. - /// - /// Строка с двоичным представлением состояния кнопок - public static string GetLastInputState() => lastInputBits; - - #region Приватные методы - //Ну тут по дебаг логам все должно быть понятно - //Если интересно, то h - это хэндл. Что-то на плюсовом, типа ID соединения или что-то. Если 0, то нету соед. если меньше то ошибка, больше 0 - норм. - private void initPort() - { - GameManager.Logger.Log("ComWrapper", "Старт программы"); - ComNativeWrapper.Serial_CloseAll(); - var ports = ComNativeWrapper.ListPorts(); - GameManager.Logger.Log("ComWrapper", "Ports: " + string.Join(", ", ports)); - if (ports.Length == 0) { GameManager.Logger.Log("Нет портов, завершаю"); return; } - - string port = ports[^1]; - GameManager.Logger.Log("ComWrapper", "Открываю порт: " + port); - - - //вот эти настройки лучше не ковырять, но если очень хочется можно поменять readTimeoutMs и writeTimeoutMs - //Если read поставить слишком маленьким оно проебует кредиты когда они идут большой пачкой например при оплате через вендисту. - //Там в порт спамит "COIN" в больших количествах и процентов 10 из этих строк не успевают прочитаться при таймауте 1мс, соотв. монеты проебуются. - //Слишком большой таймаут будет тормозить игру потому что оно все выполняется синхронно и каждый кадр забирает столько мс сколько в переменной указано. - h = ComNativeWrapper.Serial_Open(port, 115200, 8, 0, 1, 5, 10); - - - GameManager.Logger.Log("ComWrapper", $"handle: {h}"); - if (h <= 0) - { - GameManager.Logger.Log("ComWrapper", "Open failed: " + ComNativeWrapper.GetLastError()); - return; - } - else - { - GameManager.Logger.Log("ComWrapper", $"Открыл порт: {port}"); - } - - // Очищаем буфер порта от старых данных - GameManager.Logger.Log("Очистка буфера порта от старых данных..."); - FlushPortBuffer(); - - // Инициализируем lastCoinTime текущим временем чтобы не обработать старые COIN - lastCoinTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - GameManager.Logger.Log($"Порт инициализирован, debounce активирован"); - } - - /// - /// Очищает буфер порта от накопившихся старых данных - /// - private static void FlushPortBuffer() - { - lock (bufferLock) - { - try - { - // Проверяем валидность handle - if (h <= 0) - { - return; - } - - // Используем локальный буфер - byte[] localBuf = new byte[1024]; - - int flushedCount = 0; - - // Читаем все накопившиеся данные из порта - int read; - while ((read = ComNativeWrapper.Serial_ReadLineUTF8_bytes(h, localBuf, localBuf.Length)) > 0) - { - flushedCount++; - if (flushedCount > 100) break; // Защита от бесконечного цикла - } - - GameManager.Logger.Log($"Очищено: {flushedCount} сообщений"); - } - catch (Exception e) - { - GameManager.Logger.Log($"FlushPortBuffer exception: {e.Message}"); - } - } - } - - private static void ClosePort() - { - Debug.Log("Закрываю все порты"); - if (h > 0) - { - ComNativeWrapper.Serial_Close(h); - h = 0; - } - } - - - //Чтение с порта - private static string ReadBufferLine() - { - lock (bufferLock) - { - try - { - // Проверяем валидность handle перед вызовом нативной функции - if (h <= 0) - { - return ""; - } - - // Используем локальный буфер для полной изоляции - byte[] localBuf = new byte[1024]; - - int read = ComNativeWrapper.Serial_ReadLineUTF8_bytes(h, localBuf, localBuf.Length); - - // Проверяем валидность значения read ПЕРЕД использованием - if (read <= 0) - { - if (read < 0) - { - GameManager.Logger.Log("ComWrapper", $"ReadLine err={read} :: {ComNativeWrapper.GetLastError()}"); - } - return ""; - } - - // Защита от выхода за границы массива - if (read > localBuf.Length) - { - GameManager.Logger.Log($"ReadBufferLine: read ({read}) превышает размер буфера ({localBuf.Length}), обрезаем"); - read = localBuf.Length; - } - - string s = Encoding.UTF8.GetString(localBuf, 0, read); - - // Обрабатываем COIN сразу, но добавляем в очередь для обработки в главном потоке - if (s == "COIN") - { - long currentTime = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - long timeSinceLastCoin = currentTime - lastCoinTime; - - // Проверяем debounce: если прошло меньше 250ms - это спам от той же монеты - if (timeSinceLastCoin < COIN_DEBOUNCE_MS) - { - GameManager.Logger.Log($"COIN проигнорирован (спам через {timeSinceLastCoin}ms < 250ms)"); - // НЕ отправляем подтверждение для спама! - } - else - { - // Это РЕАЛЬНАЯ новая монета - обрабатываем - lastCoinTime = currentTime; - - // Отправляем подтверждение - int confirmSent = ComNativeWrapper.Serial_WriteStringUTF8(h, "10", 1); - GameManager.Logger.Log($"COIN принят → Подтверждение '10' отправлено (bytes: {confirmSent})"); - - // Добавляем в очередь для обработки в главном потоке - coinQueue.Enqueue(1); - } - return ""; - } - - return s; - } - catch (Exception e) - { - GameManager.Logger.Log($"ReadBufferLine exception: {e.Message}"); - return ""; - } - } - } - - /// - /// Асинхронное чтение с порта. Выполняется в фоновом потоке. - /// - private static async Task ReadBufferLineAsync(CancellationToken cancellationToken = default) - { - try - { - return await Task.Run(() => - { - if (cancellationToken.IsCancellationRequested) - return string.Empty; - - return ReadBufferLine(); - }, cancellationToken); - } - catch (OperationCanceledException) - { - return string.Empty; - } - catch (Exception e) - { - GameManager.Logger.Log($"Ошибка асинхронного чтения порта: {e.Message}"); - return string.Empty; - } - } - - //просим прислать состояние цифровых входов. Приходит что то вроде "INPUT111111101" - private static void requestInputs() => Send(REQUEST_INPUT); - - /// - /// Асинхронный keep-alive процесс для проверки соединения с платой - /// - private async void StartKeepAliveProcess() - { - try - { - GameManager.Logger.Log("Запуск асинхронной проверки соединения"); - - while (pollCancellationTokenSource != null && - !pollCancellationTokenSource.Token.IsCancellationRequested) - { - await Task.Delay(2000, pollCancellationTokenSource.Token); // Ждем 2 секунды - - // Запрашиваем INPUT - requestInputs(); - inputToggle = 0; - - await Task.Delay(1000, pollCancellationTokenSource.Token); // Ждем ответа 1 секунду - - // Проверяем, пришел ли ответ - if (inputToggle == 0) - { - GameManager.Logger.Log("Плата не отвечает! Попытка переподключения..."); - await Task.Run(() => - { - ClosePort(); - initPort(); - }, pollCancellationTokenSource.Token); - } - - await Task.Delay(7000, pollCancellationTokenSource.Token); // Итого ~10 секунд между проверками - } - } - catch (OperationCanceledException) - { - GameManager.Logger.Log("Асинхронная проверка соединения остановлена"); - } - catch (Exception e) - { - GameManager.Logger.Log($"Ошибка проверки соединения: {e.Message}"); - } - } - - #endregion - } } - diff --git a/Assets/Scripts/Controls/Inputs/ComSystem/LightController.cs b/Assets/Scripts/Controls/Inputs/ComSystem/LightController.cs index 21b9607..a0961bf 100644 --- a/Assets/Scripts/Controls/Inputs/ComSystem/LightController.cs +++ b/Assets/Scripts/Controls/Inputs/ComSystem/LightController.cs @@ -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 /// Если вызвать без .Then() / .Loop() — эффект запустится немедленно как одиночный. /// /// Эффект подсветки - /// Время эффекта (0–99 секунд) + /// Время эффекта (0–99 секунд, либо -1 для команды без YY) 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); } /// @@ -130,7 +129,7 @@ namespace RR.Common.Controls _blinkingCoroutine = null; } - ComWrapper.Send(enableHat ? hatOnCommand : hatOffCommand); + ComSystemManager.Send(enableHat ? hatOnCommand : hatOffCommand); } /// Возвращает true, если мигание шляпы сейчас активно. @@ -147,7 +146,7 @@ namespace RR.Common.Controls /// 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}"; } - /// - /// Строитель последовательности световых эффектов. - /// Создаётся через . - /// - /// SetLight(LightMode.VerticalWave, 3).Then(LightMode.DiagonalWave, 3).Loop(); - /// SetLight(LightMode.VerticalWave, 3).Then(LightMode.Off); - /// SetLight(LightMode.Rainbow, 5); - /// - /// - 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 _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)); - } - - /// Добавляет следующий эффект с временем по умолчанию. - public LightSequence Then(LightMode mode) => Then(mode, _controller.DefaultDuration); - - /// Добавляет следующий эффект с указанным временем. - public LightSequence Then(LightMode mode, int durationSeconds) - { - _steps.Add(new Step(mode, durationSeconds)); - return this; - } - - /// - /// Зацикливает последовательность. Для остановки — . - /// - public LightSequence Loop() - { - _looping = true; - Play(); - return this; - } - - /// Запускает последовательность один раз. - public LightSequence Play() - { - Stop(); - _coroutine = _controller.StartCoroutine(RunSequence()); - return this; - } - - /// Останавливает выполнение последовательности. - public void Stop() - { - if (_coroutine == null) return; - _controller.StopCoroutine(_coroutine); - _coroutine = null; - } - - /// Возвращает true, если последовательность сейчас выполняется. - 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; - } - } } } diff --git a/Assets/Scripts/Controls/Inputs/ComSystem/LightSequence.cs b/Assets/Scripts/Controls/Inputs/ComSystem/LightSequence.cs new file mode 100644 index 0000000..b027e3b --- /dev/null +++ b/Assets/Scripts/Controls/Inputs/ComSystem/LightSequence.cs @@ -0,0 +1,93 @@ +using System.Collections; +using System.Collections.Generic; +using UnityEngine; + +namespace RR.Common.Controls +{ + /// + /// Строитель последовательности световых эффектов. + /// Создаётся через . + /// + /// SetLight(LightMode.VerticalWave, 3).Then(LightMode.DiagonalWave, 3).Loop(); + /// SetLight(LightMode.VerticalWave, 3).Then(LightMode.Off); + /// SetLight(LightMode.Rainbow, 5); + /// + /// + 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 _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)); + } + + /// Добавляет следующий эффект с временем по умолчанию. + public LightSequence Then(LightMode mode) => Then(mode, _controller.DefaultDuration); + + /// Добавляет следующий эффект с указанным временем. + public LightSequence Then(LightMode mode, int durationSeconds) + { + _steps.Add(new Step(mode, durationSeconds)); + return this; + } + + /// + /// Зацикливает последовательность. Для остановки — . + /// + public LightSequence Loop() + { + _looping = true; + Play(); + return this; + } + + /// Запускает последовательность один раз. + public LightSequence Play() + { + Stop(); + _coroutine = _controller.StartCoroutine(RunSequence()); + return this; + } + + /// Останавливает выполнение последовательности. + public void Stop() + { + if (_coroutine == null) return; + _controller.StopCoroutine(_coroutine); + _coroutine = null; + } + + /// Возвращает true, если последовательность сейчас выполняется. + 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; + } + } +} diff --git a/Assets/Scripts/Controls/Inputs/ComSystem/LightSequence.cs.meta b/Assets/Scripts/Controls/Inputs/ComSystem/LightSequence.cs.meta new file mode 100644 index 0000000..94dec44 --- /dev/null +++ b/Assets/Scripts/Controls/Inputs/ComSystem/LightSequence.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f2022e7f65aa9734ba4d9349e2fc4bdb \ No newline at end of file diff --git a/Assets/Scripts/Controls/Inputs/ComSystem/PrizeStandController.cs b/Assets/Scripts/Controls/Inputs/ComSystem/PrizeStandController.cs new file mode 100644 index 0000000..407bbc5 --- /dev/null +++ b/Assets/Scripts/Controls/Inputs/ComSystem/PrizeStandController.cs @@ -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 +{ + /// + /// По завершении инициализации. + /// bool - успех/неуспех. + /// string - сообщение в случае неуспеха + /// + public static event Action InitializationComplete; + /// + /// По завершении операции открытия. + /// bool - успех/неуспех. + /// string - сообщение в случае неуспеха + /// + public static event Action 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 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 + /// + /// Инициализировать модуль витрины. + /// + /// Id хэндла полученное после ComNativeWrapper.Serial_Open + /// Количество физических коробок витрины + /// (опционально) Логгер + 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()); + } + } + + + /// + /// Открыть коробку по номеру + /// + /// Номер коробки (считаем с 1) + 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); + + /// + /// Проверка может ли модуль произвести выдачу + /// + 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()); + } + + + /// + /// Читать статус заданной ячейки витрины. Выход когда прочитали заданное количество вхождений, или по таймауту. + /// + /// Номер ячейки которую мы ждем + /// Сколько ячеек мы ждем + /// Таймаут операции(сек.) + /// + private static async Task 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 //Общая ошибка (например дали команду на открытие пустого ящика или сломан замок) +} diff --git a/Assets/Scripts/Controls/Inputs/ComSystem/PrizeStandController.cs.meta b/Assets/Scripts/Controls/Inputs/ComSystem/PrizeStandController.cs.meta new file mode 100644 index 0000000..ac67df9 --- /dev/null +++ b/Assets/Scripts/Controls/Inputs/ComSystem/PrizeStandController.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: ce33b35c8b4042a5b85c0263727d588b +timeCreated: 1771575375 \ No newline at end of file diff --git a/Assets/Scripts/Controls/Inputs/ComSystem/PrizeSystem.cs b/Assets/Scripts/Controls/Inputs/ComSystem/PrizeSystem.cs index 1be857b..4f8a8a2 100644 --- a/Assets/Scripts/Controls/Inputs/ComSystem/PrizeSystem.cs +++ b/Assets/Scripts/Controls/Inputs/ComSystem/PrizeSystem.cs @@ -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 } -} \ No newline at end of file +} diff --git a/Assets/Scripts/Managers/BalanceManager.cs b/Assets/Scripts/Managers/BalanceManager.cs index bd04d50..91e1902 100644 --- a/Assets/Scripts/Managers/BalanceManager.cs +++ b/Assets/Scripts/Managers/BalanceManager.cs @@ -285,7 +285,7 @@ namespace RR.Common.Managers } /// - /// Синхронизирует кредиты в ComWrapper с текущим балансом + /// Синхронизирует кредиты в ComSystemManager с текущим балансом /// по формуле: кредиты = баланс / стоимость_импульса (округление вниз) /// 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); } /// diff --git a/Assets/Scripts/Managers/GameManager.cs b/Assets/Scripts/Managers/GameManager.cs index 993a907..3d6c6d1 100644 --- a/Assets/Scripts/Managers/GameManager.cs +++ b/Assets/Scripts/Managers/GameManager.cs @@ -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(); + if (Plate == null) Plate = globals.GetComponent(); 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() diff --git a/Assets/Scripts/Managers/GlobalsManager.cs b/Assets/Scripts/Managers/GlobalsManager.cs index 1fe5eaf..3af47b4 100644 --- a/Assets/Scripts/Managers/GlobalsManager.cs +++ b/Assets/Scripts/Managers/GlobalsManager.cs @@ -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(); - _comWrapper = GetComponent(); + _comSystemManager = GetComponent(); _in = GetComponent(); _prizeSystem = GetComponent(); _prizeSystemManager = GetComponent(); _paymentManager = GetComponent(); _adapter = GetComponent(); - if (_settingsManager == null) _settingsManager = gameObject.AddComponent(); - if (_comWrapper == null) gameObject.AddComponent(); - if (_in == null) gameObject.AddComponent(); - if (_prizeSystem == null) gameObject.AddComponent(); - if (_prizeSystemManager == null) gameObject.AddComponent(); - if (_paymentManager == null) gameObject.AddComponent(); - if (_adapter == null) gameObject.AddComponent(); + if (_settingsManager == null) _settingsManager = gameObject.AddComponent(); + if (_comSystemManager == null) _comSystemManager = gameObject.AddComponent(); + if (_in == null) _in = gameObject.AddComponent(); + if (_prizeSystem == null) _prizeSystem = gameObject.AddComponent(); + if (_prizeSystemManager == null) _prizeSystemManager = gameObject.AddComponent(); + if (_paymentManager == null) _paymentManager = gameObject.AddComponent(); + if (_adapter == null) _adapter = gameObject.AddComponent(); _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."); } } -} \ No newline at end of file +} diff --git a/Assets/Scripts/Managers/InactivityManager.cs b/Assets/Scripts/Managers/InactivityManager.cs index b7bf5f9..26907ed 100644 --- a/Assets/Scripts/Managers/InactivityManager.cs +++ b/Assets/Scripts/Managers/InactivityManager.cs @@ -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); } } diff --git a/Assets/Scripts/Managers/PaymentManager.cs b/Assets/Scripts/Managers/PaymentManager.cs index 01cced6..69c7c2d 100644 --- a/Assets/Scripts/Managers/PaymentManager.cs +++ b/Assets/Scripts/Managers/PaymentManager.cs @@ -7,7 +7,7 @@ using UnityEngine.InputSystem; namespace RR.Common.Managers { /// - /// Менеджер системы оплаты. Управляет кредитами и интегрируется с ComWrapper. + /// Менеджер системы оплаты. Управляет кредитами и интегрируется с ComSystemManager. /// 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 /// - /// Обработчик добавления кредита из ComWrapper + /// Обработчик добавления кредита из ComSystemManager /// /// Новое количество кредитов private void HandleCreditAdded(int newCreditsCount) @@ -115,7 +115,7 @@ namespace RR.Common.Managers } /// - /// Обработчик удаления кредита из ComWrapper + /// Обработчик удаления кредита из ComSystemManager /// /// Новое количество кредитов private void HandleCreditRemoved(int newCreditsCount) @@ -155,7 +155,7 @@ namespace RR.Common.Managers } /// - /// Выполняет списание платы за запуск игры через ComWrapper.Pay() + /// Выполняет списание платы за запуск игры через ComSystemManager.Pay() /// /// true если оплата прошла успешно 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(); } } diff --git a/Packages/manifest.json b/Packages/manifest.json index 3efee6b..907b99a 100644 --- a/Packages/manifest.json +++ b/Packages/manifest.json @@ -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", diff --git a/Packages/packages-lock.json b/Packages/packages-lock.json index 51ac49d..c683b6c 100644 --- a/Packages/packages-lock.json +++ b/Packages/packages-lock.json @@ -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,