Danro-Jump/Assets/Scripts/Runtime/UI/MainMenu/SettingsModalWindow.cs
Danil Iarosh c1b72854f4 Implement prize QR and service UI polish
Подробные изменения:

- Добавлена система призовых уровней и выдачи призов с QR-контуром Telegram, шаблоном сообщения и генерацией QR без внешних библиотек.

- Доработаны сервисные настройки: действия кнопок, модальные окна, ввод с джойстика/клавиатуры, статистика, настройки призов и маршрутизация QR.

- Переработаны gameplay HUD и overlay-панели на UI Toolkit со спрайтовыми плашками, DEV BUILD-статистикой, экраном продолжения и экраном результата приза.

- Исправлены сценарии смерти, GameOver, продолжения игры, начисления очков за высоту и перехода в главное меню.

- Перенесены UI-шаблоны из Resources в Assets/UI, подключена Addressables-загрузка шаблонов настроек и standalone-стили для MainMenuModals.

- Добавлены EditMode/PlayMode тесты для призов, QR, arcade input, UI-модалок, scene flow, HUD и gameplay-сессии.

- Обновлены сцены, настройки Addressables, service settings database, документация и дорожная карта.

Проверки:

- Targeted EditMode QR/UI/settings: 23/23 passed

- Targeted UI templates/EditMode: 17/17 passed

- MainMenuUxmlTests: 3/3 passed

- Full EditMode: 129/129 passed

- PlayMode: 6/6 passed

- Runtime Addressables template load check: loaded=5

- Unity Console после финального refresh: 0 errors
2026-06-12 16:43:28 +03:00

2057 lines
84 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;
using System.Collections.Generic;
using DanroJump.Settings;
using DanroJump.UI.Modals;
using UnityEngine;
using UnityEngine.AddressableAssets;
using UnityEngine.ResourceManagement.AsyncOperations;
using UnityEngine.UIElements;
#if UNITY_EDITOR
using UnityEditor;
#endif
namespace DanroJump.UI.MainMenu
{
/// <summary>
/// Модальное окно сервисных настроек главного меню.
/// Строит список полей динамически по базе <see cref="ServiceSettingsDatabase"/>.
/// </summary>
public sealed class SettingsModalWindow : ModalWindowBase, IDisposable
{
private const string GroupHeaderTemplateAssetPath = "Assets/UI/MainMenu/Templates/SettingsGroupHeader.uxml";
private const string BoolControlTemplateAssetPath = "Assets/UI/MainMenu/Templates/SettingsBoolControl.uxml";
private const string StepperControlTemplateAssetPath = "Assets/UI/MainMenu/Templates/SettingsStepperControl.uxml";
private const string TextControlTemplateAssetPath = "Assets/UI/MainMenu/Templates/SettingsTextControl.uxml";
private const string ButtonControlTemplateAssetPath = "Assets/UI/MainMenu/Templates/SettingsButtonControl.uxml";
private const string GroupHeaderRootName = "SettingsGroupHeaderRoot";
private const string GroupHeaderTitleName = "SettingsGroupTitle";
private const string GroupHeaderDisclosureName = "SettingsGroupDisclosure";
private const string BoolControlRootName = "SettingsBoolControlRoot";
private const string StepperControlRootName = "SettingsStepperControlRoot";
private const string StepperPreviousButtonName = "SettingsStepperPreviousButton";
private const string StepperValueLabelName = "SettingsStepperValueLabel";
private const string StepperNextButtonName = "SettingsStepperNextButton";
private const string TextControlRootName = "SettingsTextControlRoot";
private const string TextFieldName = "SettingsTextField";
private const string ButtonControlRootName = "SettingsButtonControlRoot";
private const string ActionButtonName = "SettingsActionButton";
private const string ControlTitleName = "SettingsControlTitle";
private const string BoolIndicatorName = "SettingsBoolIndicator";
private const float FocusPointerGap = 18f;
private const float FocusPointerMinLeft = 12f;
private const float FocusPointerWidth = 46f;
private const float FocusPointerHeight = 64f;
private const float FocusPointerAnimationDuration = 0.12f;
private const int FocusPointerAnimationFrameMs = 16;
private const float ScrollVisibilityPadding = 18f;
private const float ScrollOffsetEpsilon = 0.5f;
private const float VerticalNavigationCooldown = 0.18f;
private const float DuplicateNavigationSourceWindow = 0.3f;
private const float RelativeSpacingFallbackWidth = 620f;
private const float GroupHeaderTopSpacingRatio = 0.035f;
private const float GroupHeaderBottomSpacingRatio = 0.019f;
private const float SettingControlVerticalSpacingRatio = 0.01f;
private const float ActionButtonVerticalSpacingRatio = 0.019f;
private const float GroupHeaderTopSpacingMin = 16f;
private const float GroupHeaderTopSpacingMax = 34f;
private const float GroupHeaderBottomSpacingMin = 8f;
private const float GroupHeaderBottomSpacingMax = 20f;
private const float SettingControlVerticalSpacingMin = 4f;
private const float SettingControlVerticalSpacingMax = 10f;
private const float ActionButtonVerticalSpacingMin = 8f;
private const float ActionButtonVerticalSpacingMax = 20f;
private static VisualTreeAsset groupHeaderTemplate;
private static VisualTreeAsset boolControlTemplate;
private static VisualTreeAsset stepperControlTemplate;
private static VisualTreeAsset textControlTemplate;
private static VisualTreeAsset buttonControlTemplate;
private static AsyncOperationHandle<VisualTreeAsset>? groupHeaderTemplateHandle;
private static AsyncOperationHandle<VisualTreeAsset>? boolControlTemplateHandle;
private static AsyncOperationHandle<VisualTreeAsset>? stepperControlTemplateHandle;
private static AsyncOperationHandle<VisualTreeAsset>? textControlTemplateHandle;
private static AsyncOperationHandle<VisualTreeAsset>? buttonControlTemplateHandle;
private readonly ScrollView settingsList;
private readonly Label statusLabel;
private readonly Label currentGroupTitleLabel;
private readonly Button saveButton;
private readonly Button resetButton;
private readonly Button reloadButton;
private readonly Button closeButton;
private readonly Dictionary<string, VisualElement> groupHeaders = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, List<VisualElement>> groupRows = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<string, Label> groupDisclosureLabels = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> collapsedGroups = new(StringComparer.OrdinalIgnoreCase);
private readonly Dictionary<VisualElement, SettingAdjustment> settingAdjustments = new();
private readonly Dictionary<VisualElement, Action> settingActivations = new();
private readonly List<VisualElement> settingsNavigationTargets = new();
private readonly List<VisualElement> settingControls = new();
private readonly Label focusPointer;
private VisualElement firstFocusableControl;
private VisualElement currentFocusPointerTarget;
private IVisualElementScheduledItem focusPointerAnimation;
private Vector2 focusPointerPosition;
private bool hasFocusPointerPosition;
private int lastNavigationFrame = -1;
private SettingsNavigationCommand lastNavigationCommand;
private SettingsNavigationSource lastNavigationSource;
private float lastNavigationCommandTime = -DuplicateNavigationSourceWindow;
private float lastVerticalNavigationTime = -VerticalNavigationCooldown;
private IServiceSettingsService settingsService;
public event Action<ServiceSettingDefinition, ServiceSettingsActionResult> ServiceActionActivated;
/// <summary>
/// Контейнер реального содержимого ScrollView.
/// Нужен, чтобы не очищать внутреннюю служебную структуру UI Toolkit.
/// </summary>
private VisualElement SettingsContainer => settingsList?.contentContainer ?? settingsList;
/// <summary>
/// Создает окно настроек и привязывает кнопки управления к сервису настроек.
/// </summary>
/// <param name="root">Корневой VisualElement окна в UXML.</param>
/// <param name="settingsService">Сервис чтения, применения и сохранения настроек.</param>
public SettingsModalWindow(VisualElement root, IServiceSettingsService settingsService)
: base(root)
{
if (root == null)
{
return;
}
settingsList = root.Q<ScrollView>("SettingsList");
statusLabel = root.Q<Label>("SettingsStatusLabel");
currentGroupTitleLabel = root.Q<Label>("SettingsGroupTitleLabel");
saveButton = root.Q<Button>("SettingsSaveButton");
resetButton = root.Q<Button>("SettingsResetButton");
reloadButton = root.Q<Button>("SettingsReloadButton");
closeButton = root.Q<Button>("SettingsCloseButton");
focusPointer = CreateModalFocusPointer(root);
root.RegisterCallback<FocusInEvent>(HandleRootFocusIn, TrickleDown.TrickleDown);
root.RegisterCallback<FocusOutEvent>(HandleRootFocusOut, TrickleDown.TrickleDown);
root.RegisterCallback<NavigationSubmitEvent>(HandleNavigationSubmit, TrickleDown.TrickleDown);
settingsList?.RegisterCallback<GeometryChangedEvent>(HandleSettingsListGeometryChanged);
// Кнопки работают с текущим сервисом настроек, который может прийти позже через DI fallback.
if (saveButton != null)
{
saveButton.clicked += SaveSettings;
}
if (resetButton != null)
{
resetButton.clicked += ResetSettings;
}
if (reloadButton != null)
{
reloadButton.clicked += ReloadSettings;
}
if (closeButton != null)
{
closeButton.clicked += RequestClose;
}
SetSettingsService(settingsService);
}
/// <summary>
/// Обновляет ссылку на сервис настроек перед открытием окна.
/// </summary>
/// <param name="service">Актуальный сервис настроек из ProjectContext.</param>
public void SetSettingsService(IServiceSettingsService service)
{
settingsService = service;
}
/// <summary>
/// Перестраивает список полей при каждом открытии и затем показывает модальное окно.
/// </summary>
public override void Open()
{
BuildSettingsList();
base.Open();
ScheduleRelativeSpacingUpdate();
Root?.schedule.Execute(ShowFocusPointerOnCurrentOrDefault);
}
/// <summary>
/// Закрывает окно настроек и скрывает общий указатель фокуса.
/// </summary>
public override void Close()
{
currentFocusPointerTarget = null;
hasFocusPointerPosition = false;
ResetNavigationState();
focusPointerAnimation?.Pause();
HideFocusPointer();
base.Close();
}
/// <summary>
/// Отписывает обработчики кнопок, когда окно больше не используется контроллером меню.
/// </summary>
public void Dispose()
{
if (saveButton != null)
{
saveButton.clicked -= SaveSettings;
}
if (resetButton != null)
{
resetButton.clicked -= ResetSettings;
}
if (reloadButton != null)
{
reloadButton.clicked -= ReloadSettings;
}
if (closeButton != null)
{
closeButton.clicked -= RequestClose;
}
if (Root != null)
{
Root.UnregisterCallback<FocusInEvent>(HandleRootFocusIn, TrickleDown.TrickleDown);
Root.UnregisterCallback<FocusOutEvent>(HandleRootFocusOut, TrickleDown.TrickleDown);
Root.UnregisterCallback<NavigationSubmitEvent>(HandleNavigationSubmit, TrickleDown.TrickleDown);
}
settingsList?.UnregisterCallback<GeometryChangedEvent>(HandleSettingsListGeometryChanged);
}
/// <summary>
/// Возвращает первый редактируемый контрол, чтобы ModalStackController удерживал фокус внутри окна.
/// </summary>
/// <returns>Первый доступный контрол настроек или кнопка сохранения/закрытия.</returns>
protected override VisualElement GetDefaultFocusElement()
{
return firstFocusableControl ?? saveButton ?? closeButton;
}
/// <summary>
/// Полностью пересобирает UI настроек из базы данных.
/// </summary>
private void BuildSettingsList()
{
firstFocusableControl = null;
ResetNavigationState();
currentFocusPointerTarget = null;
hasFocusPointerPosition = false;
groupHeaders.Clear();
groupRows.Clear();
groupDisclosureLabels.Clear();
collapsedGroups.Clear();
settingAdjustments.Clear();
settingActivations.Clear();
settingsNavigationTargets.Clear();
settingControls.Clear();
if (settingsList == null)
{
return;
}
SettingsContainer.Clear();
if (settingsService == null || settingsService.Database == null)
{
AddStatus("Сервисные настройки пока недоступны.");
return;
}
var groupedDefinitions = new Dictionary<string, List<ServiceSettingDefinition>>(StringComparer.OrdinalIgnoreCase);
var groupOrder = new List<string>();
// Порядок и видимость настроек задаются в ServiceSettingsDatabase, а не в коде окна.
foreach (var definition in settingsService.Database.Entries)
{
if (!CanRender(definition))
{
continue;
}
var groupName = GetGroupName(definition.Group);
if (!groupedDefinitions.TryGetValue(groupName, out var definitions))
{
definitions = new List<ServiceSettingDefinition>();
groupedDefinitions[groupName] = definitions;
groupOrder.Add(groupName);
}
definitions.Add(definition);
}
if (groupOrder.Count == 0)
{
AddStatus("В базе настроек нет параметров, разрешённых для сервисного меню.");
return;
}
foreach (var groupName in groupOrder)
{
AddGroupHeader(groupName);
foreach (var definition in groupedDefinitions[groupName])
{
AddSettingRow(definition, groupName);
}
}
SetStatus("Изменения применяются сразу. Кнопка «Сохранить» записывает их в файл настроек.");
AddFooterNavigationTargets();
UpdateRelativeSettingsSpacing();
SetCurrentSelection(groupOrder[0], groupOrder[0], null, isGroup: true);
}
/// <summary>
/// В окне настроек горизонтальная навигация меняет значение настройки, а не фокус.
/// </summary>
/// <param name="current">Текущий сфокусированный элемент.</param>
/// <param name="direction">Направление UI Toolkit navigation-события.</param>
/// <returns>True, если событие обработано окном настроек.</returns>
public override bool HandleNavigationMove(VisualElement current, NavigationMoveEvent.Direction direction)
{
if (IsInsideTextField(current))
{
return false;
}
return TryGetNavigationCommand(direction, out var command) &&
HandleSettingsNavigationCommand(current, command, SettingsNavigationSource.NavigationMove);
}
/// <summary>
/// Клавиши Left/Right и A/D меняют значение текущей настройки.
/// </summary>
/// <param name="current">Текущий сфокусированный элемент.</param>
/// <param name="keyCode">Код нажатой клавиши.</param>
/// <returns>True, если клавиша обработана окном настроек.</returns>
public override bool HandleNavigationKey(VisualElement current, KeyCode keyCode)
{
if (IsInsideTextField(current))
{
return false;
}
return TryGetNavigationCommand(keyCode, out var command) &&
HandleSettingsNavigationKey(current, command);
}
/// <summary>
/// Обрабатывает Submit от UI Toolkit/геймпада на фокусируемой строке настройки.
/// </summary>
private void HandleNavigationSubmit(NavigationSubmitEvent evt)
{
if (evt.target is VisualElement current &&
!IsInsideTextField(current) &&
ActivateFocusedSetting(current))
{
evt.StopImmediatePropagation();
}
}
/// <summary>
/// Переводит направление UI Toolkit в команду сервисного меню.
/// </summary>
private static bool TryGetNavigationCommand(
NavigationMoveEvent.Direction direction,
out SettingsNavigationCommand command)
{
switch (direction)
{
case NavigationMoveEvent.Direction.Up:
command = SettingsNavigationCommand.Up;
return true;
case NavigationMoveEvent.Direction.Down:
command = SettingsNavigationCommand.Down;
return true;
case NavigationMoveEvent.Direction.Left:
command = SettingsNavigationCommand.Left;
return true;
case NavigationMoveEvent.Direction.Right:
command = SettingsNavigationCommand.Right;
return true;
default:
command = default;
return false;
}
}
/// <summary>
/// Переводит клавиши управления в команду сервисного меню.
/// </summary>
private static bool TryGetNavigationCommand(KeyCode keyCode, out SettingsNavigationCommand command)
{
switch (keyCode)
{
case KeyCode.UpArrow:
case KeyCode.W:
command = SettingsNavigationCommand.Up;
return true;
case KeyCode.DownArrow:
case KeyCode.S:
command = SettingsNavigationCommand.Down;
return true;
case KeyCode.LeftArrow:
case KeyCode.A:
command = SettingsNavigationCommand.Left;
return true;
case KeyCode.RightArrow:
case KeyCode.D:
command = SettingsNavigationCommand.Right;
return true;
default:
command = default;
return false;
}
}
/// <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>
/// Проверяет, можно ли показать настройку в сервисном меню.
/// </summary>
/// <param name="definition">Описание настройки из базы.</param>
/// <returns>True, если настройка валидна и разрешена для сервисного меню.</returns>
private static bool CanRender(ServiceSettingDefinition definition)
{
return definition != null &&
definition.ShownInServiceMenu &&
!string.IsNullOrWhiteSpace(definition.Key);
}
/// <summary>
/// Добавляет визуальный заголовок группы настроек.
/// </summary>
/// <param name="groupName">Название группы из ServiceSettingsDatabase.</param>
private void AddGroupHeader(string groupName)
{
var normalizedName = GetGroupName(groupName);
var header = CreateGroupHeader(normalizedName, out var disclosure);
header.RegisterCallback<FocusInEvent>(
_ => SetCurrentSelection(normalizedName, normalizedName, null, isGroup: true),
TrickleDown.TrickleDown);
header.clicked += () => ToggleGroup(normalizedName);
firstFocusableControl ??= header;
groupHeaders[normalizedName] = header;
groupRows[normalizedName] = new List<VisualElement>();
settingsNavigationTargets.Add(header);
if (disclosure != null)
{
groupDisclosureLabels[normalizedName] = disclosure;
}
SettingsContainer.Add(header);
}
/// <summary>
/// Создает заголовок группы из UXML-шаблона, похожего на prefab для UI Toolkit.
/// </summary>
/// <param name="groupName">Название группы, которое будет подставлено в шаблон.</param>
/// <param name="disclosure">Индикатор свернутого или раскрытого состояния группы.</param>
/// <returns>Готовый корневой элемент заголовка группы.</returns>
private static Button CreateGroupHeader(string groupName, out Label disclosure)
{
var template = GetGroupHeaderTemplate();
if (template == null)
{
return CreateFallbackGroupHeader(groupName, out disclosure);
}
var templateContainer = template.Instantiate();
var header = templateContainer.Q<Button>(GroupHeaderRootName);
if (header == null)
{
return CreateFallbackGroupHeader(groupName, out disclosure);
}
header.RemoveFromHierarchy();
var title = header.Q<Label>(GroupHeaderTitleName);
if (title != null)
{
title.text = groupName.ToUpperInvariant();
}
disclosure = header.Q<Label>(GroupHeaderDisclosureName);
if (disclosure != null)
{
disclosure.text = "▼";
}
return header;
}
/// <summary>
/// Загружает UXML-шаблон заголовка группы из Addressables один раз за сессию.
/// </summary>
private static VisualTreeAsset GetGroupHeaderTemplate()
{
return GetTemplate(
ref groupHeaderTemplate,
ref groupHeaderTemplateHandle,
UiTemplateAddressKeys.SettingsGroupHeader,
GroupHeaderTemplateAssetPath);
}
/// <summary>
/// Загружает UXML-шаблон bool-контрола из Addressables один раз за сессию.
/// </summary>
private static VisualTreeAsset GetBoolControlTemplate()
{
return GetTemplate(
ref boolControlTemplate,
ref boolControlTemplateHandle,
UiTemplateAddressKeys.SettingsBoolControl,
BoolControlTemplateAssetPath);
}
/// <summary>
/// Загружает UXML-шаблон stepper-контрола из Addressables один раз за сессию.
/// </summary>
private static VisualTreeAsset GetStepperControlTemplate()
{
return GetTemplate(
ref stepperControlTemplate,
ref stepperControlTemplateHandle,
UiTemplateAddressKeys.SettingsStepperControl,
StepperControlTemplateAssetPath);
}
/// <summary>
/// Загружает UXML-шаблон текстового контрола из Addressables один раз за сессию.
/// </summary>
private static VisualTreeAsset GetTextControlTemplate()
{
return GetTemplate(
ref textControlTemplate,
ref textControlTemplateHandle,
UiTemplateAddressKeys.SettingsTextControl,
TextControlTemplateAssetPath);
}
/// <summary>
/// Загружает UXML-шаблон кнопочного контрола из Addressables один раз за сессию.
/// </summary>
private static VisualTreeAsset GetButtonControlTemplate()
{
return GetTemplate(
ref buttonControlTemplate,
ref buttonControlTemplateHandle,
UiTemplateAddressKeys.SettingsButtonControl,
ButtonControlTemplateAssetPath);
}
/// <summary>
/// Лениво загружает UI Toolkit-шаблон и кэширует ссылку в статическом поле.
/// </summary>
private static VisualTreeAsset GetTemplate(
ref VisualTreeAsset template,
ref AsyncOperationHandle<VisualTreeAsset>? handle,
string address,
string editorFallbackPath)
{
if (template == null)
{
template = LoadAddressableTemplate(ref handle, address);
#if UNITY_EDITOR
if (template == null)
{
template = AssetDatabase.LoadAssetAtPath<VisualTreeAsset>(editorFallbackPath);
}
#endif
}
return template;
}
private static VisualTreeAsset LoadAddressableTemplate(
ref AsyncOperationHandle<VisualTreeAsset>? cachedHandle,
string address)
{
if (cachedHandle.HasValue && cachedHandle.Value.IsValid())
{
return cachedHandle.Value.Result;
}
if (string.IsNullOrWhiteSpace(address))
{
return null;
}
try
{
var handle = Addressables.LoadAssetAsync<VisualTreeAsset>(address);
var template = handle.WaitForCompletion();
if (handle.Status == AsyncOperationStatus.Succeeded && template != null)
{
cachedHandle = handle;
return template;
}
if (handle.IsValid())
{
Addressables.Release(handle);
}
}
catch (Exception exception)
{
Debug.LogWarning(
$"[{nameof(SettingsModalWindow)}] Failed to load UI template '{address}' from Addressables: {exception.Message}");
}
return null;
}
/// <summary>
/// Резервная сборка заголовка, если UXML-шаблон не найден или поврежден.
/// </summary>
private static Button CreateFallbackGroupHeader(string groupName, out Label disclosure)
{
var header = new Button { focusable = true };
header.AddToClassList("settings-group-title");
header.AddToClassList("settings-focus-target");
var title = new Label(groupName.ToUpperInvariant());
title.AddToClassList("settings-group-label");
disclosure = new Label("▼");
disclosure.AddToClassList("settings-group-disclosure");
header.Add(title);
header.Add(disclosure);
return header;
}
/// <summary>
/// Создает строку настройки и подходящий UI-контрол с подписью внутри шаблона.
/// </summary>
/// <param name="definition">Описание настройки.</param>
private void AddSettingRow(ServiceSettingDefinition definition, string groupName)
{
var row = new VisualElement { focusable = true };
row.AddToClassList("settings-row");
row.AddToClassList("settings-focus-target");
var settingTitle = GetSettingTitle(definition);
row.RegisterCallback<FocusInEvent>(
_ => SetCurrentSelection(settingTitle, groupName, definition.Description),
TrickleDown.TrickleDown);
var control = CreateSettingControl(definition, settingTitle, out var adjustment, out var activation);
if (control != null)
{
row.Add(control);
settingControls.Add(control);
control.RegisterCallback<FocusInEvent>(
_ => SetCurrentSelection(settingTitle, groupName, definition.Description),
TrickleDown.TrickleDown);
}
if (adjustment != null)
{
settingAdjustments[row] = adjustment;
}
if (activation != null)
{
settingActivations[row] = activation;
}
SettingsContainer.Add(row);
if (!groupRows.TryGetValue(groupName, out var rows))
{
rows = new List<VisualElement>();
groupRows[groupName] = rows;
}
rows.Add(row);
settingsNavigationTargets.Add(row);
row.style.display = collapsedGroups.Contains(groupName) ? DisplayStyle.None : DisplayStyle.Flex;
}
/// <summary>
/// Добавляет нижние кнопки окна в логический список навигации настроек.
/// </summary>
private void AddFooterNavigationTargets()
{
AddFooterNavigationTarget(saveButton);
AddFooterNavigationTarget(reloadButton);
AddFooterNavigationTarget(resetButton);
AddFooterNavigationTarget(closeButton);
}
/// <summary>
/// Добавляет кнопку в список навигации, если она доступна.
/// </summary>
private void AddFooterNavigationTarget(VisualElement target)
{
if (target != null && target.focusable)
{
settingsNavigationTargets.Add(target);
}
}
/// <summary>
/// Пересчитывает вертикальные интервалы как долю ширины видимой области списка.
/// Так отступы адаптируются к размеру окна, но не зависят от высоты свернутого контента.
/// </summary>
private void UpdateRelativeSettingsSpacing()
{
var baseWidth = ResolveRelativeSpacingBaseWidth();
var headerTop = ResolveRelativeSpacing(baseWidth, GroupHeaderTopSpacingRatio, GroupHeaderTopSpacingMin, GroupHeaderTopSpacingMax);
var headerBottom = ResolveRelativeSpacing(baseWidth, GroupHeaderBottomSpacingRatio, GroupHeaderBottomSpacingMin, GroupHeaderBottomSpacingMax);
var controlVertical = ResolveRelativeSpacing(baseWidth, SettingControlVerticalSpacingRatio, SettingControlVerticalSpacingMin, SettingControlVerticalSpacingMax);
var actionButtonVertical = ResolveRelativeSpacing(baseWidth, ActionButtonVerticalSpacingRatio, ActionButtonVerticalSpacingMin, ActionButtonVerticalSpacingMax);
foreach (var header in groupHeaders.Values)
{
if (header == null)
{
continue;
}
header.style.marginTop = headerTop;
header.style.marginBottom = headerBottom;
}
foreach (var control in settingControls)
{
ApplyRelativeControlSpacing(control, controlVertical, actionButtonVertical);
}
if (currentFocusPointerTarget != null && IsSettingsNavigationTargetVisible(currentFocusPointerTarget))
{
PlaceFocusPointerAtCurrentTarget(currentFocusPointerTarget);
}
}
private void ApplyRelativeControlSpacing(
VisualElement control,
float controlVertical,
float actionButtonVertical)
{
if (control == null)
{
return;
}
if (control.ClassListContains("settings-button-control"))
{
control.style.marginTop = 0f;
control.style.marginBottom = 0f;
var actionButton = control.Q<Button>(ActionButtonName);
if (actionButton != null)
{
actionButton.style.marginTop = actionButtonVertical;
actionButton.style.marginBottom = actionButtonVertical;
}
return;
}
control.style.marginTop = controlVertical;
control.style.marginBottom = controlVertical;
}
private float ResolveRelativeSpacingBaseWidth()
{
var width = settingsList?.resolvedStyle.width ?? 0f;
return float.IsNaN(width) || width <= 0f ? RelativeSpacingFallbackWidth : width;
}
private static float ResolveRelativeSpacing(float baseWidth, float ratio, float min, float max)
{
return Mathf.Clamp(baseWidth * ratio, min, max);
}
private void ScheduleRelativeSpacingUpdate()
{
Root?.schedule.Execute(UpdateRelativeSettingsSpacing);
}
private void HandleSettingsListGeometryChanged(GeometryChangedEvent evt)
{
UpdateRelativeSettingsSpacing();
}
/// <summary>
/// Сворачивает или раскрывает настройки внутри выбранной группы.
/// </summary>
/// <param name="groupName">Название группы, заголовок которой нажал оператор.</param>
private void ToggleGroup(string groupName)
{
var normalizedName = GetGroupName(groupName);
if (!groupRows.ContainsKey(normalizedName) ||
!groupHeaders.TryGetValue(normalizedName, out var header))
{
return;
}
currentFocusPointerTarget = header;
header.Focus();
if (!collapsedGroups.Add(normalizedName))
{
collapsedGroups.Remove(normalizedName);
}
ApplyGroupCollapsedState(normalizedName);
SetCurrentSelection(normalizedName, normalizedName, null, isGroup: true);
header.schedule.Execute(() =>
{
if (currentFocusPointerTarget == header && IsSettingsNavigationTargetVisible(header))
{
PlaceFocusPointerAtCurrentTarget(header);
}
});
}
/// <summary>
/// Применяет текущее свернутое состояние к строкам группы и ее заголовку.
/// </summary>
private void ApplyGroupCollapsedState(string groupName)
{
var isCollapsed = collapsedGroups.Contains(groupName);
if (groupHeaders.TryGetValue(groupName, out var header))
{
if (isCollapsed)
{
header.AddToClassList("settings-group-title--collapsed");
}
else
{
header.RemoveFromClassList("settings-group-title--collapsed");
}
}
if (groupDisclosureLabels.TryGetValue(groupName, out var disclosure))
{
disclosure.text = isCollapsed ? "▶" : "▼";
}
if (!groupRows.TryGetValue(groupName, out var rows))
{
return;
}
foreach (var row in rows)
{
row.style.display = isCollapsed ? DisplayStyle.None : DisplayStyle.Flex;
}
if (isCollapsed && currentFocusPointerTarget != null && rows.Contains(currentFocusPointerTarget) &&
groupHeaders.TryGetValue(groupName, out var fallbackHeader))
{
fallbackHeader.Focus();
currentFocusPointerTarget = fallbackHeader;
MoveFocusPointerTo(fallbackHeader);
}
}
/// <summary>
/// Меняет значение настройки, на строке которой сейчас находится фокус.
/// </summary>
/// <param name="current">Текущий сфокусированный элемент.</param>
/// <param name="increase">True для движения вправо, false для движения влево.</param>
/// <returns>Всегда true, чтобы горизонтальная навигация не двигала фокус.</returns>
private bool AdjustFocusedSetting(VisualElement current, bool increase)
{
var target = ResolveFocusPointerTarget(current) ??
ResolveFocusPointerTarget(Root?.panel?.focusController?.focusedElement as VisualElement) ??
currentFocusPointerTarget;
if (target != null && settingAdjustments.TryGetValue(target, out var adjustment))
{
_ = increase ? adjustment.Increment() : adjustment.Decrement();
MoveFocusPointerTo(target);
}
return true;
}
/// <summary>
/// Запускает действие настройки, если текущая строка поддерживает кнопку.
/// </summary>
private bool ActivateFocusedSetting(VisualElement current)
{
var target = ResolveFocusPointerTarget(current) ??
ResolveFocusPointerTarget(Root?.panel?.focusController?.focusedElement as VisualElement) ??
currentFocusPointerTarget;
if (target != null && settingActivations.TryGetValue(target, out var activation))
{
activation();
MoveFocusPointerTo(target);
return true;
}
return false;
}
/// <summary>
/// Обрабатывает одну команду навигации и отсекает дубль, если Unity прислал key и navigation в одном кадре.
/// </summary>
private bool HandleSettingsNavigationCommand(
VisualElement current,
SettingsNavigationCommand command,
SettingsNavigationSource source)
{
var now = Time.unscaledTime;
if (lastNavigationFrame == Time.frameCount || IsDuplicateNavigationSource(command, source, now))
{
KeepFocusPointerOnCurrentTarget();
return true;
}
lastNavigationFrame = Time.frameCount;
lastNavigationCommand = command;
lastNavigationSource = source;
lastNavigationCommandTime = now;
return command switch
{
SettingsNavigationCommand.Up => MoveSettingsFocus(current, direction: -1),
SettingsNavigationCommand.Down => MoveSettingsFocus(current, direction: 1),
SettingsNavigationCommand.Left => AdjustFocusedSetting(current, increase: false),
SettingsNavigationCommand.Right => AdjustFocusedSetting(current, increase: true),
_ => true,
};
}
/// <summary>
/// Обрабатывает KeyDown через общий фильтр дублей навигации.
/// </summary>
private bool HandleSettingsNavigationKey(VisualElement current, SettingsNavigationCommand command)
{
return HandleSettingsNavigationCommand(current, command, SettingsNavigationSource.KeyDown);
}
/// <summary>
/// Отсекает второй источник одного физического нажатия, не мешая повторным нажатиям того же источника.
/// </summary>
private bool IsDuplicateNavigationSource(
SettingsNavigationCommand command,
SettingsNavigationSource source,
float now)
{
return lastNavigationCommand == command &&
lastNavigationSource != source &&
now - lastNavigationCommandTime <= DuplicateNavigationSourceWindow;
}
/// <summary>
/// Сбрасывает временное состояние навигации при закрытии или пересборке окна.
/// </summary>
private void ResetNavigationState()
{
lastNavigationFrame = -1;
lastNavigationCommand = default;
lastNavigationSource = default;
lastNavigationCommandTime = -DuplicateNavigationSourceWindow;
lastVerticalNavigationTime = -VerticalNavigationCooldown;
}
/// <summary>
/// Двигает фокус по настройкам только с небольшой задержкой между командами.
/// </summary>
/// <param name="current">Текущий сфокусированный элемент.</param>
/// <param name="direction">-1 для движения вверх, 1 для движения вниз.</param>
/// <returns>Всегда true, чтобы стандартный стек не продублировал движение.</returns>
private bool MoveSettingsFocus(VisualElement current, int direction)
{
var now = Time.unscaledTime;
if (now - lastVerticalNavigationTime < VerticalNavigationCooldown)
{
KeepFocusPointerOnCurrentTarget();
return true;
}
lastVerticalNavigationTime = now;
var anchor = ResolveFocusPointerTarget(current) ?? currentFocusPointerTarget ?? current;
FocusSettingsNavigationTarget(anchor, direction);
return true;
}
/// <summary>
/// Переводит фокус по собственному списку логических элементов окна настроек.
/// </summary>
private void FocusSettingsNavigationTarget(VisualElement anchor, int direction)
{
var nextTarget = GetNextSettingsNavigationTarget(anchor, direction);
if (nextTarget == null)
{
KeepFocusPointerOnCurrentTarget();
return;
}
nextTarget.Focus();
currentFocusPointerTarget = nextTarget;
SchedulePlaceFocusPointerAt(nextTarget);
}
/// <summary>
/// Возвращает следующую видимую цель навигации без внутренних контролов строки.
/// </summary>
private VisualElement GetNextSettingsNavigationTarget(VisualElement anchor, int direction)
{
if (settingsNavigationTargets.Count == 0)
{
return null;
}
var anchorIndex = settingsNavigationTargets.IndexOf(anchor);
if (anchorIndex < 0)
{
anchorIndex = direction > 0 ? -1 : settingsNavigationTargets.Count;
}
for (var offset = 1; offset <= settingsNavigationTargets.Count; offset++)
{
var targetIndex = (anchorIndex + direction * offset + settingsNavigationTargets.Count) % settingsNavigationTargets.Count;
var candidate = settingsNavigationTargets[targetIndex];
if (IsSettingsNavigationTargetVisible(candidate))
{
return candidate;
}
}
return null;
}
/// <summary>
/// Проверяет, можно ли сейчас перейти фокусом на элемент настроек.
/// </summary>
private static bool IsSettingsNavigationTargetVisible(VisualElement target)
{
return target != null &&
target.panel != null &&
target.enabledInHierarchy &&
target.resolvedStyle.display != DisplayStyle.None &&
target.resolvedStyle.visibility != Visibility.Hidden;
}
/// <summary>
/// Подбирает UI Toolkit контрол по типу значения настройки.
/// </summary>
/// <param name="definition">Описание настройки.</param>
/// <returns>Готовый VisualElement для редактирования значения.</returns>
private VisualElement CreateSettingControl(
ServiceSettingDefinition definition,
string settingTitle,
out SettingAdjustment adjustment,
out Action activation)
{
var key = definition.Key;
var value = settingsService.Get(key);
activation = null;
return definition.ValueType switch
{
ServiceSettingValueType.Bool => CreateBoolControl(settingTitle, key, value.boolValue, out adjustment, out activation),
ServiceSettingValueType.Int => CreateIntControl(settingTitle, definition, key, value.intValue, out adjustment),
ServiceSettingValueType.Float => CreateFloatControl(settingTitle, definition, key, value.floatValue, out adjustment),
ServiceSettingValueType.String => CreateStringControl(settingTitle, key, value.stringValue, out adjustment),
ServiceSettingValueType.Button => CreateButtonControl(settingTitle, definition, out adjustment, out activation),
ServiceSettingValueType.Option => CreateOptionControl(settingTitle, definition, key, value.intValue, out adjustment),
_ => CreateEmptyControl(out adjustment),
};
}
/// <summary>
/// Создает переключатель для bool-настроек.
/// </summary>
private Button CreateBoolControl(
string settingTitle,
string key,
bool value,
out SettingAdjustment adjustment,
out Action activation)
{
var currentValue = value;
var control = CreateBoolControlFromTemplate(out var indicator);
SetControlTitle(control, settingTitle);
ApplyCheckboxState(indicator, currentValue);
bool ApplyValue(bool nextValue)
{
currentValue = nextValue;
settingsService.SetBool(key, currentValue);
ApplyCheckboxState(indicator, currentValue);
return true;
}
adjustment = new SettingAdjustment(
() => ApplyValue(false),
() => ApplyValue(true));
activation = () => ApplyValue(!currentValue);
control.clicked += activation;
return control;
}
/// <summary>
/// Создает поле или слайдер для целочисленных настроек.
/// </summary>
private VisualElement CreateIntControl(
string settingTitle,
ServiceSettingDefinition definition,
string key,
int value,
out SettingAdjustment adjustment)
{
var currentValue = Mathf.Clamp(value, definition.MinInt, definition.MaxInt);
var control = CreateStepperControl(
settingTitle,
FormatIntValue(currentValue),
() =>
{
currentValue = Mathf.Clamp(currentValue - definition.IntStep, definition.MinInt, definition.MaxInt);
settingsService.SetInt(key, currentValue);
return FormatIntValue(currentValue);
},
() =>
{
currentValue = Mathf.Clamp(currentValue + definition.IntStep, definition.MinInt, definition.MaxInt);
settingsService.SetInt(key, currentValue);
return FormatIntValue(currentValue);
},
out adjustment);
return control;
}
/// <summary>
/// Создает поле или слайдер для дробных настроек.
/// </summary>
private VisualElement CreateFloatControl(
string settingTitle,
ServiceSettingDefinition definition,
string key,
float value,
out SettingAdjustment adjustment)
{
var currentValue = Mathf.Clamp(value, definition.MinFloat, definition.MaxFloat);
var control = CreateStepperControl(
settingTitle,
FormatFloatValue(definition, currentValue),
() =>
{
currentValue = Mathf.Clamp(currentValue - definition.FloatStep, definition.MinFloat, definition.MaxFloat);
settingsService.SetFloat(key, currentValue);
return FormatFloatValue(definition, currentValue);
},
() =>
{
currentValue = Mathf.Clamp(currentValue + definition.FloatStep, definition.MinFloat, definition.MaxFloat);
settingsService.SetFloat(key, currentValue);
return FormatFloatValue(definition, currentValue);
},
out adjustment);
return control;
}
/// <summary>
/// Создает текстовое поле для строковых настроек.
/// </summary>
private VisualElement CreateStringControl(string settingTitle, string key, string value, out SettingAdjustment adjustment)
{
adjustment = null;
var control = CreateTextControlFromTemplate(out var textField);
SetControlTitle(control, settingTitle);
textField.value = value ?? string.Empty;
textField.RegisterValueChangedCallback(evt => settingsService.SetString(key, evt.newValue));
return control;
}
/// <summary>
/// Создает кнопку действия из legacy-настройки и маршрутизирует ее в сервисный action-router.
/// </summary>
private VisualElement CreateButtonControl(
string settingTitle,
ServiceSettingDefinition definition,
out SettingAdjustment adjustment,
out Action activation)
{
adjustment = null;
var control = CreateButtonControlFromTemplate(out var button);
SetControlTitle(control, settingTitle);
button.text = string.IsNullOrWhiteSpace(definition.ButtonText)
? definition.DisplayName
: definition.ButtonText;
ApplyButtonVisual(button, definition);
activation = () =>
{
var result = ServiceSettingsActionRouter.Execute(definition, settingsService);
Debug.Log(
$"[{nameof(SettingsModalWindow)}] Button setting '{definition.Key}' clicked. handled={result.Handled} data='{definition.ButtonData}'.");
SetCurrentSelection(result.Title, definition.Group, result.Status);
SetStatus(result.Status);
ServiceActionActivated?.Invoke(definition, result);
};
button.clicked += activation;
return control;
}
/// <summary>
/// Применяет цвет/спрайт кнопки из базы настроек, чтобы шаблон отвечал за форму, а данные - за вариант.
/// </summary>
private static void ApplyButtonVisual(Button button, ServiceSettingDefinition definition)
{
if (button == null || definition == null)
{
return;
}
if (definition.ButtonSprite != null)
{
button.style.backgroundImage = new StyleBackground(definition.ButtonSprite);
}
if (definition.ButtonColor.a > 0f)
{
button.style.unityBackgroundImageTintColor = definition.ButtonColor;
}
}
/// <summary>
/// Создает выпадающий список для настроек с фиксированными вариантами.
/// </summary>
private VisualElement CreateOptionControl(
string settingTitle,
ServiceSettingDefinition definition,
string key,
int value,
out SettingAdjustment adjustment)
{
var choices = new List<string>(definition.Options);
if (choices.Count == 0)
{
adjustment = null;
var empty = CreateTextControlFromTemplate(out var textField);
SetControlTitle(empty, settingTitle);
textField.value = "Нет вариантов";
textField.SetEnabled(false);
return empty;
}
var currentIndex = Mathf.Clamp(value, 0, choices.Count - 1);
return CreateStepperControl(
settingTitle,
choices[currentIndex],
() =>
{
currentIndex = (currentIndex - 1 + choices.Count) % choices.Count;
settingsService.SetOptionIndex(key, currentIndex);
return choices[currentIndex];
},
() =>
{
currentIndex = (currentIndex + 1) % choices.Count;
settingsService.SetOptionIndex(key, currentIndex);
return choices[currentIndex];
},
out adjustment);
}
/// <summary>
/// Создает операторский stepper-контрол со стрелками по единому шаблону.
/// </summary>
private static VisualElement CreateStepperControl(
string settingTitle,
string initialValue,
Func<string> decrement,
Func<string> increment,
out SettingAdjustment adjustment)
{
var container = CreateStepperControlFromTemplate(out var previousButton, out var valueLabel, out var nextButton);
SetControlTitle(container, settingTitle);
valueLabel.text = initialValue;
adjustment = new SettingAdjustment(
() =>
{
valueLabel.text = decrement();
return true;
},
() =>
{
valueLabel.text = increment();
return true;
});
var stepperAdjustment = adjustment;
previousButton.clicked += () => stepperAdjustment.Decrement();
nextButton.clicked += () => stepperAdjustment.Increment();
return container;
}
/// <summary>
/// Создает bool-контрол из UXML-шаблона, а при проблеме возвращает кодовый fallback.
/// </summary>
private static Button CreateBoolControlFromTemplate(out VisualElement indicator)
{
var template = GetBoolControlTemplate();
if (template == null)
{
return CreateFallbackBoolControl(out indicator);
}
var templateContainer = template.Instantiate();
var control = templateContainer.Q<Button>(BoolControlRootName);
indicator = control?.Q<VisualElement>(BoolIndicatorName);
if (control == null || indicator == null)
{
return CreateFallbackBoolControl(out indicator);
}
control.RemoveFromHierarchy();
return control;
}
/// <summary>
/// Создает кнопочный контрол из UXML-шаблона, а при проблеме возвращает кодовый fallback.
/// </summary>
private static VisualElement CreateButtonControlFromTemplate(out Button actionButton)
{
var template = GetButtonControlTemplate();
if (template == null)
{
return CreateFallbackButtonControl(out actionButton);
}
var templateContainer = template.Instantiate();
var control = templateContainer.Q<VisualElement>(ButtonControlRootName);
actionButton = control?.Q<Button>(ActionButtonName);
if (control == null || actionButton == null)
{
return CreateFallbackButtonControl(out actionButton);
}
control.RemoveFromHierarchy();
return control;
}
/// <summary>
/// Создает stepper-контрол из UXML-шаблона, а при проблеме возвращает кодовый fallback.
/// </summary>
private static VisualElement CreateStepperControlFromTemplate(
out Button previousButton,
out Label valueLabel,
out Button nextButton)
{
var template = GetStepperControlTemplate();
if (template == null)
{
return CreateFallbackStepperControl(out previousButton, out valueLabel, out nextButton);
}
var templateContainer = template.Instantiate();
var container = templateContainer.Q<VisualElement>(StepperControlRootName);
previousButton = container?.Q<Button>(StepperPreviousButtonName);
valueLabel = container?.Q<Label>(StepperValueLabelName);
nextButton = container?.Q<Button>(StepperNextButtonName);
if (container == null || previousButton == null || valueLabel == null || nextButton == null)
{
return CreateFallbackStepperControl(out previousButton, out valueLabel, out nextButton);
}
container.RemoveFromHierarchy();
return container;
}
/// <summary>
/// Создает текстовый контрол из UXML-шаблона, а при проблеме возвращает кодовый fallback.
/// </summary>
private static VisualElement CreateTextControlFromTemplate(out TextField textField)
{
var template = GetTextControlTemplate();
if (template == null)
{
return CreateFallbackTextControl(out textField);
}
var templateContainer = template.Instantiate();
var control = templateContainer.Q<VisualElement>(TextControlRootName);
textField = control?.Q<TextField>(TextFieldName);
if (control == null || textField == null)
{
return CreateFallbackTextControl(out textField);
}
control.RemoveFromHierarchy();
return control;
}
/// <summary>
/// Резервная сборка кнопочного контрола, если UXML-шаблон не найден или поврежден.
/// </summary>
private static VisualElement CreateFallbackButtonControl(out Button actionButton)
{
var container = CreateFallbackSettingControlContainer("Действие");
actionButton = new Button { text = "Выполнить", focusable = false };
actionButton.name = ActionButtonName;
actionButton.AddToClassList("settings-action-button");
container.Add(actionButton);
return container;
}
/// <summary>
/// Резервная сборка checkbox-контрола, если UXML-шаблон не найден или поврежден.
/// </summary>
private static Button CreateFallbackBoolControl(out VisualElement indicator)
{
var control = new Button { focusable = false };
control.AddToClassList("settings-control");
control.AddToClassList("settings-bool-control");
control.Add(CreateFallbackControlTitle("Переключатель"));
indicator = new VisualElement();
indicator.AddToClassList("settings-checkbox");
control.Add(indicator);
return control;
}
/// <summary>
/// Резервная сборка stepper-контрола, если UXML-шаблон не найден или поврежден.
/// </summary>
private static VisualElement CreateFallbackStepperControl(
out Button previousButton,
out Label valueLabel,
out Button nextButton)
{
var container = new VisualElement();
container.AddToClassList("settings-control");
container.AddToClassList("settings-stepper");
container.Add(CreateFallbackControlTitle("Значение"));
var valueContainer = new VisualElement();
valueContainer.AddToClassList("settings-stepper-value");
previousButton = new Button { text = "◀", focusable = false };
previousButton.AddToClassList("settings-arrow-button");
valueLabel = new Label();
valueLabel.AddToClassList("settings-value-label");
nextButton = new Button { text = "▶", focusable = false };
nextButton.AddToClassList("settings-arrow-button");
valueContainer.Add(previousButton);
valueContainer.Add(valueLabel);
valueContainer.Add(nextButton);
container.Add(valueContainer);
return container;
}
/// <summary>
/// Резервная сборка текстового поля, если UXML-шаблон не найден или поврежден.
/// </summary>
private static VisualElement CreateFallbackTextControl(out TextField textField)
{
var container = CreateFallbackSettingControlContainer("Текст");
textField = new TextField();
textField.AddToClassList("settings-text-field");
container.Add(textField);
return container;
}
/// <summary>
/// Создает fallback-контейнер настройки с подписью, если UXML-шаблон недоступен.
/// </summary>
private static VisualElement CreateFallbackSettingControlContainer(string title)
{
var container = new VisualElement();
container.AddToClassList("settings-control");
container.Add(CreateFallbackControlTitle(title));
return container;
}
/// <summary>
/// Создает fallback-подпись настройки в той же роли, что и label внутри UXML-шаблонов.
/// </summary>
private static Label CreateFallbackControlTitle(string title)
{
var label = new Label(title);
label.name = ControlTitleName;
label.AddToClassList("settings-name");
return label;
}
/// <summary>
/// Подставляет название настройки в label, который является частью UXML-шаблона.
/// </summary>
private static void SetControlTitle(VisualElement control, string title)
{
var titleLabel = control?.Q<Label>(ControlTitleName);
if (titleLabel != null)
{
titleLabel.text = title;
}
}
/// <summary>
/// Возвращает пустой контрол для неизвестного типа настройки.
/// </summary>
private static VisualElement CreateEmptyControl(out SettingAdjustment adjustment)
{
adjustment = null;
return null;
}
/// <summary>
/// Обновляет визуальное состояние checkbox-кнопки.
/// </summary>
private static void ApplyCheckboxState(VisualElement control, bool isChecked)
{
if (control == null)
{
return;
}
control.style.backgroundColor = isChecked
? new StyleColor(new Color(0.8078f, 1f, 0.102f, 1f))
: new StyleColor(Color.clear);
if (isChecked)
{
control.AddToClassList("settings-checkbox--checked");
return;
}
control.RemoveFromClassList("settings-checkbox--checked");
}
/// <summary>
/// Форматирует int-значение в текст для центрального поля stepper.
/// </summary>
private static string FormatIntValue(int value)
{
return value.ToString();
}
/// <summary>
/// Форматирует float-значение с учетом процентного режима настройки.
/// </summary>
private static string FormatFloatValue(ServiceSettingDefinition definition, float value)
{
return definition.DisplayFloatAsPercentage
? $"{value * 100f:0}%"
: $"{value:0.##}";
}
/// <summary>
/// Обновляет правую область с названием выбранной группы/настройки и описанием настройки.
/// </summary>
private void SetCurrentSelection(string title, string groupName, string description, bool isGroup = false)
{
var normalizedName = GetGroupName(groupName);
if (currentGroupTitleLabel != null)
{
var displayTitle = string.IsNullOrWhiteSpace(title) ? normalizedName : title;
currentGroupTitleLabel.text = isGroup ? $"Группа {displayTitle}" : displayTitle;
}
foreach (var header in groupHeaders.Values)
{
header.RemoveFromClassList("settings-group-title--active");
}
if (groupHeaders.TryGetValue(normalizedName, out var activeHeader))
{
activeHeader.AddToClassList("settings-group-title--active");
}
if (!string.IsNullOrWhiteSpace(description))
{
SetStatus(description);
}
}
/// <summary>
/// Возвращает понятное название настройки для правой панели и строки списка.
/// </summary>
private static string GetSettingTitle(ServiceSettingDefinition definition)
{
if (definition == null)
{
return string.Empty;
}
return string.IsNullOrWhiteSpace(definition.DisplayName) ? definition.Key : definition.DisplayName;
}
/// <summary>
/// Создает единый указатель фокуса, который перемещается поверх всего окна настроек.
/// </summary>
/// <param name="root">Корневой элемент модального окна.</param>
private static Label CreateModalFocusPointer(VisualElement root)
{
var pointer = new Label("▶");
pointer.AddToClassList("settings-focus-pointer");
pointer.pickingMode = PickingMode.Ignore;
pointer.style.display = DisplayStyle.None;
root.Add(pointer);
pointer.BringToFront();
return pointer;
}
/// <summary>
/// Перемещает общий указатель к элементу, который получил фокус.
/// </summary>
private void HandleRootFocusIn(FocusInEvent evt)
{
if (evt.target is not VisualElement focused)
{
return;
}
var target = ResolveFocusPointerTarget(focused);
if (target == null)
{
if (currentFocusPointerTarget != null)
{
MoveFocusPointerTo(currentFocusPointerTarget);
}
return;
}
currentFocusPointerTarget = target;
target.schedule.Execute(() =>
{
if (target == currentFocusPointerTarget && IsOpen && IsSettingsNavigationTargetVisible(target))
{
MoveFocusPointerTo(target);
}
});
}
/// <summary>
/// Прячет общий указатель, если фокус ушел с элементов окна настроек.
/// </summary>
private void HandleRootFocusOut(FocusOutEvent evt)
{
Root?.schedule.Execute(() =>
{
var focused = Root?.panel?.focusController?.focusedElement as VisualElement;
var target = ResolveFocusPointerTarget(focused);
if (target != null)
{
currentFocusPointerTarget = target;
MoveFocusPointerTo(target);
return;
}
if (currentFocusPointerTarget != null)
{
MoveFocusPointerTo(currentFocusPointerTarget);
}
});
}
/// <summary>
/// Ищет ближайший элемент, рядом с которым должен стоять указатель.
/// </summary>
private VisualElement ResolveFocusPointerTarget(VisualElement element)
{
for (var current = element; current != null && current != Root; current = current.parent)
{
if (current.ClassListContains("settings-focus-target"))
{
return current;
}
}
return element == saveButton || element == reloadButton || element == resetButton || element == closeButton
? element
: null;
}
/// <summary>
/// Ставит общий указатель слева от выбранного элемента в координатах модального окна.
/// </summary>
private void MoveFocusPointerTo(VisualElement target)
{
if (focusPointer == null || Root == null || target == null || target.resolvedStyle.display == DisplayStyle.None)
{
return;
}
if (EnsureTargetVisibleInSettingsList(target))
{
SchedulePlaceFocusPointerAt(target);
return;
}
PlaceFocusPointerAtCurrentTarget(target);
}
/// <summary>
/// Откладывает позиционирование указателя до следующего прохода layout.
/// </summary>
private void SchedulePlaceFocusPointerAt(VisualElement target)
{
Root?.schedule.Execute(() => PlaceFocusPointerAtCurrentTarget(target));
}
/// <summary>
/// Ставит указатель только если цель по-прежнему является текущей и видимой.
/// </summary>
private void PlaceFocusPointerAtCurrentTarget(VisualElement target)
{
if (target == currentFocusPointerTarget && IsOpen && IsSettingsNavigationTargetVisible(target))
{
PlaceFocusPointerAt(target);
}
}
/// <summary>
/// Ставит указатель по текущей геометрии элемента без повторной попытки прокрутки.
/// </summary>
private void PlaceFocusPointerAt(VisualElement target)
{
if (focusPointer == null || Root == null || target == null || target.resolvedStyle.display == DisplayStyle.None)
{
return;
}
var rootBounds = Root.worldBound;
var targetBounds = target.worldBound;
if (rootBounds.width <= 0f || targetBounds.width <= 0f || targetBounds.height <= 0f)
{
SchedulePlaceFocusPointerAt(target);
return;
}
focusPointer.style.display = DisplayStyle.Flex;
focusPointer.style.visibility = Visibility.Visible;
focusPointer.style.opacity = 1f;
currentFocusPointerTarget = target;
var targetPosition = new Vector2(
Mathf.Max(FocusPointerMinLeft, targetBounds.xMin - rootBounds.xMin - FocusPointerWidth - FocusPointerGap),
targetBounds.center.y - rootBounds.yMin - (FocusPointerHeight * 0.5f));
AnimateFocusPointerTo(targetPosition);
focusPointer.BringToFront();
}
/// <summary>
/// Показывает указатель после открытия окна, даже если стартовый FocusInEvent уже прошел.
/// </summary>
private void ShowFocusPointerOnCurrentOrDefault()
{
if (!IsOpen)
{
return;
}
var focused = Root?.panel?.focusController?.focusedElement as VisualElement;
var target = ResolveFocusPointerTarget(focused) ??
currentFocusPointerTarget ??
firstFocusableControl;
if (target != null && IsSettingsNavigationTargetVisible(target))
{
currentFocusPointerTarget = target;
MoveFocusPointerTo(target);
}
}
/// <summary>
/// Прокручивает список настроек так, чтобы активная строка оставалась внутри видимой области ScrollView.
/// </summary>
/// <param name="target">Элемент, к которому должен привязаться указатель.</param>
/// <returns>True, если список был прокручен и позицию указателя нужно пересчитать позже.</returns>
private bool EnsureTargetVisibleInSettingsList(VisualElement target)
{
if (settingsList == null || target == null || !ContainsElement(settingsList, target))
{
return false;
}
var viewport = settingsList.contentViewport ?? settingsList;
var viewportBounds = viewport.worldBound;
var targetBounds = target.worldBound;
if (viewportBounds.height <= 0f || targetBounds.height <= 0f)
{
settingsList.ScrollTo(target);
return true;
}
var topLimit = viewportBounds.yMin + ScrollVisibilityPadding;
var bottomLimit = viewportBounds.yMax - ScrollVisibilityPadding;
var nextOffsetY = settingsList.scrollOffset.y;
var targetNeedsScroll = false;
if (targetBounds.yMin < topLimit)
{
nextOffsetY -= topLimit - targetBounds.yMin;
targetNeedsScroll = true;
}
else if (targetBounds.yMax > bottomLimit)
{
nextOffsetY += targetBounds.yMax - bottomLimit;
targetNeedsScroll = true;
}
var maxOffsetY = Mathf.Max(0f, GetElementHeight(SettingsContainer) - GetElementHeight(viewport));
nextOffsetY = Mathf.Clamp(nextOffsetY, 0f, maxOffsetY);
if (maxOffsetY <= ScrollOffsetEpsilon && targetNeedsScroll)
{
settingsList.ScrollTo(target);
return true;
}
var currentOffset = settingsList.scrollOffset;
if (Mathf.Abs(currentOffset.y - nextOffsetY) <= ScrollOffsetEpsilon)
{
if (targetNeedsScroll)
{
settingsList.ScrollTo(target);
return true;
}
return false;
}
settingsList.scrollOffset = new Vector2(currentOffset.x, nextOffsetY);
return true;
}
/// <summary>
/// Возвращает полную высоту элемента для расчета прокрутки ScrollView.
/// </summary>
private static float GetElementHeight(VisualElement element)
{
if (element == null)
{
return 0f;
}
if (element.layout.height > 0f)
{
return element.layout.height;
}
if (element.resolvedStyle.height > 0f)
{
return element.resolvedStyle.height;
}
return element.worldBound.height;
}
/// <summary>
/// Проверяет, находится ли элемент внутри указанного визуального дерева.
/// </summary>
private static bool ContainsElement(VisualElement root, VisualElement element)
{
for (var current = element; current != null; current = current.parent)
{
if (current == root)
{
return true;
}
}
return false;
}
/// <summary>
/// Плавно перемещает общий указатель к новой позиции.
/// </summary>
/// <param name="targetPosition">Целевая позиция в координатах модального окна.</param>
private void AnimateFocusPointerTo(Vector2 targetPosition)
{
if (!hasFocusPointerPosition)
{
SetFocusPointerPosition(targetPosition);
return;
}
focusPointerAnimation?.Pause();
var startPosition = focusPointerPosition;
var startedAt = Time.unscaledTime;
focusPointerAnimation = focusPointer.schedule.Execute(() =>
{
var progress = Mathf.Clamp01((Time.unscaledTime - startedAt) / FocusPointerAnimationDuration);
var easedProgress = Mathf.SmoothStep(0f, 1f, progress);
SetFocusPointerPosition(Vector2.Lerp(startPosition, targetPosition, easedProgress));
}).Every(FocusPointerAnimationFrameMs).Until(() => Time.unscaledTime - startedAt >= FocusPointerAnimationDuration);
}
/// <summary>
/// Ставит общий указатель в конкретную позицию без дополнительных вычислений.
/// </summary>
/// <param name="position">Позиция в координатах модального окна.</param>
private void SetFocusPointerPosition(Vector2 position)
{
focusPointerPosition = position;
hasFocusPointerPosition = true;
focusPointer.style.left = position.x;
focusPointer.style.top = position.y;
}
/// <summary>
/// Скрывает общий указатель фокуса.
/// </summary>
private void HideFocusPointer()
{
if (focusPointer != null)
{
focusPointer.style.display = DisplayStyle.None;
}
}
/// <summary>
/// Возвращает указатель к последней известной строке, не меняя фокус.
/// </summary>
private void KeepFocusPointerOnCurrentTarget()
{
if (currentFocusPointerTarget != null && IsSettingsNavigationTargetVisible(currentFocusPointerTarget))
{
MoveFocusPointerTo(currentFocusPointerTarget);
}
}
/// <summary>
/// Команды навигации, которые окно настроек обрабатывает отдельно от общего стека модалок.
/// </summary>
private enum SettingsNavigationCommand
{
Up,
Down,
Left,
Right,
}
/// <summary>
/// Источник команды навигации, нужен для отсечения дублей от UI Toolkit.
/// </summary>
private enum SettingsNavigationSource
{
NavigationMove,
KeyDown,
}
/// <summary>
/// Команды изменения значения настройки при горизонтальной навигации.
/// </summary>
private sealed class SettingAdjustment
{
public SettingAdjustment(Func<bool> decrement, Func<bool> increment)
{
Decrement = decrement;
Increment = increment;
}
/// <summary>
/// Уменьшает значение или выбирает предыдущий вариант.
/// </summary>
public Func<bool> Decrement { get; }
/// <summary>
/// Увеличивает значение или выбирает следующий вариант.
/// </summary>
public Func<bool> Increment { get; }
}
/// <summary>
/// Возвращает безопасное название группы настроек.
/// </summary>
private static string GetGroupName(string groupName)
{
return string.IsNullOrWhiteSpace(groupName) ? "Общие настройки" : groupName;
}
/// <summary>
/// Показывает текстовое состояние внутри списка настроек.
/// </summary>
/// <param name="text">Сообщение для оператора.</param>
private void AddStatus(string text)
{
SetStatus(text);
var emptyState = new Label(text);
emptyState.AddToClassList("settings-empty-state");
SettingsContainer.Add(emptyState);
}
/// <summary>
/// Обновляет служебную строку состояния окна.
/// </summary>
/// <param name="text">Текст статуса.</param>
private void SetStatus(string text)
{
if (statusLabel != null)
{
statusLabel.text = text;
}
}
/// <summary>
/// Сохраняет текущие значения сервисных настроек в репозиторий.
/// </summary>
private void SaveSettings()
{
settingsService?.Save();
SetStatus("Настройки сохранены.");
RequestClose();
}
/// <summary>
/// Сбрасывает все сервисные настройки к значениям по умолчанию.
/// </summary>
private void ResetSettings()
{
settingsService?.ResetAllToDefaults();
BuildSettingsList();
if (IsOpen)
{
FocusDefault();
}
SetStatus("Настройки сброшены к значениям по умолчанию.");
}
/// <summary>
/// Перечитывает настройки из внешнего файла и обновляет содержимое окна.
/// </summary>
private void ReloadSettings()
{
settingsService?.ReloadExternalChanges();
BuildSettingsList();
if (IsOpen)
{
FocusDefault();
}
SetStatus("Настройки перечитаны из внешнего файла.");
}
}
}