The HUD
Lesson 9.2
The HUD is everything drawn over the world while you play: hearts, the hotbar, the crosshair. Mods can add their own HUD layers: a counter in the corner, a compass bar, a cooldown icon.
A first layer
class HelloHudMod final : public clight::Mod {
public:
void on_client_setup() override {
clight::hud::add_layer("my_mod:hello", [](clight::Gui g, float) {
g.text("Hello, HUD!", 4, 4, 0xFFFFFFFF, true);
});
}
};
clight::hud::add_layer(id, lambda)adds a layer. The id is in your namespace.- The lambda is called every frame with a drawing context,
g, and the partial tick (a number from 0 to 1 between two ticks, for smooth movement). The context is only valid inside that call. g.text(text, x, y, colour, shadow)draws text.xandyare in GUI pixels from the top-left corner, andshadow = truegives the vanilla text shadow.
GUI pixels follow the player’s GUI scale setting, so your layer has the same size as the vanilla HUD at every scale. g.width() and g.height() give the screen size in those pixels.
Layers are hidden with F1 like the rest of the HUD.
Colours
Drawing colours are 0xAARRGGBB: alpha (opacity), then red, green and blue. 0xFFFFFFFF is opaque white, 0xFFE0115F opaque ruby red, and 0x80000000 black at half opacity. If you leave out the alpha, as in 0xFFFFFF, the colour is fully opaque, like vanilla’s text colours. So a colour can’t be completely invisible; to hide something, don’t draw it.
Drawing
| Method | Draws |
|---|---|
g.text(s, x, y, argb, shadow) |
Text; returns the x where it ended. |
g.text_centered(s, cx, y, argb, shadow) |
Text centred on cx. |
g.text_component(text, x, y, argb, shadow) |
A clight::Text with its styles and translation. |
g.fill(x0, y0, x1, y1, argb) |
A filled rectangle from corner to corner. |
g.fill_gradient(x0, y0, x1, y1, top, bottom) |
A rectangle fading from the top colour to the bottom one. |
g.outline(x, y, w, h, argb) |
A 1-pixel frame. |
g.item_by_name(id, x, y) |
An item icon, 16 x 16. |
g.item(stack, x, y, decorations) |
A stack’s icon, with count and durability bar if decorations is true. |
g.sprite(id, x, y, w, h) |
A GUI sprite, like "minecraft:hud/heart/full". |
g.blit(texture, x, y, w, h, u0, v0, u1, v1, argb) |
Part of any texture, tinted. |
g.tooltip(s, x, y) |
A vanilla tooltip box (\n for more lines). |
clight::gui::text_width(s) gives a text’s width in pixels, and clight::gui::line_height() the height of a line. Together they make layouts that fit:
clight::hud::add_layer("my_mod:label", [](clight::Gui g, float) {
std::string_view label = "Rubies: 12";
int32_t w = clight::gui::text_width(label);
int32_t x = g.width() - w - 6; // right edge, 6 pixels in
g.fill(x - 3, 3, x + w + 3, 15, 0x80000000);
g.text(label, x, 5, 0xFFE0115F, true);
});
Your own sprites go into assets/my_mod/textures/gui/sprites/. assets/my_mod/textures/gui/sprites/ruby_icon.png is drawn with g.sprite("my_mod:ruby_icon", x, y, 16, 16).
Scaling and moving
push_pose saves the current position and size, translate and scale change them, and pop_pose goes back:
g.push_pose();
g.scale(2.0f, 2.0f);
g.text("BIG", 10, 10, 0xFFFFFF55, true); // drawn at 20, 20 and twice the size
g.pop_pose();
Every push needs a pop, or everything drawn after it is moved as well. push_scissor and pop_scissor work the same way and clip drawing to a rectangle.
Keep render cheap
The layer’s lambda runs every frame: 60, 144 or more times a second. Don’t search the inventory or the world in it, and don’t build strings you could build once. Work out values in ev::TickClientPost (20 times a second) and only draw them in the layer.
When a layer has nothing to show, it is even better not to be called at all. Give add_layer a visible function in its options:
class RubyHudMod final : public clight::Mod {
public:
void on_client_setup() override {
clight::on<clight::ev::TickClientPost>([this](cl_ev_tick&) {
clight::Entity me = clight::entity::client_player();
int32_t n = me ? me.count_item("my_mod:ruby") : 0;
if (n != rubies_) {
rubies_ = n;
label_ = "x" + std::to_string(n);
}
}).detach();
clight::hud::add_layer("my_mod:rubies", [this](clight::Gui g, float) {
g.item_by_name("my_mod:ruby", 4, 4);
g.text(label_, 22, 8, 0xFFFFFFFF, true);
}, {.visible = [this] { return rubies_ > 0; }});
}
private:
int32_t rubies_ = 0;
std::string label_;
};
Counting a whole inventory is cheap 20 times a second, but not 300 times. visible is asked before drawing. While it returns false, the drawing lambda isn’t called at all, which costs nothing. The label text is only rebuilt when the count changes.
Both lambdas capture this, the mod object, which lives as long as the mod.
Where the layer goes
Without more options, a layer is drawn on top of the vanilla HUD. The last two arguments of add_layer (or .above and .below in the options, next to .visible) put it between vanilla layers:
clight::hud::add_layer("my_mod:above_hotbar", [](clight::Gui g, float) { g.text("hi", 4, 4, 0xFFFFFFFF, true); }, "minecraft:hotbar");
That one is drawn above the hotbar. Some vanilla layer ids: minecraft:hotbar, minecraft:food, minecraft:chat. To sit just above the hearts and the food bar like vanilla’s armour and air bubbles, ask how high they reach: clight::hud::left_height() (hearts side) and clight::hud::right_height() (food side) are the heights above the bottom of the screen.
add_layer gives you a clight::HudLayer in its Expected; remove() on it removes the layer again and frees its lambdas:
if (auto layer = clight::hud::add_layer("my_mod:hint", [](clight::Gui g, float) { g.text("Press G", 4, 4, 0xFFFFFFFF, true); })) {
hint_ = *layer; // a clight::HudLayer member; later: hint_.remove();
}
Try it
Draw a small clock in the top-right corner: a dark box with the time of day as HH:MM, where 0 ticks is 06:00 and the day has 24000 ticks. Compute the text in the client tick.
Show a solution
#include <clight.hpp>
class ClockMod final : public clight::Mod {
public:
void on_client_setup() override {
clight::on<clight::ev::TickClientPost>([this](cl_ev_tick&) {
clight::Level world = clight::level::client_level();
if (!world) {
clock_.clear();
return;
}
int64_t t = world.day_time() % 24000;
int minutes = static_cast<int>((t * 60 / 1000 + 6 * 60) % (24 * 60));
clock_ = std::format("{:02}:{:02}", minutes / 60, minutes % 60);
}).detach();
clight::hud::add_layer("my_mod:clock", [this](clight::Gui g, float) {
if (clock_.empty()) return;
int32_t w = clight::gui::text_width(clock_);
int32_t x = g.width() - w - 6;
g.fill(x - 3, 3, x + w + 3, 15, 0x90000000);
g.text(clock_, x, 5, 0xFFFFFFFF, true);
});
}
private:
std::string clock_;
};
CLIGHT_MOD(ClockMod)
1000 ticks are one in-game hour, so t * 60 / 1000 is the minutes since 06:00. Multiplying before dividing keeps it in whole numbers without losing precision. {:02} pads a number to two digits with a leading zero. add_layer takes a lambda with captures, so the layer can read clock_ directly; it returns early when there is nothing to show.
Recap: clight::hud::add_layer(id, lambda) draws every frame with g.text, g.fill and friends in 0xAARRGGBB colours; compute values in the client tick, keep drawing cheap, and give the layer a .visible function when it is often empty.