Danro-Jump/Assets/Scripts/Runtime/Bootstrap/BootstrapEntryPoint.cs
Danil Iarosh d39d160d92 Implement physical prize dispensing, service stats, and QR screen timeout
- Добавлен асинхронный процесс аппаратной выдачи физических призов (хоппер и витрина) в GameplayHudController.
- Реализована блокировка выдачи по 10-секундному таймауту датчика хоппера с гарантированной отправкой команды отключения двигателя '300'.
- Реализован автоматический таймаут неактивности на экране QR-кода с последующим переходом в главное меню.
- Расширен сервис статистики PrizeSessionStateService новыми долгосрочными счетчиками (сыгранные игры, очки, монеты, сбои связи COM, физ. выдачи и ошибки).
- Обновлен построитель сводки статистики в меню настроек для отображения новых счетчиков и списка последних 5 сессий.
- Написаны EditMode тесты для проверки инкремента счетчиков и запуска выдачи призов.
2026-06-23 10:36:13 +03:00

268 lines
10 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.IO;
using System.Threading;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.SceneManagement;
namespace DanroJump.Bootstrap
{
/// <summary>
/// Первая точка входа приложения: фиксирует портретное соотношение сторон и загружает главное меню.
/// </summary>
[DefaultExecutionOrder(-10000)]
public sealed class BootstrapEntryPoint : MonoBehaviour
{
private const float AspectTolerance = 0.0025f;
private static BootstrapEntryPoint instance;
[SerializeField] private string mainMenuSceneName = "MainMenu";
[SerializeField] private bool forceBuildAspectRatio = true;
[SerializeField] [Min(1)] private int targetAspectWidth = 9;
[SerializeField] [Min(1)] private int targetAspectHeight = 16;
[SerializeField] [Min(1)] private int preferredPortraitHeight = 1920;
[SerializeField] private bool clampToDisplaySize = true;
[SerializeField] private FullScreenMode buildFullScreenMode = FullScreenMode.Windowed;
[SerializeField] [Min(1)] private int startupEnforcementFrames = 8;
[SerializeField] [Min(0.1f)] private float aspectCheckInterval = 0.5f;
private float nextAspectCheckTime;
/// <summary>
/// Создает singleton bootstrap-объект и применяет разрешение билда до загрузки меню.
/// </summary>
private void Awake()
{
if (instance != null && instance != this)
{
Destroy(gameObject);
return;
}
instance = this;
if (Application.isPlaying)
{
DontDestroyOnLoad(gameObject);
}
// В редакторе не вмешиваемся в Game View, а в билде принудительно держим 9:16.
if (!Application.isEditor && forceBuildAspectRatio)
{
ApplyBuildAspectResolution();
EnforceBuildAspectOnStartupAsync(this.GetCancellationTokenOnDestroy()).Forget(Debug.LogException);
}
}
/// <summary>
/// Периодически возвращает разрешение к целевому aspect ratio, если окно было изменено.
/// </summary>
private void Update()
{
if (Application.isEditor || !forceBuildAspectRatio || Time.unscaledTime < nextAspectCheckTime)
{
return;
}
nextAspectCheckTime = Time.unscaledTime + aspectCheckInterval;
if (!HasTargetAspectRatio(Screen.width, Screen.height))
{
ApplyBuildAspectResolution();
}
}
/// <summary>
/// Загружает сцену главного меню после bootstrap-настройки.
/// </summary>
private void Start()
{
LoadMainMenuAsync(this.GetCancellationTokenOnDestroy()).Forget(Debug.LogException);
}
private async UniTask LoadMainMenuAsync(CancellationToken cancellationToken)
{
try
{
if (StartupProtectionController.HasInstance)
{
StartupProtectionState protectionState =
await StartupProtectionController.WaitUntilFinishedAsync(cancellationToken);
if (protectionState == StartupProtectionState.Failed)
{
Debug.LogError("[BootstrapEntryPoint] Startup protection failed. Main menu loading was blocked.", this);
return;
}
}
else
{
Debug.LogWarning("[BootstrapEntryPoint] Startup protection controller is missing in Bootstrap scene.", this);
}
StartupWindowFocusController startupWindowFocus =
FindAnyObjectByType<StartupWindowFocusController>();
if (startupWindowFocus != null)
{
await startupWindowFocus.RunStartupFocusAsync(cancellationToken);
}
if (string.IsNullOrWhiteSpace(mainMenuSceneName))
{
Debug.LogError("[BootstrapEntryPoint] Main menu scene name is empty.", this);
return;
}
if (!SceneExistsInBuildSettings(mainMenuSceneName))
{
Debug.LogError($"[BootstrapEntryPoint] Scene '{mainMenuSceneName}' is not registered in Build Settings.", this);
return;
}
AsyncOperation operation = SceneManager.LoadSceneAsync(mainMenuSceneName, LoadSceneMode.Single);
if (operation == null)
{
Debug.LogError($"[BootstrapEntryPoint] Failed to start loading scene '{mainMenuSceneName}'.", this);
return;
}
while (!operation.isDone)
{
await UniTask.Yield(PlayerLoopTiming.Update, cancellationToken);
}
}
catch (System.OperationCanceledException)
{
}
}
/// <summary>
/// Очищает singleton-ссылку при выходе из Play Mode или уничтожении bootstrap-объекта.
/// </summary>
private void OnDestroy()
{
if (instance == this)
{
instance = null;
}
}
/// <summary>
/// Несколько кадров подряд повторяет установку разрешения, пока Unity завершает старт окна.
/// </summary>
private async UniTask EnforceBuildAspectOnStartupAsync(CancellationToken cancellationToken)
{
try
{
for (int i = 0; i < startupEnforcementFrames; i++)
{
if (!HasTargetAspectRatio(Screen.width, Screen.height))
{
ApplyBuildAspectResolution();
}
await UniTask.Yield(PlayerLoopTiming.Update, cancellationToken);
}
}
catch (System.OperationCanceledException)
{
}
}
/// <summary>
/// Применяет рассчитанное разрешение к окну игры.
/// </summary>
private void ApplyBuildAspectResolution()
{
Vector2Int resolution = CalculateTargetResolution();
FullScreenMode mode = Debug.isDebugBuild ? buildFullScreenMode : FullScreenMode.FullScreenWindow;
Screen.SetResolution(resolution.x, resolution.y, mode);
}
/// <summary>
/// Рассчитывает ближайшее разрешение с целевым aspect ratio, сохраняя пользовательский размер окна.
/// </summary>
private Vector2Int CalculateTargetResolution()
{
var targetAspect = GetTargetAspect();
var currentWidth = Mathf.Max(1, Screen.width);
var currentHeight = Mathf.Max(1, Screen.height);
var widthByCurrentHeight = Mathf.Max(1, Mathf.RoundToInt(currentHeight * targetAspect));
var heightByCurrentWidth = Mathf.Max(1, Mathf.RoundToInt(currentWidth / targetAspect));
var keepHeightDelta = Mathf.Abs(widthByCurrentHeight - currentWidth);
var keepWidthDelta = Mathf.Abs(heightByCurrentWidth - currentHeight);
var width = currentWidth;
var height = currentHeight;
if (currentWidth <= 1 || currentHeight <= 1)
{
height = Mathf.Max(1, preferredPortraitHeight);
width = Mathf.Max(1, Mathf.RoundToInt(height * targetAspect));
}
else if (keepHeightDelta <= keepWidthDelta)
{
width = widthByCurrentHeight;
}
else
{
height = heightByCurrentWidth;
}
if (clampToDisplaySize && Display.main != null)
{
int displayWidth = Display.main.systemWidth;
int displayHeight = Display.main.systemHeight;
if (displayHeight > 0 && height > displayHeight)
{
height = displayHeight;
width = Mathf.Max(1, Mathf.RoundToInt(height * targetAspect));
}
if (displayWidth > 0 && width > displayWidth)
{
width = displayWidth;
height = Mathf.Max(1, Mathf.RoundToInt(width / targetAspect));
}
}
return new Vector2Int(width, height);
}
/// <summary>
/// Проверяет, совпадает ли текущее разрешение с целевым соотношением сторон.
/// </summary>
private bool HasTargetAspectRatio(int width, int height)
{
if (width <= 0 || height <= 0)
{
return false;
}
return Mathf.Abs(width / (float)height - GetTargetAspect()) <= AspectTolerance;
}
/// <summary>
/// Возвращает целевой aspect ratio из настроек ширины и высоты.
/// </summary>
private float GetTargetAspect()
{
return targetAspectWidth / (float)targetAspectHeight;
}
/// <summary>
/// Проверяет, добавлена ли сцена в Build Settings.
/// </summary>
private static bool SceneExistsInBuildSettings(string sceneName)
{
for (int i = 0; i < SceneManager.sceneCountInBuildSettings; i++)
{
string scenePath = SceneUtility.GetScenePathByBuildIndex(i);
string buildSceneName = Path.GetFileNameWithoutExtension(scenePath);
if (buildSceneName == sceneName)
return true;
}
return false;
}
}
}