Danro-Jump/Assets/Scripts/Runtime/UI/Modals/ModalStackController.cs
Danil Iarosh 1f6884c5ac Build gameplay systems and service settings
Подробные изменения:
- Добавлены и доработаны модальные окна главного меню, сервисные настройки, шаблоны UI Toolkit и единый scene-flow сервис для переходов между сценами.
- Расширена gameplay-архитектура: сессия забега, HUD, спавн контента платформ, collectible-предметы, оборудование, хрупкие и движущиеся платформы.
- Добавлена база противников: типы спавна, патрулирование, процедурный спавнер, правила размещения через ScriptableObject-сложности и убийство врагов прыжком сверху.
- Исправлены смерть и respawn игрока, финальный GameOver, отскоки от врагов, пружин и boost-предметов, а также проход сквозь врагов при усиленном подъеме.
- Проведен рефакторинг взаимодействия систем: общий landing context, единые правила player-contact, NonLazy gameplay session, fail-fast installer и централизованный выбор точек спавна.
- Обновлены сцены, prefab-ассеты, difficulty stages, ресурсы главного меню, настройки проекта, roadmap и аппаратная COM-интеграция.
- Добавлены ассеты летучей мыши, врагов, collectibles, TextMesh Pro, ProjectContext и gameplay UI.

Проверки:
- dotnet build .\\Assembly-CSharp.csproj --no-restore
- Unity validation ключевых gameplay/UI/scene-flow скриптов
- Unity Console после force refresh без ошибок
2026-05-27 16:34:51 +03:00

434 lines
14 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.Collections.Generic;
using UnityEngine;
using UnityEngine.UIElements;
namespace DanroJump.UI.Modals
{
/// <summary>
/// Управляет стеком UI Toolkit-модалок и удерживает фокус внутри последнего открытого окна.
/// </summary>
public sealed class ModalStackController
{
private readonly VisualElement layer;
private readonly VisualElement backdrop;
private readonly List<ModalWindowBase> stack = new();
private VisualElement focusBeforeFirstModal;
private VisualElement eventRoot;
private bool isRegistered;
/// <summary>
/// Создает контроллер для слоя модальных окон и фоновой затемняющей подложки.
/// </summary>
public ModalStackController(VisualElement layer, VisualElement backdrop)
{
this.layer = layer;
this.backdrop = backdrop;
HideLayer();
}
/// <summary>
/// Показывает, есть ли открытые модальные окна.
/// </summary>
public bool HasOpenModals => stack.Count > 0;
/// <summary>
/// Последняя открытая модалка, которая сейчас удерживает фокус.
/// </summary>
public ModalWindowBase TopModal => stack.Count > 0 ? stack[^1] : null;
/// <summary>
/// Открывает модалку поверх текущего стека и переводит в нее фокус.
/// </summary>
public void Open(ModalWindowBase modal)
{
if (modal == null || modal.Root == null)
{
return;
}
if (stack.Contains(modal))
{
Close(modal);
}
if (stack.Count == 0)
{
focusBeforeFirstModal = GetFocusedVisualElement();
ShowLayer();
RegisterFocusGuards();
}
stack.Add(modal);
modal.CloseRequested += Close;
modal.Open();
modal.Root.BringToFront();
modal.FocusDefault();
}
/// <summary>
/// Закрывает указанную модалку и все окна, которые были открыты поверх нее.
/// </summary>
public void Close(ModalWindowBase modal)
{
if (modal == null)
{
return;
}
var index = stack.LastIndexOf(modal);
if (index < 0)
{
return;
}
for (var i = stack.Count - 1; i >= index; i--)
{
CloseAt(i);
}
if (stack.Count > 0)
{
TopModal.FocusDefault();
return;
}
UnregisterFocusGuards();
HideLayer();
focusBeforeFirstModal?.Focus();
focusBeforeFirstModal = null;
}
/// <summary>
/// Закрывает верхнюю модалку в стеке.
/// </summary>
public void CloseTop()
{
Close(TopModal);
}
/// <summary>
/// Закрывает все модалки и снимает глобальные обработчики фокуса.
/// </summary>
public void Dispose()
{
while (stack.Count > 0)
{
CloseAt(stack.Count - 1);
}
UnregisterFocusGuards();
HideLayer();
focusBeforeFirstModal = null;
}
/// <summary>
/// Закрывает модалку по индексу стека без восстановления фокуса наружу.
/// </summary>
private void CloseAt(int index)
{
var modal = stack[index];
modal.CloseRequested -= Close;
modal.Close();
stack.RemoveAt(index);
}
/// <summary>
/// Показывает общий слой модалок и backdrop.
/// </summary>
private void ShowLayer()
{
if (layer != null)
{
layer.style.display = DisplayStyle.Flex;
layer.SetEnabled(true);
layer.BringToFront();
}
if (backdrop != null)
{
backdrop.style.display = DisplayStyle.Flex;
backdrop.SetEnabled(true);
}
}
/// <summary>
/// Скрывает общий слой модалок и backdrop.
/// </summary>
private void HideLayer()
{
if (backdrop != null)
{
backdrop.style.display = DisplayStyle.None;
backdrop.SetEnabled(false);
}
if (layer != null)
{
layer.style.display = DisplayStyle.None;
layer.SetEnabled(false);
}
}
/// <summary>
/// Подключает обработчики к корню панели, чтобы фокус нельзя было увести за верхнюю модалку.
/// </summary>
private void RegisterFocusGuards()
{
if (layer == null || isRegistered)
{
return;
}
eventRoot = layer.panel?.visualTree ?? layer;
eventRoot.RegisterCallback<FocusInEvent>(HandleFocusIn, TrickleDown.TrickleDown);
eventRoot.RegisterCallback<FocusOutEvent>(HandleFocusOut, TrickleDown.TrickleDown);
eventRoot.RegisterCallback<NavigationMoveEvent>(HandleNavigationMove, TrickleDown.TrickleDown);
eventRoot.RegisterCallback<KeyDownEvent>(HandleKeyDown, TrickleDown.TrickleDown);
isRegistered = true;
}
/// <summary>
/// Снимает обработчики, удерживающие фокус внутри модального стека.
/// </summary>
private void UnregisterFocusGuards()
{
if (eventRoot == null || !isRegistered)
{
return;
}
eventRoot.UnregisterCallback<FocusInEvent>(HandleFocusIn, TrickleDown.TrickleDown);
eventRoot.UnregisterCallback<FocusOutEvent>(HandleFocusOut, TrickleDown.TrickleDown);
eventRoot.UnregisterCallback<NavigationMoveEvent>(HandleNavigationMove, TrickleDown.TrickleDown);
eventRoot.UnregisterCallback<KeyDownEvent>(HandleKeyDown, TrickleDown.TrickleDown);
eventRoot = null;
isRegistered = false;
}
/// <summary>
/// Возвращает фокус назад в верхнюю модалку, если он попал во внешний UI.
/// </summary>
private void HandleFocusIn(FocusInEvent evt)
{
var top = TopModal;
if (top == null || evt.target is not VisualElement element)
{
return;
}
if (!top.Contains(element))
{
top.FocusDefault();
IgnoreFocusControllerEvent(evt);
evt.StopImmediatePropagation();
return;
}
top.RememberFocusedElement(element);
}
/// <summary>
/// Планирует проверку фокуса после того, как UI Toolkit завершит FocusOut.
/// </summary>
private void HandleFocusOut(FocusOutEvent evt)
{
if (TopModal == null)
{
return;
}
layer?.schedule.Execute(KeepFocusInsideTopModal);
}
/// <summary>
/// Преобразует UI Toolkit navigation-событие в линейное движение по элементам модалки.
/// </summary>
private void HandleNavigationMove(NavigationMoveEvent evt)
{
var top = TopModal;
if (top == null || evt.target is not VisualElement current)
{
return;
}
if (IsInsideTextField(current))
{
return;
}
if (top.HandleNavigationMove(current, evt.direction))
{
IgnoreFocusControllerEvent(evt);
evt.StopImmediatePropagation();
return;
}
var direction = GetNavigationOffset(evt.direction);
if (direction == 0)
{
return;
}
top.FocusRelativeTo(current, direction);
IgnoreFocusControllerEvent(evt);
evt.StopImmediatePropagation();
}
/// <summary>
/// Обрабатывает клавиатурную навигацию и закрытие верхнего окна по back-кнопкам.
/// </summary>
private void HandleKeyDown(KeyDownEvent evt)
{
var top = TopModal;
if (top == null)
{
return;
}
var current = evt.target as VisualElement;
if (IsInsideTextField(current) && IsTextFieldKey(evt.keyCode))
{
return;
}
if (IsBackKey(evt.keyCode))
{
if (top.CanCloseByBack)
{
CloseTop();
}
IgnoreFocusControllerEvent(evt);
evt.StopImmediatePropagation();
return;
}
if (top.HandleNavigationKey(current, evt.keyCode))
{
IgnoreFocusControllerEvent(evt);
evt.StopImmediatePropagation();
return;
}
var direction = GetKeyNavigationOffset(evt);
if (direction == 0)
{
return;
}
top.FocusRelativeTo(current, direction);
IgnoreFocusControllerEvent(evt);
evt.StopImmediatePropagation();
}
/// <summary>
/// Отложенно восстанавливает фокус после FocusOut, когда UI Toolkit уже обновил focusController.
/// </summary>
private void KeepFocusInsideTopModal()
{
var top = TopModal;
if (top == null)
{
return;
}
var focused = GetFocusedVisualElement();
if (focused == null || !top.Contains(focused))
{
top.FocusDefault();
}
}
/// <summary>
/// Возвращает текущий сфокусированный элемент панели.
/// </summary>
private VisualElement GetFocusedVisualElement()
{
return layer?.panel?.focusController?.focusedElement as VisualElement;
}
/// <summary>
/// Переводит направление NavigationMoveEvent в сдвиг по линейному списку фокуса.
/// </summary>
private static int GetNavigationOffset(NavigationMoveEvent.Direction direction)
{
return direction switch
{
NavigationMoveEvent.Direction.Up => -1,
NavigationMoveEvent.Direction.Down => 1,
_ => 0,
};
}
/// <summary>
/// Переводит клавиши навигации и WASD в сдвиг по линейному списку фокуса.
/// </summary>
private static int GetKeyNavigationOffset(KeyDownEvent evt)
{
return evt.keyCode switch
{
KeyCode.Tab => evt.shiftKey ? -1 : 1,
KeyCode.UpArrow => -1,
KeyCode.W => -1,
KeyCode.DownArrow => 1,
KeyCode.S => 1,
_ => 0,
};
}
/// <summary>
/// Проверяет клавиши, которые должны закрывать верхнюю модалку.
/// </summary>
private static bool IsBackKey(KeyCode keyCode)
{
return keyCode == KeyCode.Escape ||
keyCode == KeyCode.Backspace ||
keyCode == KeyCode.JoystickButton1;
}
/// <summary>
/// Проверяет, находится ли событие внутри TextField или его внутреннего текстового элемента.
/// </summary>
private static bool IsInsideTextField(VisualElement element)
{
for (var current = element; current != null; current = current.parent)
{
if (current is TextField)
{
return true;
}
}
return false;
}
/// <summary>
/// Клавиши, которые TextField должен получать без модальной навигации и закрытия.
/// </summary>
private static bool IsTextFieldKey(KeyCode keyCode)
{
return keyCode == KeyCode.Backspace ||
keyCode == KeyCode.Space ||
keyCode == KeyCode.Return ||
keyCode == KeyCode.KeypadEnter ||
keyCode == KeyCode.UpArrow ||
keyCode == KeyCode.DownArrow ||
keyCode == KeyCode.LeftArrow ||
keyCode == KeyCode.RightArrow ||
keyCode == KeyCode.W ||
keyCode == KeyCode.A ||
keyCode == KeyCode.S ||
keyCode == KeyCode.D;
}
/// <summary>
/// Запрещает встроенной UI Toolkit-навигации повторно двигать фокус после кастомной обработки.
/// </summary>
private static void IgnoreFocusControllerEvent(EventBase evt)
{
(evt?.target as VisualElement)?.panel?.focusController?.IgnoreEvent(evt);
}
}
}