Меню

Как создавать интерактивные WASD-меню, а также меню в чате, консоли и по центру экрана (center-HTML) для игроков.

Система меню позволяет создавать интерактивные постраничные списки опций, по которым игроки могут перемещаться и выбирать пункты. Один и тот же API создания меню и добавления пунктов работает одинаково для четырёх встроенных стилей отображения — экранная панель с навигацией WASD либо классические меню с выбором по цифрам в чате, консоли и по центру экрана. Благодаря этому вы описываете логику меню один раз и выбираете (или позволяете выбрать игрокам), как оно будет отображаться.

Обзор

Существует четыре встроенных типа меню, выбираемых по имени при создании или отображении меню:

ТипКак игрок взаимодействует
button (по умолчанию)Стиль WASD: перемещение курсора клавишами, выбор другой клавишей. Отображается как экранная HUD-панель.
chatВыбор по цифрам: строки пунктов выводятся в чат и выбираются вводом команды (см. Типы меню).
consoleТот же ввод по цифрам, но вывод в консоль игрока, а не в чат.
centerhtmlТот же ввод по цифрам, но отображается как HTML-панель по центру экрана вместо обычного текста.

Вы также можете зарегистрировать собственный стиль отображения с помощью RegisterMenuType — см. Пользовательские типы меню.

Быстрый пример: простое меню

Вот полный минимальный пример меню, привязанного к консольной команде: он создаёт меню, добавляет три пункта и показывает его тому, кто выполнил команду.

c#
c++
python
go
js
lua
using Plugify;
using static s2sdk.s2sdk;

public unsafe class Sample : Plugin
{
    public void OnPluginStart()
    {
        var flags = ConVarFlag.LinkedConcommand | ConVarFlag.Release | ConVarFlag.ClientCanExecute;
        AddConsoleCommand("sm_colors", "Opens a color picker menu", flags, Command_Colors, HookMode.Post);
    }

    public ResultType Command_Colors(int caller, CommandCallingContext context, string[] arguments)
    {
        if (caller == -1) return ResultType.Handled;

        MenuId menu = CreateMenu("Pick a Color", OnColorMenu, "button");
        AddMenuItem(menu, "red", "Red", MenuItemStyle.Default);
        AddMenuItem(menu, "green", "Green", MenuItemStyle.Default);
        AddMenuItem(menu, "blue", "Blue", MenuItemStyle.Default);
        DisplayMenu(menu, caller, 0);

        return ResultType.Handled;
    }

    public static void OnColorMenu(MenuId id, MenuAction action, int playerSlot, int param)
    {
        switch (action)
        {
            case MenuAction.Select:
                string info = GetMenuItemInfoText(id, param);
                PrintToChat(playerSlot, $"You picked: {info}");
                break;
            case MenuAction.End:
                DestroyMenu(id);
                break;
        }
    }
}

Использование класса Menu

В языках, где это поддерживается, класс Menu оборачивает хэндл, позволяя вызывать menu.AddItem(...) вместо AddMenuItem(menu, ...). Его конструктор напрямую вызывает CreateMenu, а деструктор вызывает за вас DestroyMenu — но этот деструктор срабатывает при обычном выходе из области видимости, как и у любого другого RAII-объекта, поэтому он не заменяет вызов DestroyMenu в вашем обработчике.

c#
c++
using Plugify;
using static s2sdk.s2sdk;

public unsafe class Sample : Plugin
{
    public void OnPluginStart()
    {
        var flags = ConVarFlag.LinkedConcommand | ConVarFlag.Release | ConVarFlag.ClientCanExecute;
        AddConsoleCommand("sm_colors", "Opens a color picker menu", flags, Command_Colors, HookMode.Post);
    }

    public ResultType Command_Colors(int caller, CommandCallingContext context, string[] arguments)
    {
        if (caller == -1) return ResultType.Handled;

        var menu = new Menu("Pick a Color", OnColorMenu, "button");
        menu.AddItem("red", "Red", MenuItemStyle.Default);
        menu.AddItem("green", "Green", MenuItemStyle.Default);
        menu.AddItem("blue", "Blue", MenuItemStyle.Default);
        menu.Display(caller, 0);

        return ResultType.Handled;
    }

    public static void OnColorMenu(MenuId id, MenuAction action, int playerSlot, int param)
    {
        switch (action)
        {
            case MenuAction.Select:
                string info = GetMenuItemInfoText(id, param);
                PrintToChat(playerSlot, $"You picked: {info}");
                break;
            case MenuAction.End:
                DestroyMenu(id); // очистка происходит здесь, а не через Dispose/финализатор Menu
                break;
        }
    }
}

Далее в этом руководстве повсюду используется стиль обычных вызовов функций, поскольку он одинаков во всех языках — всё показанное также работает как вызов menu.MethodName(...) через класс, если вам так удобнее.

Обработчик меню

Каждое меню управляется одной функцией-обработчиком, которую вы передаёте в CreateMenu. Она вызывается с MenuAction, описывающим произошедшее событие:

ДействиеКогдаparam
StartМеню было только что показано клиентуне используется
SelectКлиент выбрал пунктАбсолютный индекс выбранного пункта — передайте его в GetMenuItemInfoText/GetMenuItemDisplay
CancelСессия отображения завершилась без итогового выбораЗначение MenuCancelReason (см. Подменю и возврат назад)
EndСессия отображения полностью закрыта — всегда приходит последним, после Select или Cancelне используется

У каждого добавляемого пункта есть две отдельные строки:

  • info — внутренний идентификатор, который никогда не показывается клиенту. Используйте его, чтобы понять, какой пункт был выбран, не завися от отображаемого текста (который может измениться или быть локализован).
  • display — текст, который фактически показывается игроку.

Подменю и возврат назад

Отдельного типа пунктов «подменю» нет — многоуровневые меню строятся созданием и отображением нового меню изнутри ветки Select вашего обработчика. Чтобы позволить игроку вернуться в родительское меню, вызовите SetMenuExitBackButton для дочернего меню: это заменяет обычный пункт выхода на пункт «назад», который сообщает в вашу ветку Cancel причину MenuCancelReason.ExitBack вместо MenuCancelReason.Exit, что позволяет различать эти случаи и заново показывать родительское меню.

MenuCancelReasonЗначение
ExitКлиент использовал обычный пункт выхода
TimeoutИстёк таймер отображения
DisconnectКлиент отключился, пока меню было открыто
InterruptedДругой вызов DisplayMenu заменил это отображение для клиента
DestroyedХэндл меню был уничтожен во время отображения
ExitBackКлиент использовал пункт «назад», настроенный через SetMenuExitBackButton
c#
c++
python
go
js
lua
using Plugify;
using static s2sdk.s2sdk;

public unsafe class Sample : Plugin
{
    public void OnPluginStart()
    {
        var flags = ConVarFlag.LinkedConcommand | ConVarFlag.Release | ConVarFlag.ClientCanExecute;
        AddConsoleCommand("sm_settings", "Opens a settings menu", flags, Command_Settings, HookMode.Post);
    }

    MenuId CreateMainMenu()
    {
        MenuId menu = CreateMenu("Settings", OnMainMenu, "button");
        AddMenuItem(menu, "hello", "Say Hello", MenuItemStyle.Default);
        AddMenuItem(menu, "colors", "Color Options", MenuItemStyle.Default);
        return menu;
    }

    public ResultType Command_Settings(int caller, CommandCallingContext context, string[] arguments)
    {
        if (caller == -1) return ResultType.Handled;
        DisplayMenu(CreateMainMenu(), caller, 0);
        return ResultType.Handled;
    }

    public void OnMainMenu(MenuId id, MenuAction action, int playerSlot, int param)
    {
        switch (action)
        {
            case MenuAction.Select:
                string info = GetMenuItemInfoText(id, param);
                if (info == "hello")
                {
                    PrintToChat(playerSlot, "Hello!");
                }
                else if (info == "colors")
                {
                    MenuId sub = CreateMenu("Color Options", OnColorSubMenu, "button");
                    AddMenuItem(sub, "red", "Red", MenuItemStyle.Default);
                    AddMenuItem(sub, "blue", "Blue", MenuItemStyle.Default);
                    SetMenuExitBackButton(sub, true); // показывает «Назад» вместо «Выход»
                    DisplayMenu(sub, playerSlot, 0);
                }
                break;
            case MenuAction.End:
                DestroyMenu(id);
                break;
        }
    }

    public void OnColorSubMenu(MenuId id, MenuAction action, int playerSlot, int param)
    {
        switch (action)
        {
            case MenuAction.Select:
                string info = GetMenuItemInfoText(id, param);
                PrintToChat(playerSlot, $"Color set to: {info}");
                break;
            case MenuAction.Cancel:
                if ((MenuCancelReason)param == MenuCancelReason.ExitBack)
                {
                    DisplayMenu(CreateMainMenu(), playerSlot, 0);
                }
                break;
            case MenuAction.End:
                DestroyMenu(id);
                break;
        }
    }
}

Пункты меню

Помимо AddMenuItem (добавляет в конец), вы можете вставлять пункты в определённую позицию и управлять существующими:

Стиль пунктаЗначение
DefaultОтображается, нумеруется, доступен для выбора
DisabledОтображается, нумеруется, недоступен для выбора — типы с курсором пропускают его
SpacerПустая строка, не нумеруется, недоступна для выбора
int index = InsertMenuItemAt(menu, 0, "vip_only", "VIP Perks", MenuItemStyle.Disabled);
RemoveMenuItem(menu, index);
RemoveAllMenuItems(menu);

int count = GetMenuItemsCount(menu);
string info = GetMenuItemInfoText(menu, 0);
string display = GetMenuItemDisplay(menu, 0);
MenuItemStyle style = GetMenuItemStyle(menu, 0);
bool selectable = IsMenuItemSelectable(menu, 0);

SetMenuItemDisplay(menu, 0, "Updated Text");
SetMenuItemStyle(menu, 0, MenuItemStyle.Disabled);

Отображение и навигация

DisplayMenu(id, playerSlot, time) показывает меню начиная с первого пункта; DisplayMenuAtItem(id, playerSlot, firstItem, time) вместо этого начинает с указанного пункта. time — таймаут в секундах: 0 или отрицательное значение означают отсутствие таймаута, а по его истечении сессия отображения завершается с MenuCancelReason.Timeout.

Если в меню больше пунктов, чем помещается на одной странице, используйте SetMenuPagination, чтобы задать количество пунктов на страницу (0 отключает разбиение на страницы — все пункты выводятся сразу):

SetMenuPagination(menu, 5); // 5 пунктов на страницу
int perPage = GetMenuPagination(menu);

bool hasPrev = ClientMenuHasPrevPage(playerSlot);
bool hasNext = ClientMenuHasNextPage(playerSlot);
MenuNextPage(playerSlot);
MenuPrevPage(playerSlot);

По умолчанию выбор пункта автоматически закрывает отображение меню (срабатывает MenuAction.Select, затем отображение закрывается, затем срабатывает MenuAction.End). Отключите это через SetMenuCloseOnSelect(menu, false), если хотите, чтобы меню оставалось открытым после выбора — тогда ваш обработчик сам отвечает за его закрытие или повторный показ (например, меню опций-переключателей, которое перерисовывается после каждого выбора).

Типы меню

Задайте стиль отображения меню при создании (третий аргумент CreateMenu) или позже через SetMenuType/GetMenuType. Пустая строка означает использование типа по умолчанию для сервера — им управляют SetDefaultMenuType/GetDefaultMenuType (встроенное значение по умолчанию — button).

button стоит особняком: он управляется клавишами WASD (перемещение курсора, выбор клавишей) и отображается как экранная HUD-панель вместо нумерованного текста — о его настройке см. следующий раздел.

Настройка WASD-меню (button)

Управление для типа button настраивается на стороне сервера, в settings.jsonc, а не через API плагина — это позволяет владельцам серверов настраивать его без того, чтобы каждый плагин предоставлял собственные настройки для того же самого.

// Замораживать движение игрока, пока открыто WASD-меню
"MenuButtonFreezePlayer": true,

// Максимум пунктов на страницу; сохраняет область пунктов и подвал фиксированной, всегда видимой высоты
"MenuButtonMaxItems": 6,

// Кнопки управления курсором (имена InputBitMask_t, например IN_FORWARD, IN_BACK, IN_USE, IN_MOVELEFT, IN_MOVERIGHT, IN_RELOAD, ...)
"MenuButtonKeyUp": "IN_FORWARD",
"MenuButtonKeyDown": "IN_BACK",
"MenuButtonKeyLeft": "IN_MOVELEFT",   // перескакивает на страницу назад, быстрее чем Вверх/Вниз
"MenuButtonKeyRight": "IN_MOVERIGHT", // перескакивает на страницу вперёд
"MenuButtonKeySelect": "IN_USE",
"MenuButtonKeyExit": "IN_RELOAD",

// CSS-классы Panorama (например, "fontSize-sm") для независимого уменьшения/увеличения заголовка, списка пунктов и подвала
"MenuButtonBodyFontClass": "",
"MenuButtonTitleFontClass": "",
"MenuButtonFooterFontStyle": "",

// Звуковые события при взаимодействии; оставьте пустыми, чтобы ничего не воспроизводить
"MenuSoundScroll": "",
"MenuSoundClick": "",
"MenuSoundBack": "",
"MenuSoundExit": "",
"MenuSoundDisabled": ""

Пользовательские типы меню (продвинутое)

Если ни один из встроенных стилей отображения не подходит — например, вам нужен интерфейс на основе Panorama — вы можете зарегистрировать собственный бэкенд через RegisterMenuType(name, display, close, frame). display/close вызываются для отрисовки и скрытия вашего интерфейса для клиента; frame (необязательный) выполняется каждый серверный кадр, пока у клиента открыт ваш тип, — для опроса пользовательского ввода.

RegisterMenuType("my_ui", OnMyDisplay, OnMyClose, OnMyFrame);
UnregisterMenuType("my_ui");
bool registered = IsMenuTypeRegistered("my_ui");
string[] types = GetMenuTypes();

Ваш колбэк display читает состояние меню и клиента через те же геттеры, что используют авторы плагинов (GetMenuTitle, GetMenuItemDisplay, GetClientMenuOffset, GetClientMenuCursor, ...), а обработка frame и ввода преобразует сырой ввод в действие, обращаясь к общей внутренней логике:

  • HandleDigitInput(playerSlot, digit) — общий путь 1-7/8/9/0 «выбрать пункт / предыдущая страница / следующая страница / выход», используемый типами chat/console/centerhtml.
  • SelectMenuItem(playerSlot, itemIndex) — напрямую выбрать конкретный абсолютный индекс пункта.
  • MenuNextPage(playerSlot) / MenuPrevPage(playerSlot) — сдвинуть окно отображения на одну страницу.

Справочник методов

Жизненный цикл

МетодОписание
CreateMenu(title, handler, menuType)Создаёт меню и возвращает его хэндл
DestroyMenu(id)Уничтожает меню; у любого клиента, просматривающего его, отображение сначала отменяется с MenuCancelReason.Destroyed
IsValidMenu(id)Возвращает true, если хэндл ссылается на существующее меню

Свойства

МетодОписание
SetMenuTitle(id, title) / GetMenuTitle(id)Заголовок меню
SetMenuType(id, typeName) / GetMenuType(id)Какой зарегистрированный тип меню отрисовывает это меню (пусто = по умолчанию)
SetMenuPagination(id, itemsPerPage) / GetMenuPagination(id)Пунктов на страницу; 0 отключает разбиение на страницы
SetMenuExitButton(id, enabled) / GetMenuExitButton(id)Показывать ли пункт выхода
SetMenuExitBackButton(id, enabled) / GetMenuExitBackButton(id)Заменяет пункт выхода на пункт «назад» (MenuCancelReason.ExitBack)
SetMenuCloseOnSelect(id, enabled) / GetMenuCloseOnSelect(id)Закрывает ли выбор пункта отображение автоматически

Пункты

МетодОписание
AddMenuItem(id, info, display, style)Добавляет пункт в конец, возвращая его индекс (или -1)
InsertMenuItemAt(id, index, info, display, style)Вставляет пункт по указанному индексу
RemoveMenuItem(id, index) / RemoveAllMenuItems(id)Удаляет один пункт или все
GetMenuItemsCount(id)Количество пунктов
GetMenuItemInfoText(id, index) / GetMenuItemDisplay(id, index)Строка info/display пункта
GetMenuItemStyle(id, index) / SetMenuItemStyle(id, index, style)Стиль отрисовки пункта
IsMenuItemSelectable(id, index)true, если стиль пункта — Default
SetMenuItemDisplay(id, index, display)Изменяет отображаемый текст пункта

Отображение и навигация

МетодОписание
DisplayMenu(id, playerSlot, time) / DisplayMenuAtItem(id, playerSlot, firstItem, time)Показывает меню клиенту
CancelClientMenu(playerSlot, reason)Отменяет меню, открытое у клиента в данный момент
GetClientMenu(playerSlot)Хэндл меню, открытого у клиента, или 0
GetClientMenuOffset(playerSlot) / GetClientMenuCursor(playerSlot) / SetClientMenuCursor(playerSlot, index)Текущее смещение страницы / индекс выделенного курсором пункта
ClientMenuHasPrevPage(playerSlot) / ClientMenuHasNextPage(playerSlot)Есть ли ещё страницы в том или ином направлении
MenuNextPage(playerSlot) / MenuPrevPage(playerSlot)Сдвигает отображение на одну страницу

Советы и рекомендации

  1. Всегда уничтожайте созданные меню — вызывайте DestroyMenu в ветке End вашего обработчика, в том числе для динамически создаваемых подменю.
  2. Стройте логику выбора на info, а не на display — отображаемый текст может меняться (переводы, динамические подписи), а строки info остаются стабильными и полностью под вашим контролем.
  3. Используйте SetMenuExitBackButton для навигации к родительскому меню, а не отдельный пункт «Назад» — это даёт отдельную причину MenuCancelReason и работает одинаково во всех типах меню.
  4. Сначала протестируйте на типе, отличном от button, если не уверены, что воркшоп-аддон подключён — типам chat/console/centerhtml не нужны внешние ресурсы, и это самый быстрый способ проверить работу логики меню, прежде чем заниматься оформлением.
  5. Не рассчитывайте на фиксированный размер страницы — учитывайте то, что возвращает GetMenuPagination, вместо жёстко заданного числа пунктов на страницу, поскольку владельцы серверов могут менять MenuButtonMaxItems, а вы — вызывать SetMenuPagination.