Skip to content

LuaUI Design

wezzzyrek1 edited this page Sep 2, 2026 · 3 revisions

Lua UI - Designing a Window

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.

Drawing

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.

Loading a bitmap

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.tga
UI.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
end

Naming the cuts

A 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.btnNormal skins 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.nineSlice and 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.

Skinning the frame

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.

Placing things on a stretched frame

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 * K

Only 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)
end

Skinning buttons

A 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.

The timing rule

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.

Animating a drawing

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)

The three modes

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.

What this covers

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.

Static images

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.

A drawn HUD panel

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)
end

Exchange 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.

See Also

Clone this wiki locally