Danro-Jump/Assets/Scripts/Runtime/UI/MainMenu/MainMenuController.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

558 lines
20 KiB
C#

using System.Collections.Generic;
using System.Threading;
using Cysharp.Threading.Tasks;
using DanroJump.Bootstrap;
using DanroJump.SceneFlow;
using DanroJump.Settings;
using DanroJump.UI.Modals;
using UnityEngine;
using UnityEngine.EventSystems;
using UnityEngine.Serialization;
using UnityEngine.UIElements;
using Zenject;
#if ENABLE_INPUT_SYSTEM
using UnityEngine.InputSystem;
#endif
namespace DanroJump.UI.MainMenu
{
/// <summary>
/// Управляет главным меню: модальными окнами, запуском игры и сервисными настройками.
/// </summary>
[RequireComponent(typeof(UIDocument))]
public sealed class MainMenuController : MonoBehaviour
{
[SerializeField] private string gameplaySceneName = "Game";
[SerializeField] private string settingsSceneName = "";
[SerializeField] private string defaultFocusedButtonName = "RulesButton";
[SerializeField] private Color normalButtonImageTint = Color.white;
[SerializeField] private Color focusedButtonImageTint = new Color32(0xCE, 0xFF, 0x1A, 0xFF);
#if ENABLE_INPUT_SYSTEM
private const string LegacySecretActionPath = "UI/Secret";
[SerializeField] private InputActionReference secretActionReference;
[SerializeField, FormerlySerializedAs("inputActionsAsset")] private InputActionAsset legacyInputActionsAsset;
#endif
private UIDocument document;
private VisualElement root;
private Button startButton;
private Button rulesButton;
private Button settingsButton;
private Button serviceButton;
private Button quitButton;
private Label livesValueLabel;
private Label creditsValueLabel;
private Label exchangeLivesValueLabel;
private Label priceTextLabel;
private Label versionLabel;
private ModalStackController modalStack;
private RulesModalWindow rulesModal;
private SettingsModalWindow settingsModal;
private readonly List<Button> menuButtons = new();
private IReadOnlyServiceSettings serviceSettings;
private IServiceSettingsService serviceSettingsService;
private ISceneFlowService sceneFlow;
#if ENABLE_INPUT_SYSTEM
private InputAction secretAction;
private bool secretActionWasEnabled;
#endif
/// <summary>
/// Получает сервисные настройки через Extenject.
/// Если DI еще не готов, контроллер попробует найти сервис в ProjectContext позже.
/// </summary>
[Inject]
public void Construct(
[InjectOptional] IReadOnlyServiceSettings injectedServiceSettings,
[InjectOptional] IServiceSettingsService injectedSettingsService,
[InjectOptional] ISceneFlowService injectedSceneFlow)
{
serviceSettingsService = injectedSettingsService;
serviceSettings = injectedServiceSettings ?? injectedSettingsService;
sceneFlow = injectedSceneFlow;
}
/// <summary>
/// Кэширует UIDocument, от которого строится всё главное меню.
/// </summary>
private void Awake()
{
document = GetComponent<UIDocument>();
}
/// <summary>
/// Находит элементы UI, подключает обработчики и подготавливает фокус главного меню.
/// </summary>
private void OnEnable()
{
root = document.rootVisualElement;
// Находим элементы по именам из MainMenu.uxml.
startButton = root.Q<Button>("StartButton");
rulesButton = root.Q<Button>("RulesButton");
settingsButton = root.Q<Button>("SettingsButton");
serviceButton = root.Q<Button>("ServiceButton");
quitButton = root.Q<Button>("QuitButton");
livesValueLabel = root.Q<Label>("LivesValue");
creditsValueLabel = root.Q<Label>("CreditsValue");
exchangeLivesValueLabel = root.Q<Label>("ExchangeLivesValue");
priceTextLabel = root.Q<Label>("PriceText");
versionLabel = root.Q<Label>("VersionLabel");
menuButtons.Clear();
root.Query<Button>(className: "menu-button").ForEach(menuButtons.Add);
// Настройки могут прийти через DI или через ProjectContext, если меню создано раньше инъекции.
ResolveServiceSettingsIfNeeded();
ResolveSceneFlowIfNeeded();
SetupModals(root);
RegisterCallbacks();
RegisterButtonFocusCallbacks();
RegisterInputActions();
RefreshStaticLabels();
FocusDefaultButtonNextFrameAsync(this.GetCancellationTokenOnDestroy()).Forget(Debug.LogException);
}
/// <summary>
/// Освобождает обработчики UI и Input System при выключении документа.
/// </summary>
private void OnDisable()
{
UnregisterInputActions();
modalStack?.Dispose();
rulesModal?.Dispose();
settingsModal?.Dispose();
modalStack = null;
rulesModal = null;
settingsModal = null;
UnregisterButtonFocusCallbacks();
UnregisterCallbacks();
menuButtons.Clear();
root = null;
}
/// <summary>
/// Создает runtime-обертки над модальными окнами, описанными в UXML.
/// </summary>
/// <param name="root">Корневой элемент UIDocument.</param>
private void SetupModals(VisualElement root)
{
var modalLayer = root.Q<VisualElement>("ModalLayer");
var modalBackdrop = root.Q<VisualElement>("ModalBackdrop");
var rulesModalRoot = root.Q<VisualElement>("RulesModal");
var settingsModalRoot = root.Q<VisualElement>("SettingsModal");
modalStack = new ModalStackController(modalLayer, modalBackdrop);
rulesModal = new RulesModalWindow(rulesModalRoot);
settingsModal = new SettingsModalWindow(settingsModalRoot, serviceSettingsService);
rulesModal.Close();
settingsModal.Close();
}
/// <summary>
/// Подписывает кнопки главного меню на действия.
/// </summary>
private void RegisterCallbacks()
{
if (startButton != null)
{
startButton.clicked += StartGame;
}
if (rulesButton != null)
{
rulesButton.clicked += OpenRules;
}
if (settingsButton != null)
{
settingsButton.clicked += OpenSettings;
}
if (serviceButton != null)
{
serviceButton.clicked += OpenServicePanel;
}
if (quitButton != null)
{
quitButton.clicked += QuitApplication;
}
}
/// <summary>
/// Отписывает кнопки главного меню от действий.
/// </summary>
private void UnregisterCallbacks()
{
if (startButton != null)
{
startButton.clicked -= StartGame;
}
if (rulesButton != null)
{
rulesButton.clicked -= OpenRules;
}
if (settingsButton != null)
{
settingsButton.clicked -= OpenSettings;
}
if (serviceButton != null)
{
serviceButton.clicked -= OpenServicePanel;
}
if (quitButton != null)
{
quitButton.clicked -= QuitApplication;
}
}
/// <summary>
/// Подключает визуальную реакцию кнопок на фокус.
/// </summary>
private void RegisterButtonFocusCallbacks()
{
foreach (var button in menuButtons)
{
button.focusable = true;
button.style.unityBackgroundImageTintColor = new StyleColor(normalButtonImageTint);
button.RegisterCallback<FocusInEvent>(HandleButtonFocusIn);
button.RegisterCallback<FocusOutEvent>(HandleButtonFocusOut);
}
}
/// <summary>
/// Отключает обработчики фокуса, чтобы повторное включение меню не создавало дубликаты.
/// </summary>
private void UnregisterButtonFocusCallbacks()
{
foreach (var button in menuButtons)
{
button.UnregisterCallback<FocusInEvent>(HandleButtonFocusIn);
button.UnregisterCallback<FocusOutEvent>(HandleButtonFocusOut);
}
}
/// <summary>
/// Подсвечивает кнопку, на которую попал UI-фокус.
/// </summary>
private void HandleButtonFocusIn(FocusInEvent evt)
{
if (evt.currentTarget is Button button)
{
button.style.unityBackgroundImageTintColor = new StyleColor(focusedButtonImageTint);
}
}
/// <summary>
/// Возвращает обычный tint кнопки после потери UI-фокуса.
/// </summary>
private void HandleButtonFocusOut(FocusOutEvent evt)
{
if (evt.currentTarget is Button button)
{
button.style.unityBackgroundImageTintColor = new StyleColor(normalButtonImageTint);
}
}
/// <summary>
/// Ждет один кадр, чтобы UI Toolkit успел построить панель перед установкой фокуса.
/// </summary>
private async UniTask FocusDefaultButtonNextFrameAsync(CancellationToken cancellationToken)
{
try
{
await UniTask.Yield(PlayerLoopTiming.Update, cancellationToken);
await UniTask.Yield(PlayerLoopTiming.Update, cancellationToken);
if (!isActiveAndEnabled || root == null)
{
return;
}
StartupWindowFocusController.FocusGameWindow();
FocusDefaultButton();
}
catch (System.OperationCanceledException)
{
}
}
/// <summary>
/// Ставит фокус на кнопку по умолчанию.
/// </summary>
private void FocusDefaultButton()
{
if (menuButtons.Count == 0)
{
return;
}
var defaultButton = !string.IsNullOrWhiteSpace(defaultFocusedButtonName)
? menuButtons.Find(button => button.name == defaultFocusedButtonName)
: null;
var buttonToFocus = defaultButton ?? menuButtons[0];
buttonToFocus.Focus();
EventSystem.current?.SetSelectedGameObject(document != null ? document.gameObject : gameObject);
}
/// <summary>
/// Обновляет статические подписи главного меню из версии приложения и сервисных настроек.
/// </summary>
private void RefreshStaticLabels()
{
if (versionLabel != null)
{
versionLabel.text = $"Версия: {Application.version}";
}
if (serviceSettings == null)
{
return;
}
var startingLives = Mathf.Max(0, serviceSettings.GetInt(ServiceSettingsKeys.StartingLives));
var isFreePlay = serviceSettings.GetBool(ServiceSettingsKeys.FreePlay);
var gamePrice = isFreePlay ? 0 : Mathf.Max(0, serviceSettings.GetInt(ServiceSettingsKeys.StartGameCost));
if (livesValueLabel != null)
{
livesValueLabel.text = startingLives.ToString();
}
if (creditsValueLabel != null)
{
creditsValueLabel.text = "0";
}
if (exchangeLivesValueLabel != null)
{
exchangeLivesValueLabel.text = $"= {startingLives}";
}
if (priceTextLabel != null)
{
priceTextLabel.text = gamePrice <= 0 ? "БЕСПЛАТНО" : $"{gamePrice} РУБ.";
}
}
/// <summary>
/// Ищет сервис настроек в ProjectContext, если он не был внедрен напрямую через Extenject.
/// </summary>
private void ResolveServiceSettingsIfNeeded()
{
if (serviceSettings != null && serviceSettingsService != null)
{
return;
}
try
{
var projectContext = ProjectContext.Instance;
if (projectContext == null || projectContext.Container == null)
{
return;
}
if (serviceSettingsService == null &&
projectContext.Container.HasBinding<IServiceSettingsService>())
{
serviceSettingsService = projectContext.Container.Resolve<IServiceSettingsService>();
}
if (serviceSettings == null && serviceSettingsService != null)
{
serviceSettings = serviceSettingsService;
}
if (serviceSettings == null &&
projectContext.Container.HasBinding<IReadOnlyServiceSettings>())
{
serviceSettings = projectContext.Container.Resolve<IReadOnlyServiceSettings>();
}
if (sceneFlow == null &&
projectContext.Container.HasBinding<ISceneFlowService>())
{
sceneFlow = projectContext.Container.Resolve<ISceneFlowService>();
}
}
catch (ZenjectException exception)
{
Debug.LogWarning($"[{nameof(MainMenuController)}] Service settings are not available yet: {exception.Message}", this);
}
}
/// <summary>
/// Ищет централизованный scene-flow сервис в ProjectContext.
/// </summary>
private void ResolveSceneFlowIfNeeded()
{
if (sceneFlow != null)
{
return;
}
ResolveServiceSettingsIfNeeded();
if (sceneFlow != null)
{
return;
}
if (sceneFlow == null)
{
Debug.LogWarning($"{nameof(MainMenuController)} requires {nameof(ISceneFlowService)} from ProjectContext DI.", this);
}
}
/// <summary>
/// Загружает игровую сцену из главного меню.
/// </summary>
private void StartGame()
{
ResolveSceneFlowIfNeeded();
if (sceneFlow == null || !sceneFlow.SceneExists(gameplaySceneName))
{
Debug.LogWarning($"Gameplay scene '{gameplaySceneName}' is not available in Build Settings yet.");
return;
}
sceneFlow.StartGameplay(gameplaySceneName);
}
/// <summary>
/// Открывает окно правил.
/// </summary>
private void OpenRules()
{
modalStack?.Open(rulesModal);
}
/// <summary>
/// Открывает модальное окно настроек или fallback-сцену настроек, если окно недоступно.
/// </summary>
private void OpenSettings()
{
ResolveServiceSettingsIfNeeded();
if (settingsModal != null && settingsModal.Root != null)
{
settingsModal.SetSettingsService(serviceSettingsService);
modalStack?.Open(settingsModal);
return;
}
ResolveSceneFlowIfNeeded();
if (!string.IsNullOrWhiteSpace(settingsSceneName) &&
sceneFlow != null &&
sceneFlow.SceneExists(settingsSceneName))
{
sceneFlow.OpenSettingsScene(settingsSceneName);
return;
}
Debug.Log("Main menu settings entry selected. Service settings UI will be connected later.");
}
/// <summary>
/// Открывает сервисную панель. Сейчас использует то же окно, что и настройки.
/// </summary>
private void OpenServicePanel()
{
OpenSettings();
}
/// <summary>
/// Подключает сервисные Input System actions главного меню.
/// </summary>
private void RegisterInputActions()
{
#if ENABLE_INPUT_SYSTEM
if (secretAction != null)
{
return;
}
secretAction = ResolveInputAction(secretActionReference, LegacySecretActionPath);
if (secretAction == null)
{
Debug.LogWarning($"[{nameof(MainMenuController)}] Secret action is not assigned.", this);
return;
}
secretActionWasEnabled = secretAction.enabled;
secretAction.performed += HandleSecretActionPerformed;
// Включаем action только если он не был активен до регистрации меню.
if (!secretAction.enabled)
{
secretAction.Enable();
}
#endif
}
#if ENABLE_INPUT_SYSTEM
private InputAction ResolveInputAction(InputActionReference reference, string legacyActionPath)
{
if (reference != null && reference.action != null)
{
return reference.action;
}
return legacyInputActionsAsset != null
? legacyInputActionsAsset.FindAction(legacyActionPath, false)
: null;
}
#endif
/// <summary>
/// Отключает Input System actions и возвращает их исходное состояние.
/// </summary>
private void UnregisterInputActions()
{
#if ENABLE_INPUT_SYSTEM
if (secretAction == null)
{
return;
}
secretAction.performed -= HandleSecretActionPerformed;
if (!secretActionWasEnabled)
{
secretAction.Disable();
}
secretAction = null;
#endif
}
#if ENABLE_INPUT_SYSTEM
/// <summary>
/// Открывает окно настроек по action path UI/Secret.
/// </summary>
private void HandleSecretActionPerformed(InputAction.CallbackContext context)
{
OpenSettings();
}
#endif
/// <summary>
/// Завершает приложение или останавливает Play Mode в редакторе.
/// </summary>
private void QuitApplication()
{
#if UNITY_EDITOR
UnityEditor.EditorApplication.isPlaying = false;
#else
Application.Quit();
#endif
}
}
}