Danro-Jump/Assets/Scripts/Runtime/Bootstrap/BootstrapEntryPoint.cs
Danil Iarosh 77186697f8 Prepare v0.1 gameplay and hardware foundation
Подробные изменения:

- Расширен основной gameplay loop: джетпак, новые платформы, оборудование, враги, прогрессия сложности и ограничения взаимодействий игрока.

- Добавлены Bootstrap-сервисы запуска приложения, защита, фокус окна, управление курсором и настройки сервисов через DI.

- Добавлена COM-интеграция с технической схемой Input System, обработкой кнопок/ADC, подсветкой и консольной диагностикой.

- Добавлен отдельный debug/logging модуль с каналами, обработчиками консоли и файловым выводом.

- Добавлены asmdef, link.xml, EditMode/PlayMode тесты и обновлена дорожная карта проекта.

Проверки:

- dotnet build .\\DanroJump.Runtime.csproj --no-restore

- dotnet build .\\DanroJump.Tests.EditMode.csproj --no-restore

- dotnet build .\\DanroJump.Tests.PlayMode.csproj --no-restore
2026-06-08 14:22:48 +03:00

267 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();
Screen.SetResolution(resolution.x, resolution.y, buildFullScreenMode);
}
/// <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;
}
}
}