Как создавать интерактивные WASD-меню, а также меню в чате, консоли и по центру экрана (center-HTML) для игроков.
Система меню позволяет создавать интерактивные постраничные списки опций, по которым игроки могут перемещаться и выбирать пункты. Один и тот же API создания меню и добавления пунктов работает одинаково для четырёх встроенных стилей отображения — экранная панель с навигацией WASD либо классические меню с выбором по цифрам в чате, консоли и по центру экрана. Благодаря этому вы описываете логику меню один раз и выбираете (или позволяете выбрать игрокам), как оно будет отображаться.
Как это работает: вы создаёте хэндл меню, добавляете в него пункты и показываете его игроку. Ваш обработчик вызывается по мере того, как игрок взаимодействует с меню (открывает его, выбирает пункт, отменяет или отображение закрывается), поэтому всё поведение меню находится в одной функции.
Вот полный минимальный пример меню, привязанного к консольной команде: он создаёт меню, добавляет три пункта и показывает его тому, кто выполнил команду.
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;
}
}
}
#include <plg/plugin.hpp>
#include "s2sdk.hpp"
using namespace s2sdk;
void OnColorMenu(MenuId id, MenuAction action, int playerSlot, int param) {
switch (action) {
case MenuAction::Select: {
plg::string info = GetMenuItemInfoText(id, param);
PrintToChat(playerSlot, "You picked: " + info);
break;
}
case MenuAction::End:
DestroyMenu(id);
break;
default:
break;
}
}
class Sample : public plg::Plugin {
public:
void OnPluginStart() override {
ConVarFlag flags = ConVarFlag::LinkedConcommand | ConVarFlag::Release | ConVarFlag::ClientCanExecute;
AddConsoleCommand("sm_colors", "Opens a color picker menu", flags,
[](int caller, CommandCallingContext context, const plg::vector<plg::string>& arguments) -> ResultType {
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;
}, HookMode::Post);
}
};
from plugify.plugin import Plugin
from plugify.pps import s2sdk as s2
def on_color_menu(id, action, player_slot, param):
if action == s2.MenuAction.Select:
info = s2.GetMenuItemInfoText(id, param)
s2.PrintToChat(player_slot, f"You picked: {info}")
elif action == s2.MenuAction.End:
s2.DestroyMenu(id)
class Sample(Plugin):
def plugin_start(self):
flags = s2.ConVarFlag.LinkedConcommand | s2.ConVarFlag.Release | s2.ConVarFlag.ClientCanExecute
def command_colors(caller, context, arguments):
if caller == -1:
return s2.ResultType.Handled
menu = s2.CreateMenu("Pick a Color", on_color_menu, "button")
s2.AddMenuItem(menu, "red", "Red", s2.MenuItemStyle.Default)
s2.AddMenuItem(menu, "green", "Green", s2.MenuItemStyle.Default)
s2.AddMenuItem(menu, "blue", "Blue", s2.MenuItemStyle.Default)
s2.DisplayMenu(menu, caller, 0)
return s2.ResultType.Handled
s2.AddConsoleCommand("sm_colors", "Opens a color picker menu", flags, command_colors, s2.HookMode.Post)
import { Plugin } from 'plugify';
import * as s2 from ':s2sdk';
function onColorMenu(id, action, playerSlot, param) {
if (action === s2.MenuAction.Select) {
const info = s2.GetMenuItemInfoText(id, param);
s2.PrintToChat(playerSlot, `You picked: ${info}`);
} else if (action === s2.MenuAction.End) {
s2.DestroyMenu(id);
}
}
export class Sample extends Plugin {
pluginStart() {
const flags = s2.ConVarFlag.LinkedConcommand | s2.ConVarFlag.Release | s2.ConVarFlag.ClientCanExecute;
s2.AddConsoleCommand("sm_colors", "Opens a color picker menu", flags,
(caller, context, arguments) => {
if (caller === -1) return s2.ResultType.Handled;
const menu = s2.CreateMenu("Pick a Color", onColorMenu, "button");
s2.AddMenuItem(menu, "red", "Red", s2.MenuItemStyle.Default);
s2.AddMenuItem(menu, "green", "Green", s2.MenuItemStyle.Default);
s2.AddMenuItem(menu, "blue", "Blue", s2.MenuItemStyle.Default);
s2.DisplayMenu(menu, caller, 0);
return s2.ResultType.Handled;
}, s2.HookMode.Post);
}
}
local plugify = require 'plugify'
local Plugin = plugify.Plugin
local s2 = require 's2sdk'
local function on_color_menu(id, action, player_slot, param)
if action == s2.MenuAction.Select then
local info = s2:GetMenuItemInfoText(id, param)
s2:PrintToChat(player_slot, "You picked: " .. info)
elseif action == s2.MenuAction.End then
s2:DestroyMenu(id)
end
end
local Sample = {}
setmetatable(Sample, { __index = Plugin })
function Sample:plugin_start()
local flags = bit.bor(s2.ConVarFlag.LinkedConcommand, s2.ConVarFlag.Release, s2.ConVarFlag.ClientCanExecute)
local function command_colors(caller, context, arguments)
if caller == -1 then return s2.ResultType.Handled end
local menu = s2:CreateMenu("Pick a Color", on_color_menu, "button")
s2:AddMenuItem(menu, "red", "Red", s2.MenuItemStyle.Default)
s2:AddMenuItem(menu, "green", "Green", s2.MenuItemStyle.Default)
s2:AddMenuItem(menu, "blue", "Blue", s2.MenuItemStyle.Default)
s2:DisplayMenu(menu, caller, 0)
return s2.ResultType.Handled
end
s2:AddConsoleCommand("sm_colors", "Opens a color picker menu", flags, command_colors, s2.HookMode.Post)
end
local M = {}
M.Sample = Sample
return M
В языках, где это поддерживается, класс Menu оборачивает хэндл, позволяя вызывать menu.AddItem(...) вместо AddMenuItem(menu, ...). Его конструктор напрямую вызывает CreateMenu, а деструктор вызывает за вас DestroyMenu — но этот деструктор срабатывает при обычном выходе из области видимости, как и у любого другого RAII-объекта, поэтому он не заменяет вызов DestroyMenu в вашем обработчике.
Меню живёт дольше функции, которая его создала. Оно должно оставаться живым, пока игрок не выберет пункт, не отменит меню или не истечёт таймаут отображения — а это происходит позже, в вашем обработчике. Если объект Menu выйдет из области видимости (блок using в C#, стековая переменная в C++) сразу после вызова Display, его деструктор сработает немедленно и уничтожит только что показанное меню. Используйте класс ради удобства его методов при настройке, но уничтожайте меню в ветке End вашего обработчика обычным вызовом DestroyMenu(id) — обработчик всегда получает только сырой хэндл, но не экземпляр Menu, поэтому очистка возможна только там.
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;
}
}
}
#include <plg/plugin.hpp>
#include "s2sdk.hpp"
using namespace s2sdk;
void OnColorMenu(MenuId id, MenuAction action, int playerSlot, int param) {
switch (action) {
case MenuAction::Select: {
plg::string info = GetMenuItemInfoText(id, param);
PrintToChat(playerSlot, "You picked: " + info);
break;
}
case MenuAction::End:
DestroyMenu(id); // очистка происходит здесь, а не через деструктор Menu
break;
default:
break;
}
}
class Sample : public plg::Plugin {
public:
void OnPluginStart() override {
ConVarFlag flags = ConVarFlag::LinkedConcommand | ConVarFlag::Release | ConVarFlag::ClientCanExecute;
AddConsoleCommand("sm_colors", "Opens a color picker menu", flags,
[](int caller, CommandCallingContext context, const plg::vector<plg::string>& arguments) -> ResultType {
if (caller == -1) return ResultType::Handled;
Menu 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;
}, HookMode::Post);
}
};
Далее в этом руководстве повсюду используется стиль обычных вызовов функций, поскольку он одинаков во всех языках — всё показанное также работает как вызов menu.MethodName(...) через класс, если вам так удобнее.
Сессия отображения полностью закрыта — всегда приходит последним, после Select или Cancel
не используется
Всегда уничтожайте свои меню: CreateMenu выделяет хэндл, который не очищается автоматически. Вызывайте DestroyMenu(id) в ветке End вашего обработчика (как в примере выше) — иначе каждое показанное меню будет утекать на всё время работы плагина.
У каждого добавляемого пункта есть две отдельные строки:
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;
}
}
}
#include <plg/plugin.hpp>
#include "s2sdk.hpp"
using namespace s2sdk;
void OnMainMenu(MenuId id, MenuAction action, int playerSlot, int param);
MenuId CreateMainMenu() {
MenuId menu = CreateMenu("Settings", &OnMainMenu, "button");
AddMenuItem(menu, "hello", "Say Hello", MenuItemStyle::Default);
AddMenuItem(menu, "colors", "Color Options", MenuItemStyle::Default);
return menu;
}
void OnColorSubMenu(MenuId id, MenuAction action, int playerSlot, int param) {
switch (action) {
case MenuAction::Select: {
plg::string info = GetMenuItemInfoText(id, param);
PrintToChat(playerSlot, "Color set to: " + info);
break;
}
case MenuAction::Cancel:
if (static_cast<MenuCancelReason>(param) == MenuCancelReason::ExitBack) {
DisplayMenu(CreateMainMenu(), playerSlot, 0);
}
break;
case MenuAction::End:
DestroyMenu(id);
break;
default:
break;
}
}
void OnMainMenu(MenuId id, MenuAction action, int playerSlot, int param) {
switch (action) {
case MenuAction::Select: {
plg::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;
default:
break;
}
}
class Sample : public plg::Plugin {
public:
void OnPluginStart() override {
ConVarFlag flags = ConVarFlag::LinkedConcommand | ConVarFlag::Release | ConVarFlag::ClientCanExecute;
AddConsoleCommand("sm_settings", "Opens a settings menu", flags,
[](int caller, CommandCallingContext context, const plg::vector<plg::string>& arguments) -> ResultType {
if (caller == -1) return ResultType::Handled;
DisplayMenu(CreateMainMenu(), caller, 0);
return ResultType::Handled;
}, HookMode::Post);
}
};
from plugify.plugin import Plugin
from plugify.pps import s2sdk as s2
def create_main_menu():
menu = s2.CreateMenu("Settings", on_main_menu, "button")
s2.AddMenuItem(menu, "hello", "Say Hello", s2.MenuItemStyle.Default)
s2.AddMenuItem(menu, "colors", "Color Options", s2.MenuItemStyle.Default)
return menu
def on_color_submenu(id, action, player_slot, param):
if action == s2.MenuAction.Select:
info = s2.GetMenuItemInfoText(id, param)
s2.PrintToChat(player_slot, f"Color set to: {info}")
elif action == s2.MenuAction.Cancel:
if param == s2.MenuCancelReason.ExitBack:
s2.DisplayMenu(create_main_menu(), player_slot, 0)
elif action == s2.MenuAction.End:
s2.DestroyMenu(id)
def on_main_menu(id, action, player_slot, param):
if action == s2.MenuAction.Select:
info = s2.GetMenuItemInfoText(id, param)
if info == "hello":
s2.PrintToChat(player_slot, "Hello!")
elif info == "colors":
sub = s2.CreateMenu("Color Options", on_color_submenu, "button")
s2.AddMenuItem(sub, "red", "Red", s2.MenuItemStyle.Default)
s2.AddMenuItem(sub, "blue", "Blue", s2.MenuItemStyle.Default)
s2.SetMenuExitBackButton(sub, True) # показывает «Назад» вместо «Выход»
s2.DisplayMenu(sub, player_slot, 0)
elif action == s2.MenuAction.End:
s2.DestroyMenu(id)
class Sample(Plugin):
def plugin_start(self):
flags = s2.ConVarFlag.LinkedConcommand | s2.ConVarFlag.Release | s2.ConVarFlag.ClientCanExecute
def command_settings(caller, context, arguments):
if caller == -1:
return s2.ResultType.Handled
s2.DisplayMenu(create_main_menu(), caller, 0)
return s2.ResultType.Handled
s2.AddConsoleCommand("sm_settings", "Opens a settings menu", flags, command_settings, s2.HookMode.Post)
package main
import (
"s2sdk"
"github.com/untrustedmodders/go-plugify"
)
func createMainMenu() s2sdk.MenuId {
menu := s2sdk.CreateMenu("Settings", onMainMenu, "button")
s2sdk.AddMenuItem(menu, "hello", "Say Hello", s2sdk.MenuItemStyleDefault)
s2sdk.AddMenuItem(menu, "colors", "Color Options", s2sdk.MenuItemStyleDefault)
return menu
}
func onColorSubMenu(id s2sdk.MenuId, action s2sdk.MenuAction, playerSlot int32, param int32) {
switch action {
case s2sdk.MenuActionSelect:
info := s2sdk.GetMenuItemInfoText(id, param)
s2sdk.PrintToChat(playerSlot, "Color set to: "+info)
case s2sdk.MenuActionCancel:
if s2sdk.MenuCancelReason(param) == s2sdk.MenuCancelReasonExitBack {
s2sdk.DisplayMenu(createMainMenu(), playerSlot, 0)
}
case s2sdk.MenuActionEnd:
s2sdk.DestroyMenu(id)
}
}
func onMainMenu(id s2sdk.MenuId, action s2sdk.MenuAction, playerSlot int32, param int32) {
switch action {
case s2sdk.MenuActionSelect:
info := s2sdk.GetMenuItemInfoText(id, param)
if info == "hello" {
s2sdk.PrintToChat(playerSlot, "Hello!")
} else if info == "colors" {
sub := s2sdk.CreateMenu("Color Options", onColorSubMenu, "button")
s2sdk.AddMenuItem(sub, "red", "Red", s2sdk.MenuItemStyleDefault)
s2sdk.AddMenuItem(sub, "blue", "Blue", s2sdk.MenuItemStyleDefault)
s2sdk.SetMenuExitBackButton(sub, true) // показывает «Назад» вместо «Выход»
s2sdk.DisplayMenu(sub, playerSlot, 0)
}
case s2sdk.MenuActionEnd:
s2sdk.DestroyMenu(id)
}
}
func onPluginStart() error {
flags := s2sdk.ConVarFlag.LinkedConcommand | s2sdk.ConVarFlag.Release | s2sdk.ConVarFlag.ClientCanExecute
s2sdk.AddConsoleCommand("sm_settings", "Opens a settings menu", flags,
func(caller int32, context s2sdk.CommandCallingContext, arguments []string) s2sdk.ResultType {
if caller == -1 {
return s2sdk.ResultType.Handled
}
s2sdk.DisplayMenu(createMainMenu(), caller, 0)
return s2sdk.ResultType.Handled
}, s2sdk.HookMode.Post)
return nil
}
func init() {
...
}
import { Plugin } from 'plugify';
import * as s2 from ':s2sdk';
function createMainMenu() {
const menu = s2.CreateMenu("Settings", onMainMenu, "button");
s2.AddMenuItem(menu, "hello", "Say Hello", s2.MenuItemStyle.Default);
s2.AddMenuItem(menu, "colors", "Color Options", s2.MenuItemStyle.Default);
return menu;
}
function onColorSubMenu(id, action, playerSlot, param) {
if (action === s2.MenuAction.Select) {
const info = s2.GetMenuItemInfoText(id, param);
s2.PrintToChat(playerSlot, `Color set to: ${info}`);
} else if (action === s2.MenuAction.Cancel) {
if (param === s2.MenuCancelReason.ExitBack) {
s2.DisplayMenu(createMainMenu(), playerSlot, 0);
}
} else if (action === s2.MenuAction.End) {
s2.DestroyMenu(id);
}
}
function onMainMenu(id, action, playerSlot, param) {
if (action === s2.MenuAction.Select) {
const info = s2.GetMenuItemInfoText(id, param);
if (info === "hello") {
s2.PrintToChat(playerSlot, "Hello!");
} else if (info === "colors") {
const sub = s2.CreateMenu("Color Options", onColorSubMenu, "button");
s2.AddMenuItem(sub, "red", "Red", s2.MenuItemStyle.Default);
s2.AddMenuItem(sub, "blue", "Blue", s2.MenuItemStyle.Default);
s2.SetMenuExitBackButton(sub, true); // показывает «Назад» вместо «Выход»
s2.DisplayMenu(sub, playerSlot, 0);
}
} else if (action === s2.MenuAction.End) {
s2.DestroyMenu(id);
}
}
export class Sample extends Plugin {
pluginStart() {
const flags = s2.ConVarFlag.LinkedConcommand | s2.ConVarFlag.Release | s2.ConVarFlag.ClientCanExecute;
s2.AddConsoleCommand("sm_settings", "Opens a settings menu", flags,
(caller, context, arguments) => {
if (caller === -1) return s2.ResultType.Handled;
s2.DisplayMenu(createMainMenu(), caller, 0);
return s2.ResultType.Handled;
}, s2.HookMode.Post);
}
}
local plugify = require 'plugify'
local Plugin = plugify.Plugin
local s2 = require 's2sdk'
local on_main_menu -- предварительное объявление
local function create_main_menu()
local menu = s2:CreateMenu("Settings", on_main_menu, "button")
s2:AddMenuItem(menu, "hello", "Say Hello", s2.MenuItemStyle.Default)
s2:AddMenuItem(menu, "colors", "Color Options", s2.MenuItemStyle.Default)
return menu
end
local function on_color_submenu(id, action, player_slot, param)
if action == s2.MenuAction.Select then
local info = s2:GetMenuItemInfoText(id, param)
s2:PrintToChat(player_slot, "Color set to: " .. info)
elseif action == s2.MenuAction.Cancel then
if param == s2.MenuCancelReason.ExitBack then
s2:DisplayMenu(create_main_menu(), player_slot, 0)
end
elseif action == s2.MenuAction.End then
s2:DestroyMenu(id)
end
end
on_main_menu = function(id, action, player_slot, param)
if action == s2.MenuAction.Select then
local info = s2:GetMenuItemInfoText(id, param)
if info == "hello" then
s2:PrintToChat(player_slot, "Hello!")
elseif info == "colors" then
local sub = s2:CreateMenu("Color Options", on_color_submenu, "button")
s2:AddMenuItem(sub, "red", "Red", s2.MenuItemStyle.Default)
s2:AddMenuItem(sub, "blue", "Blue", s2.MenuItemStyle.Default)
s2:SetMenuExitBackButton(sub, true) -- показывает «Назад» вместо «Выход»
s2:DisplayMenu(sub, player_slot, 0)
end
elseif action == s2.MenuAction.End then
s2:DestroyMenu(id)
end
end
local Sample = {}
setmetatable(Sample, { __index = Plugin })
function Sample:plugin_start()
local flags = bit.bor(s2.ConVarFlag.LinkedConcommand, s2.ConVarFlag.Release, s2.ConVarFlag.ClientCanExecute)
local function command_settings(caller, context, arguments)
if caller == -1 then return s2.ResultType.Handled end
s2:DisplayMenu(create_main_menu(), caller, 0)
return s2.ResultType.Handled
end
s2:AddConsoleCommand("sm_settings", "Opens a settings menu", flags, command_settings, s2.HookMode.Post)
end
local M = {}
M.Sample = Sample
return M
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).
Как игроки выбирают в chat/console/centerhtml: эти три типа управляются цифрами — пункты нумеруются от 1 до 7 на страницу, а 8/9/0 зарезервированы для предыдущей страницы, следующей страницы и выхода. Фактическая команда, которую нужно выполнить игроку, — <префикс><номер> (из серверной настройки MenuCommandPrefixes, например css_1), введённая в консоли, либо menuselect <номер> — оба варианта настраиваются на стороне сервера. Чтобы выбрать пункт из чата, игроку также нужно добавить перед командой серверный чат-триггер (например, !css_1), поскольку сообщения из чата попадают в команды только если начинаются с него. Встроенные меню выводят подсказку, отражающую фактическую конфигурацию, поэтому прописывать это вручную не требуется.
button стоит особняком: он управляется клавишами WASD (перемещение курсора, выбор клавишей) и отображается как экранная HUD-панель вместо нумерованного текста — о его настройке см. следующий раздел.
Управление для типа 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": ""
Элементы управления всегда отображаются как изображения, берущиеся из подключённого воркшоп-аддона (например, workshop id 3763619947) — текстового запасного варианта нет. Если этот аддон не подключён на вашем сервере, строка управления Вверх/Вниз/Выбор/Выход и встроенная подсказка выбора будут показаны как «битые» изображения вместо текста. MenuButtonImagePath/MenuButtonImageExtension задают расположение файлов изображений (по умолчанию resource/menus/*.vsvg), а каждый параметр MenuButtonImage* (Up/Down/Left/Right/Select/Exit) — имя изображения соответствующего элемента управления; к каждому из них автоматически добавляется суффикс -p, пока его клавиша удерживается. Если такая зависимость вам не нужна, используйте тип chat, console или centerhtml (либо задайте один из них через SetDefaultMenuType).
Если ни один из встроенных стилей отображения не подходит — например, вам нужен интерфейс на основе Panorama — вы можете зарегистрировать собственный бэкенд через RegisterMenuType(name, display, close, frame). display/close вызываются для отрисовки и скрытия вашего интерфейса для клиента; frame (необязательный) выполняется каждый серверный кадр, пока у клиента открыт ваш тип, — для опроса пользовательского ввода.
Ваш колбэк display читает состояние меню и клиента через те же геттеры, что используют авторы плагинов (GetMenuTitle, GetMenuItemDisplay, GetClientMenuOffset, GetClientMenuCursor, ...), а обработка frame и ввода преобразует сырой ввод в действие, обращаясь к общей внутренней логике:
HandleDigitInput(playerSlot, digit) — общий путь 1-7/8/9/0 «выбрать пункт / предыдущая страница / следующая страница / выход», используемый типами chat/console/centerhtml.
SelectMenuItem(playerSlot, itemIndex) — напрямую выбрать конкретный абсолютный индекс пункта.
MenuNextPage(playerSlot) / MenuPrevPage(playerSlot) — сдвинуть окно отображения на одну страницу.
Всегда уничтожайте созданные меню — вызывайте DestroyMenu в ветке End вашего обработчика, в том числе для динамически создаваемых подменю.
Стройте логику выбора на info, а не на display — отображаемый текст может меняться (переводы, динамические подписи), а строки info остаются стабильными и полностью под вашим контролем.
Используйте SetMenuExitBackButton для навигации к родительскому меню, а не отдельный пункт «Назад» — это даёт отдельную причину MenuCancelReason и работает одинаково во всех типах меню.
Сначала протестируйте на типе, отличном от button, если не уверены, что воркшоп-аддон подключён — типам chat/console/centerhtml не нужны внешние ресурсы, и это самый быстрый способ проверить работу логики меню, прежде чем заниматься оформлением.
Не рассчитывайте на фиксированный размер страницы — учитывайте то, что возвращает GetMenuPagination, вместо жёстко заданного числа пунктов на страницу, поскольку владельцы серверов могут менять MenuButtonMaxItems, а вы — вызывать SetMenuPagination.