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

348 lines
12 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;
using DanroJump.Hardware.Com;
using DanroJump.Prizes;
using DanroJump.SceneFlow;
using DanroJump.Settings;
using UnityEngine;
namespace DanroJump.Gameplay
{
/// <summary>
/// Хранит состояние текущего забега: счет, монеты, жизни, высоту и рекорд.
/// </summary>
public sealed class GameplaySession : IDisposable
{
private const string RecordHeightPrefsKey = "DanroJump.Gameplay.RecordHeight";
private const int DefaultStartingLives = 3;
private const int DefaultAdditionalLives = 1;
private const float HeightMetersPerScorePoint = 10f;
private static readonly TimeSpan GameOverOverlayReturnDelay = TimeSpan.FromSeconds(8f);
private readonly JumpPlayerController player;
private readonly IReadOnlyServiceSettings serviceSettings;
private readonly ISceneFlowService sceneFlow;
private readonly IPrizeFlowService prizeFlow;
private readonly IComCreditWallet creditWallet;
private readonly int startingLives;
private int baseScore;
private float savedRecordHeight;
/// <summary>
/// Итоговые очки текущего забега: игровые начисления плюс бонус за максимальную высоту.
/// </summary>
public int Score => baseScore + HeightScore;
/// <summary>
/// Очки, начисленные за максимальную высоту текущего забега.
/// </summary>
public int HeightScore => Mathf.FloorToInt(MaxRunHeight / HeightMetersPerScorePoint);
/// <summary>
/// Монеты, собранные в текущем забеге.
/// </summary>
public int Coins { get; private set; }
/// <summary>
/// Количество смертей в текущем забеге.
/// </summary>
public int DeathCount { get; private set; }
/// <summary>
/// Оставшиеся жизни до завершения забега.
/// </summary>
public int RemainingLives { get; private set; }
/// <summary>
/// Текущее состояние gameplay-сессии.
/// </summary>
public GameplaySessionState State { get; private set; }
/// <summary>
/// Фактическая текущая высота игрока над стартом.
/// </summary>
public float CurrentHeight => player != null ? Mathf.Max(0f, player.transform.position.y) : 0f;
/// <summary>
/// Максимальная высота, достигнутая в текущем забеге.
/// </summary>
public float MaxRunHeight => player != null ? Mathf.Max(0f, player.HighestY) : 0f;
/// <summary>
/// Лучшая высота текущего забега.
/// </summary>
public float BestHeight => MaxRunHeight;
/// <summary>
/// Абсолютный рекорд с учетом сохраненного значения и текущего забега.
/// </summary>
public float RecordHeight => Mathf.Max(savedRecordHeight, MaxRunHeight);
/// <summary>
/// Показывает, находится ли игрок в состоянии смерти или завершенного забега.
/// </summary>
public bool IsPlayerDead => State == GameplaySessionState.Respawning || State == GameplaySessionState.GameOver;
public int ContinueGameCost => ResolveContinueCost();
public int ContinueGameLives => ResolveAdditionalLives();
public bool CanContinueGame => State == GameplaySessionState.GameOver &&
IsContinueEnabled() &&
HasContinueBalance(ContinueGameCost);
public event Action Changed;
/// <summary>
/// Создает сессию и подписывается на события смерти/respawn игрока.
/// </summary>
public GameplaySession(
JumpPlayerController player,
[Zenject.InjectOptional] IReadOnlyServiceSettings serviceSettings = null,
[Zenject.InjectOptional] ISceneFlowService sceneFlow = null,
[Zenject.InjectOptional] IPrizeFlowService prizeFlow = null,
[Zenject.InjectOptional] IComCreditWallet creditWallet = null)
{
this.player = player;
this.serviceSettings = serviceSettings;
this.sceneFlow = sceneFlow;
this.prizeFlow = prizeFlow;
this.creditWallet = creditWallet;
startingLives = ResolveStartingLives();
RemainingLives = startingLives;
State = GameplaySessionState.Running;
savedRecordHeight = PlayerPrefs.GetFloat(RecordHeightPrefsKey, 0f);
if (this.player != null)
{
this.player.Died += HandlePlayerDied;
this.player.Respawned += HandlePlayerRespawned;
}
}
/// <summary>
/// Добавляет очки в текущий забег.
/// </summary>
public void AddScore(int amount)
{
if (amount <= 0)
{
return;
}
baseScore += amount;
Changed?.Invoke();
}
/// <summary>
/// Добавляет монеты в текущий забег.
/// </summary>
public void AddCoins(int amount)
{
if (amount <= 0)
{
return;
}
Coins += amount;
Changed?.Invoke();
}
/// <summary>
/// Полностью сбрасывает забег, сохранив рекорд при необходимости.
/// </summary>
public void ResetSession()
{
SaveRecordIfNeeded();
baseScore = 0;
Coins = 0;
DeathCount = 0;
RemainingLives = startingLives;
State = GameplaySessionState.Running;
player?.ResetRun();
Changed?.Invoke();
}
public bool TryContinueGame()
{
if (!CanContinueGame)
{
return false;
}
if (!TrySpendContinueBalance(ContinueGameCost))
{
Changed?.Invoke();
return false;
}
sceneFlow?.CancelGameOverReturn();
RemainingLives = ContinueGameLives;
State = GameplaySessionState.Respawning;
if (player != null)
{
player.AllowRespawn();
player.Respawn();
}
else
{
State = GameplaySessionState.Running;
}
Changed?.Invoke();
return true;
}
/// <summary>
/// Сохраняет рекорд и отписывается от событий игрока.
/// </summary>
public void Dispose()
{
SaveRecordIfNeeded();
if (player == null)
{
return;
}
player.Died -= HandlePlayerDied;
player.Respawned -= HandlePlayerRespawned;
}
/// <summary>
/// Обновляет состояние сессии после смерти игрока.
/// </summary>
private void HandlePlayerDied(JumpPlayerController _)
{
DeathCount++;
RemainingLives = Mathf.Max(0, RemainingLives - 1);
State = RemainingLives > 0 ? GameplaySessionState.Respawning : GameplaySessionState.GameOver;
if (State == GameplaySessionState.GameOver)
{
player?.BlockPendingRespawn();
var prizeResult = prizeFlow?.StartPrizeFlow(this) ?? default;
if (!prizeResult.HasPrize)
{
StartGameOverReturn(GameOverOverlayReturnDelay);
}
}
else
{
player?.ScheduleRespawn(player.DeathRespawnDelay);
}
SaveRecordIfNeeded();
Changed?.Invoke();
}
/// <summary>
/// Возвращает сессию в Running после respawn, если игра еще не закончена.
/// </summary>
private void HandlePlayerRespawned(JumpPlayerController _)
{
if (State != GameplaySessionState.GameOver)
{
State = GameplaySessionState.Running;
}
Changed?.Invoke();
}
/// <summary>
/// Читает стартовое количество жизней из сервисных настроек.
/// </summary>
private int ResolveStartingLives()
{
if (serviceSettings == null)
{
return DefaultStartingLives;
}
var configuredLives = serviceSettings.GetInt(ServiceSettingsKeys.StartingLives);
return Mathf.Max(1, configuredLives);
}
private int ResolveAdditionalLives()
{
if (serviceSettings == null)
{
return DefaultAdditionalLives;
}
return Mathf.Max(1, serviceSettings.GetInt(ServiceSettingsKeys.AdditionalLives));
}
private int ResolveContinueCost()
{
return serviceSettings != null
? Mathf.Max(0, serviceSettings.GetInt(ServiceSettingsKeys.ContinueGameCost))
: 0;
}
private bool IsContinueEnabled()
{
return serviceSettings != null && serviceSettings.GetBool(ServiceSettingsKeys.CanContinueGame);
}
private bool HasContinueBalance(int cost)
{
return IsFreePlay() || cost <= 0 || creditWallet != null && creditWallet.Credits >= cost;
}
private bool TrySpendContinueBalance(int cost)
{
if (IsFreePlay() || cost <= 0)
{
return true;
}
return creditWallet != null && creditWallet.TrySpendCredits(cost);
}
private bool IsFreePlay()
{
return serviceSettings != null && serviceSettings.GetBool(ServiceSettingsKeys.FreePlay);
}
/// <summary>
/// Сохраняет лучший результат по высоте в PlayerPrefs.
/// </summary>
private void SaveRecordIfNeeded()
{
if (MaxRunHeight <= savedRecordHeight)
{
return;
}
savedRecordHeight = MaxRunHeight;
PlayerPrefs.SetFloat(RecordHeightPrefsKey, savedRecordHeight);
PlayerPrefs.Save();
}
/// <summary>
/// Запускает возврат в главное меню после финальной смерти.
/// </summary>
private void StartGameOverReturn(TimeSpan delay)
{
if (sceneFlow == null)
{
Debug.LogWarning($"{nameof(GameplaySession)} cannot return to main menu because {nameof(ISceneFlowService)} is not bound.");
return;
}
sceneFlow.ReturnToMainMenuAfterGameOver(delay);
}
}
/// <summary>
/// Состояние игрового забега.
/// </summary>
public enum GameplaySessionState
{
Waiting = 0,
Running = 1,
Respawning = 2,
GameOver = 3,
}
}