Danro-Jump/Assets/Scripts/Runtime/UI/Gameplay/GameplayHudController.cs
Danil Iarosh c1b72854f4 Implement prize QR and service UI polish
Подробные изменения:

- Добавлена система призовых уровней и выдачи призов с QR-контуром Telegram, шаблоном сообщения и генерацией QR без внешних библиотек.

- Доработаны сервисные настройки: действия кнопок, модальные окна, ввод с джойстика/клавиатуры, статистика, настройки призов и маршрутизация QR.

- Переработаны gameplay HUD и overlay-панели на UI Toolkit со спрайтовыми плашками, DEV BUILD-статистикой, экраном продолжения и экраном результата приза.

- Исправлены сценарии смерти, GameOver, продолжения игры, начисления очков за высоту и перехода в главное меню.

- Перенесены UI-шаблоны из Resources в Assets/UI, подключена Addressables-загрузка шаблонов настроек и standalone-стили для MainMenuModals.

- Добавлены EditMode/PlayMode тесты для призов, QR, arcade input, UI-модалок, scene flow, HUD и gameplay-сессии.

- Обновлены сцены, настройки Addressables, service settings database, документация и дорожная карта.

Проверки:

- Targeted EditMode QR/UI/settings: 23/23 passed

- Targeted UI templates/EditMode: 17/17 passed

- MainMenuUxmlTests: 3/3 passed

- Full EditMode: 129/129 passed

- PlayMode: 6/6 passed

- Runtime Addressables template load check: loaded=5

- Unity Console после финального refresh: 0 errors
2026-06-12 16:43:28 +03:00

436 lines
14 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Threading;
using Cysharp.Threading.Tasks;
using DanroJump.Gameplay;
using DanroJump.Prizes;
using DanroJump.Prizes.Qr;
using DanroJump.SceneFlow;
using DanroJump.Settings;
using UnityEngine;
using UnityEngine.UIElements;
using Zenject;
namespace DanroJump.UI.Gameplay
{
/// <summary>
/// Отображает runtime-показатели игрового забега в UI Toolkit HUD.
/// </summary>
public sealed class GameplayHudController : MonoBehaviour
{
[SerializeField] private VisualTreeAsset visualTreeAsset;
[SerializeField] private PanelSettings panelSettings;
[SerializeField] private int sortingOrder = 10;
[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 IPrizeQrService prizeQrService;
private ISceneFlowService sceneFlow;
private IReadOnlyServiceSettings serviceSettings;
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 CancellationTokenSource refreshCancellation;
private bool isSubscribedToSession;
private bool isSubscribedToPrizeFlow;
/// <summary>
/// Получает игровую сессию из DI-контейнера сцены.
/// </summary>
[Inject]
public void Construct(
GameplaySession injectedSession,
[InjectOptional] IPrizeFlowService injectedPrizeFlow = null,
[InjectOptional] IPrizeQrService injectedPrizeQrService = null,
[InjectOptional] ISceneFlowService injectedSceneFlow = null,
[InjectOptional] IReadOnlyServiceSettings injectedServiceSettings = null)
{
prizeFlow = injectedPrizeFlow;
prizeQrService = injectedPrizeQrService;
sceneFlow = injectedSceneFlow;
serviceSettings = injectedServiceSettings;
SetSession(injectedSession);
SubscribeToPrizeFlow();
}
private void Start()
{
EnsureDocument();
BindLabels();
BindPrizeOverlay();
BindContinueOverlay();
ShowLastPrizeResultIfNeeded();
Refresh();
}
/// <summary>
/// Подписывается на изменения сессии и запускает кадровую подстраховку обновления HUD.
/// </summary>
private void OnEnable()
{
SubscribeToSession();
SubscribeToPrizeFlow();
if (refreshCancellation == null)
{
refreshCancellation = new CancellationTokenSource();
RefreshContinuouslyAsync(refreshCancellation.Token).Forget(Debug.LogException);
}
Refresh();
}
/// <summary>
/// Останавливает обновление HUD и снимает подписку с игровой сессии.
/// </summary>
private void OnDisable()
{
if (refreshCancellation != null)
{
refreshCancellation.Cancel();
refreshCancellation.Dispose();
refreshCancellation = null;
}
UnsubscribeFromSession();
UnsubscribeFromPrizeFlow();
prizeResultOverlay?.Dispose();
prizeResultOverlay = null;
prizeOverlayRoot = null;
continueGameOverlay?.Dispose();
continueGameOverlay = null;
continueOverlayRoot = null;
}
/// <summary>
/// Создает или настраивает UIDocument, чтобы HUD мог жить на отдельном GameObject без ручной сборки.
/// </summary>
private void EnsureDocument()
{
if (document == null && !TryGetComponent(out document))
{
document = gameObject.AddComponent<UIDocument>();
}
if (document == null)
{
return;
}
document.visualTreeAsset = visualTreeAsset;
document.panelSettings = panelSettings;
document.sortingOrder = sortingOrder;
}
/// <summary>
/// Находит текстовые поля в UXML по именам, заданным в инспекторе.
/// </summary>
private void BindLabels()
{
var root = document != null ? document.rootVisualElement : null;
if (root == null)
{
return;
}
developmentStatsRoot = root.Q<VisualElement>(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);
}
private void BindPrizeOverlay()
{
var root = document != null ? document.rootVisualElement : null;
var overlayRoot = root?.Q<VisualElement>("PrizeResultOverlay");
if (overlayRoot == null)
{
return;
}
if (prizeResultOverlay != null && prizeOverlayRoot == overlayRoot)
{
return;
}
prizeResultOverlay?.Dispose();
prizeOverlayRoot = overlayRoot;
prizeResultOverlay = new PrizeResultOverlayWindow(overlayRoot);
prizeResultOverlay.ReturnRequested += HandlePrizeReturnRequested;
}
private void BindContinueOverlay()
{
var root = document != null ? document.rootVisualElement : null;
var overlayRoot = root?.Q<VisualElement>("ContinueGameOverlay");
if (overlayRoot == null)
{
return;
}
if (continueGameOverlay != null && continueOverlayRoot == overlayRoot)
{
return;
}
continueGameOverlay?.Dispose();
continueOverlayRoot = overlayRoot;
continueGameOverlay = new ContinueGameOverlayWindow(overlayRoot);
continueGameOverlay.ContinueRequested += HandleContinueRequested;
continueGameOverlay.MenuRequested += HandleContinueMenuRequested;
}
/// <summary>
/// Обновляет все значения HUD из текущей игровой сессии.
/// </summary>
private void Refresh()
{
RefreshDevelopmentStatsVisibility();
if (session == null)
{
SetLabel(stateLabel, "WAITING");
return;
}
SetLabel(scoreLabel, session.Score.ToString());
SetLabel(heightLabel, $"{session.MaxRunHeight:0} м");
SetLabel(bestHeightLabel, $"{session.RecordHeight:0} м");
SetLabel(livesLabel, session.RemainingLives.ToString());
SetLabel(coinsLabel, session.Coins.ToString());
SetLabel(deathsLabel, session.DeathCount.ToString());
SetLabel(stateLabel, FormatState(session.State));
RefreshContinueOverlay();
}
/// <summary>
/// Безопасно обновляет текст label, если элемент найден в UXML.
/// </summary>
private static void SetLabel(Label label, string value)
{
if (label != null)
{
label.text = value;
}
}
/// <summary>
/// Ищет Label по имени, заданному в настройках контроллера.
/// </summary>
private static Label FindLabel(VisualElement root, string labelName)
{
return root != null && !string.IsNullOrEmpty(labelName)
? root.Q<Label>(labelName)
: null;
}
private void RefreshDevelopmentStatsVisibility()
{
if (developmentStatsRoot == null)
{
return;
}
developmentStatsRoot.style.display = ShouldShowDevelopmentStats
? DisplayStyle.Flex
: DisplayStyle.None;
}
private static bool ShouldShowDevelopmentStats => Debug.isDebugBuild || Application.isEditor;
/// <summary>
/// Подстраховывает HUD от событий, которые могли прийти до подписки.
/// </summary>
private async UniTask RefreshContinuouslyAsync(CancellationToken cancellationToken)
{
try
{
while (enabled && !cancellationToken.IsCancellationRequested)
{
Refresh();
await UniTask.Yield(PlayerLoopTiming.Update, cancellationToken);
}
}
catch (System.OperationCanceledException)
{
}
}
/// <summary>
/// Преобразует состояние сессии в короткий текст для HUD.
/// </summary>
private static string FormatState(GameplaySessionState state)
{
return state switch
{
GameplaySessionState.Running => "RUN",
GameplaySessionState.Respawning => "RESPAWN",
GameplaySessionState.GameOver => "GAME OVER",
_ => "WAITING",
};
}
private void SetSession(GameplaySession injectedSession)
{
if (session == injectedSession)
{
return;
}
UnsubscribeFromSession();
session = injectedSession;
SubscribeToSession();
Refresh();
}
private void SubscribeToSession()
{
if (!isActiveAndEnabled || session == null || isSubscribedToSession)
{
return;
}
session.Changed += Refresh;
isSubscribedToSession = true;
}
private void UnsubscribeFromSession()
{
if (session == null || !isSubscribedToSession)
{
return;
}
session.Changed -= Refresh;
isSubscribedToSession = false;
}
private void SubscribeToPrizeFlow()
{
if (!isActiveAndEnabled || prizeFlow == null || isSubscribedToPrizeFlow)
{
return;
}
prizeFlow.PrizeFlowStarted += HandlePrizeFlowStarted;
isSubscribedToPrizeFlow = true;
}
private void UnsubscribeFromPrizeFlow()
{
if (prizeFlow == null || !isSubscribedToPrizeFlow)
{
return;
}
prizeFlow.PrizeFlowStarted -= HandlePrizeFlowStarted;
isSubscribedToPrizeFlow = false;
}
private void HandlePrizeFlowStarted(PrizeFlowResult result)
{
if (!result.HasPrize)
{
return;
}
EnsureDocument();
BindPrizeOverlay();
PrizeQrTicket qrTicket = null;
var qrError = string.Empty;
if (result.RewardKind == PrizeRewardKind.QRCode && prizeQrService != null)
{
prizeQrService.TryCreateTicket(result, out qrTicket, out qrError);
}
prizeResultOverlay?.SetResult(
result,
serviceSettings?.GetString(ServiceSettingsKeys.BigPrize),
serviceSettings?.GetString(ServiceSettingsKeys.PrizePhoneNumber),
qrTicket,
qrError);
prizeResultOverlay?.Open();
}
private void HandlePrizeReturnRequested()
{
prizeResultOverlay?.Close();
sceneFlow?.OpenMainMenu();
}
private void ShowLastPrizeResultIfNeeded()
{
if (prizeFlow == null || !prizeFlow.LastResult.HasPrize)
{
return;
}
HandlePrizeFlowStarted(prizeFlow.LastResult);
}
private void RefreshContinueOverlay()
{
if (session == null || session.State != GameplaySessionState.GameOver || prizeResultOverlay != null && prizeResultOverlay.IsOpen)
{
continueGameOverlay?.Close();
return;
}
EnsureDocument();
BindContinueOverlay();
if (session.CanContinueGame)
{
continueGameOverlay?.SetOffer(session.ContinueGameCost, session.ContinueGameLives);
}
else
{
continueGameOverlay?.SetUnavailable();
}
if (continueGameOverlay != null && !continueGameOverlay.IsOpen)
{
continueGameOverlay.Open();
}
}
private void HandleContinueRequested()
{
if (session != null && session.TryContinueGame())
{
continueGameOverlay?.Close();
return;
}
continueGameOverlay?.SetUnavailable();
}
private void HandleContinueMenuRequested()
{
continueGameOverlay?.Close();
sceneFlow?.OpenMainMenu();
}
}
}