using System; using System.Threading; using Cysharp.Threading.Tasks; using DanroJump.Audio; using DanroJump.Gameplay; using DanroJump.Hardware.Com; using DanroJump.Prizes; using DanroJump.Prizes.Qr; using DanroJump.SceneFlow; using DanroJump.Settings; using UnityEngine; using UnityEngine.UIElements; using Zenject; namespace DanroJump.UI.Gameplay { /// /// Отображает runtime-показатели игрового забега в UI Toolkit HUD. /// public sealed class GameplayHudController : MonoBehaviour { [SerializeField] private VisualTreeAsset visualTreeAsset; [SerializeField] private PanelSettings panelSettings; [SerializeField] private int sortingOrder = 5000; [SerializeField] private string scoreLabelName = "ScoreValue"; [SerializeField] private string heightLabelName = "HeightValue"; [SerializeField] private string bestHeightLabelName = "BestHeightValue"; [SerializeField] private string livesLabelName = "LivesValue"; [SerializeField] private string coinsLabelName = "CoinsValue"; [SerializeField] private string deathsLabelName = "DeathsValue"; [SerializeField] private string stateLabelName = "StateValue"; [SerializeField] private string developmentStatsRootName = "RightStats"; private UIDocument document; private GameplaySession session; private IPrizeFlowService prizeFlow; private IPrizeSessionStateService prizeSessionState; private IPrizeQrService prizeQrService; private ISceneFlowService sceneFlow; private IReadOnlyServiceSettings serviceSettings; private IGameAudioService audioService; private IComSystem comSystem; private PrizeResultOverlayWindow prizeResultOverlay; private ContinueGameOverlayWindow continueGameOverlay; private VisualElement prizeOverlayRoot; private VisualElement continueOverlayRoot; private VisualElement developmentStatsRoot; private Label scoreLabel; private Label heightLabel; private Label bestHeightLabel; private Label livesLabel; private Label coinsLabel; private Label deathsLabel; private Label stateLabel; private Label comboLabel; private CancellationTokenSource refreshCancellation; private CancellationTokenSource qrTimeoutCancellation; private CancellationTokenSource physicalDispenseCancellation; private bool isSubscribedToSession; private bool isSubscribedToPrizeFlow; private int lastScore = -1; private int lastHeight = -1; private int lastRecord = -1; private int lastLives = -1; private int lastCoins = -1; private int lastDeaths = -1; private int lastCombo = -1; private GameplaySessionState lastState = (GameplaySessionState)(-1); private bool isSubscribedToSettings; private bool enabledStatsInSettings; /// /// Получает игровую сессию из DI-контейнера сцены. /// [Inject] public void Construct( GameplaySession injectedSession, [InjectOptional] IPrizeFlowService injectedPrizeFlow = null, [InjectOptional] IPrizeSessionStateService injectedPrizeSessionState = null, [InjectOptional] IPrizeQrService injectedPrizeQrService = null, [InjectOptional] ISceneFlowService injectedSceneFlow = null, [InjectOptional] IReadOnlyServiceSettings injectedServiceSettings = null, [InjectOptional] IGameAudioService injectedAudioService = null, [InjectOptional] IComSystem injectedComSystem = null) { prizeFlow = injectedPrizeFlow; prizeSessionState = injectedPrizeSessionState; prizeQrService = injectedPrizeQrService; sceneFlow = injectedSceneFlow; serviceSettings = injectedServiceSettings; audioService = injectedAudioService; comSystem = injectedComSystem; SetSession(injectedSession); SubscribeToPrizeFlow(); } private void Start() { EnsureDocument(); BindLabels(); BindPrizeOverlay(); BindContinueOverlay(); ShowLastPrizeResultIfNeeded(); ResetCache(); Refresh(); } /// /// Подписывается на изменения сессии и запускает кадровую подстраховку обновления HUD. /// private void OnEnable() { SubscribeToSession(); SubscribeToPrizeFlow(); SubscribeToSettings(); if (refreshCancellation == null) { refreshCancellation = new CancellationTokenSource(); RefreshContinuouslyAsync(refreshCancellation.Token).Forget(Debug.LogException); } ResetCache(); Refresh(); } /// /// Останавливает обновление HUD и снимает подписку с игровой сессии. /// private void OnDisable() { if (refreshCancellation != null) { refreshCancellation.Cancel(); refreshCancellation.Dispose(); refreshCancellation = null; } if (qrTimeoutCancellation != null) { qrTimeoutCancellation.Cancel(); qrTimeoutCancellation.Dispose(); qrTimeoutCancellation = null; } if (physicalDispenseCancellation != null) { physicalDispenseCancellation.Cancel(); physicalDispenseCancellation.Dispose(); physicalDispenseCancellation = null; } UnsubscribeFromSession(); UnsubscribeFromPrizeFlow(); UnsubscribeFromSettings(); prizeResultOverlay?.Dispose(); prizeResultOverlay = null; prizeOverlayRoot = null; continueGameOverlay?.Dispose(); continueGameOverlay = null; continueOverlayRoot = null; } /// /// Создает или настраивает UIDocument, чтобы HUD мог жить на отдельном GameObject без ручной сборки. /// private void EnsureDocument() { if (document == null && !TryGetComponent(out document)) { document = gameObject.AddComponent(); } if (document == null) { return; } if (document.visualTreeAsset != visualTreeAsset) { document.visualTreeAsset = visualTreeAsset; } if (document.panelSettings != panelSettings) { document.panelSettings = panelSettings; } if (document.sortingOrder != sortingOrder) { document.sortingOrder = sortingOrder; } } /// /// Находит текстовые поля в UXML по именам, заданным в инспекторе. /// private void BindLabels() { var root = document != null ? document.rootVisualElement : null; if (root == null) { return; } developmentStatsRoot = root.Q(developmentStatsRootName); scoreLabel = FindLabel(root, scoreLabelName); heightLabel = FindLabel(root, heightLabelName); bestHeightLabel = FindLabel(root, bestHeightLabelName); livesLabel = FindLabel(root, livesLabelName); coinsLabel = FindLabel(root, coinsLabelName); deathsLabel = FindLabel(root, deathsLabelName); stateLabel = FindLabel(root, stateLabelName); comboLabel = FindLabel(root, "ComboValue"); } private void BindPrizeOverlay() { if (prizeResultOverlay != null && prizeOverlayRoot != null && prizeOverlayRoot.panel != null) { return; } var root = document != null ? document.rootVisualElement : null; var overlayRoot = root?.Q("PrizeResultOverlay"); if (overlayRoot == null) { return; } prizeOverlayRoot = overlayRoot; prizeResultOverlay = new PrizeResultOverlayWindow(overlayRoot, audioService); prizeResultOverlay.ReturnRequested += HandlePrizeReturnRequested; } private void BindContinueOverlay() { if (continueGameOverlay != null && continueOverlayRoot != null && continueOverlayRoot.panel != null) { return; } var root = document != null ? document.rootVisualElement : null; var overlayRoot = root?.Q("ContinueGameOverlay"); if (overlayRoot == null) { return; } continueOverlayRoot = overlayRoot; continueGameOverlay = new ContinueGameOverlayWindow(overlayRoot, audioService); continueGameOverlay.ContinueRequested += HandleContinueRequested; continueGameOverlay.MenuRequested += HandleContinueMenuRequested; } /// /// Обновляет все значения HUD из текущей игровой сессии. /// private void Refresh() { RefreshDevelopmentStatsVisibility(); if (session == null) { SetLabel(stateLabel, "WAITING"); return; } int currentScore = session.Score; if (currentScore != lastScore) { SetLabel(scoreLabel, currentScore.ToString()); lastScore = currentScore; } int currentHeight = Mathf.FloorToInt(session.MaxRunHeight); if (currentHeight != lastHeight) { SetLabel(heightLabel, $"{currentHeight} м"); lastHeight = currentHeight; } int currentRecord = Mathf.FloorToInt(session.RecordHeight); if (currentRecord != lastRecord) { SetLabel(bestHeightLabel, $"{currentRecord} м"); lastRecord = currentRecord; } int currentLives = session.RemainingLives; if (currentLives != lastLives) { SetLabel(livesLabel, currentLives.ToString()); lastLives = currentLives; } int currentCoins = session.Coins; if (currentCoins != lastCoins) { SetLabel(coinsLabel, currentCoins.ToString()); lastCoins = currentCoins; } int currentDeaths = session.DeathCount; if (currentDeaths != lastDeaths) { SetLabel(deathsLabel, currentDeaths.ToString()); lastDeaths = currentDeaths; } GameplaySessionState currentState = session.State; if (currentState != lastState) { SetLabel(stateLabel, FormatState(currentState)); lastState = currentState; } if (comboLabel != null) { int currentCombo = session.CurrentCombo; if (currentCombo > 1) { if (currentCombo != lastCombo) { var comboText = $"x{currentCombo} COMBO!"; comboLabel.text = comboText; comboLabel.style.display = DisplayStyle.Flex; PulseComboLabel(); lastCombo = currentCombo; } } else { if (lastCombo != currentCombo) { comboLabel.style.display = DisplayStyle.None; lastCombo = currentCombo; } } } RefreshContinueOverlay(); } private void PulseComboLabel() { if (comboLabel == null) { return; } comboLabel.style.scale = new StyleScale(new Scale(new Vector3(1.4f, 1.4f, 1f))); ResetComboScaleAsync().Forget(); } private async UniTaskVoid ResetComboScaleAsync() { await UniTask.Delay(TimeSpan.FromMilliseconds(80)); if (comboLabel != null) { comboLabel.style.scale = new StyleScale(new Scale(new Vector3(1.0f, 1.0f, 1f))); } } /// /// Безопасно обновляет текст label, если элемент найден в UXML. /// private static void SetLabel(Label label, string value) { if (label != null) { label.text = value; } } /// /// Ищет Label по имени, заданному в настройках контроллера. /// private static Label FindLabel(VisualElement root, string labelName) { return root != null && !string.IsNullOrEmpty(labelName) ? root.Q