refactor: improve COM flow and light timing
Some checks failed
Gitea Actions Demo / Explore-Gitea-Actions (push) Has been cancelled

Архитектура COM и роли
- ComMultiManager переименован в ComHub и переведен на фабричный паттерн обработчиков по ROLE.
- Добавлен единый контракт обработчиков COM-контроллеров:
  - IComControllerHandler
  - DefaultComControllerHandler
- Ответственность за оркестрацию перенесена в ComSystemManager, чтобы инициализация COM и жизненный цикл контроллеров управлялись централизованно.
- ComWrapper сохранен как слой совместимости на время миграции логики в новую схему.
- PrizeStandController отрефакторен под новый role-handler pipeline вместо старой прямой привязки.
- Добавлен удобный доступ к PrizeStandController из менеджера системы там, где это требуется игровому коду.

Handshake/авторизация платы (ComDeviceClient)
- Авторизация платы переработана в строгую поэтапную последовательность:
  1) ПК отправляет 07
  2) ожидает CHAL
  3) отправляет 08 в ответ на CHAL
  4) ожидает RAES
- Парсер стадий теперь принимает только ожидаемые токены на каждой стадии:
  - WaitingChal -> только CHAL
  - WaitingRaes -> только RAES
- Любой посторонний вывод из serial во время handshake игнорируется (без немедленного падения), что устраняет ложные ошибки на нерелевантных COM-устройствах.
- Если ожидаемый ответ после 07 не приходит, порт быстро пропускается как нерелевантный, без эскалации в критическую ошибку.
- Для случаев с неподходящими/сторонними устройствами уровень логирования снижен с ошибок до предупреждений там, где это уместно.
- Добавлена подробная диагностика авторизации (переходы стадий, принятые значимые кадры, контекст таймаутов) для упрощения отладки.

Тестовая утилита handshake
- Добавлен Assets/Temp/Handshake_Test.cs как изолированный диагностический скрипт.
- Включены очень подробные логи по каждому шагу handshake и по каждому решению парсера.
- Позволяет отдельно гонять тестовый сценарий и сравнивать поведение платы, не ломая production-поток.

Стабильность COM и поведение reconnect
- Подправлена логика keep-alive, чтобы исключить агрессивные реконнекты.
- Reconnect теперь срабатывает только после нескольких последовательных пропусков INPUT-ответа, что снижает случайные обрывы COM и прерывания подсветки.

Поведение подсветки во время выполнения
- Циклы подсветки сделаны независимыми от Time.timeScale:
  - LightController: WaitForSeconds -> WaitForSecondsRealtime
  - LightSequence: WaitForSeconds -> WaitForSecondsRealtime
- Это устраняет случайные паузы/замедления световых циклов при изменении игрового времени (пауза, slow-mo, временные изменения scale).

Итоговый эффект
- COM-стек стал расширяемым по ролям и проще в сопровождении.
- Handshake стал детерминированным, устойчивым к serial-шуму и безопасным при работе со сторонними устройствами.
- Циклы подсветки стабилизированы для длительных сессий и больше не зависят от масштабирования игрового времени.
This commit is contained in:
Данил Ярош 2026-02-24 11:34:25 +03:00
parent 7e675618c8
commit 2963e342c2
18 changed files with 906 additions and 1001 deletions

View File

@ -11,7 +11,6 @@ GameObject:
- component: {fileID: 6708844088101150}
- component: {fileID: -6830167632558745604}
- component: {fileID: 7290507550622501242}
- component: {fileID: 4577859410310799261}
- component: {fileID: -3401184227323537348}
- component: {fileID: -6245296037342915518}
- component: {fileID: 6566017136146807207}
@ -66,30 +65,6 @@ MonoBehaviour:
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::RR.Common.Settings.SettingsManager
database: {fileID: 11400000, guid: 29cc91230b52fab4494eb90856f51c2c, type: 2}
--- !u!114 &4577859410310799261
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: d793a5f3669a676418d43519feaf3a8d, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::RR.Common.Controls.ComWrapper
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
@ -110,10 +85,17 @@ MonoBehaviour:
roleRequestCommand: 97
roleResponsePrefix: ROLE
roleRequestTimeoutMs: 1500
mainControllerRole: main_controller
mainControllerRole: main
useAllDetectedPorts: 1
explicitPorts: []
defaultDevicePort:
roleHandlerBindings:
- role: prize_stand
handlerType: 1
prizeStandBoxesCount: 24
- role: prize_stand_controller
handlerType: 1
prizeStandBoxesCount: 24
--- !u!114 &-6245296037342915518
MonoBehaviour:
m_ObjectHideFlags: 0

View File

@ -8504,6 +8504,59 @@ Transform:
m_CorrespondingSourceObject: {fileID: 4000011826580284, guid: 93b90907e99e38a448aca4707806a9be, type: 3}
m_PrefabInstance: {fileID: 1487759007}
m_PrefabAsset: {fileID: 0}
--- !u!1 &1500633490
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1500633492}
- component: {fileID: 1500633491}
m_Layer: 0
m_Name: Handshake_Test
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 0
--- !u!114 &1500633491
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1500633490}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 50f614532ed49204cb6644534a1c5d81, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::Handshake_Test
portName: COM3
baudRate: 115200
readTimeoutMs: 5
writeTimeoutMs: 10
openOnStart: 1
autoRunHandshake: 1
handshakeTimeoutMs: 10000
closePortOnDisable: 1
logRawReadLoop: 1
--- !u!4 &1500633492
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1500633490}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: -2.75464, y: -3.18366, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 0}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1513556535
GameObject:
m_ObjectHideFlags: 0
@ -289291,3 +289344,4 @@ SceneRoots:
- {fileID: 2033907137}
- {fileID: 1553660625}
- {fileID: 348531057}
- {fileID: 1500633492}

View File

@ -3929,7 +3929,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 1669870415}
- {fileID: 1962581740}
m_Father: {fileID: 139708007}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
@ -4351,7 +4350,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 1877012750}
- {fileID: 1906929718}
m_Father: {fileID: 139708007}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
@ -4945,42 +4943,6 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 683903126}
m_CullTransparentMesh: 1
--- !u!1 &700047461
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 700047462}
m_Layer: 5
m_Name: VFX
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 0
--- !u!224 &700047462
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 700047461}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 997740367}
m_Father: {fileID: 1460883867}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 100, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!1 &705422857
GameObject:
m_ObjectHideFlags: 0
@ -7459,124 +7421,6 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 996229917}
m_CullTransparentMesh: 1
--- !u!1 &997740366
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 997740367}
- component: {fileID: 997740369}
- component: {fileID: 997740368}
m_Layer: 5
m_Name: Visual Effect
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!4 &997740367
Transform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 997740366}
serializedVersion: 2
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 100, y: 100, z: 100}
m_ConstrainProportionsScale: 1
m_Children: []
m_Father: {fileID: 700047462}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!73398921 &997740368
VFXRenderer:
serializedVersion: 1
m_ObjectHideFlags: 2
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 997740366}
m_Enabled: 0
m_CastShadows: 0
m_ReceiveShadows: 0
m_DynamicOccludee: 1
m_StaticShadowCaster: 0
m_MotionVectors: 0
m_LightProbeUsage: 0
m_ReflectionProbeUsage: 0
m_RayTracingMode: 0
m_RayTraceProcedural: 0
m_RayTracingAccelStructBuildFlagsOverride: 0
m_RayTracingAccelStructBuildFlags: 1
m_SmallMeshCulling: 1
m_ForceMeshLod: -1
m_MeshLodSelectionBias: 0
m_RenderingLayerMask: 4294967295
m_RendererPriority: 0
m_StaticBatchInfo:
firstSubMesh: 0
subMeshCount: 0
m_StaticBatchRoot: {fileID: 0}
m_ProbeAnchor: {fileID: 0}
m_LightProbeVolumeOverride: {fileID: 0}
m_ScaleInLightmap: 1
m_ReceiveGI: 1
m_PreserveUVs: 0
m_IgnoreNormalsForChartDetection: 0
m_ImportantGI: 0
m_StitchLightmapSeams: 1
m_SelectedEditorRenderState: 3
m_MinimumChartSize: 4
m_AutoUVMaxDistance: 0.5
m_AutoUVMaxAngle: 89
m_LightmapParameters: {fileID: 0}
m_GlobalIlluminationMeshLod: 0
m_SortingLayerID: 981438795
m_SortingLayer: 2
m_SortingOrder: 0
m_MaskInteraction: 0
--- !u!2083052967 &997740369
VisualEffect:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 997740366}
m_Enabled: 1
m_Asset: {fileID: 8926484042661614526, guid: 6c4fdce0f9a1b184d9c3660014bdfdb5, type: 3}
m_InitialEventName: OnPlay
m_InitialEventNameOverriden: 0
m_StartSeed: 0
m_ResetSeedOnPlay: 1
m_AllowInstancing: 1
m_ResourceVersion: 1
m_PropertySheet:
m_Float:
m_Array: []
m_Vector2f:
m_Array: []
m_Vector3f:
m_Array: []
m_Vector4f:
m_Array: []
m_Uint:
m_Array: []
m_Int:
m_Array: []
m_Matrix4x4f:
m_Array: []
m_AnimationCurve:
m_Array: []
m_Gradient:
m_Array: []
m_NamedObject:
m_Array: []
m_Bool:
m_Array: []
--- !u!1 &1010062389
GameObject:
m_ObjectHideFlags: 0
@ -9936,8 +9780,8 @@ MonoBehaviour:
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: -5002131913879957837, guid: 67ae0b8315a808542a646c540231aa88, type: 3}
m_Type: 0
m_Sprite: {fileID: 21300000, guid: 67ae0b8315a808542a646c540231aa88, type: 3}
m_Type: 1
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
@ -10384,7 +10228,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 1412406034}
- {fileID: 700047462}
m_Father: {fileID: 139708007}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
@ -13553,7 +13396,7 @@ RectTransform:
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 1}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0.00094176567}
m_AnchoredPosition: {x: 0, y: 0.00095310435}
m_SizeDelta: {x: 0, y: 300}
m_Pivot: {x: 0, y: 1}
--- !u!114 &1792218764
@ -13925,7 +13768,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 374987852}
- {fileID: 2141805163}
m_Father: {fileID: 139708007}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
@ -14825,74 +14667,6 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1906471957}
m_CullTransparentMesh: 1
--- !u!1 &1906929717
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1906929718}
- component: {fileID: 1906929719}
m_Layer: 5
m_Name: SettingsUIGenerator
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &1906929718
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1906929717}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 650017796}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 100, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1906929719
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1906929717}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 2bec3b41f31ece6499468cf44ed8ed4e, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::RR.Common.UI.SettingsUIGenerator
database: {fileID: 11400000, guid: 29cc91230b52fab4494eb90856f51c2c, type: 2}
contentRoot: {fileID: 0}
groupByGroup: 1
collapsibleGroups: 1
groupPanelPrefab: {fileID: 2186865876444500735, guid: 1983dbab136fb73419f146d56772a8c8, type: 3}
defaultGroupsExpanded: 1
rememberGroupFoldout: 1
groupOrder:
- "\u041C\u0443\u0437\u044B\u043A\u0430 \u0438 \u0437\u0432\u0443\u043A\u0438"
sortByNameInsideGroup: 0
toggleRowPrefab: {fileID: -3497382869404314779, guid: 3f18baed1f4d89f4ab9b4caf0c789a38, type: 3}
sliderRowPrefab: {fileID: 5445087308684328405, guid: a85153c0090994b438ad050a1dbcc306, type: 3}
valueSelectorRowPrefab: {fileID: 5605032421712587294, guid: ef3fe155416356b4babfbdf354df7056, type: 3}
dropdownRowPrefab: {fileID: 1137542491512761884, guid: 1aa96bf602ff6b243b6f9844b30d87f1, type: 3}
inputRowPrefab: {fileID: 895630567611506380, guid: b9bddf8fad39ed546a60c1ca412c134d, type: 3}
buttonRowPrefab: {fileID: 6489304585556535039, guid: af71450598473d841b596834d93a58db, type: 3}
optionPickerRowPrefab: {fileID: 0}
buildOnEnable: 1
clearBeforeBuild: 1
showDescription: 1
--- !u!1 &1925001478
GameObject:
m_ObjectHideFlags: 0
@ -15184,74 +14958,6 @@ MonoBehaviour:
m_StringArgument:
m_BoolArgument: 0
m_CallState: 2
--- !u!1 &1935615349
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1935615350}
- component: {fileID: 1935615351}
m_Layer: 5
m_Name: SettingsUIGenerator
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &1935615350
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1935615349}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 2009866677}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 100, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1935615351
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1935615349}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 2bec3b41f31ece6499468cf44ed8ed4e, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::RR.Common.UI.SettingsUIGenerator
database: {fileID: 11400000, guid: 29cc91230b52fab4494eb90856f51c2c, type: 2}
contentRoot: {fileID: 0}
groupByGroup: 1
collapsibleGroups: 1
groupPanelPrefab: {fileID: 2186865876444500735, guid: 1983dbab136fb73419f146d56772a8c8, type: 3}
defaultGroupsExpanded: 1
rememberGroupFoldout: 1
groupOrder:
- "\u041C\u0443\u0437\u044B\u043A\u0430 \u0438 \u0437\u0432\u0443\u043A\u0438"
sortByNameInsideGroup: 0
toggleRowPrefab: {fileID: -3497382869404314779, guid: 3f18baed1f4d89f4ab9b4caf0c789a38, type: 3}
sliderRowPrefab: {fileID: 5445087308684328405, guid: a85153c0090994b438ad050a1dbcc306, type: 3}
valueSelectorRowPrefab: {fileID: 5605032421712587294, guid: ef3fe155416356b4babfbdf354df7056, type: 3}
dropdownRowPrefab: {fileID: 1137542491512761884, guid: 1aa96bf602ff6b243b6f9844b30d87f1, type: 3}
inputRowPrefab: {fileID: 895630567611506380, guid: b9bddf8fad39ed546a60c1ca412c134d, type: 3}
buttonRowPrefab: {fileID: 6489304585556535039, guid: af71450598473d841b596834d93a58db, type: 3}
optionPickerRowPrefab: {fileID: 0}
buildOnEnable: 1
clearBeforeBuild: 1
showDescription: 1
--- !u!1 &1959311158
GameObject:
m_ObjectHideFlags: 0
@ -15422,74 +15128,6 @@ Transform:
- {fileID: 1594047442}
m_Father: {fileID: 1381629685}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
--- !u!1 &1962581739
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1962581740}
- component: {fileID: 1962581741}
m_Layer: 5
m_Name: SettingsUIGenerator
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &1962581740
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1962581739}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 554903410}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 100, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1962581741
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1962581739}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 2bec3b41f31ece6499468cf44ed8ed4e, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::RR.Common.UI.SettingsUIGenerator
database: {fileID: 11400000, guid: 29cc91230b52fab4494eb90856f51c2c, type: 2}
contentRoot: {fileID: 0}
groupByGroup: 1
collapsibleGroups: 1
groupPanelPrefab: {fileID: 2186865876444500735, guid: 1983dbab136fb73419f146d56772a8c8, type: 3}
defaultGroupsExpanded: 1
rememberGroupFoldout: 1
groupOrder:
- "\u041C\u0443\u0437\u044B\u043A\u0430 \u0438 \u0437\u0432\u0443\u043A\u0438"
sortByNameInsideGroup: 0
toggleRowPrefab: {fileID: -3497382869404314779, guid: 3f18baed1f4d89f4ab9b4caf0c789a38, type: 3}
sliderRowPrefab: {fileID: 5445087308684328405, guid: a85153c0090994b438ad050a1dbcc306, type: 3}
valueSelectorRowPrefab: {fileID: 5605032421712587294, guid: ef3fe155416356b4babfbdf354df7056, type: 3}
dropdownRowPrefab: {fileID: 1137542491512761884, guid: 1aa96bf602ff6b243b6f9844b30d87f1, type: 3}
inputRowPrefab: {fileID: 895630567611506380, guid: b9bddf8fad39ed546a60c1ca412c134d, type: 3}
buttonRowPrefab: {fileID: 6489304585556535039, guid: af71450598473d841b596834d93a58db, type: 3}
optionPickerRowPrefab: {fileID: 0}
buildOnEnable: 1
clearBeforeBuild: 1
showDescription: 1
--- !u!1 &1986812974
GameObject:
m_ObjectHideFlags: 0
@ -15860,7 +15498,6 @@ RectTransform:
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 765378070}
- {fileID: 1935615350}
m_Father: {fileID: 139708007}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
@ -16798,74 +16435,6 @@ MonoBehaviour:
m_FlexibleWidth: -1
m_FlexibleHeight: -1
m_LayoutPriority: 1
--- !u!1 &2141805162
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 2141805163}
- component: {fileID: 2141805164}
m_Layer: 5
m_Name: SettingsUIGenerator
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &2141805163
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2141805162}
m_LocalRotation: {x: 0, y: 0, z: 0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 1828951630}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 100, y: 100}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &2141805164
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 2141805162}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 2bec3b41f31ece6499468cf44ed8ed4e, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::RR.Common.UI.SettingsUIGenerator
database: {fileID: 11400000, guid: 29cc91230b52fab4494eb90856f51c2c, type: 2}
contentRoot: {fileID: 0}
groupByGroup: 1
collapsibleGroups: 1
groupPanelPrefab: {fileID: 2186865876444500735, guid: 1983dbab136fb73419f146d56772a8c8, type: 3}
defaultGroupsExpanded: 1
rememberGroupFoldout: 1
groupOrder:
- "\u041C\u0443\u0437\u044B\u043A\u0430 \u0438 \u0437\u0432\u0443\u043A\u0438"
sortByNameInsideGroup: 0
toggleRowPrefab: {fileID: -3497382869404314779, guid: 3f18baed1f4d89f4ab9b4caf0c789a38, type: 3}
sliderRowPrefab: {fileID: 5445087308684328405, guid: a85153c0090994b438ad050a1dbcc306, type: 3}
valueSelectorRowPrefab: {fileID: 5605032421712587294, guid: ef3fe155416356b4babfbdf354df7056, type: 3}
dropdownRowPrefab: {fileID: 1137542491512761884, guid: 1aa96bf602ff6b243b6f9844b30d87f1, type: 3}
inputRowPrefab: {fileID: 895630567611506380, guid: b9bddf8fad39ed546a60c1ca412c134d, type: 3}
buttonRowPrefab: {fileID: 6489304585556535039, guid: af71450598473d841b596834d93a58db, type: 3}
optionPickerRowPrefab: {fileID: 0}
buildOnEnable: 1
clearBeforeBuild: 1
showDescription: 1
--- !u!1 &1000009598396499
GameObject:
m_ObjectHideFlags: 0

View File

@ -4,6 +4,7 @@ using System.Text;
using System.Threading;
using System.Threading.Tasks;
using RR.Game2D.Core.Managers;
using UnityEngine;
namespace RR.Common.Controls
{
@ -37,25 +38,27 @@ namespace RR.Common.Controls
private int _handle;
private long _lastCoinTime;
private int _inputToggle;
private int _missedInputResponses;
private bool _isAuthorized;
private bool _isOperational;
private bool _handshakeInProgress;
private string _requestHex = string.Empty;
private string _lastHandshakeResponse = string.Empty;
private string _lastInputBits = DefaultInputBits;
private byte[] _aesKey32;
private HandshakeStage _handshakeStage = HandshakeStage.None;
private TaskCompletionSource<bool> _handshakeCompletion;
private TaskCompletionSource<bool> _chalReceivedCompletion;
private TaskCompletionSource<string> _roleCompletion;
private enum HandshakeStage
{
None,
WaitingRaes,
WaitingChal,
WaitingReady,
WaitingRaes,
Completed,
Failed
}
@ -69,6 +72,7 @@ namespace RR.Common.Controls
public event Action<string> CoinAccepted;
public event Action<string, string> InputReceived;
public event Action<string, string> RawLineReceived;
public event Action<string> Authorized;
public event Action<string> Disconnected;
@ -146,8 +150,10 @@ namespace RR.Common.Controls
_handshakeInProgress = false;
_handshakeStage = HandshakeStage.None;
_handshakeCompletion?.TrySetCanceled();
_chalReceivedCompletion?.TrySetCanceled();
_roleCompletion?.TrySetCanceled();
ControllerRole = string.Empty;
_keepAliveTask = null;
Disconnected?.Invoke(DeviceId);
@ -230,7 +236,10 @@ namespace RR.Common.Controls
string bits = DecimalToBinary(rawData);
_lastInputBits = bits;
InputReceived?.Invoke(DeviceId, bits);
return;
}
RawLineReceived?.Invoke(DeviceId, line);
}
private async Task RunHandshakeAsync(CancellationToken token)
@ -238,59 +247,88 @@ namespace RR.Common.Controls
_handshakeInProgress = true;
_isAuthorized = false;
_isOperational = false;
_handshakeStage = HandshakeStage.WaitingRaes;
_handshakeStage = HandshakeStage.WaitingChal;
_lastHandshakeResponse = string.Empty;
_handshakeCompletion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
SendRaw("00", true);
await Task.Delay(1000, token);
_chalReceivedCompletion = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
Debug.Log($"[COM AUTH][{DeviceId}] Handshake started. Stage={_handshakeStage}");
_requestHex = GenerateRandomBlock16Hex();
// Порядок handshake по протоколу платы:
// PC -> 07[random], board -> CHAL, PC -> 08[enc(CHAL)], board -> RAES, board -> AUTH_CNT_OK.
SendRaw("07" + _requestHex, true);
Debug.Log($"[COM AUTH][{DeviceId}] -> 07{_requestHex}");
// Если на 07 не получили ожидаемый CHAL быстро, сразу пропускаем устройство.
int chalFastTimeoutMs = Math.Min(1200, Math.Max(250, _handshakeTimeoutMs / 4));
Task chalFastTimeoutTask = Task.Delay(chalFastTimeoutMs, token);
Task chalFastFinished = await Task.WhenAny(_chalReceivedCompletion.Task, chalFastTimeoutTask);
if (chalFastFinished != _chalReceivedCompletion.Task)
{
Debug.LogWarning($"[COM AUTH][{DeviceId}] No CHAL after 07 in {chalFastTimeoutMs}ms. Skipping device.");
FailHandshake($"Нет ожидаемого CHAL на 07 за {chalFastTimeoutMs}ms");
return;
}
Task timeoutTask = Task.Delay(_handshakeTimeoutMs, token);
Task finished = await Task.WhenAny(_handshakeCompletion.Task, timeoutTask);
if (finished == _handshakeCompletion.Task && _handshakeCompletion.Task.Result)
{
Debug.Log($"[COM AUTH][{DeviceId}] Handshake completed successfully");
MarkOperational();
return;
}
FailHandshake("Таймаут рукопожатия");
Debug.LogWarning($"[COM AUTH][{DeviceId}] Handshake timeout. Last board response: {_lastHandshakeResponse}");
FailHandshake($"Таймаут рукопожатия. Последний ответ платы: {_lastHandshakeResponse}");
}
private void TryProcessHandshakeLine(string line)
{
if (line.Equals("READY", StringComparison.OrdinalIgnoreCase))
Debug.Log($"[COM AUTH][{DeviceId}] <- {line}; stage={_handshakeStage}");
_lastHandshakeResponse = line;
// Во время handshake обрабатываем только ожидаемые сообщения:
// WaitingChal -> CHAL
// WaitingRaes -> RAES
// Все остальные строки игнорируем.
if (_handshakeStage == HandshakeStage.WaitingChal)
{
if (_handshakeStage != HandshakeStage.WaitingReady)
if (line.StartsWith("CHAL", StringComparison.Ordinal))
{
FailHandshake($"READY в неверной фазе: {_handshakeStage}");
return;
HandleChal(line);
}
else
{
Debug.Log($"[COM AUTH][{DeviceId}] Ignored while WaitingChal: {line}");
}
_handshakeStage = HandshakeStage.Completed;
_isAuthorized = true;
_handshakeCompletion?.TrySetResult(true);
return;
}
if (line.StartsWith("RAES", StringComparison.Ordinal))
if (_handshakeStage == HandshakeStage.WaitingRaes)
{
HandleRaes(line);
if (line.StartsWith("RAES", StringComparison.Ordinal))
{
HandleRaes(line);
}
else
{
Debug.Log($"[COM AUTH][{DeviceId}] Ignored while WaitingRaes: {line}");
}
return;
}
if (line.StartsWith("CHAL", StringComparison.Ordinal))
{
HandleChal(line);
}
Debug.Log($"[COM AUTH][{DeviceId}] Ignored in stage {_handshakeStage}: {line}");
}
private void HandleRaes(string line)
{
try
{
Debug.Log($"[COM AUTH][{DeviceId}] Handling RAES...");
if (_handshakeStage != HandshakeStage.WaitingRaes)
{
FailHandshake($"RAES в неверной фазе: {_handshakeStage}");
@ -317,7 +355,10 @@ namespace RR.Common.Controls
return;
}
_handshakeStage = HandshakeStage.WaitingChal;
_handshakeStage = HandshakeStage.Completed;
_isAuthorized = true;
Debug.Log($"[COM AUTH][{DeviceId}] RAES verified. Raw RAES={line}; decrypted={decrypted}; handshake complete");
_handshakeCompletion?.TrySetResult(true);
}
catch (Exception e)
{
@ -329,12 +370,15 @@ namespace RR.Common.Controls
{
try
{
Debug.Log($"[COM AUTH][{DeviceId}] Handling CHAL...");
if (_handshakeStage != HandshakeStage.WaitingChal)
{
FailHandshake($"CHAL в неверной фазе: {_handshakeStage}");
return;
}
_chalReceivedCompletion?.TrySetResult(true);
if (line.Length < 36)
{
FailHandshake("Некорректная длина CHAL");
@ -350,7 +394,8 @@ namespace RR.Common.Controls
string encrypted = EncryptHexBlock16(hex);
SendRaw("08" + encrypted, true);
_handshakeStage = HandshakeStage.WaitingReady;
_handshakeStage = HandshakeStage.WaitingRaes;
Debug.Log($"[COM AUTH][{DeviceId}] -> 08{encrypted}; stage={_handshakeStage}");
}
catch (Exception e)
{
@ -365,10 +410,12 @@ namespace RR.Common.Controls
_isOperational = true;
_handshakeStage = HandshakeStage.Completed;
ControllerRole = string.Empty;
_missedInputResponses = 0;
Debug.Log($"[COM AUTH][{DeviceId}] Device marked operational");
SendRaw("2171", true);
if (_keepAlive)
if (_keepAlive && (_keepAliveTask == null || _keepAliveTask.IsCompleted))
{
_keepAliveTask = Task.Run(() => KeepAliveLoopAsync(_lifecycleCts.Token), _lifecycleCts.Token);
}
@ -383,7 +430,12 @@ namespace RR.Common.Controls
_handshakeInProgress = false;
_handshakeStage = HandshakeStage.Failed;
_handshakeCompletion?.TrySetResult(false);
GameManager.Logger?.LogError("ComDeviceClient", $"[{DeviceId}] {message}");
Debug.LogWarning($"[COM AUTH][{DeviceId}] Handshake failed: {message}");
GameManager.Logger?.LogWarning("ComDeviceClient", $"[{DeviceId}] {message}");
// Если порт отвечает не по ожидаемому протоколу платы,
// отключаем устройство, чтобы менеджер выбрал другой порт.
Stop(false);
}
private async Task KeepAliveLoopAsync(CancellationToken token)
@ -406,7 +458,18 @@ namespace RR.Common.Controls
await Task.Delay(1000, token);
if (_inputToggle == 0)
{
await ReconnectAsync(token);
_missedInputResponses++;
if (_missedInputResponses >= 3)
{
GameManager.Logger?.LogWarning("ComDeviceClient",
$"[{DeviceId}] Нет INPUT-ответа {_missedInputResponses} цикла подряд, выполняю reconnect");
_missedInputResponses = 0;
await ReconnectAsync(token);
}
}
else
{
_missedInputResponses = 0;
}
await Task.Delay(7000, token);
@ -443,6 +506,9 @@ namespace RR.Common.Controls
if (!OpenPort())
{
// Порт недоступен при переподключении: отключаем устройство и даем менеджеру
// переключиться на другие доступные порты.
Stop(false);
return;
}

View File

@ -6,9 +6,10 @@ using RR.Game2D.Core.Managers;
namespace RR.Common.Controls
{
internal sealed class ComMultiManager
internal sealed class ComHub
{
private readonly Dictionary<string, ComDeviceClient> _devices = new();
private readonly Dictionary<string, IComControllerHandler> _handlers = new();
private readonly ConcurrentQueue<string> _coinQueue = new();
private readonly ConcurrentQueue<DeviceInputMessage> _inputQueue = new();
private readonly ConcurrentQueue<string> _authorizedQueue = new();
@ -23,10 +24,11 @@ namespace RR.Common.Controls
private readonly string _roleResponsePrefix;
private readonly int _roleRequestTimeoutMs;
private readonly string _mainControllerRole;
private readonly Func<string, string, IComControllerHandler> _handlerFactory;
private string _defaultDeviceId = string.Empty;
public ComMultiManager(
public ComHub(
bool useAllDetectedPorts,
string[] explicitPorts,
string defaultDevicePort,
@ -36,7 +38,8 @@ namespace RR.Common.Controls
string roleRequestCommand,
string roleResponsePrefix,
int roleRequestTimeoutMs,
string mainControllerRole)
string mainControllerRole,
Func<string, string, IComControllerHandler> handlerFactory)
{
_useAllDetectedPorts = useAllDetectedPorts;
_explicitPorts = explicitPorts ?? Array.Empty<string>();
@ -48,6 +51,7 @@ namespace RR.Common.Controls
_roleResponsePrefix = roleResponsePrefix ?? "ROLE";
_roleRequestTimeoutMs = roleRequestTimeoutMs;
_mainControllerRole = string.IsNullOrWhiteSpace(mainControllerRole) ? "main_controller" : mainControllerRole;
_handlerFactory = handlerFactory;
}
public string DefaultDeviceId => _defaultDeviceId;
@ -61,7 +65,7 @@ namespace RR.Common.Controls
string[] allPorts = ComNativeWrapper.ListPorts();
if (allPorts.Length == 0)
{
GameManager.Logger?.LogWarning("ComMultiManager", "Не найдено COM-портов");
GameManager.Logger?.LogWarning("ComHub", "Не найдено COM-портов");
return;
}
@ -82,24 +86,50 @@ namespace RR.Common.Controls
client.Stop(sendShutdownCommands);
}
foreach (IComControllerHandler handler in _handlers.Values)
{
handler.Dispose();
}
_devices.Clear();
_handlers.Clear();
_defaultDeviceId = string.Empty;
while (_coinQueue.TryDequeue(out _)) { }
while (_inputQueue.TryDequeue(out _)) { }
while (_authorizedQueue.TryDequeue(out _)) { }
while (_coinQueue.TryDequeue(out _))
{
}
while (_inputQueue.TryDequeue(out _))
{
}
while (_authorizedQueue.TryDequeue(out _))
{
}
}
public bool SendToDefault(string message)
{
if (string.IsNullOrEmpty(_defaultDeviceId)) return false;
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;
if (string.IsNullOrWhiteSpace(deviceId))
{
return false;
}
if (!_devices.TryGetValue(deviceId, out ComDeviceClient client))
{
return false;
}
client.Send(message);
return true;
}
@ -114,7 +144,11 @@ namespace RR.Common.Controls
public string GetLastInputState(string deviceId)
{
if (string.IsNullOrWhiteSpace(deviceId)) return "11111111111111111111111111";
if (string.IsNullOrWhiteSpace(deviceId))
{
return "11111111111111111111111111";
}
return _devices.TryGetValue(deviceId, out ComDeviceClient client)
? client.LastInputBits
: "11111111111111111111111111";
@ -128,18 +162,49 @@ namespace RR.Common.Controls
public string GetDeviceRole(string deviceId)
{
if (string.IsNullOrWhiteSpace(deviceId)) return string.Empty;
if (string.IsNullOrWhiteSpace(deviceId))
{
return string.Empty;
}
return _devices.TryGetValue(deviceId, out ComDeviceClient client)
? client.ControllerRole
: string.Empty;
}
public PrizeStandController GetPrizeStandController(string deviceId = null)
{
if (!string.IsNullOrWhiteSpace(deviceId))
{
return _handlers.TryGetValue(deviceId, out IComControllerHandler handler)
? handler as PrizeStandController
: null;
}
foreach (IComControllerHandler handler in _handlers.Values)
{
if (handler is PrizeStandController standController)
{
return standController;
}
}
return null;
}
private string[] SelectPorts(string[] allPorts)
{
if (_useAllDetectedPorts) return allPorts;
if (_explicitPorts.Length == 0) return Array.Empty<string>();
if (_useAllDetectedPorts)
{
return allPorts;
}
HashSet<string> explicitSet = new HashSet<string>(_explicitPorts.Where(p => !string.IsNullOrWhiteSpace(p)));
if (_explicitPorts.Length == 0)
{
return Array.Empty<string>();
}
HashSet<string> explicitSet = new(_explicitPorts.Where(p => !string.IsNullOrWhiteSpace(p)));
return allPorts.Where(explicitSet.Contains).ToArray();
}
@ -156,7 +221,7 @@ namespace RR.Common.Controls
private void TryCreateDevice(string port)
{
ComDeviceClient client = new ComDeviceClient(
ComDeviceClient client = new(
port,
_enableEncryptedHandshake,
_handshakeTimeoutMs,
@ -180,6 +245,7 @@ namespace RR.Common.Controls
{
client.CoinAccepted += OnDeviceCoinAccepted;
client.InputReceived += OnDeviceInputReceived;
client.RawLineReceived += OnDeviceRawLineReceived;
client.Authorized += OnDeviceAuthorized;
client.Disconnected += OnDeviceDisconnected;
}
@ -188,20 +254,41 @@ namespace RR.Common.Controls
{
client.CoinAccepted -= OnDeviceCoinAccepted;
client.InputReceived -= OnDeviceInputReceived;
client.RawLineReceived -= OnDeviceRawLineReceived;
client.Authorized -= OnDeviceAuthorized;
client.Disconnected -= OnDeviceDisconnected;
}
private void OnDeviceCoinAccepted(string deviceId)
{
if (_handlers.TryGetValue(deviceId, out IComControllerHandler handler))
{
handler.HandleCoinAccepted();
return;
}
_coinQueue.Enqueue(deviceId);
}
private void OnDeviceInputReceived(string deviceId, string inputBits)
{
if (_handlers.TryGetValue(deviceId, out IComControllerHandler handler))
{
handler.HandleInputReceived(inputBits);
return;
}
_inputQueue.Enqueue(new DeviceInputMessage(deviceId, inputBits));
}
private void OnDeviceRawLineReceived(string deviceId, string line)
{
if (_handlers.TryGetValue(deviceId, out IComControllerHandler handler))
{
handler.HandleRawLine(line);
}
}
private void OnDeviceAuthorized(string deviceId)
{
if (_devices.TryGetValue(deviceId, out ComDeviceClient client) &&
@ -214,20 +301,48 @@ namespace RR.Common.Controls
_defaultDeviceId = deviceId;
}
if (_handlers.TryGetValue(deviceId, out IComControllerHandler existing))
{
existing.Dispose();
}
_handlers[deviceId] = CreateHandlerForDevice(deviceId);
_authorizedQueue.Enqueue(deviceId);
}
private void OnDeviceDisconnected(string deviceId)
{
if (!_devices.TryGetValue(deviceId, out ComDeviceClient client)) return;
if (!_devices.TryGetValue(deviceId, out ComDeviceClient client))
{
return;
}
UnsubscribeDevice(client);
_devices.Remove(deviceId);
if (_handlers.TryGetValue(deviceId, out IComControllerHandler handler))
{
handler.Dispose();
_handlers.Remove(deviceId);
}
if (_defaultDeviceId == deviceId)
{
_defaultDeviceId = _devices.Keys.FirstOrDefault() ?? string.Empty;
}
}
private IComControllerHandler CreateHandlerForDevice(string deviceId)
{
string role = GetDeviceRole(deviceId);
IComControllerHandler customHandler = _handlerFactory?.Invoke(deviceId, role);
if (customHandler != null)
{
return customHandler;
}
return new DefaultComControllerHandler(deviceId, _coinQueue, _inputQueue);
}
}
internal readonly struct DeviceInputMessage
@ -242,3 +357,5 @@ namespace RR.Common.Controls
}
}
}

View File

@ -1,13 +1,305 @@
using System;
using RR.Game2D.Core.Managers;
using UnityEngine;
using UnityEngine.Events;
namespace RR.Common.Controls
{
/// <summary>
/// Новый публичный компонент COM-системы.
/// Наследует реализацию от <see cref="ComWrapper"/> для плавной миграции.
/// Публичный компонент COM-системы.
/// </summary>
[AddComponentMenu("RR/Controls/Com System Manager")]
public class ComSystemManager : ComWrapper
public class ComSystemManager : MonoBehaviour
{
private enum ControllerHandlerType
{
Default = 0,
PrizeStand = 1
}
[Serializable]
private sealed class RoleHandlerBinding
{
public string role;
public ControllerHandlerType handlerType = ControllerHandlerType.Default;
[Min(0)] public int prizeStandBoxesCount;
}
[Header("Основные настройки")]
[SerializeField] private bool saveMoneyBetweenSessions;
[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 = "";
[Header("Фабрика обработчиков по ROLE")]
[SerializeField] private RoleHandlerBinding[] roleHandlerBindings =
{
new RoleHandlerBinding { role = "prize_stand", handlerType = ControllerHandlerType.PrizeStand, prizeStandBoxesCount = 24 },
new RoleHandlerBinding { role = "prize_stand_controller", handlerType = ControllerHandlerType.PrizeStand, prizeStandBoxesCount = 24 }
};
public static UnityEvent<int> OnCreditAdded;
public static UnityEvent<int> OnCreditRemoved;
public static UnityEvent<string> OnInputReceived;
public static Action OnInitialize;
public static UnityEvent<string, string> OnDeviceInputReceived;
public static UnityEvent<string> OnDeviceAuthorized;
private static ComSystemManager _instance;
private static int _creditsCount;
private ComHub _manager;
private LightController _light;
private bool _initializeInvoked;
public LightController Light => _light;
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);
_light = GetComponent<LightController>() ?? gameObject.AddComponent<LightController>();
_manager = new ComHub(
useAllDetectedPorts,
explicitPorts,
defaultDevicePort,
enableEncryptedHandshake,
handshakeTimeoutMs,
keepAlive || enablePrizeChecking,
roleRequestCommand,
roleResponsePrefix,
roleRequestTimeoutMs,
mainControllerRole,
CreateRoleHandler);
_manager.InitializeDevices();
}
public static void AddCredit()
{
_creditsCount++;
PlayerPrefs.SetInt("CoinsCount", _creditsCount);
OnCreditAdded?.Invoke(_creditsCount);
}
public static bool Pay(int amount = 1)
{
if (_creditsCount < amount)
{
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 static int GetCredits() => _creditsCount;
public static void SetCredits(int amount)
{
int oldCredits = _creditsCount;
_creditsCount = Mathf.Max(0, amount);
PlayerPrefs.SetInt("CoinsCount", _creditsCount);
if (_creditsCount > oldCredits)
{
OnCreditAdded?.Invoke(_creditsCount);
}
else if (_creditsCount < oldCredits)
{
OnCreditRemoved?.Invoke(_creditsCount);
}
}
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 static PrizeStandController GetPrizeStandController(string deviceId = null)
{
return _instance?._manager?.GetPrizeStandController(deviceId);
}
private IComControllerHandler CreateRoleHandler(string deviceId, string role)
{
if (string.IsNullOrWhiteSpace(role) || roleHandlerBindings == null || roleHandlerBindings.Length == 0)
{
return null;
}
RoleHandlerBinding binding = FindRoleBinding(role);
if (binding == null)
{
return null;
}
if (binding.handlerType == ControllerHandlerType.PrizeStand)
{
PrizeStandController controller = new PrizeStandController(
deviceId,
(id, message) => _manager != null && _manager.SendToDevice(id, message),
message => GameManager.Logger?.Log("PrizeStandController", $"[{deviceId}] {message}"));
controller.SetBoxesCount(binding.prizeStandBoxesCount);
return controller;
}
return null;
}
private RoleHandlerBinding FindRoleBinding(string role)
{
for (int i = 0; i < roleHandlerBindings.Length; i++)
{
RoleHandlerBinding binding = roleHandlerBindings[i];
if (binding == null || string.IsNullOrWhiteSpace(binding.role))
{
continue;
}
if (string.Equals(binding.role.Trim(), role.Trim(), StringComparison.OrdinalIgnoreCase))
{
return binding;
}
}
return null;
}
public void Blink() => _light.Blink();
public void StopBlink(bool enableHat = true) => _light.StopBlink(enableHat);
private void Update()
{
if (_manager == null) return;
int coinsProcessed = 0;
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("ComSystemManager", $"Обработано монет: {coinsProcessed}");
}
}
private void OnDisable()
{
_manager?.ShutdownAll(false);
_manager = null;
_initializeInvoked = false;
}
private void OnApplicationQuit()
{
_manager?.ShutdownAll(true);
_manager = null;
_initializeInvoked = false;
}
}
}

View File

@ -1,231 +1,14 @@
using System;
using RR.Game2D.Core.Managers;
using UnityEngine;
using UnityEngine.Events;
namespace RR.Common.Controls
{
/// <summary>
/// Совместимый фасад над мульти-устройственной COM-системой.
/// Старый API сохранен для текущего проекта.
/// Устаревшее имя компонента COM-системы.
/// Используйте <see cref="ComSystemManager"/>.
/// </summary>
public class ComWrapper : MonoBehaviour
[Obsolete("Use ComSystemManager instead")]
public class ComWrapper : ComSystemManager
{
[Header("Основные настройки")]
[SerializeField] private bool saveMoneyBetweenSessions;
[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;
public static UnityEvent<string, string> OnDeviceInputReceived;
public static UnityEvent<string> OnDeviceAuthorized;
private static ComWrapper _instance;
private static int _creditsCount;
private ComMultiManager _manager;
private LightController _light;
private bool _initializeInvoked;
public LightController Light => _light;
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);
_light = GetComponent<LightController>() ?? gameObject.AddComponent<LightController>();
_manager = new ComMultiManager(
useAllDetectedPorts,
explicitPorts,
defaultDevicePort,
enableEncryptedHandshake,
handshakeTimeoutMs,
keepAlive || enablePrizeChecking,
roleRequestCommand,
roleResponsePrefix,
roleRequestTimeoutMs,
mainControllerRole);
_manager.InitializeDevices();
}
public static void AddCredit()
{
_creditsCount++;
PlayerPrefs.SetInt("CoinsCount", _creditsCount);
OnCreditAdded?.Invoke(_creditsCount);
}
public static bool Pay(int amount = 1)
{
if (_creditsCount < amount)
{
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 static int GetCredits() => _creditsCount;
public static void SetCredits(int amount)
{
int oldCredits = _creditsCount;
_creditsCount = Mathf.Max(0, amount);
PlayerPrefs.SetInt("CoinsCount", _creditsCount);
if (_creditsCount > oldCredits)
{
OnCreditAdded?.Invoke(_creditsCount);
}
else if (_creditsCount < oldCredits)
{
OnCreditRemoved?.Invoke(_creditsCount);
}
}
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()
{
if (_manager == null) return;
int coinsProcessed = 0;
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("ComWrapper", $"Обработано монет: {coinsProcessed}");
}
}
private void OnDisable()
{
_manager?.ShutdownAll(false);
_manager = null;
_initializeInvoked = false;
}
private void OnApplicationQuit()
{
_manager?.ShutdownAll(true);
_manager = null;
_initializeInvoked = false;
}
}
}

View File

@ -0,0 +1,40 @@
using System.Collections.Concurrent;
namespace RR.Common.Controls
{
internal sealed class DefaultComControllerHandler : IComControllerHandler
{
private readonly string _deviceId;
private readonly ConcurrentQueue<string> _coinQueue;
private readonly ConcurrentQueue<DeviceInputMessage> _inputQueue;
public DefaultComControllerHandler(
string deviceId,
ConcurrentQueue<string> coinQueue,
ConcurrentQueue<DeviceInputMessage> inputQueue)
{
_deviceId = deviceId;
_coinQueue = coinQueue;
_inputQueue = inputQueue;
}
public void HandleCoinAccepted()
{
_coinQueue.Enqueue(_deviceId);
}
public void HandleInputReceived(string inputBits)
{
_inputQueue.Enqueue(new DeviceInputMessage(_deviceId, inputBits));
}
public void HandleRawLine(string line)
{
// Для контроллера по умолчанию обработка сырых строк не требуется.
}
public void Dispose()
{
}
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3aed53dc839b7134c91148aca38412db

View File

@ -0,0 +1,11 @@
using System;
namespace RR.Common.Controls
{
internal interface IComControllerHandler : IDisposable
{
void HandleCoinAccepted();
void HandleInputReceived(string inputBits);
void HandleRawLine(string line);
}
}

View File

@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 4ff383aa1ebc9874c9019bf48d7bd5b8

View File

@ -160,7 +160,7 @@ namespace RR.Common.Controls
? Mathf.Max(0.05f, interval + Random.Range(-blinkIntervalVariance, blinkIntervalVariance))
: interval;
yield return new WaitForSeconds(wait);
yield return new WaitForSecondsRealtime(wait);
}
}
@ -179,3 +179,4 @@ namespace RR.Common.Controls
}
}

View File

@ -82,7 +82,7 @@ namespace RR.Common.Controls
_controller.SendCommand(step.Mode, step.DurationSeconds);
if (step.Mode != LightMode.Off && step.DurationSeconds > 0)
yield return new WaitForSeconds(step.DurationSeconds);
yield return new WaitForSecondsRealtime(step.DurationSeconds);
}
}
while (_looping);
@ -91,3 +91,4 @@ namespace RR.Common.Controls
}
}
}

View File

@ -1,315 +1,294 @@
using System;
using System;
using System.Collections.Concurrent;
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
namespace RR.Common.Controls
{
/// <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)
// Зависит от ComHub и обрабатывает контроллеры витрины по ROLE.
public sealed class PrizeStandController : IComControllerHandler
{
bool hasError = false;
StringBuilder sb = new StringBuilder();
public static readonly string[] SupportedRoles = { "prize_stand", "prize_stand_controller" };
//Проверка хэндла. h<=0 - ошибка открытия порта
if (handleId <= 0)
/// <summary>
/// По завершении операции открытия.
/// bool - успех/неуспех.
/// string - сообщение в случае неуспеха
/// </summary>
public event Action<bool, string> OpeningFinished;
private const string RequestStandStatus = "25";
private const string OpenBoxPrefix = "2501";
private const float StatusRequestTimeout = 3f;
private const float WaitOpenStatusTimeout = 5f;
private static readonly IReadOnlyList<BoxStatus> ReadyToOpenStatuses =
new[] { BoxStatus.OK, BoxStatus.OKplusCard };
private readonly string _deviceId;
private readonly Func<string, string, bool> _sendToDevice;
private readonly Action<string> _log;
private readonly SemaphoreSlim _lock = new(1, 1);
private readonly ConcurrentQueue<BoxStatusEntry> _statusQueue = new();
private int _boxesCount;
public PrizeStandController(string deviceId, Func<string, string, bool> sendToDevice, Action<string> log)
{
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;
_deviceId = deviceId;
_sendToDevice = sendToDevice;
_log = log;
}
try
public void SetBoxesCount(int boxesCount)
{
//Валидация
var validationResult = await CanExecuteOpenAsync(boxNumber);
if (!validationResult.OK)
_boxesCount = Math.Max(0, boxesCount);
}
public async Task OpenBoxAsync(int boxNumber)
{
if (!await _lock.WaitAsync(0))
{
OpeningFinished?.Invoke(false, validationResult.Message);
OpeningFinished?.Invoke(false, "Выдача уже в процессе");
return;
}
SendOpenCommand(boxNumber);
try
{
var validationResult = await CanExecuteOpenAsync(boxNumber);
if (!validationResult.OK)
{
OpeningFinished?.Invoke(false, validationResult.Message);
return;
}
//Обработка результатов
BoxStatus openStatus = await ReadBoxStatus(boxNumber, 1, WAIT_OPEN_STATUS_TIMEOUT);
if (openStatus == BoxStatus.OPENED)
{
OpeningFinished?.Invoke(true, $"Ячейка {boxNumber} успешно открыта");
ClearStatusQueue();
SendCommand($"{OpenBoxPrefix}{boxNumber:D2}");
BoxStatus openStatus = await ReadBoxStatusAsync(boxNumber, 1, WaitOpenStatusTimeout);
if (openStatus == BoxStatus.OPENED)
{
OpeningFinished?.Invoke(true, $"Ячейка {boxNumber} успешно открыта");
}
else
{
_log?.Invoke($"[Модуль витрины] Ошибка при открытии ячейки {boxNumber}");
OpeningFinished?.Invoke(false, $"Ошибка при открытии ячейки номер {boxNumber}");
}
}
else
catch (Exception ex)
{
_logger?.Log($"[Модуль витрины] Ошибка при открытии ячейки {boxNumber}");
OpeningFinished?.Invoke(false, $"Ошибка при открытии ячейки номер {boxNumber}");
_log?.Invoke($"[Модуль витрины] Ошибка при открытии ячейки {boxNumber}: {ex.Message}");
OpeningFinished?.Invoke(false, ex.Message);
}
finally
{
_lock.Release();
}
}
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)
public void HandleCoinAccepted()
{
isOk = false;
sb.AppendLine("- Модуль не инициализирован.");
// Для витрины монеты не обрабатываются.
}
bool boxNumberIsValid = boxNumber > 0 && boxNumber <= boxesCount;
if (!boxNumberIsValid)
public void HandleInputReceived(string inputBits)
{
isOk = false;
sb.AppendLine($"- Номер коробки={boxNumber} невалидный. Требуется от 1 до {boxesCount}.");
// Для витрины INPUT-пакеты не используются.
}
//Не лезем в порт с запросом статуса коробки, если то, что выше дало НеОК
if (isOk)
public void HandleRawLine(string line)
{
SendRequestStatusCommand();
BoxStatus boxStatus = await ReadBoxStatus(boxNumber, boxesCount, STATUS_REQUEST_TIMEOUT);
var parseResult = ParseBoxStatus(line);
if (parseResult.BoxNumber < 0)
{
return;
}
if (!READY_TO_OPEN_STATUSES.Contains(boxStatus))
_statusQueue.Enqueue(new BoxStatusEntry(parseResult.BoxNumber, parseResult.Status));
}
public void Dispose()
{
_lock.Dispose();
ClearStatusQueue();
}
public static bool IsSupportedRole(string role)
{
if (string.IsNullOrWhiteSpace(role))
{
return false;
}
return SupportedRoles.Any(r => string.Equals(r, role, StringComparison.OrdinalIgnoreCase));
}
private async Task<(bool OK, string Message)> CanExecuteOpenAsync(int boxNumber)
{
bool isOk = true;
StringBuilder sb = new();
sb.AppendLine("[Модуль витрины. Проверка перед открытием]");
if (boxNumber <= 0)
{
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;
sb.AppendLine("- Номер коробки должен быть больше 0.");
}
string entry = Encoding.UTF8.GetString(buffer, 0, entryLength).Trim();
var ParseResult = ParseBoxStatus(entry);
if(ParseResult.BoxNumber == -1)
if (_boxesCount > 0 && boxNumber > _boxesCount)
{
_logger?.Log($"[Модуль витрины. Ошибка получения статуса ячейки]\n{ParseResult.Message}");
isOk = false;
sb.AppendLine($"- Номер коробки={boxNumber} невалидный. Требуется от 1 до {_boxesCount}.");
}
else
if (isOk)
{
boxesRecieved++;
if (ParseResult.BoxNumber == expectedBoxNumber)
status = ParseResult.Status;
}
}
ClearStatusQueue();
SendCommand(RequestStandStatus);
if (status == BoxStatus.NotFound)
_logger?.Log($"[Модуль витрины. Не дождался ответа по статусу ячейки]\n" +
$"Ожидаемый номер: {expectedBoxNumber}\n" +
$"Получено статусов ячеек: {boxesRecieved}/{expectedEntriesCount}");
return status;
}
int expectedEntriesCount = _boxesCount > 0 ? _boxesCount : 1;
BoxStatus boxStatus = await ReadBoxStatusAsync(boxNumber, expectedEntriesCount, StatusRequestTimeout);
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)
if (!ReadyToOpenStatuses.Contains(boxStatus))
{
number = parsedNumber;
status = MapStatusFromString(parts[1]);
isOk = false;
sb.AppendLine($"- Статус коробки {boxNumber}: {boxStatus} не позволяет провести открытие.");
}
else message = $"Не удалось спарсить номер коробки: {entryString}";
}
else
{
message = $"Ошибка парсинга. Часть строки не пришла: {entryString}";
sb.AppendLine($"- Невозможно проверить статус коробки {boxNumber} из-за ошибок выше.");
}
return (isOk, sb.ToString());
}
private async Task<BoxStatus> ReadBoxStatusAsync(int expectedBoxNumber, int expectedEntriesCount, float timeout)
{
BoxStatus status = BoxStatus.NotFound;
int boxesReceived = 0;
Stopwatch sw = Stopwatch.StartNew();
while (sw.Elapsed.TotalSeconds < timeout && boxesReceived < expectedEntriesCount)
{
while (_statusQueue.TryDequeue(out BoxStatusEntry entry))
{
boxesReceived++;
if (entry.BoxNumber == expectedBoxNumber)
{
status = entry.Status;
}
if (boxesReceived >= expectedEntriesCount)
{
break;
}
}
if (boxesReceived >= expectedEntriesCount)
{
break;
}
await Task.Delay(10);
}
if (status == BoxStatus.NotFound)
{
_log?.Invoke("[Модуль витрины. Не дождался ответа по статусу ячейки]\n" +
$"Ожидаемый номер: {expectedBoxNumber}\n" +
$"Получено статусов ячеек: {boxesReceived}/{expectedEntriesCount}");
}
return status;
}
private void SendCommand(string command)
{
if (!_sendToDevice(_deviceId, command))
{
throw new InvalidOperationException($"Не удалось отправить команду '{command}' в устройство {_deviceId}");
}
}
else message = $"Неожиданный префикс при парсинге ячеек: {entryString}";
private void ClearStatusQueue()
{
while (_statusQueue.TryDequeue(out _))
{
}
}
return (number, status, message);
private static (int BoxNumber, BoxStatus Status) ParseBoxStatus(string entryString)
{
if (string.IsNullOrWhiteSpace(entryString) || !entryString.StartsWith("BOX", StringComparison.OrdinalIgnoreCase))
{
return (-1, BoxStatus.ParsingError);
}
string noPrefixEntry = entryString.Substring(3);
string[] parts = noPrefixEntry.Split('_');
if (parts.Length != 2)
{
return (-1, BoxStatus.ParsingError);
}
if (!int.TryParse(parts[0], out int parsedNumber))
{
return (-1, BoxStatus.ParsingError);
}
return (parsedNumber, MapStatusFromString(parts[1]));
}
private static BoxStatus MapStatusFromString(string statusString)
{
return 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
};
}
private readonly struct BoxStatusEntry
{
public readonly int BoxNumber;
public readonly BoxStatus Status;
public BoxStatusEntry(int boxNumber, BoxStatus status)
{
BoxNumber = boxNumber;
Status = status;
}
}
}
private static BoxStatus MapStatusFromString(string statusString) => statusString switch
public enum BoxStatus
{
"OK" => BoxStatus.OK,
"OK+CARD" => BoxStatus.OKplusCard,
"EMPTY" => BoxStatus.Empty,
"OPEN" => BoxStatus.Open,
// Статусы ДО открытия
OK,
OKplusCard,
Empty,
Open,
"OPENED" => BoxStatus.OPENED,
"OPEN_ERROR" => BoxStatus.OPEN_ERROR,
// Статусы ПОСЛЕ открытия
OPENED,
OPEN_ERROR,
"ERROR" => BoxStatus.Error,
_ => BoxStatus.ParsingError
};
#endregion
// Служебные статусы
ParsingError,
NotFound,
Error
}
}
enum BoxStatus
{
//Статусы ДО открытия
OK, //Готов к выдаче
OKplusCard, //Готов к выдаче + готов выдать по карте
Empty, //Пусто, уже выдали
Open, //Дверка УЖЕ открыта. Приз предположительно пустой
//Статусы ПОСЛЕ открытия
OPENED, //Всё хорошо, открылся
OPEN_ERROR, //Ошибка открытия
//Служебные статусы
ParsingError, //При парсинге что то пошло не так, или пришла непредусмотренная фигня
NotFound, //Коробка с требуемым номером не найдена
Error //Общая ошибка (например дали команду на открытие пустого ящика или сломан замок)
}

View File

@ -34,8 +34,8 @@ namespace RR.Game2D.Core.Managers
// Plate control for game
public ComSystemManager Plate { get; private set; }
// Light control for game
public LightController Light => Plate != null ? Plate.Light : null;
public LightController Light { get; private set; }
// Globals
[Header("Globals")]
[SerializeField] private string globalsPrefabResourcePath = "Prefabs/Global/Globals";
@ -158,10 +158,16 @@ namespace RR.Game2D.Core.Managers
if (Inactivity == null) Inactivity = gameObject.AddComponent<InactivityManager>();
if (Inactivity == null) Inactivity = InactivityManager.Instance;
// Apply loaded settings (new system applies automatically in Awake)
if (Plate == null) Plate = globals.GetComponent<ComSystemManager>();
if (Plate != null) Light = Plate.Light;
if (Light) Light.SetLight(LightMode.DiagonalWave)
.Then(LightMode.DiagonalWave2)
.Then(LightMode.Rainbow)
.Then(LightMode.FallingRows)
.Then(LightMode.SeaWave)
.Loop();
Payment.OnCreditAdded.AddListener(OnCreditAdded);
_gameStartTime = Time.unscaledTime;
@ -174,8 +180,11 @@ namespace RR.Game2D.Core.Managers
private void OnDestroy()
{
if (!Light) return;
Light.StopBlink(false);
Light.StopSequence();
if (Instance == this) Instance = null;
Plate?.StopBlink(false);
}
private void OnApplicationQuit()
@ -331,8 +340,13 @@ public void TogglePause() => SetPause(!IsPaused);
_gameStartTime = Time.time;
Logger.Log(gameObject.name,$"Game start time = {_gameStartTime}");
if (Plate) Plate.StopBlink();
Light?.SetLight(LightMode.Rainbow, 99);
// Активируем шапку и эффекты при начале игры
if (Light)
{
Light.StopBlink();
if (Light.IsSequencePlaying) Light.StopSequence();
Light.SetLight(LightMode.Rainbow).Loop();
}
// Включаем отслеживание бездействия
if (Inactivity != null) Inactivity.EnableInactivityCheck();

View File

@ -1,7 +1,6 @@
{
"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,13 +7,6 @@
"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,