Deep Dive: Building ForgeMenu — a Reusable Nested-Menu Library
Status: new, built on already-confirmed pieces. The
forge.gfxmovie and theFlashWidget-renders- and-drives rendering approach are the exact confirmed-live mechanics from ForgeCam and MissionForge — this library is a direct, deliberate generalization of MissionForge’s own render loop (the code says so directly:renderis “a generic version of MissionForge.refresh”). The library itself hasn’t accumulated its own separate live-test history yet, but one specific fix baked into it (theWARMUPre-render workaround below) reads like a real bug that was actually hit and fixed, not a defensive guess.
Building Nested Menus with MrxMultiPageMenu covers the only way to nest menus using the game’s native menu system — and it’s real work: no built-in submenu concept, every level hand-written as its own Reset/AddOption/Display call, menu-builder functions deliberately declared as bare globals to dodge a declaration-order hazard. ForgeMenu is the answer to “what if nesting didn’t require any of that?” — a small library that reuses the forge.gfx Scaleform movie ForgeCam and MissionForge already prove works, and turns building a nested menu into declaring a plain tree of categories and entries. No widget code, no navigation-stack bookkeeping, no bare-global gotcha to know about at all.
The whole API, up front
local menu = ForgeMenu.new("MY MENU", { key = "F11" }) -- title shown at the top; key = your toggle key (label only)
menu:entry("Do a thing", function(ctx) ... end) -- an action leaf
menu:category("Group", function(c) -- a sub-menu; c is another menu you add to
c:entry("Nested action", function(ctx) ... end)
c:category("Even deeper", function(cc) ... end) -- categories nest as far as you like
end)
menu:toggle() -- open if closed / close if open (call in OnKey)
That’s the entire building-block set: :entry and :category. No separate “Back” option to wire up anywhere — :category gives you a real nested tree, and back-navigation is built into the library once, not re-derived by every menu author the way the MrxMultiPageMenu pattern requires.
The ctx every action receives
| field / call | what it is |
|---|---|
ctx.x, ctx.y, ctx.z | your character’s position, read fresh at the moment the action runs |
ctx.yaw | your character’s facing, in degrees |
ctx.char, ctx.player | the local character / player guids |
ctx:spawn(template [, dist]) | spawns template at your feet, or dist metres ahead (using the same forward-vector convention as MissionForge); returns the guid |
ctx:hint("text") | flashes a message on the menu’s bottom line, auto-clearing after ~3s |
ctx:print("text") | writes a line to lua_loader_printf.log, prefixed "ForgeMenu: " |
ctx:close() | closes the menu from inside an action |
Live ON/OFF labels
A label isn’t limited to a plain string — pass a function that returns one, and it’s re-evaluated on every render, which is what a toggle needs:
_G.MyMenuState = _G.MyMenuState or {}
local S = _G.MyMenuState
menu:entry(function() return S.god and "God: ON" or "God: OFF" end, function(ctx)
S.god = not S.god
pcall(Object.SetInvincible, ctx.char, S.god and true or false, "MyMenu")
end)
The _G-guarded state table is the exact same idiom Your First Menu establishes for a single flat menu — ForgeMenu doesn’t change how you persist state across presses, it only changes how you build the menu structure around it.
Why the world doesn’t pause
This is the detail that separates ForgeMenu from everything upstream of it in this wiki’s menu lineage, and it’s worth being explicit about rather than assuming it follows from ForgeCam. The freecam’s entire input-hijack trick exists to solve one specific problem: continuous analog stick input has no native Lua touchpoint, and the only way to get it is to hijack the PDA widget’s "ControllerInput" event — which, as a side effect neither MissionForge nor ForgeCam ever needed but had to live with, pauses the world. MissionForge’s own deep dive inherits that pause because it inherits the PDA hijack for its camera.
A menu doesn’t need continuous analog input at all — just discrete “move selection” / “choose” / “back” events, which is exactly what Loader.PopKeyEvents already provides on its own, no PDA involved. So ForgeMenu.lua never opens the PDA, never touches MrxState, and never pauses anything — it just puts a FlashWidget on the HUD and drives it from a plain, unpaused Event.TimerRelative heartbeat:
tick = function(rt, dt)
rt.now = (rt.now or 0) + dt
local ev = Loader.PopKeyEvents()
if ev and ev ~= "" then
for i = 1, #ev do dispatchKey(rt, string.byte(ev, i)) end
end
...
end
The practical consequence is the first gotcha worth knowing: the game keeps running while the menu is open. Arrow keys and Enter still do whatever they normally do in gameplay at the same time they’re driving your menu, since nothing has suppressed them. Fine for a menu you open standing still; if it matters, ForgeMenu.new’s opts.keys lets you remap navigation off the arrow keys entirely.
How it works
The render loop is MissionForge’s, generalized
The module comment says it outright: render is “a generic version of MissionForge.refresh.” Where MissionForge’s own render function was written against one specific, hardcoded catalog tree, ForgeMenu’s version walks whatever tree the caller built with :entry/:category — same SetRow/SetCrumb/ SetHint/SetSelected/SetScroll/SetPanel calls into the movie, same breadcrumb-joining, same scroll-thumb math, same eased panel-height animation — just parameterized over an arbitrary tree instead of one fixed catalog. The layout constants at the top of the file (VISIBLE = 12, ROW_PITCH = 26, TRACK_Y/TRACK_H/PANEL_H) are called out in the source as having to match the forge.gfx author values exactly — this library doesn’t re-author the movie, it drives the identical one MissionForge does.
One menu at a time, by design
Like MrxMultiPageMenu (see its own module page), only one ForgeMenu can be visually open at once — openMenu explicitly closes whatever else is open first:
if FM._openId and FM._openId ~= menu.id then
local other = FM._rt[FM._openId]
if other and other.active then closeMenu(other) end
end
Unlike MrxMultiPageMenu, this isn’t a side effect of shared module state you have to reason about — it’s one explicit guard, because every ForgeMenu-based menu shares the same one on-screen slot (there’s only one forge.gfx widget instance in play). Multiple menu trees can coexist fine (each gets its own runtime table keyed by id); what can’t coexist is two of them being visibly open at the same instant.
The generation counter, reused from ForgeCam’s own lesson
rt.gen is incremented every time a menu opens, and the heartbeat loop checks it on every tick (if not rt.active or rt.gen ~= gen then return end) before rescheduling itself. This is the same “kill the old loop, don’t let two ever run at once” pattern the freecam/ForgeCam lineage already had to solve for its own re-arming timers — closing a menu (or opening a different one, which force-closes this one) invalidates the generation, and the next tick of the old heartbeat sees the mismatch and simply doesn’t reschedule itself again, instead of piling up a second heartbeat alongside a new one.
A real, specific bug already fixed: WARMUP
local WARMUP = 8 -- force a re-render for the first N ticks after opening (defeats the async
-- SetSwfFile load dropping the very first render)
This is the one line in the file that reads like a documented war story rather than defensive-by-default coding: SetSwfFile loading a Scaleform movie is asynchronous, so a render issued immediately after buildWidget can be dropped on the floor if the movie hasn’t actually finished loading yet — the menu would open with a briefly blank or stale first frame. The fix is a deliberately crude one: keep re-rendering unconditionally for the first 8 ticks (0.4s at the 0.05s tick rate) after every open, so whichever tick actually lands after the movie’s ready gets through. Cheap, doesn’t need to know exactly when the load finishes, and self-corrects to normal (only-render-on-change) behavior after the window closes.
Recipes
A spawn menu:
local menu = ForgeMenu.new("SPAWNER", { key = "F11" })
menu:category("Vehicles", function(c)
c:entry("Tank", function(ctx) ctx:spawn("M1A2 (Full)", 8) end)
c:entry("Heli", function(ctx) ctx:spawn("AH1Z (Full)", 12) end)
end)
menu:toggle()
A cheat menu:
local menu = ForgeMenu.new("CHEATS", { key = "F11" })
menu:entry("Heal", function(ctx) pcall(Object.SetHealth, ctx.char, 100) end)
menu:entry("Kill Nearby", function(ctx) --[[ your own logic ]] end)
menu:toggle()
Gotchas
- The world stays unpaused — see above. Pick a toggle key that isn’t one of your navigation keys (arrows/Enter/Backspace by default), since those keep doing their normal in-game thing while the menu is up.
- Give distinct menus distinct titles, or pass an explicit
{ id = "..." }— theidis what keys a menu’s saved runtime state (FM._rt[id]) across separateForgeMenu.newcalls in the same session; two menus that happen to share a title/id would share state. - Only one menu is visibly open at a time, by design — see above.
- Entry actions run inside a
pcall. A bug in your function surfaces asERROR (see log)on the hint line plus aLoader.Printfline, rather than breaking the menu outright — checklua_loader_printf.logfirst if an entry seems to silently do nothing. - Depends only on
forge.gfxalready being in the WAD (shared with ForgeCam/MissionForge) — noContractFrameworkor any other dependency.
The current scripts
ForgeMenu.lua (the library — scripts/OnLoad/, needs a low lua_loader.ini number so it loads before any menu that uses it, e.g. ForgeMenu.lua=5):
-- =====================================================================
-- ForgeMenu - a tiny, beginner-friendly library for building your OWN in-game
-- pop-up menus on top of the shipped forge.gfx movie.
--
-- WHY THIS EXISTS
-- MissionForge / ForgeCam drive a nice scrolling menu movie ("forge.gfx"). This file lifts ALL of
-- that plumbing - the Flash widget, the breadcrumb + scrollbar + panel animation, the keyboard
-- heartbeat, the navigation stack - out into a library so you can make a menu in ~10 lines and
-- NEVER touch any of it. You declare categories + entries; an entry runs a plain Lua function.
--
-- DEPENDENCIES: none. This does not require ContractFramework or anything else - just the forge.gfx
-- movie, which is already injected in the wad (shared with ForgeCam / MissionForge).
-- =====================================================================
import("MrxGuiBase")
import("MrxGuiManager")
_G.ForgeMenu = _G.ForgeMenu or {}
local FM = _G.ForgeMenu
FM._rt = FM._rt or {} -- persistent runtime state, one table per menu id (survives the OnKey re-run)
-- ---------------------------------------------------------------------
-- Layout constants - these MUST match the forge.gfx author values (same as ForgeCam / MissionForge).
-- ---------------------------------------------------------------------
local VISIBLE = 12 -- rows the movie shows at once
local ROW_PITCH = 26
local TRACK_Y = 88
local TRACK_H = 316
local PANEL_H = 324
local LOC_X, LOC_Y, LOC_W, LOC_H = 40, 80, 380, 420
local TICK = 0.05 -- heartbeat interval (s)
local HINT_HOLD = 3.0 -- how long a ctx:hint message stays before the hint reverts
local WARMUP = 8 -- force a re-render for the first N ticks after opening (defeats the async
-- SetSwfFile load dropping the very first render)
-- Default navigation keys (Windows VK codes). Override per-menu via ForgeMenu.new(title,{keys={...}}).
local DEFAULT_KEYS = { up = 0x26, down = 0x28, open = 0x27, enter = 0x0D, back = 0x25, back2 = 0x08 }
-- Forward declarations (so the functions below can reference each other in any order).
local render, move, back, activate, dispatchKey, tick, startTick, closeMenu, openMenu
-- ---------------------------------------------------------------------
-- Small helpers
-- ---------------------------------------------------------------------
local function clamp(v, lo, hi) if v < lo then return lo elseif v > hi then return hi else return v end end
-- Talk to the movie. Every visible change goes through one of the forge.gfx callbacks.
local function callGfx(rt, fn, args)
if rt.w then pcall(function() rt.w:CallActionScriptCallback(fn, args or {}) end) end
end
-- Where is the player standing / facing right now? (used to fill ctx and for 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
-- A label may be a plain string OR a function returning one (for live ON/OFF text).
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 function rowText(node)
local l = resolveLabel(node)
if node.children then return l .. " >" end -- categories get a chevron
return l
end
-- Build the forge.gfx widget once and keep it (toggled with SetVisible), just like MissionForge.
local function buildWidget(rt)
if rt.w then return end
local player = Player.GetLocalPlayer()
local w = MrxGuiBase.FlashWidget:new()
pcall(function() w:SetOwner(player) end)
w:SetLocation(LOC_X, LOC_Y, LOC_W, LOC_H)
w:SetSwfFile("forge.gfx", nil, nil)
MrxGuiBase.AddWidget(w)
pcall(function() w:SetVisible(true) end)
pcall(function() MrxGuiManager.AddWidgetToHud(player, w) end)
rt.w = w
end
-- Flash a transient message on the hint line (reverts after HINT_HOLD seconds).
local function showHint(rt, msg)
rt.hintMsg = tostring(msg)
rt.hintExpiry = (rt.now or 0) + HINT_HOLD
render(rt)
end
-- The context object handed to every entry's action function.
local function makeCtx(rt)
local px, py, pz, yaw, char, player = pose()
local ctx = { x = px, y = py, z = pz, yaw = yaw or 0, char = char, player = player }
function ctx:hint(msg) showHint(rt, msg) end
function ctx:print(msg) Loader.Printf("ForgeMenu: " .. tostring(msg)) end
function ctx:close() closeMenu(rt) end
-- Spawn `template` at your feet, or `dist` metres in front of you. Sets facing to yours. Returns guid.
function ctx:spawn(template, dist)
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 -- "forward" in the game's yaw convention (matches MissionForge)
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))
Loader.Printf("ForgeMenu: spawn failed '" .. tostring(template) .. "'")
return nil
end
return ctx
end
-- ---------------------------------------------------------------------
-- Rendering (draw the current menu level into the movie - generic version of MissionForge.refresh)
-- ---------------------------------------------------------------------
render = function(rt, instant)
if not rt.stack then return end
local lv = rt.stack[#rt.stack]
local list = lv.node.children
local n = #list
if lv.sel > n - 1 then lv.sel = n - 1 end
if lv.sel < 0 then lv.sel = 0 end
if lv.off > lv.sel then lv.off = lv.sel end
if lv.sel > lv.off + VISIBLE - 1 then lv.off = lv.sel - VISIBLE + 1 end
if lv.off < 0 then lv.off = 0 end
for i = 0, VISIBLE - 1 do
local it = list[lv.off + i + 1]
callGfx(rt, "SetRow", { i, it and rowText(it) or "" })
end
-- breadcrumb: TITLE > category > subcategory ...
local crumb = resolveLabel(rt.stack[1].node)
for i = 2, #rt.stack do crumb = crumb .. " > " .. resolveLabel(rt.stack[i].node) end
callGfx(rt, "SetCrumb", { crumb })
-- hint line (transient message wins; otherwise a context-aware control hint).
-- NB: O / L / K / J are the forge.gfx glyph tokens for up / down / right / left (same as MissionForge).
local hint
if rt.hintMsg and rt.now < rt.hintExpiry then
hint = rt.hintMsg
else
hint = "O/L MOVE"
local it = list[lv.sel + 1]
if it then
if it.children then hint = hint .. " K OPEN" else hint = hint .. " K RUN" end
end
if #rt.stack > 1 then hint = hint .. " J BACK" end
if rt.closeKey then hint = hint .. " " .. tostring(rt.closeKey) .. " CLOSE" end
end
callGfx(rt, "SetHint", { hint })
if n == 0 then callGfx(rt, "SetSelected", { -1 }) else callGfx(rt, "SetSelected", { lv.sel - lv.off }) end
if n > VISIBLE then
local th = TRACK_H * VISIBLE / n
if th < 18 then th = 18 end
local ty = TRACK_Y + (TRACK_H - th) * lv.off / (n - VISIBLE)
callGfx(rt, "SetScroll", { math.floor(ty), math.floor(th) })
else
callGfx(rt, "SetScroll", { 0, 0 })
end
local shown = n
if shown > VISIBLE then shown = VISIBLE end
if shown < 1 then shown = 1 end
rt.panelTgt = 100 * (shown * ROW_PITCH + 12) / PANEL_H
if instant then rt.panelCur = rt.panelTgt; callGfx(rt, "SetPanel", { rt.panelCur }) end
end
-- ---------------------------------------------------------------------
-- Navigation
-- ---------------------------------------------------------------------
move = function(rt, d)
local lv = rt.stack[#rt.stack]
local n = #lv.node.children
if n == 0 then return end
local s = clamp(lv.sel + d, 0, n - 1)
if s ~= lv.sel then lv.sel = s; render(rt) end
end
back = function(rt)
if #rt.stack > 1 then rt.stack[#rt.stack] = nil; render(rt) end
end
activate = function(rt)
local lv = rt.stack[#rt.stack]
local it = lv.node.children[lv.sel + 1]
if not it then return end
if it.children then
rt.stack[#rt.stack + 1] = { node = it, sel = 0, off = 0 }
render(rt)
elseif it.action then
local ctx = makeCtx(rt)
local ok, err = pcall(it.action, ctx)
if not ok then
Loader.Printf("ForgeMenu: '" .. resolveLabel(it) .. "' error -> " .. tostring(err))
showHint(rt, "ERROR (see log)")
end
if rt.active then render(rt) end -- refresh so dynamic ON/OFF labels update at once
end
end
dispatchKey = function(rt, b)
local k = rt.keys
if b == k.up then move(rt, -1)
elseif b == k.down then move(rt, 1)
elseif b == k.open or b == k.enter then activate(rt)
elseif b == k.back or b == k.back2 then back(rt)
end
end
-- ---------------------------------------------------------------------
-- Heartbeat: drain keys, run warm-up renders, expire transient hints, ease the panel.
-- ---------------------------------------------------------------------
local function easePanel(rt)
if rt.panelCur and rt.panelTgt then
local d = rt.panelTgt - rt.panelCur
if d > 0.5 or d < -0.5 then rt.panelCur = rt.panelCur + d * 0.35; callGfx(rt, "SetPanel", { rt.panelCur })
elseif rt.panelCur ~= rt.panelTgt then rt.panelCur = rt.panelTgt; callGfx(rt, "SetPanel", { rt.panelCur }) end
end
end
tick = function(rt, dt)
rt.now = (rt.now or 0) + dt
local ev = Loader.PopKeyEvents()
if ev and ev ~= "" then
for i = 1, #ev do dispatchKey(rt, string.byte(ev, i)) end
end
if rt.warmup and rt.warmup > 0 then rt.warmup = rt.warmup - 1; render(rt) end
if rt.hintMsg and rt.now >= rt.hintExpiry then rt.hintMsg = nil; render(rt) end
easePanel(rt)
end
startTick = function(rt, gen)
local function loop()
if not rt.active or rt.gen ~= gen then return end -- generation guard: dies when toggled off
local dt = TICK
if rt.stamp then
local e = Sys.TimeStampGetElapsed(rt.stamp)
if e and e > 0 then dt = e end
Sys.TimeStampMark(rt.stamp)
end
if dt > 0.25 then dt = 0.25 end
local ok, err = pcall(tick, rt, dt)
if not ok then Loader.Printf("ForgeMenu: tick error -> " .. tostring(err)) end
Event.Create(Event.TimerRelative, { TICK }, loop)
end
Event.Create(Event.TimerRelative, { TICK }, loop)
end
-- ---------------------------------------------------------------------
-- Open / close lifecycle
-- ---------------------------------------------------------------------
closeMenu = function(rt)
rt.active = false
rt.gen = (rt.gen or 0) + 1 -- invalidate the running heartbeat
if rt.w then pcall(function() rt.w:SetVisible(false) end) end
if FM._openId == rt.id then FM._openId = nil end
Loader.Printf("ForgeMenu: '" .. tostring(rt.id) .. "' closed")
end
openMenu = function(menu)
local rt = menu._rt
if not (Player.GetLocalPlayer() and Player.GetLocalCharacter()) then
Loader.Printf("ForgeMenu: no local player yet - can't open '" .. tostring(menu.id) .. "'")
return
end
-- only one ForgeMenu open at a time (they share the same on-screen slot)
if FM._openId and FM._openId ~= menu.id then
local other = FM._rt[FM._openId]
if other and other.active then closeMenu(other) end
end
rt.active = true
rt.now = 0
rt.root = menu.root -- snapshot THIS run's tree + action closures
rt.stack = { { node = menu.root, sel = 0, off = 0 } }
rt.keys = menu.keys
rt.closeKey = menu.closeKey
rt.panelCur, rt.panelTgt = 100, 100
rt.hintMsg = nil
rt.warmup = WARMUP
pcall(Loader.ClearKeyEvents) -- swallow the toggle keypress itself
buildWidget(rt)
pcall(function() rt.w:SetVisible(true) end)
render(rt, true)
rt.gen = (rt.gen or 0) + 1
rt.stamp = Sys.RealTimeStamp()
startTick(rt, rt.gen)
FM._openId = menu.id
Loader.Printf("ForgeMenu: '" .. tostring(menu.id) .. "' open")
end
-- ---------------------------------------------------------------------
-- Builder (what :entry / :category add to). A category returns another Builder you keep building on.
-- ---------------------------------------------------------------------
local Builder = {}
Builder.__index = Builder
function Builder:entry(label, action)
if type(action) ~= "function" then
Loader.Printf("ForgeMenu: entry '" .. tostring(label) .. "' needs a function as its 2nd argument")
action = function() end
end
self._children[#self._children + 1] = { label = label, action = action }
return self
end
function Builder:category(label, buildFn)
local node = { label = label, children = {} }
self._children[#self._children + 1] = node
local child = setmetatable({ _children = node.children }, Builder)
if type(buildFn) == "function" then buildFn(child) end -- optional: build children inline
return child -- ...or keep the returned object and add to it later
end
-- ---------------------------------------------------------------------
-- Menu (a root Builder + open/close lifecycle). Returned by ForgeMenu.new.
-- ---------------------------------------------------------------------
local Menu = setmetatable({}, { __index = Builder }) -- Menu also has :entry / :category (via Builder)
Menu.__index = Menu
function Menu:toggle() if self._rt.active then closeMenu(self._rt) else openMenu(self) end return self end
function Menu:open() if not self._rt.active then openMenu(self) end return self end
function Menu:close() if self._rt.active then closeMenu(self._rt) end return self end
function Menu:isOpen() return self._rt.active == true end
-- ForgeMenu.new(title [, opts])
-- title : string shown as the breadcrumb root.
-- opts : optional table - { id = "unique-id", -- defaults to the title; give distinct menus distinct ids
-- key = "F8", -- your toggle key, shown in the hint (display only)
-- keys = { up=.., down=.., open=.., enter=.., back=.., back2=.. } } -- VK overrides
-- (passing a string instead of a table is treated as the id.)
function FM.new(title, opts)
if type(opts) == "string" then opts = { id = opts } end
opts = opts or {}
local id = opts.id or title or "menu"
local root = { label = title or "MENU", children = {} }
FM._rt[id] = FM._rt[id] or { id = id }
local menu = setmetatable({
root = root,
_children = root.children, -- so Builder:entry / :category add to the root
id = id,
_rt = FM._rt[id],
keys = opts.keys or DEFAULT_KEYS,
closeKey = opts.key,
}, Menu)
return menu
end
-- This OnLoad file re-runs on every world (re)load, by which point any FlashWidget from a previous
-- world has been torn down. Forget stale widget handles and force every menu closed, so the next toggle
-- rebuilds cleanly and no orphaned heartbeat survives a load.
for _, rt in pairs(FM._rt) do
rt.active = false
rt.gen = (rt.gen or 0) + 1
rt.w = nil
end
FM._openId = nil
Loader.Printf("ForgeMenu: library ready (ForgeMenu.new / :category / :entry / :toggle)")
ExampleMenu.lua (the copy-me demo — scripts/OnKey/, bound to F11):
local KEYVAL = "f11" -- must be in the first 10 lines (your toggle key; also add "ExampleMenu.lua=f11" under [OnKey])
if not _G.ForgeMenu then
Loader.Printf("ExampleMenu: ForgeMenu library not loaded - put ForgeMenu.lua in scripts/OnLoad/ and add it to [OnLoad] in lua_loader.ini")
return
end
local menu = ForgeMenu.new("EXAMPLE MENU", { key = KEYVAL:upper() })
-- --- top-level actions -------------------------------------------------
menu:entry("Heal Me", function(ctx)
if ctx.char then pcall(Object.SetHealth, ctx.char, 100) end
ctx:hint("HEALED")
end)
menu:entry("Face North", function(ctx)
if ctx.char then pcall(Object.SetYaw, ctx.char, 0) end
ctx:hint("FACING NORTH")
end)
-- --- a category of spawn buttons --------------------------------------
menu:category("Spawn Vehicles", function(c)
c:entry("Diplomat Tank", function(ctx) ctx:spawn("M1A2 (Full)", 8); ctx:hint("TANK SPAWNED") end)
c:entry("Ambassador Heli", function(ctx) ctx:spawn("AH1Z (Full)", 12); ctx:hint("HELI SPAWNED") end)
c:entry("Softtop HMMWV", function(ctx) ctx:spawn("HMMWV (Softtop) (Full)", 8); ctx:hint("HMMWV SPAWNED") end)
end)
-- --- a category with a NESTED subcategory ------------------------------
menu:category("Spawn Enemies", function(c)
c:category("Guerilla", function(g)
g:entry("Soldier", function(ctx) ctx:spawn("Guerilla Soldier", 6); ctx:hint("PLACED") end)
g:entry("Heavy (RPG)", function(ctx) ctx:spawn("Guerilla Heavy (RPG)", 6); ctx:hint("PLACED") end)
g:entry("Boss", function(ctx) ctx:spawn("Guerilla Boss", 6); ctx:hint("PLACED") end)
end)
c:category("Chinese", function(g)
g:entry("Soldier", function(ctx) ctx:spawn("Chinese Soldier", 6); ctx:hint("PLACED") end)
g:entry("Sniper", function(ctx) ctx:spawn("Chinese Sniper", 6); ctx:hint("PLACED") end)
end)
end)
-- --- a live ON/OFF toggle (label is a function, so it redraws each time) -----
_G.ExampleMenuState = _G.ExampleMenuState or {} -- keep the flag in _G so it survives the OnKey re-run
local S = _G.ExampleMenuState
menu:entry(function() return S.god and "God Mode: ON" or "God Mode: OFF" end, function(ctx)
S.god = not S.god
if ctx.char then pcall(Object.SetInvincible, ctx.char, S.god and true or false, "ExampleMenu") end
ctx:hint(S.god and "INVINCIBLE" or "MORTAL AGAIN")
end)
-- --- close button ------------------------------------------------------
menu:entry("Close Menu", function(ctx) ctx:close() end)
-- Flip the menu open/closed. (This file re-runs every time you press F8, so this line does the toggle.)
menu:toggle()
General lessons
- A rendering pattern extracted from one specific tool becomes a library by parameterizing over the one thing that was hardcoded. MissionForge’s
refreshonly ever had to draw one catalog tree; ForgeMenu is that same function taught to walk any tree, plus a builder API for constructing one declaratively. Nothing about the Scaleform side changed at all. - Not every input problem needs the PDA-pause trick. The freecam lineage needed it because continuous analog input has no other Lua touchpoint. A menu’s input — discrete moves and selections — was already fully served by
Loader.PopKeyEvents, so ForgeMenu simply never pays the “world pauses” cost the tools built on the PDA hijack all inherit. - A generation counter is a reusable answer to “how do I safely kill a self-rescheduling timer.” Same shape here as in the freecam/ForgeCam lineage: bump a counter on stop, have the loop check it before rescheduling itself, and a stale loop quietly stops instead of piling up alongside a new one.
- A single crude constant can be the right fix for an async-load race.
WARMUP’s “just re-render unconditionally for the first few ticks” doesn’t need to know exactly whenSetSwfFilefinishes loading — it just needs to keep trying long enough that one of those ticks lands after it does.
See also
- Building Nested Menus with MrxMultiPageMenu — the harder, DIY way to nest menus using the native dialog-box system instead of
forge.gfx; still the right tool if you specifically want the native look-and-feel rather than the Scaleform one. - Building a Real Freecam — the PDA-hijack trick and the “why not just poll” reasoning ForgeMenu deliberately avoids needing.
- Building ForgeCam / MissionForge — where
forge.gfxand the render/heartbeat pattern this library generalizes originally came from. - Your First Menu — the
_G-guarded persistent-state idiom used for live ON/OFF labels. - lua-bridge API: Loader —
PopKeyEvents, the input primitive this whole library is built on. - UI Kit — this exact input/heartbeat/warm-up engine, generalized further into a full nine-widget toolkit (lists, panels, progress bars, toasts, modal dialogs, a chat log, a two-pane board) — reach for it over ForgeMenu directly if you need more than just a menu.