Menus
Menus with numbered options, paging, per-player instances and cleanup on unload — in the center panel (CenterHtmlMenu) or on the Panorama HUD (HudMenu).
ISource2ModMenus shows a menu to a player and turns their !1–!9 presses into
option callbacks. You build the menu, Source2Mod keeps one active instance per
player and handles paging and the close button. There are two kinds of screen:
a CenterHtmlMenu, drawn in the center panel every frame, and a HudMenu,
drawn by Panorama with clickable rows — same IBaseMenu, everything on this
page applies to both; the HUD one needs an addon on the client, see
HUD menus.
#include "source2mod/ISource2ModMenus.h"#include <source2mod/api>Building and opening a menu
A menu is a CenterHtmlMenu (an IBaseMenu) with a title and a list of
ChatMenuOptions. AddMenuOption() takes the display text, the callback and
whether the option starts disabled.
static CenterHtmlMenu g_TeamMenu("Pick a team");
void BuildTeamMenu()
{
g_TeamMenu.ClearOptions();
g_TeamMenu.AddMenuOption("Terrorists", [](CCSPlayerController* player, ChatMenuOption& option)
{
player->PrintToChat("You picked T.");
});
g_TeamMenu.AddMenuOption("Counter-Terrorists", [](CCSPlayerController* player, ChatMenuOption& option)
{
player->PrintToChat("You picked CT.");
});
// Shown in the disabled colour, cannot be selected.
g_TeamMenu.AddMenuOption("Spectators (closed)", nullptr, true);
}
REGISTER_CON_COMMAND("team_menu", [](const Source2ModCommandContext& context, const Source2ModCommandArgs& args, bool post)
{
CCSPlayerController* player = CCSPlayerController::FromSlot(context.GetPlayerSlot());
if (!player)
return;
OPEN_CENTER_HTML_MENU(player, &g_TeamMenu);
});CenterHtmlMenu g_TeamMenu;
void BuildTeamMenu()
{
if (g_TeamMenu == null)
g_TeamMenu = new CenterHtmlMenu("Pick a team");
g_TeamMenu.ClearOptions();
g_TeamMenu.AddMenuOption("Terrorists", OnPickT);
g_TeamMenu.AddMenuOption("Counter-Terrorists", OnPickCT);
// Shown in the disabled colour, cannot be selected.
g_TeamMenu.AddMenuOption("Spectators (closed)", OnPickT, 0, true);
}
public void OnPickT(CCSPlayerController player, CenterHtmlMenu menu, int option, any data)
{
player.PrintToChat("You picked T.");
}
public void OnPickCT(CCSPlayerController player, CenterHtmlMenu menu, int option, any data)
{
player.PrintToChat("You picked CT.");
}
public void OnPluginStart()
{
BuildTeamMenu();
Source2ModCommands.RegisterConCommand("team_menu", Cmd_TeamMenu);
}
public void Cmd_TeamMenu(Source2ModCommandContext context, Source2ModCommandArgs args, bool post)
{
CCSPlayerController player = CCSPlayerController.FromSlot(context.GetPlayerSlot());
if (player == NULL_ENTITY)
return;
Source2ModMenus.OpenCenterHtmlMenu(player, g_TeamMenu);
}OPEN_CENTER_HTML_MENU(player, menu) expands to
g_pSource2ModMenus->OpenCenterHtmlMenu(g_PluginID, player, menu); a script calls
Source2ModMenus.OpenCenterHtmlMenu(player, menu). Opening a menu
closes whatever the player had open before. A script's option handlers are
named functions; they get the player, the menu, the option's index and the
data passed to AddMenuOption().
How players choose
Options are numbered from !1 on each page. A page holds five options (four
once both the previous and next buttons are shown, one more without the exit
button); !7 goes to the previous page, !8 to the next, and !9 closes the menu when
the exit button is on (SetExitButton(true), the default for
CenterHtmlMenu). Source2Mod registers the 1–9 commands itself, so the
keys work in chat (!3) and from the console (3, bindable to a key).
On the HUD
A HudMenu is opened with g_pCustomhudManager->OpenMenu(g_PluginID, player, menu)
instead — the CustomhudManager_s2t plugin's interface,
fetched in OnAllSource2ModPluginsLoaded(). Its rows are clickable,
1–6 pick the options of the page (7, 8, 9 as above), and the
navigation texts are fields of the menu so they can be in the player's
language:
static HudMenu g_TeamMenu("Pick a team");
g_TeamMenu.PrevText = "Zpět";
g_TeamMenu.NextText = "Další";
g_TeamMenu.CloseText = "Zavřít";
g_pCustomhudManager->OpenMenu(g_PluginID, player, &g_TeamMenu);CaptureInput (cursor and clicks, or keys with free movement),
CaptureWhenDead (the cursor for a dead player, whose binds do nothing) and
Position (left, center, right) are fields of the menu too — see
HUD menus. It is not redrawn every frame, only
when what would be drawn changed. A player without the layout sees nothing, so
a public server ships the addon first.
HudMenu is C++ only; a script uses CenterHtmlMenu.
A menu of your own
The core's menu system takes instances other libraries build
(ISource2ModMenus::OpenMenu, ISource2ModMenus005): derive from IMenuInstance,
draw in Display(), react in OnKeyPress(), and the core does the rest —
it holds one instance per player, calls OnFrame() every frame (redraw, or
refresh only when something changed), feeds it the 1–9 keys unless
AcceptsKeys() says no, and frees it through Destroy() when it is closed,
replaced, the player leaves or your plugin unloads. That is how the s2t_hud
plugin serves HudMenu; a plugin with a screen of its own does the same (C++
only — a script cannot implement IMenuInstance):
class MyScreenMenu final : public IMenuInstance
{
public:
MyScreenMenu(CCSPlayerController* player, IBaseMenu* menu) : IMenuInstance(player, menu) {}
void Display() override { /* draw the current page somewhere */ }
void OnKeyPress(CCSPlayerController* player, int key) override { /* pick, page, close */ }
void Close() override { CLOSE_ACTIVE_MENU(player_); }
};
g_pSource2ModMenus->OpenMenu(g_PluginID, player, new MyScreenMenu(player, &g_Menu));Appearance
CenterHtmlMenu has public colour fields used when the HTML is drawn:
TitleColor, EnabledColor, DisabledColor, PrevPageColor,
NextPageColor, CloseColor. A script sets them with SetColor() and
MenuColor_Title, MenuColor_Enabled, MenuColor_Disabled,
MenuColor_PrevPage, MenuColor_NextPage, MenuColor_Close.
CenterHtmlMenu menu("Admin");
menu.TitleColor = "red";
menu.DisabledColor = "#555555";CenterHtmlMenu menu = new CenterHtmlMenu("Admin");
menu.SetColor(MenuColor_Title, "red");
menu.SetColor(MenuColor_Disabled, "#555555");What happens after a selection
SetPostSelectAction() decides what the menu does once an option's callback
returned:
PostSelectAction | Effect |
|---|---|
Reset (default) | Stays open, jumps back to the first page |
Close | Closes the menu |
Nothing | Stays open on the current page |
In a script it is the PostSelect property (PostSelect_Reset,
PostSelect_Close, PostSelect_Nothing); a single option can override it with
the MenuOption_Close or MenuOption_Stay flag (SetOptionFlags()).
If the callback opened another menu or closed this one, the post-select action is skipped — the instance is no longer the active one.
Options that change state
ChatMenuOption::Disabled can be flipped at any time, and DisabledEvaluator
(a std::function<bool()>) is evaluated every time the menu is drawn, so an
option can grey itself out while the menu is on screen. A script uses
SetOptionDisabled(option, disabled) and
SetOptionDisabledEvaluator(option, callback), the callback asked for each
player the menu is shown to.
ChatMenuOption& buy = menu.AddMenuOption("Buy AWP", OnBuyAwp);
buy.DisabledEvaluator = [] { return !g_bBuyTimeOpen; };int buy = menu.AddMenuOption("Buy AWP", OnBuyAwp);
menu.SetOptionDisabledEvaluator(buy, IsBuyTimeOver);
public bool IsBuyTimeOver(CCSPlayerController player, CenterHtmlMenu menu, int option, any data)
{
return !g_bBuyTimeOpen;
}Global cooldown
AddMenuOptionWithCooldown(text, action, disabled = false, close = true, disabledEvaluator = nullptr)
wraps the callback in two global hooks you install once with
IBaseMenu::SetCooldownHandlers(canSelect, onSelect): canSelect(player) is
asked before the action runs (return false to swallow the press), onSelect
runs after it. With close = true the menu is closed after the action and the
option's text is shown in its place. This is how FUNPLAY rate-limits every menu
at once:
IBaseMenu::SetCooldownHandlers(
[](CCSPlayerController* player) { return !IsOnMenuCooldown(player); },
[](CCSPlayerController* player) { StartMenuCooldown(player); });The handlers are static members of IBaseMenu — global to your plugin's
binary, not per menu. SetCooldownHandlers() is C++ only: a script's
AddMenuOptionWithCooldown() uses the default handlers (with close, the menu
closes and the option's text is shown centered for 5 seconds).
The active menu
| Call | Macro | What it does |
|---|---|---|
GetActiveMenu(player) | GET_ACTIVE_MENU(player) | The player's open IMenuInstance*, or nullptr |
CloseActiveMenu(player) | CLOSE_ACTIVE_MENU(player) | Closes it |
OnKeyPress(player, key) | MENU_ON_KEY_PRESS(player, key) | Feeds a key (1–9) to the open menu, as if the player had typed it |
IMenuInstance::Menu() returns the IMenu* behind the instance, so you can
tell whether the open menu is yours. A script calls
Source2ModMenus.GetActiveMenu(player), which returns only the script's own
menus (null otherwise), CloseActiveMenu(player) and OnKeyPress(player, key);
HasActiveMenu(player) tells whether any menu is open:
IMenuInstance* active = GET_ACTIVE_MENU(player);
if (active && active->Menu() == &g_TeamMenu)
CLOSE_ACTIVE_MENU(player);CenterHtmlMenu active = Source2ModMenus.GetActiveMenu(player);
if (active != null && active == g_TeamMenu)
Source2ModMenus.CloseActiveMenu(player);Close menus whose options no longer make sense — FUNPLAY closes the menu of a player who died or disconnected:
HOOK_GAME_EVENT("player_death", [](IGameEvent* event, bool post, bool& dontBroadcast) -> Action
{
auto* victim = static_cast<CCSPlayerController*>(event->GetPlayerController("userid"));
if (victim)
CLOSE_ACTIVE_MENU(victim);
return Action::Ignore;
}, true);public void OnPluginStart()
{
Source2ModEvents.HookGameEvent("player_death", OnPlayerDeath, true);
}
public Source2ModAction OnPlayerDeath(IGameEvent event, bool post, bool &dontBroadcast)
{
CCSPlayerController victim = event.GetPlayerController("userid");
if (victim != NULL_ENTITY)
Source2ModMenus.CloseActiveMenu(victim);
return Action_Ignore;
}Ownership and lifetime
- You own the menu object. Source2Mod stores a raw
CenterHtmlMenu*/HudMenu*in the player's instance and never deletes it. It has to stay alive for as long as it is open for anyone: a static or member menu, or a per-playerstd::unique_ptryou replace only after the new menu is open. A script'sCenterHtmlMenuhandle is its own until it is deleted, and deleting it closes it for everyone who has it open. - Do not free or rebuild a menu from inside one of its own option
callbacks. The callback being executed is a
std::functionstored in that menu's option list. Opening a different, still-alive menu from a callback is fine; freeing the current one or callingClearOptions()on it is not. Defer that withNEXT_FRAME(Source2ModScheduler.NextFramein a script), and carry a handle rather than the raw player pointer (a script's player is a handle already):
menu.AddMenuOption("Refresh", [](CCSPlayerController* player, ChatMenuOption&)
{
CHandle<CCSPlayerController> hPlayer = player->GetHandle();
NEXT_FRAME([hPlayer]()
{
if (CCSPlayerController* p = hPlayer.Get())
OpenFreshMenu(p); // rebuilds / replaces the old menu safely
});
});menu.AddMenuOption("Refresh", OnRefresh);
public void OnRefresh(CCSPlayerController player, CenterHtmlMenu menu, int option, any data)
{
// The player is an entity handle: next frame, IsValid() says whether
// they are still there.
Source2ModScheduler.NextFrame(RefreshNextFrame, player);
}
public void RefreshNextFrame(any data)
{
CCSPlayerController player = view_as<CCSPlayerController>(data);
if (player.IsValid())
OpenFreshMenu(player); // rebuilds / replaces the old menu safely
}- One instance per player slot. Opening a menu for a player replaces whatever was open, including another plugin's menu.
- Unload. Every menu your plugin still has open is closed for you when it
unloads — the option handlers live in your library, and the next key press
would jump into unmapped code. Nothing to do in
Unload(). - Main thread only. Open and close menus from game callbacks, commands,
timers or
NEXT_FRAME, never from a worker thread.
Reference
ISource2ModMenusAPI reference- Scheduler —
NEXT_FRAMEfor deferring menu work
Translations
Phrases per plugin in each player's language — phrase files, phrases pushed at runtime from JSON or code, formatting them for a player, and how a player's language is chosen.
Scheduler
Run code on the next frame, after a delay or on a repeat — on the main thread, and cleaned up with your plugin.