Подробные изменения: - Расширен основной 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
558 lines
20 KiB
C#
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
|
|
}
|
|
|
|
}
|
|
}
|