Repository navigation
LuaUI Design
A window's appearance comes from two sources, and they mix freely:
- drawn - rectangles and text painted by your own code. No assets, instant to change.
- skinned - regions cut from a bitmap atlas. Matches the game's own artwork; the formats are below.
Draw while the layout is still moving; skin it once the layout settles.
win:background(fn) runs every frame, behind the controls. It receives the window
wrapper and its raw handle; draw in coordinates relative to the window's top-left.
win:background(function(s, h)
h:renderColor(0, 0, s.w, s.h, RGBA(0, 0, 0, 190)) -- fill
h:renderColor(0, 0, s.w, 1, RGBA(198, 157, 0, 140)) -- top rule
h:renderColor(0, s.h - 1, s.w, 1, RGBA(198, 157, 0, 140)) -- bottom rule
end)The three draw calls, all on the handle:
renderColor(x, y, w, h, color)
renderText(text, x, y, w, h [, align [, color [, fontSize]]])
renderImage(slot, x, y, w, h [, sx, sy, sw, sh] [, color [, mode]])renderImage without the source rectangle draws the whole bitmap; with it, only
that region. The source rectangle is all four values or none, so color is read
correctly whether or not you passed one.
color tints the image: it multiplies the texture, so white leaves it exactly as
it is and the alpha byte fades it. mode picks how it is drawn, see
Animating a drawing.
| Argument | Values |
|---|---|
align |
ALIGN_LEFT (1), ALIGN_RIGHT (2), ALIGN_CENTER (4) |
fontSize |
screen pixels, or a preset: FontS FontM FontL FontLB; omitted or 0 uses the base size, see Controls
|
color |
RGBA(r, g, b, a), each component 0-255 |
mode |
Enums.Image.NORMAL (0), Enums.Image.ADD (1), Enums.Image.SMOOTH (2), added together to combine |
Panels expose the same three methods, relative to the panel.
UI.RenderColor / UI.RenderText / UI.RenderImage do the same in screen
coordinates. They only work inside a render callback and are ignored elsewhere -
reach for them when you need to draw outside any window's box.
UI.GetTextWidth(text [, fontSize]) measures a string, for centring something by
hand or sizing a box to its label. Pass the size you are going to draw it at, or
the measurement is taken at the base size and lands somewhere else.
The answer is in canvas units, the same as every x, y, w and h, so it can
be compared with a box width without converting anything.
Text wider than its box is not truncated - it slides. The renderer treats the
overflow as a scrolling label and shows a moving window onto the string, which on a
static readout reads as a glitch rather than as motion. Two ways out: give the box
room, or shorten what you put in it. On a narrow HUD strip the second is usually
right - 100 / 100k says as much as 100000 / 100000 and fits.
Every atlas occupies a numbered slot, 0-511. Name the slots in Registry.lua so
two scripts can share one loaded bitmap instead of loading it twice:
R.slot.frame = 0 -- Custom\Interface\UI\CommandWindowFrame.tga
R.slot.reward = 4 -- Custom\Interface\UI\Rewards.tgaUI.LoadImage(R.slot.reward, "Custom\\Interface\\UI\\Rewards.tga")Paths are relative to the client's data root and use escaped backslashes. .. is
rejected.
Loading goes through the client's own bitmap loader, so it takes the same three formats the rest of the game does. You always write the logical name, even though what ships is the encrypted counterpart:
| You write | What ships | Alpha | Good for |
|---|---|---|---|
.tga |
.ozt |
yes | frames, buttons, icons - anything with transparency |
.jpg |
.ozj |
no | large opaque artwork, smallest on disk |
.bmp |
.ozb |
no | opaque artwork that must stay lossless |
In practice a UI atlas is .tga: the moment a frame or a button needs a
transparent corner, the other two are out.
UI.LoadImage returns false if the bitmap did not load, which is worth checking
for an atlas you load by hand:
if UI.LoadImage(R.slot.reward, "Custom\\Interface\\UI\\Rewards.tga") == false then
-- atlas missing: fall back or skip drawing from this slot
endA source rectangle is four bare numbers, and the same rectangle usually appears in several places. Put them in one table per atlas and use the names:
local SR = {
frameTop = { 327, 0, 323, 22 },
frameMid = { 327, 80, 323, 6 },
frameBottom = { 327, 158, 323, 22 },
btnNormal = { 653, 76, 123, 33 },
btnHover = { 780, 76, 123, 33 },
btnPressed = { 780, 113, 123, 33 },
}You do not have to read those four numbers off in an image editor - the Bitmap Slicer that comes with the plugin finds the pieces and copies the finished table to the clipboard.
Now the coordinates exist once. Re-exporting the atlas with everything shifted is one edit rather than a hunt through the script:
local function skinButton(btn)
btn:setImage(ui.STATE.NORMAL, R.slot.frame, unpack(SR.btnNormal))
btn:setImage(ui.STATE.HOVER, R.slot.frame, unpack(SR.btnHover))
btn:setImage(ui.STATE.PRESSED, R.slot.frame, unpack(SR.btnPressed))
end
skinButton(convertBtn)
skinButton(rulesBtn)Three kinds of reuse are worth knowing about:
-
One cut, many controls. Nothing is consumed by being drawn - the same
SR.btnNormalskins every button in the window. -
One cut, many sizes. A region is stretched to whatever size the control was
created with, so a single 123x33 button graphic serves a 120-wide button and a
200-wide one. Corners distort as it stretches, which is what
ui.nineSliceand the three-slice frame exist to avoid. - One atlas, many consumers. A slot is loaded once and shared: the window frame, its buttons and its scrollbar can all come from the same file, in the same slot, and no second load happens.
unpack is the Lua 5.1 spelling and the client runs LuaJIT, so it is a global
there. On the GameServer the same call is table.unpack.
A window frame is a vertical three-slice: a top cap, a middle band repeated down
the window, and a bottom cap. Give the source rectangle of each piece as
{ sx, sy, sw, sh }, in bitmap pixels - unlike window and control sizes, which
are canvas units:
local win = ui.Window{
id = R.win.exchange,
center = true,
w = 280, height = 346,
title = "Coin Exchange",
titleY = 44, -- inside the plaque's bar, not above it
drag = { 0, 0, 228, 73 }, -- stops short of the close button
skin = {
slot = R.slot.frame,
path = "Custom\\Interface\\UI\\CommandWindowFrame.tga",
top = { 653, 0, 325, 73 }, -- header plaque
middle = { 327, 60, 323, 8 }, -- a slice of the body, repeated down
bottom = { 1, 348, 323, 36 }, -- the closing edge
topH = 73,
botH = 28,
},
}The plaque is drawn at its natural 73 rather than squeezed into a thin strip: its
rounded bar sits in the lower half, which is where titleY puts the title and
where the close button belongs. No header here - that draws a separator line,
and the plaque already has an edge.
The atlas is loaded on demand, so path belongs in the skin table - you do not
call UI.LoadImage yourself for a frame.
A window with neither skin nor background draws nothing. Its title and its
controls appear, floating over the terrain with no panel behind them. That is not a
bug to hunt - it is a window that was never told what it looks like.
Sharing a slot is safe; swapping one is not. Asking for a bitmap that is
already in its slot does nothing at all, so several scripts can name the same slot
in Registry.lua and draw from one atlas - which is what the slot pool is for.
What does bite is pointing an occupied slot at a different file: that really does
replace the texture, and every other script drawing from that slot gets the new
bitmap's pixels at the old one's coordinates. One file per slot, for the life of
the session.
A skin piece is authored at one width and stretched to whatever width your window is. Anything you position on top of it has to be scaled by the same ratio, or it drifts - and the further right it sits, the further off it lands:
local PLAQUE_W = 325 -- the width the header graphic was drawn at
local K = W / PLAQUE_W -- W is this window's width
local closeX = 270 * K -- 270 in the original artwork
local closeS = 20 * KOnly the stretched axis needs it. A three-slice frame stretches horizontally, and vertically only in its repeated middle - so a cap drawn at its natural height maps its y coordinates one to one.
Optional topH, midH, botH override how tall each piece is drawn; without them
each piece keeps its source height. Setting header overrides topH for you, so
the cap stretches to exactly the header strip you reserved.
For a box that is not a window - a section behind a group of controls - use the nine-slice helper, which keeps the corners crisp and stretches only the edges:
ui.nineSlice(h, R.slot.frame, dx, dy, dw, dh, sx, sy, sw, sh, 8)dx, dy, dw, dh is where it lands, sx, sy, sw, sh the region it comes from, and
the last argument the corner size in pixels.
The three-slice frame is available on its own as ui.renderFrame(h, w, height, skin), taking the same skin table. frameSkin calls it for you; call it directly
to give a panel the same frame the window has:
panel:handle().render = function(h)
ui.renderFrame(h, 252, 150, mySkin)
endA button takes one cut per state, named in ui.STATE:
btn:setImage(ui.STATE.NORMAL, R.slot.frame, 653, 76, 123, 33)
btn:setImage(ui.STATE.HOVER, R.slot.frame, 780, 76, 123, 33)
btn:setImage(ui.STATE.PRESSED, R.slot.frame, 780, 113, 123, 33)Those three are the states a button actually enters; anything left unset falls back
to NORMAL. See Controls.
A bitmap becomes a usable texture on the first frame the window draws, not when the script runs. Skinning a control at script time caches an image with no texture behind it and you get white boxes.
Wait for the atlas instead:
local skinned = false
Event.on("update", function()
if win:textureReady() and not skinned then
skinButton(convertBtn)
skinned = true
end
end)win:textureReady() reports the window's own skin atlas. Anything sharing that
slot - buttons, a panel's scrollbar - is ready at the same moment. Until then those
controls fall back to their plain colours, which is why a freshly opened window can
flash flat for one frame.
Render callbacks run every frame, so anything you work out inside one can change from frame to frame at no extra cost. What was missing was a clock, and the tint to apply it to.
UI.GetTickCount() returns milliseconds from a monotonic clock. Only the
difference between two readings means anything; the absolute number is however
long the machine has been running. Drive effects off it rather than counting
frames, or the same animation runs at a different speed on every machine.
win:background(function(s, h)
-- one full breath every 1.2 seconds
local t = UI.GetTickCount() % 1200 / 1200
local a = math.floor(90 + 90 * math.sin(t * math.pi * 2))
h:renderImage(R.slot.icons, 8, 8, 32, 32, 0, 96, 32, 32,
RGBA(255, 255, 255, a), Enums.Image.ADD)
end)Enums.Image.NORMAL is the default and is what every draw did before these were added:
straight alpha blending onto what is behind it.
Enums.Image.ADD adds the image to what is already on screen instead of covering it, so
it reads as light rather than as a sticker. This is the difference between a glow
that looks lit and one that looks like a semi-transparent decal, and it is worth
reaching for on anything meant to shine: notification halos, highlights, sparks.
Black is invisible in this mode, so a glow slice wants a black background rather
than a cut-out one.
Enums.Image.SMOOTH draws with linear filtering instead of nearest. Images are normally
drawn at their own size, where nearest is sharper and correct. The moment you
animate the size, nearest shows the pixels stepping; ask for Enums.Image.SMOOTH there.
The filter belongs to the texture rather than to the draw, so the mode is applied
on each call and the same atlas can be drawn sharp in one place and smooth in
another.
Combine them by adding: Enums.Image.ADD + Enums.Image.SMOOTH.
The three values are grouped in Enums.lua, so local Enums = require("Enums")
is all a script needs. They also exist as the bare globals IMG_NORMAL, IMG_ADD
and IMG_SMOOTH, which keep working; the grouped names are the ones to reach for.
Both spellings are the same numbers.
A tint, a clock and a per-frame callback are enough for the usual interface
effects, all of them written in Lua with nothing added on the client side:
pulsing glows, fades in and out, flashing notification icons, button highlights,
attention indicators, animated overlays, and scale or pulse animation by feeding
the same value into w and h.
Scrolling a texture needs no new call at all: animate sx or sy and the source
rectangle walks across the atlas.
Two things to keep in mind. Build the colour with RGBA on the spot rather than
keeping a table of animation state around, since this runs every frame and the
garbage adds up. And an effect that never rests is an effect players stop seeing:
pulse what has just changed, not everything at once.
For decoration that is not a button, add an image control:
local icon = win:handle():addImage(win:nextId(), 12, 40, 32, 32)
icon:setImage(R.slot.reward, 0, 0, 32, 32)Images support x, y, width, height, visible - enough to move or hide
them later. They do not react to the mouse; use a button for that.
The running example's ticker: a rate, and how much of today's allowance is left. No assets, no controls, one callback:
-- The exchange window script creates this table too. Whichever of the two loads
-- first wins, and both start it the same way - scripts load in alphabetical
-- order, which is not something to depend on.
Exchange = Exchange or { rate = 0, usedToday = 0, dailyLimit = 0, history = {} }
-- bottomright rather than right: a vertically centred anchor ignores margin.y,
-- so "right" cannot be moved out of another panel's way
local hud = ui.Window{
id = R.win.exchangehud,
anchor = "bottomright", margin = { 6, 150 },
mode = "hud", clickThrough = true,
w = 190, height = 64,
}
if hud then
hud:background(function(s, h)
h:renderColor(0, 0, s.w, s.h, RGBA(0, 0, 0, 110))
h:renderColor(0, 0, s.w, 18, RGBA(30, 24, 12, 200))
h:renderText("Coin Exchange", 0, 2, s.w, 14, ALIGN_CENTER,
RGBA(198, 157, 0, 255), FontM)
h:renderText(("%d WCoin = 1 Ruud"):format(Exchange.rate),
0, 20, s.w, 12, ALIGN_CENTER, RGBA(220, 220, 220, 255), FontS)
-- today's allowance, as a rail rather than a number. The guard is not
-- optional: until the first ticker packet arrives the limit is 0, and
-- dividing by it feeds the bar a width of inf.
local used = 0
if Exchange.dailyLimit > 0 then
used = math.min(Exchange.usedToday / Exchange.dailyLimit, 1)
end
h:renderColor(10, 44, s.w - 20, 6, RGBA(0, 0, 0, 150))
h:renderColor(10, 44, (s.w - 20) * used, 6, RGBA(198, 157, 0, 230))
end)
endExchange is where this system keeps its numbers. The background redraws every
frame, so assigning to it is enough to move the bar - there is nothing to
invalidate or refresh.
Two things this example is careful about, and both bite in any HUD fed from the
network. A background callback runs from the first frame, long before any
packet has arrived, so every value it reads needs a sane starting point and any
division needs a guard. And a table shared between two scripts must not assume
which one ran first - X = X or {...} in both is the whole fix.
Where the numbers come from is on the protocol page: the server pushes the rate on the general channel, so the ticker works whether or not the window is open.
Replacing the two rail renderColor calls with renderImage later turns it into a
skinned bar without touching the layout.
- Scrolling and scrollbars - skinning the bar itself
- Controls
MuLua Scripting Plugin | Home
🖼️ Lua UI
- File map
- Creating windows
- Client and server
- Designing a window
- Protocol
- Scrolling and scrollbars
- Controls
- Custom text
- Text input
- Debug console
- Bitmap Slicer
- Encrypting scripts
Maps
Monsters
Shared
Version 1.14 | 2026-09-09