Screens and menus
Lesson 9.3
A screen is a full window over the game, like the options menu: the mouse is free and players click buttons. Mods can build their own screens in the client, or open chest-style menus from the server.
Opening a screen
class HelloScreenMod final : public clight::Mod {
public:
void open_hello() {
clight::screen::open({
.title = "Hello",
.pause = false,
.init = [](clight::Screen s) {
int32_t cx = s.width() / 2;
s.widget_label(cx, 40, "This is my first screen.", 0xFFFFFFFF, true);
s.button(cx - 100, s.height() - 40, 200, 20, "Done", [s] { s.close(); });
},
});
}
};
Open it from client code, for example on a keybind (see Keys and client events):
clight::keybind::add("my_mod:hello", "key.keyboard.j", [this] { open_hello(); });
clight::screen::open takes a clight::ScreenSpec. You write it with designated initializers, .name = value, in the order of this table, and leave out what you don’t need. The functions are lambdas and may capture what they need ([this], values):
| Field | Default | Means |
|---|---|---|
.title |
A translation key, or plain text. Read out by the narrator. | |
.pause |
true |
Pause a singleplayer game while open. |
.background |
true |
Darken the world behind it. |
.close_on_esc |
true |
Esc closes it. |
.init(s) |
Build the widgets. | |
.render(s, g, mouse_x, mouse_y, partial_tick) |
Draw extra things every frame. | |
.tick(s) |
Runs 20 times a second while open. | |
.key(s, key, scancode, modifiers) |
A key was pressed. Return true if you used it. |
|
.char_typed, .mouse, .scroll |
Typed characters, mouse clicks and the wheel. Return true if you used them. |
|
.close(s) |
The screen closed. |
init runs when the screen opens and again whenever the window is resized, after the old widgets were removed. So build all widgets in init, and place them from s.width() and s.height(); then they move to the right place on every resize.
When the screen closes, its lambdas and those of its widgets are freed. open returns an Expected<clight::Screen> if you want to close the screen from somewhere else (s.close()).
Widgets
Widgets look and behave like vanilla’s. Each is created on the screen s and comes back as an Expected<clight::Widget>, in case you want to change it later. Their callbacks are lambdas too; they get the widget, or nothing if you leave the parameter out.
class SettingsMod final : public clight::Mod {
public:
void open_settings() {
clight::screen::open({.title = "Settings", .init = [this](clight::Screen s) {
int32_t x = s.width() / 2 - 100;
s.button(x, 50, 200, 20, "Click me", [](clight::Widget w) { w.set_label("Clicked!"); });
s.checkbox(x, 80, "Show sparkles", sparkles_, [this](clight::Widget w) { sparkles_ = w.value() == 1.0; });
s.slider(x, 110, 200, 20, "Volume", 0, 100, volume_, 5, [this](clight::Widget w) {
volume_ = static_cast<int>(w.value());
});
s.text_field(x, 140, 200, 20, "Your name", 32, [this](clight::Widget w) { name_ = w.text().value_or(""); });
}});
}
private:
int volume_ = 50;
bool sparkles_ = true;
std::string name_;
};
| Widget | Its callback reads |
|---|---|
s.button(x, y, w, h, label, on_press) |
|
s.checkbox(x, y, label, checked, on_change) |
widget.value() is 1 or 0 |
s.slider(x, y, w, h, label, min, max, value, step, on_change) |
widget.value() is the number; step snaps it, 0 for smooth |
s.text_field(x, y, w, h, hint, max_length, on_change) |
widget.text(); hint shows while empty |
s.widget_label(x, y, text, argb, centered) |
(no callback) |
A slider shows its value in the label, like Volume: 50. Labels are plain text, not translation keys.
Every widget can be changed later through its clight::Widget: set_label, set_tooltip, set_active(false) (greyed out), set_visible, set_value, set_text, set_bounds and remove.
Keep state outside the screen
The widgets are rebuilt on every resize, so keep the values in members of your mod (like volume_ above) and read them when building the widgets. Then a resize never loses what the player chose.
Drawing on a screen
.render gets the same drawing context as a HUD layer (see The HUD), plus the mouse position. It is called after the background and before the widgets:
clight::screen::open({
.title = "My Mod",
.render = [](clight::Screen s, clight::Gui g, int32_t, int32_t, float) {
g.text_centered("My Mod", s.width() / 2, 20, 0xFFFFFFFF, true);
g.item_by_name("my_mod:ruby", s.width() / 2 - 8, 170);
},
});
Bigger layouts: RML documents
For screens with a lot of content, you can write the layout as a document, similar to HTML and CSS, in assets/my_mod/gui/shop.rml, and open it with clight::rml::open. Your code fills in values with rml_set_text(id, text) and gets clicks in the descriptor’s on_event:
void open_shop() {
cl_rml_desc d = CL_RML_DESC_INIT;
d.path = CL_STR("assets/my_mod/gui/shop.rml");
d.on_event = [](cl_rml_doc, cl_str element_id, cl_str event, cl_str, void*) {
clight::log::info("{} on {}", clight::sv(event), clight::sv(element_id));
};
if (auto doc = clight::rml::open(&d)) {
doc->rml_set_text("balance", "12 rubies");
}
}
The reference docs cover the format; for most mods, the widgets above are enough.
Menus from the server
A menu is a chest-style window that the server opens for a player: item slots instead of buttons. It uses vanilla’s chest menus, so it even works for players without your mod. That makes it the right tool for shops, kits and choices.
void open_shop_menu(clight::Entity player) {
auto shop = clight::menu::open(player, {
.kind = CL_MENU_CHEST_9X1,
.title = "Ruby Shop",
.on_click = [](clight::Menu, clight::Entity who, int32_t slot, int32_t, cl_click_type) {
if (slot >= 0 && slot < 9) {
who.give("my_mod:ruby");
who.send_action_bar("+1 ruby");
}
return false; // don't let vanilla move the item
},
});
if (!shop) return;
clight::ItemStack ruby = clight::item("my_mod:ruby");
for (int32_t slot = 0; slot < 9; ++slot) shop->set_item(slot, ruby);
}
Open it from server code, for example in a command (open_shop_menu(ctx.player())). clight::MenuSpec has these fields:
.kindis the size:CL_MENU_CHEST_9X1up toCL_MENU_CHEST_9X6(the default isCL_MENU_CHEST_9X3),CL_MENU_DISPENSER_3X3orCL_MENU_HOPPER..titleis plain text..locked: menus are locked by default, so nothing can be taken out or put in unlesson_clickreturnstrue..locked = falseturns that off..on_click(menu, player, slot, button, click)runs for every click. Slot numbers start at 0 for your slots, then come the player’s inventory slots;-999is a click outside the window. Returntrueto let the click happen normally,falseto cancel it..on_close(menu, player)runs when it closes. After that the menu’s lambdas are freed.
set_item copies the stack into the slot, so one stack can fill all nine. menu.get_item(slot) gives you a clight::ItemStack copy back, menu.size() the number of slots, and menu.close() closes it.
Try it
Make a client screen with a slider “Size” from 1 to 10 and a button “Roll”. Pressing the button sets its own label to a random number between 1 and the slider’s value.
Show a solution
#include <clight.hpp>
#include <random>
class RollerMod final : public clight::Mod {
public:
void open_roller() {
clight::screen::open({.title = "Roller", .pause = false, .init = [this](clight::Screen s) {
int32_t x = s.width() / 2 - 100;
s.slider(x, 60, 200, 20, "Size", 1, 10, size_, 1, [this](clight::Widget w) { size_ = static_cast<int>(w.value()); });
s.button(x, 90, 200, 20, "Roll", [this](clight::Widget w) {
int n = std::uniform_int_distribution<int>(1, size_)(rng_);
w.set_label(std::format("Rolled {}", n));
});
}});
}
private:
int size_ = 6;
std::mt19937 rng_{12345};
};
step = 1 makes the slider jump between whole numbers. size_ lives in the mod, not in the screen, so the slider keeps its position when the window is resized.
Recap: clight::screen::open({...}) builds a client screen whose widgets are created in .init (again on every resize) with s.button, s.slider and friends; clight::menu::open(player, {...}) opens a server-side chest menu that works for every player. All their callbacks are lambdas, freed when the screen or menu closes.