Интерфейс: первое меню и структура UI
Полное адаптивное меню C#: иерархия Control, общий стиль, кнопки и проверка размеров.
Из чего собрать интерфейс
Control — сущность интерфейса с прямоугольником, размерами, привязками и фокусом. Label показывает текст, Button выполняет действие, LineEdit принимает строку. Контейнеры распределяют место между дочерними Control. Они входят в обычную иерархию Entity; отдельной ECS-модели для UI нет.
| Задача | Материал |
|---|---|
| Первый экран меню | Готовый пример ниже |
| HUD у края и изменение окна | Размеры и привязки |
| Списки, свободное место и прокрутка | Контейнеры |
| Tab, стрелки, подтверждение и возврат | Фокус и навигация |
| Выбрать поле, список или переключатель | Элементы интерфейса |
| Собственный рисунок и поведение ввода | Свой Control |
Запустить меню
В C#-проекте создайте уровень с корнем Control, сохраните UiSampleStyle.cs и ResponsiveMenu.cs, прикрепите ResponsiveMenu к корню и соберите. Запустите F6. При необходимости подготовки C# начните с первого проекта.
Начать увеличивает счётчик, Enter активирует выбранную кнопку, Tab переключает фокус. Кнопка настроек показывает место подключения вашего экрана; она не создаёт настройки игры. При изменении окна карточка остаётся по центру и занимает не более 520 логических пикселей, с внешними отступами 24. Пример рассчитан на область не меньше 390 × 480; произвольно маленькое окно не может вместить любой текст.
UiSampleStyle нужен также следующим примерам раздела: он задаёт общие цвета, размер текста и отступы. В игре общую тему удобно хранить ресурсом и назначать корню UI. Наследование темы и настройка её типов разобраны в руководстве по темам.
UiSampleStyle.cs
using System;
using Reckfell;
public static class UiSampleStyle
{
public static string Text(string ru, string en) =>
TranslationServer.GetLocale().StartsWith("ru", StringComparison.OrdinalIgnoreCase) ? ru : en;
public static Theme Create()
{
var theme = new Theme { DefaultFontSize = 16 };
theme.SetColor("font_color", "Label", Colors.White);
theme.SetConstant("separation", "VBoxContainer", 12);
theme.SetConstant("separation", "HBoxContainer", 12);
foreach (string state in new[] { "normal", "hover", "pressed", "disabled" })
{
var style = Box(state == "pressed" ? new Color(.12f, .27f, .75f) : new Color(.14f, .14f, .16f), 12);
theme.SetStylebox(state, "Button", style);
}
var focus = Box(Colors.Transparent, 0);
focus.DrawCenter = false; focus.BorderColor = new Color(.25f, .45f, 1);
focus.SetBorderWidthAll(2); theme.SetStylebox("focus", "Button", focus);
theme.SetStylebox("panel", "PanelContainer", Box(new Color(.065f, .065f, .075f), 20));
return theme;
}
private static StyleBoxFlat Box(Color color, int padding)
{
var style = new StyleBoxFlat { BgColor = color };
style.SetCornerRadiusAll(6);
foreach (Side side in Enum.GetValues<Side>()) style.SetContentMargin(side, padding);
return style;
}
public static void Prepare(Control root)
{
root.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
root.Theme = Create();
var background = new ColorRect { Color = new Color(.02f, .02f, .025f), MouseFilter = Control.MouseFilterEnum.Ignore };
root.AddChild(background); background.SetAnchorsAndOffsetsPreset(Control.LayoutPreset.FullRect);
}
}ResponsiveMenu.cs
using Reckfell;
public partial class ResponsiveMenu : Control
{
public PanelContainer Card { get; private set; } = null!;
public Button Start { get; private set; } = null!;
public Label Status { get; private set; } = null!;
public int StartCount { get; private set; }
public override void _Ready()
{
UiSampleStyle.Prepare(this);
Card = new PanelContainer { Name = "Card" }; AddChild(Card);
var column = new VBoxContainer(); Card.AddChild(column);
column.AddChild(new Label { Text = "Reckfell" });
column.AddChild(new Label
{
Text = UiSampleStyle.Text("Начните игру или настройте управление.", "Start a game or adjust the controls."),
AutowrapMode = TextServer.AutowrapMode.WordSmart
});
Start = new Button { Name = "Start", Text = UiSampleStyle.Text("Начать", "Start"), FocusMode = FocusModeEnum.All };
column.AddChild(Start);
var settings = new Button { Text = UiSampleStyle.Text("Настройки управления", "Control settings") }; column.AddChild(settings);
Status = new Label { Text = UiSampleStyle.Text("Готово", "Ready") }; column.AddChild(Status);
Start.Pressed += () => { StartCount++; Status.Text = UiSampleStyle.Text("Нажатий: ", "Presses: ") + StartCount; };
settings.Pressed += () => Status.Text = UiSampleStyle.Text("Здесь откройте свои настройки.", "Open your settings here.");
Resized += LayoutCard;
Card.MinimumSizeChanged += () => Callable.From(LayoutCard).CallDeferred();
LayoutCard(); Callable.From(() => Start.GrabFocus()).CallDeferred();
}
private void LayoutCard()
{
if (Card is null || !IsInsideTree()) return;
float width = Mathf.Min(520, Mathf.Max(0, Size.X - 48));
Card.Size = new Vector2(width, Card.GetCombinedMinimumSize().Y);
Card.Position = (Size - Card.Size) / 2;
}
public override void _UnhandledInput(InputEvent @event)
{
if (IsVisibleInTree() && @event.IsAction("ui_accept")) GetViewport().SetInputAsHandled();
}
}Кто управляет размерами
В примере родитель Card — обычный Control, поэтому LayoutCard задаёт положение и размер карточки. Внутри неё PanelContainer и VBoxContainer раскладывают содержимое. Не выставляйте вручную Position кнопок этой колонки: следующий проход контейнера перезапишет её.
MinimumSizeChanged может прийти во время пересчёта дочерних элементов. Пример откладывает повторное размещение карточки, чтобы не проводить всю цепочку расчёта рекурсивно в сигнале. Перенос текста разрешён явно через AutowrapMode; уменьшение окна само по себе не включает перенос у всех типов текста.
Для HUD поверх движущегося мира обычно поместите корень Control под CanvasLayer, чтобы камера мира не двигала экранный интерфейс. Размер Control определяется логической областью родителя/viewport, а не обязательно физическими пикселями монитора. Сначала добейтесь корректной раскладки, затем отдельно настраивайте масштабирование проекта под разрешения и DPI.
Если экран пуст или карточка обрезана, сначала проверьте Size корневого Control, GetParentAreaSize и GetCombinedMinimumSize карточки. Сравнивайте размеры в одной логической системе координат. Большой минимум длинной надписи не помещается автоматически только потому, что у родителя FullRect.
Что проверить перед подключением к игре
Измените окно между широким и узким форматом, увеличьте длину надписей, пройдите меню без мыши и вернитесь в него после скрытия. Задайте действия игры отдельно от ui_*. _Input() вызывается раньше GUI; опрос Input.IsActionJustPressed() тоже не отменяется AcceptEvent. Игровую реакцию нужно отключать состоянием игры/меню, а необработанные события получать в _UnhandledInput().