UI и вы

Или как я перестал беспокоиться и полюбил Sheetlets.

Note

UI-система в SS14 прошла через несколько итераций, и многие части кодовой базы устарели по сравнению с текущими UI-соглашениями. Используя существующие UI как пример, пожалуйста, учитывайте возраст кода.

Если вы найдёте код, не соответствующий текущим соглашениям, рефакторинг всегда приветствуется!

Прежде чем узнать, как это следует делать в SS14, важно понять, как движок обрабатывает UI. Сначала вам следует обратиться к документации по пользовательскому интерфейсу.

Прочитали? Отлично.

Хорошо, но как сделать это навороченным?

FancyWindow

DefaultWindow не рекомендуется. Если только вы не создаёте собственное окно, FancyWindow следует использовать во всех случаях. У него есть дополнительные свойства, которые интегрируются с SS14 лучше, чем у DefaultWindow.

Свойство Stylesheet позволяет окну получать информацию о стилях из заданного стилевого листа. Мы поговорим о стилевых листах подробнее позже, но то, какой стилевой лист использует данный UI, определяет, какой набор стилевых правил к нему применяется. В настоящее время существуют следующие стилевые листы:

  • Nanotrasen — стилевой лист по умолчанию. Используется для любых стандартных UI, обращённых к игроку
  • System — в основном используется для админских и песочных UI (в настоящее время не реализован)

StyleClass

Стилевые классы позволяют стилевым правилам применять оформление к элементу. Вы можете задать стилевые классы элементу, установив свойство StyleClasses в XAML.

Content.Client/Stylesheets/StyleClass.cs: это статический класс для определения строк стилевых классов, которые могут применяться к любому UI-элементу. Это делается для централизации расположения всех доступных стилевых классов, удобства доступа и предотвращения дублирования стилевых классов.

Любые стилевые классы, которые являются общими / могут использоваться более чем для одного элемента, определяются в верхней части. Например, стилевой класс positive влияет на Button, Panel и Label.

Остальные стилевые классы определены для конкретного общего UI-элемента. Некоторые распространённые стилевые классы:

  • OpenLeft: делает кнопку плоской с левой стороны
  • OpenRight: делает кнопку плоской с правой стороны
  • OpenBoth: делает кнопку плоской с обеих сторон; квадратной
  • LabelSubtext: делает метку меньше и приглушает цвет
  • LabelKeyText: делает метку жирной и выделяет цветом
  • LabelWeak: слабый — противоположность сильному; делает цвет метки более приглушённым

Их гораздо больше, но если вы хотите точно узнать, что делает конкретная метка, достаточно просто посмотреть на использования поля и прочитать определение стилевого правила.

Tip

В целом, если вы занимаетесь разработкой UI, я бы рекомендовал IDE Rider. Она съедает довольно много оперативной памяти, но обеспечивает автодополнение в XAML-файлах, множество очень удобных функций авторефакторинга и поиска, а также вполне приличную интеграцию с git. Попробуйте!

Не думаю, что это возможно в VSCode, но если разберётесь, оставьте настройку здесь.

Написание стилей

Этот раздел касается стилевых правил. Для большинства UI их редактирование не потребуется, однако вам ВСЕГДА следует предпочитать использование стилевых классов вместо жёсткого задания цветов или ресурсов, которые могут часто использоваться.

Слава могучему Sheetlet

Важно понимать, что по сути стилевой лист — это огромный список каждого отдельного стилевого правила. Вместо создания одного гигантского списка стилевых правил (потому что это было бы нелепо… ха-ха… ха…), ответственность за пополнение этого списка распределена между множеством Sheetlet. Каждый Sheetlet возвращает небольшой фрагмент стилевых правил, который в конце объединяется в итоговый список.

Note

Раньше каждое отдельное стилевое правило находилось в одном гигантском списке: StyleNano.cs, яма отчаяния на 1600 строк, где умирали мечты. Он был настолько исполинским, что ломал подсветку синтаксиса в IDE. НЕ допускайте, чтобы что-то подобное повторилось.

Существуют в основном два типа Sheetlet:

  • Общие Sheetlet: находятся в Content.Client/Stylesheets/Sheetlets. Эти sheetlet касаются общих UI-элементов, используемых во многих разных UI, и должны писаться обобщённо, чтобы работать с любым стилевым листом.
  • Специфичные Sheetlet: находятся рядом с *.xaml-файлами, с которыми они связаны. Эти sheetlet касаются UI-элементов, специфичных для одного UI, и должны писаться для работы с конкретными sheetlet, с которыми они связаны.

Позже в этом документе будут подробнее описаны конкретные соглашения, которых следует придерживаться для обоих типов.

Все sheetlet должны иметь атрибут [CommonSheetlet].

Tip

Не забудьте атрибут [CommonSheetlet].

Стилевые правила

Стилевые правила применяют оформление к XML-элементам, не в пример аналогичным в CSS. Они состоят из селектора, который указывает, на какие элементы влияет это стилевое правило, и набора свойств, определяющих оформление, применяемое к этим элементам. Сначала рассмотрим селекторы, которые фильтруют элементы по нескольким различным признакам:

  • Type: тип элемента, на который влияет это правило. Всё, что наследуется от этого типа, также будет затронуто этим правилом.
  • StyleClasses: классы, которые должны быть у элемента, чтобы на него влияло это правило. Чтобы правило на него влияло, элемент должен иметь все указанные в правиле классы. Это задаётся в XML с помощью свойства StyleClasses.
  • StyleIdentifier: идентификатор элемента. Это уникальный идентификатор, который можно использовать для нацеливания на конкретный элемент. Его следует использовать, когда существует только один экземпляр элемента, который нужно оформить очень специфичным образом. У элемента может быть только один идентификатор, который задаётся в XML с помощью свойства StyleIdentifier.
  • PseudoClasses: это специальные классы, которые можно использовать для нацеливания на элементы в определённом состоянии. Например, это используется для оформления кнопок по-разному при наведении, нажатии или в других случаях. Они срабатывают автоматически при взаимодействии пользователя.
  • Элементы также можно оформлять на основе их родительского элемента и всех его стилевых свойств. В определении стилевого правила это делается с помощью метода .ParentOf(...), который принимает другой селектор, описывающий дочерний элемент, к которому будут применены стили.

Селекторы, задающие больше таких фильтров, более «специфичны» и имеют приоритет над менее специфичными селекторами. Если вы хотите, чтобы ваше стилевое правило переопределяло другие, сделайте его более специфичным.

Ко всем элементам, соответствующим селектору, затем будут применены свойства, определённые в стилевом правиле. Свойства одинаковы как в C#, так и в XAML.

Чтобы помочь с построением этих стилевых правил, в Content.Client/Stylesheets/StylesheetHelpers определены вспомогательные методы. Чтобы увидеть это в действии, разберём несколько примеров стилевых правил:

// вам нужна эта директива using, чтобы использовать вспомогательные методы
using static Content.Client.Stylesheets.Redux.StylesheetHelpers;

var rules =
[
    // выбрать любой элемент...
    E()
        // ...с классом "negative"
        .Class(StyleClass.Negative)
        // ...и задать его цвет шрифта как цвет текста из негативной палитры
        .FontColor(sheet.NegativePalette.Text),

    // выбрать любой `Label`...
    E<Label>()
        // ...с классом "LabelHeading"
        .Class(StyleClass.LabelHeading)
        // ...и задать его шрифт как жирный 16pt
        .Font(sheet.BaseFont.GetFont(16, FontKind.Bold))
        // ...и его цвет шрифта как цвет текста из палитры выделения
        .FontColor(sheet.HighlightPalette.Text)

    // выбрать любой `ContainerButton`...
     E<ContainerButton>()
        // ...с классом "button"
        .Class(ContainerButton.StyleClassButton)
        // ...и классом "ButtonSmall"
        .Class(StyleClass.ButtonSmall)
        // ...являющийся родителем `Label`,
        .ParentOf(E<Label>())
        // ...и задать шрифт этого `Label` как 8pt
        .Font(sheet.BaseFont.GetFont(8))
];

Разумеется, они способны на гораздо большее. Читайте sheetlet, реализованные в игре, чтобы узнать, как что-то делается!

Смерть хардкоду!

По возможности избегайте жёсткого задания значений в определениях стилевых правил, чтобы они оставались максимально переиспользуемыми / общими. Следующие системы предназначены для того, чтобы держать все определения в одном центральном месте, а ваши стилевые правила должны быть шаблонами, применяющими эти определения.

ColorPalette

На самом деле существует довольно надёжная (ха-ха) система цветовых палитр, которая, будем надеяться, делает жёсткое задание цветов ненужным. В классе Palettes определён набор общих палитр, и каждый стилевой лист использует их для следующих общих палитр, на которые ссылаются sheetlet:

  • PrimaryPalette: используется для элементов переднего плана
  • SecondaryPalette: используется для фоновых элементов
  • PositivePalette: традиционно зелёная палитра, используемая для обозначения успеха / хорошего / полного
  • NegativePalette: традиционно красная палитра, используемая для обозначения ошибок / плохого / пустого
  • HighlightPalette: используется для выделения заголовков или важных элементов

В C# вы получаете доступ к цветам через свойства класса ColorPalette. От самого светлого к самому тёмному свойства (на момент написания) расположены так (где меньшие числа темнее):

  • +0: Text Base
  • -1: TextDark, Element
  • -2: BackgroundLight, PressedElement
  • -3: Background
  • -4: BackgroundDark, DisabledElement

Причина, по которой используется такой подход, а не простой массив цветов, — читаемость. Цвета в палитре имеют конкретное предназначение, поэтому отражение этого намерения в коде важно для избежания ошибок и для того, чтобы код был чище и читабельнее.

Вот визуализация цветов, используемых в палитре NanotrasenStylesheet:

Цветовая палитра Nanotrasen

ISheetletConfig

ISheetletConfig предназначен для сокращения повторяющегося кода путём предоставления общей функциональности и определений между стилевыми листами. Любой Sheetlet, которому требуются значения из некоторого экземпляра ISheetletConfig, должен иметь ограничение обобщённого типа, требующее интерфейс ISheetletConfig.

[CommonSheetlet] // не забудьте `[CommonSheetlet]`!
public sealed class ExampleSheetlet<T> : Sheetlet<T> where T : PalettedStylesheet, IExampleConfig

ISheetletConfig также служит проверкой зависимостей. Когда стилевые листы собирают все sheetlet с [CommonSheetlet], они сначала проверяют, что те удовлетворяют ограничению типа, прежде чем добавлять правила в стилевой лист.

Info

Если sheetlet не удовлетворяет ограничениям типа ни одного стилевого листа, игра запишет ошибку в лог. Если ваши стили не отображаются, возможно, причина в этом.

Доступ к ресурсам

Доступ к ресурсам в sheetlet отличается от других частей кодовой базы. Каждый стилевой лист предоставляет список каталогов (корней), используемых при запросе ресурса (например, корень TextureResource в NanotrasenStylesheet — /Textures/Interface/Nano). Это означает, что любая текстура, запрошенная через GetTexture, будет искаться относительно этого каталога.

Общие Sheetlet

Общие sheetlet используются для общих UI-элементов, применяемых во многих разных UI. Они сгруппированы в Content.Client/Stylesheets/Sheetlets. Вот несколько соглашений, которых следует придерживаться при написании общих sheetlet:

  • Всегда следует выбирать элементы с помощью .Class, а не .Identifier.
  • При доступе к ресурсам используйте метод GetTextureOr, чтобы получить текстуру и указать запасной корень на случай, если текстура не найдена среди корней стилевого листа.
  • Избегайте ручного жёсткого задания классов. При ссылке на классы следует использовать только классы, определённые у оформляемого элемента (в свойствах StyleClass*), или определять собственные в StyleClass.cs.
  • Если вам нужно получить доступ к ресурсу, который ещё не предоставлен, следует добавить путь в соответствующий ISheetletConfig или создать новый с нуля.
Пример кода (нажмите, чтобы развернуть)
using Content.Client.Stylesheets.Redux.SheetletConfigs;
using Content.Client.Stylesheets.Redux.Stylesheets;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
// эту строку нужно добавить вручную, чтобы получить доступ к вспомогательным методам
using static Content.Client.Stylesheets.Redux.StylesheetHelpers;

namespace Content.Client.Stylesheets.Sheetlets;

// ОБЯЗАТЕЛЬНО ВКЛЮЧИТЕ АТРИБУТ [CommonSheetlet]
[CommonSheetlet]
// определите sheetlet и его зависимости
public sealed class CheckboxSheetlet<T> : Sheetlet<T> where T : PalettedStylesheet, ICheckboxConfig
{
    public override StyleRule[] GetRules(T sheet, object config)
    {
        // приведите sheet к любой из его требуемых зависимостей здесь
        ICheckboxConfig checkboxCfg = sheet;

        // получите текстуры / создайте сложные ресурсы здесь
        var uncheckedTex = sheet.GetTextureOr(checkboxCfg.CheckboxUncheckedPath, NanotrasenStylesheet.TextureRoot);
        var checkedTex = sheet.GetTextureOr(checkboxCfg.CheckboxCheckedPath, NanotrasenStylesheet.TextureRoot);

        // и наконец, определите все стилевые правила и верните большой список
        return
        [
            E<TextureRect>()
                .Class(CheckBox.StyleClassCheckBox)
                .Prop(TextureRect.StylePropertyTexture, uncheckedTex),
            E<TextureRect>()
                .Class(CheckBox.StyleClassCheckBox)
                .Class(CheckBox.StyleClassCheckBoxChecked)
                .Prop(TextureRect.StylePropertyTexture, checkedTex),
            E<BoxContainer>()
                .Class(CheckBox.StyleClassCheckBox)
                .Prop(BoxContainer.StylePropertySeparation, 10),
        ];
    }
}

Специфичные Sheetlet

Специфичные sheetlet используются вместе с UI-элементами, которые применяются лишь несколько раз, чаще всего все в одном UI. Эти sheetlet располагаются в том же каталоге, что и *.xaml-файл, с которым они связаны.

Как правило, эти sheetlet следуют немного иным соглашениям по сравнению с общими sheetlet:

  • Следует предпочитать выбор элементов с помощью .Identifier, а не .Class.
  • Любые стили, которые МОГЛИ БЫ использоваться другим UI, следует перенести в общий sheetlet.
  • Жёсткое задание здесь более вольное; всё равно старайтесь избегать его, когда возможно, но жёсткое задание StyleIdentifier вполне допустимо.
  • Если вам ОЧЕНЬ нужен конкретный ресурс и его не стоит добавлять в ISheetletConfig, вы можете получить к нему доступ через ResCache как обычно.
  • Вам не нужно возиться с ограничениями обобщённых типов, поскольку sheetlet должен быть специфичен для одного UI и, следовательно, для одного стилевого листа.
Пример кода (нажмите, чтобы развернуть)
using Content.Client.Resources;
using Content.Client.Stylesheets.Redux;
using Content.Client.Stylesheets.Redux.SheetletConfigs;
using Content.Client.Stylesheets.Redux.Stylesheets;
using Robust.Client.Graphics;
using Robust.Client.UserInterface;
using Robust.Client.UserInterface.Controls;
// эту строку нужно добавить вручную, чтобы получить доступ к вспомогательным методам
using static Content.Client.Stylesheets.Redux.StylesheetHelpers;

namespace Content.Client.Paper.UI;

// ОБЯЗАТЕЛЬНО ВКЛЮЧИТЕ АТРИБУТ [CommonSheetlet]
[CommonSheetlet]
// для какого стилевого листа этот sheetlet
public sealed class PaperSheetlet : Sheetlet<NanotrasenStylesheet>
{
    public override StyleRule[] GetRules(NanotrasenStylesheet sheet, object config)
    {
        // определите здесь любые нужные вам IConfig
        IWindowConfig windowCfg = sheet;

        // получите текстуры / создайте сложные ресурсы здесь
        var paperBackground = ResCache.GetTexture("/Textures/Interface/Paper/paper_background_default.svg.96dpi.png")
            .IntoPatch(StyleBox.Margin.All, 16);
        var paperBox = new StyleBoxTexture
            { Texture = sheet.GetTexture(windowCfg.TransparentWindowBackgroundBorderedPath) };
        paperBox.SetPatchMargin(StyleBox.Margin.All, 2);

        // и наконец, определите все стилевые правила и верните большой список
        return
        [
            E<PanelContainer>().Identifier("PaperContainer").Panel(paperBox),
            E<PanelContainer>()
                .Identifier("PaperDefaultBorder")
                .Prop(PanelContainer.StylePropertyPanel, paperBackground),
        ];
    }
}

Создание собственного стилевого листа

Note

Несколько стилевых листов стали возможны лишь недавно, с появлением системы Sheetlet, поэтому весь спектр возможностей, которые это открывает, ещё не изучен. Если стилевые листы начнут использоваться интересными способами помимо замены палитр, пожалуйста, обновите этот раздел!

Создание нового стилевого листа не так сложно, как написание всех стилевых правил. При хорошо написанных стилевых правилах и sheetlet новый стилевой лист — это просто определение общих цветов, определений и ресурсов, используемых всеми стилевыми правилами. В простейшем воплощении новый стилевой лист — это просто новая цветовая палитра!

Новые стилевые листы следует создавать с намерением передать другой контекст. Например, недиегетический контекст админских UI, передаваемый с помощью SystemStylesheet.

Цвета для стилевых листов определяются с помощью цветового пространства OKLAB, перцептивно равномерного цветового пространства. Когда вы выбираете новые цвета для своего стилевого листа, может быть полезно использовать OKLCH Color Picker и изменить существующий цвет.

Написание C# для UI

TODO: Я недостаточно уверен в своих знаниях, чтобы подробно описывать, что делать и что не делать. Пока это лишь общий обзор, и его следует обновить.

К тому же это, вероятно, должно быть отдельной страницей.

Лучший способ научиться писать код для UI — смотреть на существующий код. Некоторые UI определённо делают ужасные вещи, которые вам никогда не следует повторять, но в SS14 горы ужасного кода, так что это не аномалия. Я не могу научить вас знанию внутренностей этой игры, но я могу дать общий обзор.

Код, на который можно ориентироваться:

  • Консоль робототехники
  • Раздатчик реагентов
  • BatteryMenu

В любом UI есть несколько разных частей. Допустим, мы работаем с сущностью под названием MyThing, для которой хотим показать UI. Вот как, в общем, будет выглядеть структура:

Content.Server/MyThing/:
    - Systems/:
          - MyThingSystem.cs # Наследуется от `SharedMyThingSystem.cs`
            # Принимает сообщения и вносит изменения в мир, а также берёт данные из мира для обновления состояния UI.
Content.Shared/MyThing/:
    - Components/:
          - MyThingComponent.cs # Определяет компонент для сущности
    - Systems/:
          - SharedMyThingSystem.cs # Управляет данными внешнего вида и общей разделяемой логикой
    - SharedMyThing.cs # Определяет сообщения, которые можно отправлять между сервером и клиентом, и состояние UI

Content.Client/MyThing/:
    - Ui/:
          - MyThingWindow.xaml # Главное окно, где определяется основная структура UI
          - MyThingWindow.xaml.cs # Определяет поведение для `MyThingWindow.xaml`, считывая ввод и вызывая `Action`
          - MyThingBoundUserInterface.cs # Взаимодействует с сервером, отправляя сообщения и обновляя состояние UI
    - Systems/:
          - MyThingSystem.cs # Наследуется от `SharedMyThingSystem.cs`
            # Принимает данные внешнего вида и вносит изменения в мир, чтобы отразить их

Привязанные пользовательские интерфейсы

TODO: кто-нибудь, кто знаком с BUI лучше меня, должен написать о том, как писать хорошие BUI. Пока я просто вставлю сюда заметки из #codebase-changes от Bard в discord:

Предсказанные BUI находятся в:

Для передачи данных по сети:

Вариант 1 (предпочтительный). Перенесите состояние BUI в состояния компонентов. Используйте существующую клиентскую систему / создайте её, чтобы обрабатывать обновление BUI при обновлении состояния (используйте TryGetOpenUi) и при вызове Open в BoundUserInterface. Смотрите JukeboxSystem для примера, например

private void OnJukeboxAfterState(Entity<JukeboxComponent> ent, ref AfterAutoHandleStateEvent args)
{
    if (!_uiSystem.TryGetOpenUi<JukeboxBoundUserInterface>(ent.Owner, JukeboxUiKey.Key, out var bui))
        return;

    bui.Reload();
}

Вариант 2. Сделайте элемент управления BUI пустышкой, пока не придёт состояние.

Для UI: по возможности вызывайте TryOpenUi в shared, а клиент просто должен обработать это. Вызов со сервера также по-прежнему будет работать аналогично старому поведению.

Для сообщений: по возможности используйте SendPredictedMessage в BUI. В какой-то момент это, вероятно, станет значением по умолчанию вместо SendMessage.

В целом: предпочитайте перегрузки, принимающие EntityUid, а не ICommonSession; это облегчит в будущем программирование NPC, которые смогут взаимодействовать с UI.

  • Для BUI есть хелпер в this.CreateWindow(), который берёт на себя dispose + открытие + подписку на закрытие за вас.
  • Есть метод OnProtoReload, который вызывается у BUI, чтобы вы могли переопределить и обработать его без ручной подписки в другой системе.
  • Я добавил поддержку перезагрузки прототипов в некоторые вещи.
  • Я почистил много кода BUI. Теперь окна просто поднимают события, а сам BUI обрабатывает отправку сообщений.

Несколько заметок на будущее:

  • Создавать / удалять управляющие сущности следует внутри EnteredTree и ExitedTree, а не внутри Dispose.
  • Элементы управления должны уметь конструироваться пустым конструктором и не должны вызывать методы BUI напрямую. Это значительно упрощает повторное использование.
  • Все новые элементы управления должны обрабатывать перезагрузку прототипов, если это применимо.
  • Все новые элементы управления должны по возможности предпочитать состояния компонентов, а не состояния BUI. Они лучше работают с предсказанием и проще в использовании.
  • Элементы управления должны уметь обрабатывать исчезновение компонентов и не полагаться повсюду на GetComponent, так как нет гарантий, что компонент существует.

Subpages