uilib.lua
The complete, current source of the engine every other UI Kit page documents one widget at a time — UI.Menu, UI.List, UI.Panel/UI.Bar/UI.Toast, UI.Confirm/UI.Input, and UI.Chat/UI.Board. Reproduced here in full for anyone who wants to read the whole thing start to finish, or diff it against their own copy. Version 2.2 per the file’s own UI.VERSION, with a 2.2a point-fix noted inline in the version-history comment — see UI.Menu for what that point-fix actually changed.
Deploy
Copy to scripts/OnLoad/ and give it a low [OnLoad] number so it loads before anything built on it — see UI Kit’s Deploy section for the full checklist (movies required, the guard clause every consumer script should open with).
The full script
-- =====================================================================
-- uilib.lua -- modular in-game UI kit for Mercenaries 2 modders (v2.0)
--
-- v2 REWRITE: same movies, same friendly API, brand-new engine room.
-- v1 drove input with an always-on 33Hz IsKeyDown poll + focus model that
-- misbehaved in-game. v2 is rebuilt on the exact plumbing that made
-- ForgeMenu rock-solid:
-- * input via Loader.PopKeyEvents() (edge drain) + GetKeyboardState()
-- * a self-re-arming Event.TimerRelative heartbeat with a generation
-- guard that RUNS ONLY WHILE SOMETHING IS ACTIVE and idles otherwise
-- * async-load warm-up re-paints (the movie loads a frame late, so the
-- first paint can be dropped - we re-send it for a few ticks)
-- * every engine call wrapped in pcall
-- Movies: ui_list.gfx ui_panel.gfx ui_bar.gfx ui_toast.gfx
-- ui_confirm.gfx ui_input.gfx (all already in your wad)
--
-- ============================ QUICK REFERENCE ========================
-- UI.Menu{ title, key, x, y, onClose } <- the ForgeMenu-style star
-- A declarative drill-down menu. You only declare categories + entries;
-- an entry runs a plain function. Put :toggle() at the end of your
-- OnKey file (the toggle key opens/closes it).
-- local m = UI.Menu{ title = "MY MENU", key = "F8" }
-- m:entry("Do a thing", function(ctx) ctx:hint("done") end)
-- m:category("Spawns", function(c)
-- c:entry("Tank", function(ctx) ctx:spawn("M1A2 (Full)", 8) end)
-- c:category("Enemies", function(cc) cc:entry(...) end) -- nests
-- end)
-- m:toggle()
-- Entry actions get ctx: ctx.x/y/z/yaw, ctx.char/player,
-- ctx:spawn(template[,dist]) ctx:hint(msg) ctx:print(msg) ctx:close()
-- A label may be a function returning a string (live ON/OFF toggles).
-- :entry :category :header(text) :toggle :open :close :isOpen
--
-- UI.List{ x, y, title, crumb, hint, items, empty, focus,
-- onChoose, onBack, onSelect } <- the raw list (advanced)
-- 10 visible rows, section headers the cursor skips, scrollbar, body
-- that auto-resizes to content. Swap :items() in onChoose for drill-downs.
-- items = { {header="SECTION"}, {label="Entry", any=yourdata}, ... }
-- :items(t) :selected()->item,i :select(i) :paint()
-- :title(s) :crumb(s) :hint(s)
-- onChoose(item,i,list) onSelect(item,i,list) onBack(list)
--
-- UI.Panel{ x, y, title, lines }
-- Title bar + up to 8 lines, body auto-resizes. :title(s) :line(i,s) :fit(n) :clear()
--
-- UI.Bar{ x, y, label, value }
-- Label + progress bar. :set(0..1) :label(s)
--
-- UI.Toast("text"[, { ttl = seconds }])
-- Transient notification, 3 stacked slots, oldest replaced, auto-hides.
--
-- UI.Confirm{ text, title, yes, no, onResult }
-- Modal yes/no. Grabs keys (Left/Right pick, Enter choose, Esc = no;
-- defaults to NO), restores focus, then onResult(true|false). One at a time.
--
-- UI.Input{ prompt, text, max, onSubmit, onCancel }
-- One-shot typed prompt. Enter -> onSubmit(text), Esc -> onCancel().
-- US-layout shifted symbols (edit PUNCT for other layouts). One at a time.
--
-- Every widget also has: :show() :hide() :focus() :blur() :destroy() (chainable)
-- Utilities: UI.wrap(s,width)->lines UI.comma(n) UI.fmt_time(s)
-- Focus: UI.Focus(w) UI.Focused() (exactly one widget hears keys)
--
-- DEPLOY
-- 1) uilib.lua -> scripts/OnLoad/ and register it: [OnLoad] uilib.lua=5
-- (loads once at world load; re-runs harmlessly on reload)
-- 2) the six ui_*.gfx movies injected in your wad (already done).
-- 3) your menu -> scripts/OnKey/MyMenu.lua with a toggle key in [OnKey],
-- and `local KEYVAL="f8"` in its first 10 lines. Guard with:
-- if not (UI and UI.Menu) then Loader.Printf("load uilib first"); return end
-- KEY NOTE: pick a toggle key that ISN'T an arrow / Enter / Esc / Backspace -
-- those drive the focused widget while it is open.
-- =====================================================================
import("MrxGuiBase")
import("MrxGuiManager")
_G.UI = _G.UI or {}
UI._S = UI._S or {} -- internals; survive re-runs
local S = UI._S
UI.VERSION = "2.2" -- 2.2: UI.Menu ForgeMenu parity (persistent per-id state = real toggle, no leak),
-- + :switch / ctx:confirm/ask/toast / UI.KEYS remap / UI.hero + reload-safe reset.
-- 2.2a: ctx:spawn rejects blank templates (Pg.Spawn("") is a native CTD pcall can't catch).
-- 2.1: SetLocation corner-coords fix (broken toasts/dialogs); added UI.Chat + UI.Board
UI.FILES = UI.FILES or {
list = "ui_list.gfx", panel = "ui_panel.gfx", bar = "ui_bar.gfx",
toast = "ui_toast.gfx", confirm = "ui_confirm.gfx", input = "ui_input.gfx",
chat = "chat.gfx", board = "contracts.gfx", -- the two richer movies UI.Chat / UI.Board wrap
}
-- Widget coordinates live in a fixed 640x480 virtual canvas (Scaleform scales it to any resolution),
-- so the right edge is x=640 and the bottom is y=480. Toasts default to the RIGHT side (320 wide,
-- right-aligned with an 8px margin), a little below the top corner so a top-right panel/chat has room.
UI.TOAST_X = UI.TOAST_X or (640 - 320 - 8) -- = 312 (right-aligned)
UI.TOAST_Y = UI.TOAST_Y or 150
UI.TOAST_SLOTS = UI.TOAST_SLOTS or 3
UI.TOAST_TTL = UI.TOAST_TTL or 4 -- seconds
local TICK = 0.05 -- heartbeat interval (s)
local WARMUP = 8 -- ticks of re-paint after a widget shows (defeats async movie load)
-- ============================ utilities ==============================
function UI.wrap(s, width)
s = tostring(s or ""); width = width or 46
local out = {}
while #s > width do
local cut = width
for i = width, math.max(1, width - 15), -1 do
if s:sub(i, i) == " " then cut = i; break end
end
out[#out + 1] = s:sub(1, cut)
s = s:sub(cut + 1):gsub("^%s+", "")
end
if #s > 0 then out[#out + 1] = s end
if #out == 0 then out[1] = "" end
return out
end
function UI.comma(n)
local s = tostring(math.floor(tonumber(n) or 0))
local r = s:reverse():gsub("(%d%d%d)", "%1,")
return (r:reverse():gsub("^,", ""))
end
function UI.fmt_time(sec)
sec = math.floor(tonumber(sec) or 0)
local m = math.floor(sec / 60)
local s2 = sec - m * 60
if s2 < 10 then return m .. ":0" .. s2 end
return m .. ":" .. s2
end
-- ========================= engine forward decls ======================
local ensureTick, register
-- ============================ core plumbing ==========================
local function make_widget(file, x, y, w, h)
local player = Player.GetLocalPlayer()
local wg = MrxGuiBase.FlashWidget:new()
pcall(function() wg:SetOwner(player) end)
-- SetLocation takes CORNER coords (x1, y1, x2, y2), NOT (x, y, width, height).
-- Passing w/h as the corners makes any widget whose w<x or h<y collapse into an
-- inverted rect and render as a giant smear (that was the "broken toasts" bug).
wg:SetLocation(x, y, x + w, y + h)
wg:SetSwfFile(file, nil, nil)
MrxGuiBase.AddWidget(wg)
pcall(function() wg:SetVisible(true) end)
pcall(function() MrxGuiManager.AddWidgetToHud(player, wg) end)
return wg
end
-- focus: exactly one widget hears keys. Setting it swallows any buffered keys
-- (so the toggle press doesn't leak in) and wakes the heartbeat.
local function ui_focus(o)
S.focus = o
pcall(Loader.ClearKeyEvents)
ensureTick()
end
function UI.Focus(w) ui_focus(w) end
function UI.Focused() return S.focus end
-- show/hide/focus/blur/destroy shared by every widget object.
local function attach_common(o)
function o:show()
if o.w then pcall(function() o.w:SetVisible(true) end) end
o._shown = true
o._warmup = WARMUP
ensureTick()
pcall(function() o:_repaint() end)
return self
end
function o:hide()
if o.w then pcall(function() o.w:SetVisible(false) end) end
o._shown = false
if S.focus == o then S.focus = nil end
return self
end
function o:destroy()
-- no widget-removal API in the loader reference, so hide + drop the
-- reference and let it fall out of the live list. Prefer reuse.
if S.focus == o then S.focus = nil end
if o.w then pcall(function() o.w:SetVisible(false) end) end
o.w = nil
return self
end
function o:focus() ui_focus(o); return self end
function o:blur() if S.focus == o then S.focus = nil end return self end
function o:_repaint() end -- widgets override to re-send their state
end
-- body-resize easing target (the "Forge feel"); waking the heartbeat animates it
local function set_target(o, pct)
o._tgt = pct
ensureTick()
end
-- ============================ input maps =============================
-- Navigation keys, remappable globally (Windows VK codes). e.g. UI.KEYS.up = 0x57 -- 'W'
UI.KEYS = UI.KEYS or { up = 0x26, down = 0x28, left = 0x25, right = 0x27, enter = 0x0D, esc = 0x1B }
local function navName(vk)
local k = UI.KEYS
if vk == k.up then return "up"
elseif vk == k.down then return "down"
elseif vk == k.left then return "left"
elseif vk == k.right then return "right"
elseif vk == k.enter then return "enter"
elseif vk == k.esc then return "esc" end
return nil
end
-- vk -> { n = normal char, s = shifted char } (US layout; edit for others)
local CHAR = {}
for c = 0x41, 0x5A do CHAR[c] = { n = string.char(c + 32), s = string.char(c) } end
local DIGSHIFT = { [0] = ")", "!", "@", "#", "$", "%", "^", "&", "*", "(" }
for d = 0, 9 do CHAR[0x30 + d] = { n = tostring(d), s = DIGSHIFT[d] } end
CHAR[0x20] = { n = " ", s = " " }
local PUNCT = {
{ 0xBC, ",", "<" }, { 0xBE, ".", ">" }, { 0xBF, "/", "?" }, { 0xBD, "-", "_" },
{ 0xBB, "=", "+" }, { 0xBA, ";", ":" }, { 0xDE, "'", "\"" }, { 0xDB, "[", "{" },
{ 0xDD, "]", "}" }, { 0xDC, "\\", "|" }, { 0xC0, "`", "~" },
}
for _, p in ipairs(PUNCT) do CHAR[p[1]] = { n = p[2], s = p[3] } end
-- player pose helper (for UI.Menu ctx:spawn)
local function pose()
local char = Player.GetLocalCharacter()
local player = Player.GetLocalPlayer()
if not char then return nil, nil, nil, 0, nil, player end
local ok, px, py, pz = pcall(Object.GetPosition, char)
if not ok or not px then return nil, nil, nil, 0, char, player end
local yaw = 0
local oky, yv = pcall(Object.GetYaw, char); if oky and yv then yaw = yv end
return px, py, pz, yaw, char, player
end
-- convenience shortcuts modders reach for constantly
function UI.hero() return Player.GetLocalCharacter() end -- local character guid (nil if none)
function UI.heroPos() return pose() end -- x, y, z, yaw, char, player
-- ======================= the shared heartbeat ========================
-- Services: (1) keys for the focused widget, (2) warm-up re-paints + size
-- easing for live widgets, (3) toast lifetimes, (4) input caret blink.
local function service(dt)
-- 1. keyboard: only the focused widget hears anything, edge-drained
local f = S.focus
if f and f._keyvk and f.w and f._shown ~= false then
local ev = Loader.PopKeyEvents()
if ev and ev ~= "" then
local ks = Loader.GetKeyboardState()
local shift = ks and (string.byte(ks, 0x10 + 1) or 0) >= 128
for i = 1, #ev do
if S.focus ~= f then break end -- an action changed focus (closed / opened a modal): stop feeding the old widget
f:_keyvk(string.byte(ev, i), shift)
end
end
end
-- 2. live widgets: warm-up re-paint + resize easing
if S.live then
for i = #S.live, 1, -1 do
local o = S.live[i]
if not o or not o.w then
table.remove(S.live, i)
else
if o._warmup and o._warmup > 0 then
o._warmup = o._warmup - 1
pcall(function() o:_repaint() end)
end
if o._cur and o._tgt and o._cur ~= o._tgt then
local d = o._tgt - o._cur
if d > 0.5 or d < -0.5 then o._cur = o._cur + d * 0.35 else o._cur = o._tgt end
if o._setsize then o._setsize(o._cur) end
end
end
end
end
-- 3. toasts
if S.toasts then
for i = 1, UI.TOAST_SLOTS do
local t = S.toasts[i]
if t and t.ttl then
if t.warmup and t.warmup > 0 then t.warmup = t.warmup - 1; pcall(t.repaint) end
t.ttl = t.ttl - dt
if t.ttl <= 0 then t.ttl = nil; pcall(function() t.w:SetVisible(false) end) end
end
end
end
-- 4. input caret blink
if f and f._isInput and f.w and f._shown ~= false then
f._blinkClock = (f._blinkClock or 0) + dt
if f._blinkClock >= 0.35 then f._blinkClock = 0; f._blink = not f._blink; f:_echo() end
end
end
local function needsTick()
if S.focus then return true end
if S.live then
for _, o in ipairs(S.live) do
if o.w and ((o._warmup and o._warmup > 0) or (o._cur and o._tgt and o._cur ~= o._tgt)) then
return true
end
end
end
if S.toasts then
for i = 1, UI.TOAST_SLOTS do if S.toasts[i] and S.toasts[i].ttl then return true end end
end
return false
end
-- start the heartbeat if it isn't already running; it self-stops when idle.
ensureTick = function()
if S.running then return end
S.running = true
S.tickGen = (S.tickGen or 0) + 1
local myGen = S.tickGen
S.stamp = Sys.RealTimeStamp()
local function loop()
if not S.running or S.tickGen ~= myGen then return end -- superseded / stopped
local dt = TICK
if S.stamp then
local e = Sys.TimeStampGetElapsed(S.stamp)
if e and e > 0 then dt = e end
Sys.TimeStampMark(S.stamp)
end
if dt > 0.25 then dt = 0.25 end
local ok, err = pcall(service, dt)
if not ok then Loader.Printf("[uilib] tick error: " .. tostring(err)) end
if needsTick() then
Event.Create(Event.TimerRelative, { TICK }, loop)
else
S.running = false -- idle -> stop; ensureTick restarts on demand
end
end
Event.Create(Event.TimerRelative, { TICK }, loop)
end
register = function(o)
S.live = S.live or {}
for _, e in ipairs(S.live) do if e == o then return end end
S.live[#S.live + 1] = o
end
-- =============================== UI.List =============================
function UI.List(opts)
opts = opts or {}
local o = {}
o.w = make_widget(UI.FILES.list, opts.x or 40, opts.y or 60, opts.w or 320, opts.h or 360)
o._shown = true
local function c(fn, args) if o.w then pcall(function() o.w:CallActionScriptCallback(fn, args) end) end end
o._call = c
attach_common(o); register(o)
o._cur, o._tgt = 100, 100
o._setsize = function(v) c("SetSize", { v }) end
o._items, o._sel, o._off = {}, 1, 0
o._title, o._crumb, o._hint = opts.title, opts.crumb, opts.hint
o.onChoose, o.onBack, o.onSelect = opts.onChoose, opts.onBack, opts.onSelect
local VIS, TOP, PITCH, TRH, BODY = 10, 64, 24, 232, 296
local function selectable(i) local it = o._items[i]; return it ~= nil and not it.header end
local function nearest(from, dir)
local i = from
while i >= 1 and i <= #o._items do
if selectable(i) then return i end
i = i + dir
end
return nil
end
local function hdr_text(it)
if it.header == true then return tostring(it.label or "") end
return tostring(it.header)
end
function o:paint()
local n = #o._items
if n > 0 then
if not selectable(o._sel) then o._sel = nearest(o._sel, 1) or nearest(o._sel, -1) or 1 end
local s0 = o._sel - 1
if o._off > s0 then o._off = s0 end
if s0 > o._off + VIS - 1 then o._off = s0 - VIS + 1 end
if o._off < 0 then o._off = 0 end
else
o._off = 0
end
for i = 0, VIS - 1 do
local it = o._items[o._off + i + 1]
if not it then c("SetRow", { i, "" }); c("SetHdr", { i, "" })
elseif it.header then c("SetHdr", { i, hdr_text(it) }); c("SetRow", { i, "" })
else c("SetRow", { i, tostring(it.label or "?") }); c("SetHdr", { i, "" }) end
end
if n == 0 then
c("SetHdr", { 0, tostring(opts.empty or "EMPTY") }); c("SetSelected", { -1 }); c("SetScroll", { 0, 0 })
else
if selectable(o._sel) then c("SetSelected", { (o._sel - 1) - o._off }) else c("SetSelected", { -1 }) end
if n > VIS then
local th = TRH * VIS / n; if th < 16 then th = 16 end
local ty = TOP + (TRH - th) * o._off / (n - VIS)
c("SetScroll", { math.floor(ty), math.floor(th) })
else
c("SetScroll", { 0, 0 })
end
end
local shown = n; if shown > VIS then shown = VIS end; if shown < 1 then shown = 1 end
set_target(o, 100 * (PITCH * shown + 12) / BODY)
return self
end
function o:_repaint()
if o._title then c("SetTitle", { tostring(o._title) }) end
if o._crumb then c("SetCrumb", { tostring(o._crumb) }) end
if o._hint then c("SetHint", { tostring(o._hint) }) end
o:paint()
if o._setsize then o._setsize(o._cur) end
end
function o:title(s) o._title = s; c("SetTitle", { tostring(s) }) return self end
function o:crumb(s) o._crumb = s; c("SetCrumb", { tostring(s) }) return self end
function o:hint(s) o._hint = s; c("SetHint", { tostring(s) }) return self end
function o:items(t)
o._items = t or {}
o._sel = nearest(1, 1) or 1
o._off = 0
return o:paint()
end
function o:selected() return o._items[o._sel], o._sel end
function o:select(i) if selectable(i) then o._sel = i; o:paint() end return self end
function o:_keyvk(vk)
local k = navName(vk); if not k then return end
if k == "up" or k == "down" then
local d = (k == "up") and -1 or 1
local t = nearest(o._sel + d, d)
if t and t ~= o._sel then
o._sel = t; o:paint()
if o.onSelect then pcall(o.onSelect, o._items[o._sel], o._sel, o) end
end
elseif k == "enter" or k == "right" then
local it = o._items[o._sel]
if it and not it.header and o.onChoose then pcall(o.onChoose, it, o._sel, o) end
elseif k == "left" or k == "esc" then
if o.onBack then pcall(o.onBack, o) end
end
end
if o._title then c("SetTitle", { tostring(o._title) }) end
if o._crumb then c("SetCrumb", { tostring(o._crumb) }) end
if o._hint then c("SetHint", { tostring(o._hint) }) end
o:items(opts.items or {})
o._cur = o._tgt; o._setsize(o._cur)
o._warmup = WARMUP
if opts.focus then o:focus() end
ensureTick()
return o
end
-- =============================== UI.Panel ============================
local function panel_px(n) return 40 + 18 * n end
function UI.Panel(opts)
opts = opts or {}
local o = {}
o.w = make_widget(UI.FILES.panel, opts.x or 20, opts.y or 120, opts.w or 300, opts.h or 200)
o._shown = true
local function c(fn, args) if o.w then pcall(function() o.w:CallActionScriptCallback(fn, args) end) end end
o._call = c
attach_common(o); register(o)
o._lines = 8
o._titleStr = opts.title
o._L = {}
o._cur, o._tgt = 100, 100
o._setsize = function(v) c("SetSize", { v }) end
function o:title(s) o._titleStr = s; c("SetTitle", { tostring(s) }) return self end
function o:fit(n)
n = tonumber(n) or 0; if n < 0 then n = 0 end; if n > 8 then n = 8 end
o._lines = n
set_target(o, 100 * panel_px(n) / 200)
return self
end
function o:line(i, s)
o._L[i] = tostring(s)
c("SetLine", { i, tostring(s) })
if o._L[i]:gsub("%s", "") ~= "" and (i + 1) > (o._lines or 0) then o:fit(i + 1) end
return self
end
function o:clear()
for i = 0, 7 do o._L[i] = ""; c("SetLine", { i, "" }) end
o:fit(0)
return self
end
function o:_repaint()
if o._titleStr then c("SetTitle", { tostring(o._titleStr) }) end
for i = 0, 7 do if o._L[i] then c("SetLine", { i, o._L[i] }) end end
if o._setsize then o._setsize(o._cur) end
end
if opts.title then o:title(opts.title) end
o:fit(opts.lines or 0)
o._cur = o._tgt; o._setsize(o._cur)
o._warmup = WARMUP
ensureTick()
return o
end
-- =============================== UI.Bar =============================
function UI.Bar(opts)
opts = opts or {}
local o = {}
o.w = make_widget(UI.FILES.bar, opts.x or 20, opts.y or 330, opts.w or 300, opts.h or 36)
o._shown = true
local function c(fn, args) if o.w then pcall(function() o.w:CallActionScriptCallback(fn, args) end) end end
o._call = c
attach_common(o); register(o)
o._pct, o._labelStr = 0, opts.label
function o:set(v)
v = tonumber(v) or 0; if v < 0 then v = 0 end; if v > 1 then v = 1 end
o._pct = math.floor(v * 100)
c("SetBar", { o._pct })
return self
end
function o:label(s) o._labelStr = s; c("SetLabel", { tostring(s) }) return self end
function o:_repaint()
if o._labelStr then c("SetLabel", { tostring(o._labelStr) }) end
c("SetBar", { o._pct })
end
if opts.label then o:label(opts.label) end
o:set(opts.value or 0)
o._warmup = WARMUP
ensureTick()
return o
end
-- ============================== UI.Toast ============================
function UI.Toast(text, opts)
opts = opts or {}
S.toasts = S.toasts or {}
local pick, soonest
for i = 1, UI.TOAST_SLOTS do
local t = S.toasts[i]
if not t or not t.ttl then pick = i; break end
if not soonest or t.ttl < S.toasts[soonest].ttl then soonest = i end
end
pick = pick or soonest or 1
local t = S.toasts[pick]
if not t then
t = {}
t.w = make_widget(UI.FILES.toast, UI.TOAST_X, UI.TOAST_Y + (pick - 1) * 50, 320, 44)
S.toasts[pick] = t
end
local function c(fn, args) if t.w then pcall(function() t.w:CallActionScriptCallback(fn, args) end) end end
local lines = UI.wrap(tostring(text), 46)
t.l0, t.l1 = lines[1] or "", lines[2] or ""
t.repaint = function() c("SetLine", { 0, t.l0 }); c("SetLine", { 1, t.l1 }) end
t.repaint()
pcall(function() t.w:SetVisible(true) end)
t.ttl = (opts.ttl or UI.TOAST_TTL)
t.warmup = WARMUP
function t:dismiss() t.ttl = nil; pcall(function() t.w:SetVisible(false) end) end
ensureTick()
return t
end
-- ============================= UI.Confirm ===========================
function UI.Confirm(opts)
opts = opts or {}
local o = S.confirm
if not o then
o = {}
o.w = make_widget(UI.FILES.confirm, opts.x or 180, opts.y or 200, 300, 110)
local function c(fn, args) if o.w then pcall(function() o.w:CallActionScriptCallback(fn, args) end) end end
o._call = c
attach_common(o); register(o)
function o:_resolve(res)
o:hide()
S.focus = o._prev; o._prev = nil
local cb = o._cb; o._cb = nil
if cb then pcall(cb, res) end
end
function o:_repaint()
c("SetTitle", { o._t or "CONFIRM" })
c("SetMsg", { 0, o._m0 or "" }); c("SetMsg", { 1, o._m1 or "" })
c("SetOpt", { 0, o._o0 or "YES" }); c("SetOpt", { 1, o._o1 or "NO" })
c("SetPick", { o._pick or 1 })
end
function o:_keyvk(vk)
local k = navName(vk); if not k then return end
if k == "left" or k == "right" or k == "up" or k == "down" then
o._pick = 1 - (o._pick or 1); c("SetPick", { o._pick })
elseif k == "enter" then o:_resolve(o._pick == 0)
elseif k == "esc" then o:_resolve(false) end
end
S.confirm = o
end
local msg = UI.wrap(tostring(opts.text or "Are you sure?"), 44)
o._t = tostring(opts.title or "CONFIRM")
o._m0, o._m1 = msg[1] or "", msg[2] or ""
o._o0, o._o1 = tostring(opts.yes or "YES"), tostring(opts.no or "NO")
o._pick = 1 -- default highlight = NO
o._cb = opts.onResult
o._prev = S.focus
o._warmup = WARMUP
o:_repaint()
o:show()
o:focus()
return o
end
-- ============================== UI.Input ============================
function UI.Input(opts)
opts = opts or {}
local o = S.input
if not o then
o = {}
o.w = make_widget(UI.FILES.input, opts.x or 160, opts.y or 260, 340, 56)
local function c(fn, args) if o.w then pcall(function() o.w:CallActionScriptCallback(fn, args) end) end end
o._call = c
attach_common(o); register(o)
o._isInput = true
function o:_echo()
local t = o._text or ""
if #t > 40 then t = "..." .. t:sub(#t - 40 + 1) end
c("SetInput", { "> " .. t .. (o._blink and "_" or " ") })
end
function o:_repaint() c("SetTitle", { o._t or "INPUT" }); o:_echo() end
function o:_char(ch)
if #(o._text or "") < (o._max or 120) then o._text = (o._text or "") .. ch; o:_echo() end
end
function o:_bs()
local t = o._text or ""
if #t > 0 then o._text = t:sub(1, #t - 1); o:_echo() end
end
function o:_finish(useCancel)
o:hide()
S.focus = o._prev; o._prev = nil
local sub, can = o._cb, o._cancel
o._cb, o._cancel = nil, nil
if useCancel then
if can then pcall(can) end
else
if sub then pcall(sub, o._text or "") end
end
end
function o:_keyvk(vk, shift)
if vk == 0x0D then o:_finish(false)
elseif vk == 0x1B then o:_finish(true)
elseif vk == 0x08 then o:_bs()
else local m = CHAR[vk]; if m then o:_char(shift and m.s or m.n) end end
end
S.input = o
end
o._t = tostring(opts.prompt or "INPUT -- ENTER SUBMIT ESC CANCEL")
o._text = tostring(opts.text or "")
o._max = opts.max or 120
o._cb, o._cancel = opts.onSubmit, opts.onCancel
o._blink, o._blinkClock = true, 0
o._prev = S.focus
o._warmup = WARMUP
o:_repaint()
o:show()
o:focus()
return o
end
-- =============================== UI.Menu ============================
-- ForgeMenu-style declarative drill-down, rendered on a reused UI.List.
local function resolveLabel(node)
local l = node.label
if type(l) == "function" then local ok, v = pcall(l); l = ok and v or "?" end
return tostring(l or "?")
end
local MenuBuilder = {}
MenuBuilder.__index = MenuBuilder
function MenuBuilder:entry(label, action)
if type(action) ~= "function" then
Loader.Printf("[uilib] Menu entry '" .. tostring(label) .. "' needs a function as 2nd arg")
action = function() end
end
self._children[#self._children + 1] = { label = label, action = action }
return self
end
function MenuBuilder:header(text)
self._children[#self._children + 1] = { header = tostring(text) }
return self
end
-- a labelled ON/OFF toggle entry: get() -> current bool, set(newBool, ctx) applies it.
-- Renders "<label>: ON" / "<label>: OFF" and flips on pick. Saves the dynamic-label boilerplate.
function MenuBuilder:switch(label, get, set)
self._children[#self._children + 1] = {
label = function() return tostring(label) .. ": " .. ((get and get()) and "ON" or "OFF") end,
action = function(ctx) local nv = not (get and get()); if set then set(nv, ctx) end end,
}
return self
end
function MenuBuilder:category(label, buildFn)
local node = { label = label, children = {} }
self._children[#self._children + 1] = node
local child = setmetatable({ _children = node.children }, MenuBuilder)
if type(buildFn) == "function" then buildFn(child) end
return child
end
local Menu = setmetatable({}, { __index = MenuBuilder })
Menu.__index = Menu
local function menu_ctx(menu)
local px, py, pz, yaw, char, player = pose()
local ctx = { x = px, y = py, z = pz, yaw = yaw or 0, char = char, player = player, _menu = menu }
function ctx:hint(msg) UI.Toast(tostring(msg)) end
function ctx:toast(msg) UI.Toast(tostring(msg)) end -- alias of :hint (clearer intent)
function ctx:print(msg) Loader.Printf("[uilib] " .. tostring(msg)) end
function ctx:close() self._menu:close() end
function ctx:confirm(text, onYes, onNo) -- pop a yes/no dialog from a menu action
UI.Confirm{ text = text, onResult = function(yes)
if yes then if onYes then pcall(onYes) end elseif onNo then pcall(onNo) end
end }
end
function ctx:ask(prompt, onSubmit, onCancel) -- pop a typed prompt from a menu action
UI.Input{ prompt = prompt, onSubmit = onSubmit, onCancel = onCancel }
end
function ctx:spawn(template, dist)
-- Guard the native call: Pg.Spawn("") hard-CRASHES the engine (empty name -> null asset in C++),
-- and pcall canNOT catch a native crash - only Lua errors. So reject blank templates up front.
if type(template) ~= "string" or template:match("^%s*$") then
self:hint("NO TEMPLATE SET"); return nil
end
if not px then self:hint("NO PLAYER POSITION"); return nil end
local sx, sz = px, pz
if dist and dist ~= 0 then
local yr = math.rad(yaw or 0)
sx = px - math.sin(yr) * dist
sz = pz + math.cos(yr) * dist
end
local ok, u = pcall(Pg.Spawn, template, sx, py, sz)
if ok and u then pcall(Object.SetYaw, u, yaw or 0); return u end
self:hint("SPAWN FAILED: " .. tostring(template))
return nil
end
return ctx
end
-- Runtime state persists across the OnKey re-run, keyed by menu id, so :toggle() really toggles and the
-- list widget is reused instead of leaked. (The menu OBJECT is rebuilt each run and carries the tree.)
local function menu_rt(id)
S.menus = S.menus or {}
S.menus[id] = S.menus[id] or { open = false }
return S.menus[id]
end
function Menu:_paint()
local lvl = self._stack[#self._stack]
local rows = {}
for _, node in ipairs(lvl.children) do
if node.header then rows[#rows + 1] = { header = node.header }
elseif node.children then rows[#rows + 1] = { label = resolveLabel(node) .. " >", _node = node }
else rows[#rows + 1] = { label = resolveLabel(node), _node = node } end
end
self._rt.list:items(rows)
local crumb = resolveLabel(self._root)
for i = 2, #self._stack do crumb = crumb .. " > " .. resolveLabel(self._stack[i]) end
self._rt.list:crumb(crumb)
end
function Menu:_choose(it)
local node = it and it._node
if not node then return end
if node.children then
self._stack[#self._stack + 1] = node
self:_paint()
elseif node.action then
local ok, err = pcall(node.action, menu_ctx(self))
if not ok then Loader.Printf("[uilib] menu action error: " .. tostring(err)); UI.Toast("ERROR (see log)") end
if self._rt.open then -- re-render so DYNAMIC labels (:switch / cyclers) show the change
local list = self._rt.list
local keep = list and list._sel -- keep the cursor where it is (a re-items resets it to the top)
self:_paint()
if list and keep then list:select(keep) end
end
end
end
function Menu:_back()
if #self._stack > 1 then
self._stack[#self._stack] = nil
self:_paint()
else
self:close()
end
end
function Menu:open()
local rt = self._rt
if rt.open then return self end
if not (Player.GetLocalPlayer() and Player.GetLocalCharacter()) then
Loader.Printf("[uilib] no local player yet - can't open menu '" .. tostring(self._title) .. "'")
return self
end
-- only one UI.Menu open at a time (they share the same on-screen slot)
if S.openId and S.openId ~= self._id then
local o = S.menus and S.menus[S.openId]
if o and o.open then
if o.list then pcall(function() o.list:hide():blur() end) end
o.open = false
if o.menu and o.menu._onClose then pcall(o.menu._onClose) end
end
end
local hint = "UP/DOWN MOVE ENTER PICK LEFT BACK"
if self._key then hint = hint .. " " .. tostring(self._key) .. " CLOSE" end
if not rt.list then
rt.list = UI.List{ x = self._x, y = self._y, title = self._title, hint = hint,
onChoose = function(it) self:_choose(it) end, onBack = function() self:_back() end }
else
rt.list.onChoose = function(it) self:_choose(it) end
rt.list.onBack = function() self:_back() end
rt.list:title(self._title):hint(hint)
end
rt.menu = self -- current run's object (holds this run's tree + action closures)
self._stack = { self._root }
self:_paint()
rt.list:show():focus()
rt.open = true
S.openId = self._id
return self
end
function Menu:close()
local rt = self._rt
if not rt.open then return self end
if rt.list then rt.list:hide():blur() end
rt.open = false
if S.openId == self._id then S.openId = nil end
if self._onClose then pcall(self._onClose) end
return self
end
function Menu:toggle() if self._rt.open then self:close() else self:open() end return self end
function Menu:isOpen() return self._rt.open == true end
-- UI.Menu{ title, id, key, x, y, onClose } (or UI.Menu("TITLE"))
-- id : distinct runtime-state key; defaults to title. Give separate menus distinct titles/ids.
-- key : your toggle key's name, shown in the hint (display only).
function UI.Menu(opts)
if type(opts) == "string" then opts = { title = opts } end
opts = opts or {}
local title = opts.title or "MENU"
local id = opts.id or title
local root = { label = title, children = {} }
local m = setmetatable({
_root = root, _children = root.children,
_title = title, _id = id,
_key = opts.key, _x = opts.x or 40, _y = opts.y or 60,
_onClose = opts.onClose,
_rt = menu_rt(id),
}, Menu)
return m
end
-- =============================== UI.Chat ============================
-- A scrolling message log (chat.gfx) with an optional typed input line.
-- local ch = UI.Chat{ x, y, title, onSubmit }
-- ch:push("a message") -- add a line (keeps the last 5 visible; body auto-resizes)
-- ch:prompt() -- enter input mode: type, Enter -> push + onSubmit(text), Esc cancels
-- ch:title(s) ch:clear()
function UI.Chat(opts)
opts = opts or {}
local o = {}
o.w = make_widget(UI.FILES.chat, opts.x or 20, opts.y or 400, opts.w or 360, opts.h or 132)
o._shown = true
local function c(fn, args) if o.w then pcall(function() o.w:CallActionScriptCallback(fn, args) end) end end
o._call = c
attach_common(o); register(o)
o._titleStr = opts.title
o._log = {}
o._max = opts.max or 60
o._cur, o._tgt = 100, 100
o._setsize = function(v) c("SetSize", { v }) end
o.onSubmit = opts.onSubmit
local BASE_H = 132
local function paintLog()
local total = #o._log
local shown = total; if shown > 5 then shown = 5 end
for i = 0, 4 do
if i < shown then c("SetMsg", { i, o._log[total - shown + i + 1] or "" }) else c("SetMsg", { i, "" }) end
end
if shown < 1 then shown = 1 end
set_target(o, 100 * (50 + 16 * shown) / BASE_H)
end
o._paintLog = paintLog
function o:title(s) o._titleStr = s; c("SetTitle", { tostring(s) }) return self end
function o:push(text)
for _, line in ipairs(UI.wrap(tostring(text), 52)) do o._log[#o._log + 1] = line end
while #o._log > o._max do table.remove(o._log, 1) end
paintLog()
return self
end
function o:clear() o._log = {}; paintLog(); return self end
function o:_echo()
local t = o._text or ""
if #t > 44 then t = "..." .. t:sub(#t - 44 + 1) end
c("SetInput", { "> " .. t .. (o._blink and "_" or " ") })
end
function o:prompt(onSubmit)
o._text = ""; o._blink, o._blinkClock = true, 0; o._isInput = true
if onSubmit then o.onSubmit = onSubmit end
o:_echo(); o:focus()
return self
end
function o:_endInput()
o._isInput = false
c("SetInput", { " " })
if S.focus == o then S.focus = nil end
end
function o:_keyvk(vk, shift)
if not o._isInput then return end
if vk == 0x0D then
local t = o._text or ""
o:_endInput()
if #t > 0 then o:push(t); if o.onSubmit then pcall(o.onSubmit, t) end end
elseif vk == 0x1B then o:_endInput()
elseif vk == 0x08 then local t = o._text or ""; if #t > 0 then o._text = t:sub(1, #t - 1); o:_echo() end
else local m = CHAR[vk]; if m and #(o._text or "") < 200 then o._text = (o._text or "") .. (shift and m.s or m.n); o:_echo() end end
end
function o:_repaint()
if o._titleStr then c("SetTitle", { tostring(o._titleStr) }) end
paintLog()
if o._isInput then o:_echo() end
if o._setsize then o._setsize(o._cur) end
end
if opts.title then o:title(opts.title) end
paintLog()
o._cur = o._tgt; o._setsize(o._cur)
o._warmup = WARMUP
ensureTick()
return o
end
-- =============================== UI.Board ===========================
-- A two-pane board (contracts.gfx): a scrolling list on the left + a details pane on the right
-- (category line, up to 4 reward lines, up to 8 objective lines, a progress bar + progress text).
-- local b = UI.Board{ x, y, title, hint, items, focus, onSelect, onChoose, onBack }
-- b:items({ {header="SECTION"}, {label="Entry", any=data}, ... }) -- same item shape as UI.List
-- b:detail({ category="OIL FIELD", rewards={"$5000","Fuel +200"},
-- objectives={"Destroy 3 tanks","Reach the LZ"}, progress=0.4, progressText="2/5" })
-- b:title(s) b:hint(s) -- onSelect(item,i,board) fires on every move so you can refresh :detail
function UI.Board(opts)
opts = opts or {}
local o = {}
o.w = make_widget(UI.FILES.board, opts.x or 60, opts.y or 60, opts.w or 660, opts.h or 420)
o._shown = true
local function c(fn, args) if o.w then pcall(function() o.w:CallActionScriptCallback(fn, args) end) end end
o._call = c
attach_common(o); register(o)
o._items, o._sel, o._off = {}, 1, 0
o._titleStr, o._hintStr = opts.title, opts.hint
o.onSelect, o.onChoose, o.onBack = opts.onSelect, opts.onChoose, opts.onBack
local VIS, TOP, PITCH, TRK_Y, TRK_H = 12, 64, 26, 64, 312
local function selectable(i) local it = o._items[i]; return it ~= nil and not it.header end
local function nearest(from, dir)
local i = from
while i >= 1 and i <= #o._items do
if selectable(i) then return i end
i = i + dir
end
return nil
end
local function fireSelect()
if o.onSelect then pcall(o.onSelect, o._items[o._sel], o._sel, o) end
end
function o:paint()
local n = #o._items
if n > 0 then
if not selectable(o._sel) then o._sel = nearest(o._sel, 1) or nearest(o._sel, -1) or 1 end
local s0 = o._sel - 1
if o._off > s0 then o._off = s0 end
if s0 > o._off + VIS - 1 then o._off = s0 - VIS + 1 end
if o._off < 0 then o._off = 0 end
else
o._off = 0
end
for i = 0, VIS - 1 do
local it = o._items[o._off + i + 1]
if not it then c("SetRow", { i, "" }); c("SetHdr", { i, "" })
elseif it.header then c("SetHdr", { i, tostring(it.header) }); c("SetRow", { i, "" })
else c("SetRow", { i, tostring(it.label or "?") }); c("SetHdr", { i, "" }) end
end
if n == 0 then
c("SetHdr", { 0, tostring(opts.empty or "EMPTY") }); c("SetSelected", { -1 }); c("SetScroll", { 0, 0 })
else
if selectable(o._sel) then c("SetSelected", { (o._sel - 1) - o._off }) else c("SetSelected", { -1 }) end
if n > VIS then
local th = TRK_H * VIS / n; if th < 18 then th = 18 end
local ty = TRK_Y + (TRK_H - th) * o._off / (n - VIS)
c("SetScroll", { math.floor(ty), math.floor(th) })
else
c("SetScroll", { 0, 0 })
end
end
return self
end
function o:title(s) o._titleStr = s; c("SetTitle", { tostring(s) }) return self end
function o:hint(s) o._hintStr = s; c("SetHint", { tostring(s) }) return self end
function o:detail(d)
d = d or {}
c("SetCat", { tostring(d.category or " ") })
local rw = d.rewards or {}
for i = 0, 3 do c("SetReward", { i, tostring(rw[i + 1] or " ") }) end
local ob = d.objectives or {}
for i = 0, 7 do c("SetObj", { i, tostring(ob[i + 1] or " ") }) end
c("SetBar", { math.floor((tonumber(d.progress) or 0) * 100) })
c("SetProg", { tostring(d.progressText or " ") })
o._detail = d
return self
end
function o:items(t)
o._items = t or {}
o._sel = nearest(1, 1) or 1
o._off = 0
o:paint()
fireSelect()
return self
end
function o:selected() return o._items[o._sel], o._sel end
function o:select(i) if selectable(i) then o._sel = i; o:paint(); fireSelect() end return self end
function o:_keyvk(vk)
local k = navName(vk); if not k then return end
if k == "up" or k == "down" then
local d = (k == "up") and -1 or 1
local t = nearest(o._sel + d, d)
if t and t ~= o._sel then o._sel = t; o:paint(); fireSelect() end
elseif k == "enter" or k == "right" then
local it = o._items[o._sel]
if it and not it.header and o.onChoose then pcall(o.onChoose, it, o._sel, o) end
elseif k == "left" or k == "esc" then
if o.onBack then pcall(o.onBack, o) end
end
end
function o:_repaint()
if o._titleStr then c("SetTitle", { tostring(o._titleStr) }) end
if o._hintStr then c("SetHint", { tostring(o._hintStr) }) end
o:paint()
if o._detail then o:detail(o._detail) end
end
if o._titleStr then c("SetTitle", { tostring(o._titleStr) }) end
if o._hintStr then c("SetHint", { tostring(o._hintStr) }) end
o:items(opts.items or {})
o:detail(opts.detail or {})
o._warmup = WARMUP
if opts.focus then o:focus() end
ensureTick()
return o
end
-- =============================== boot ===============================
-- This OnLoad file re-runs on every world (re)load, by which point the engine has torn down every
-- FlashWidget from the previous world. Forget all stale handles + state so everything rebuilds cleanly
-- (singletons on next use, menus/lists on next open) and no orphaned heartbeat or focus survives a load.
S.live, S.focus, S.running, S.openId = {}, nil, false, nil
S.confirm, S.input, S.toasts = nil, nil, nil
if S.menus then for _, rt in pairs(S.menus) do rt.list = nil; rt.open = false; rt.menu = nil end end
Loader.Printf("[uilib] UI kit v" .. UI.VERSION .. " " .. (S.loaded and "reloaded" or "loaded")
.. " -- Menu List Panel Bar Toast Confirm Input Chat Board")
S.loaded = true
See also
- UI.Menu / UI.List / UI.Panel, UI.Bar & UI.Toast / UI.Confirm & UI.Input / UI.Chat & UI.Board — each widget explained on its own, with the reasoning behind its design.
- UI Kit Scripts — complete scripts built on this library, including the two demo scripts that between them exercise every widget above.
- WaveDefense — a gamemode built on
UI.Menu’s cycler pattern for its config screen.