using System.IO; using System.Threading; using Cysharp.Threading.Tasks; using UnityEngine; using UnityEngine.SceneManagement; namespace DanroJump.Bootstrap { /// /// Первая точка входа приложения: фиксирует портретное соотношение сторон и загружает главное меню. /// [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; /// /// Создает singleton bootstrap-объект и применяет разрешение билда до загрузки меню. /// 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); } } /// /// Периодически возвращает разрешение к целевому aspect ratio, если окно было изменено. /// private void Update() { if (Application.isEditor || !forceBuildAspectRatio || Time.unscaledTime < nextAspectCheckTime) { return; } nextAspectCheckTime = Time.unscaledTime + aspectCheckInterval; if (!HasTargetAspectRatio(Screen.width, Screen.height)) { ApplyBuildAspectResolution(); } } /// /// Загружает сцену главного меню после bootstrap-настройки. /// 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(); 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) { } } /// /// Очищает singleton-ссылку при выходе из Play Mode или уничтожении bootstrap-объекта. /// private void OnDestroy() { if (instance == this) { instance = null; } } /// /// Несколько кадров подряд повторяет установку разрешения, пока Unity завершает старт окна. /// 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) { } } /// /// Применяет рассчитанное разрешение к окну игры. /// private void ApplyBuildAspectResolution() { Vector2Int resolution = CalculateTargetResolution(); FullScreenMode mode = Debug.isDebugBuild ? buildFullScreenMode : FullScreenMode.FullScreenWindow; Screen.SetResolution(resolution.x, resolution.y, mode); } /// /// Рассчитывает ближайшее разрешение с целевым aspect ratio, сохраняя пользовательский размер окна. /// 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); } /// /// Проверяет, совпадает ли текущее разрешение с целевым соотношением сторон. /// private bool HasTargetAspectRatio(int width, int height) { if (width <= 0 || height <= 0) { return false; } return Mathf.Abs(width / (float)height - GetTargetAspect()) <= AspectTolerance; } /// /// Возвращает целевой aspect ratio из настроек ширины и высоты. /// private float GetTargetAspect() { return targetAspectWidth / (float)targetAspectHeight; } /// /// Проверяет, добавлена ли сцена в Build Settings. /// 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; } } }