Ignitor Docs ← getignitor.com
🇺🇸 EN 🇪🇸 ES

GUI Editor#

The GUI Editor builds every on-screen panel that isn't the room itself — the always-on HUD bar, the Options and Save/Load screens, the inventory popup, a verb-coin, or any custom modal your project needs (a keypad, a phone, a dialog choice list). A GUI is a descriptor: shape-only data listing an id, an optional rect/backdrop, and an ordered array of widgets. There is no hand-coded UI here — computeLayout() and hitTest() (core/guis/layout.js) turn that data into screen geometry and click targets, both pure functions with no canvas or DOM access; the actual pixels are painted by drawGui() in shell/canvas_render.js. It saves into the project's guis.json (the HUD bar as mainBar/mainGui, everything else under modals).

You reach it from the Hub (dev server running). The GUI dropdown in the toolbar picks which descriptor is loaded; the canvas in the middle is a live, editable preview — there's no separate preview tab, you drag and resize widgets directly on it — and the right-hand panel shows the properties of whatever's selected. Zoom the canvas with the toolbar 🔍 / 🔍− buttons or the scroll wheel, ⊞ Fit frames the whole GUI in the preview area, and you can pan the view to work on a crowded corner up close; the zoom stays purely visual, so widget coordinates never change as you zoom.

The GUI Editor screen — a panel descriptor with its widgets laid out.
GUI Editor — a panel descriptor with its widgets.

Anatomy of a descriptor#

  • id — the GUI's key. The HUD bar resolves through mainGuiId()/getMainGui() (core/guis/index.js): a descriptor can opt in explicitly with role: 'main', or fall back to the conventional mainBar/mainGui id. Everything else is opened by OPENGUI:<id> and closed with CLOSEGUI, gated by state.activeGui — while a modal is open, room input stops responding.
  • rect — the panel's own bounds, mostly for backdrop/debug bookkeeping.
  • backdrop — an optional full-screen dim painted before the widgets (used by modals).
  • widgets — the ordered list described below. Order matters for both painting (later widgets paint on top) and hit-testing (hitTest() walks the list in reverse, so the topmost widget under the pointer wins).

Widget kinds#

Every widget can carry a shared set of properties regardless of kind: visibility (isVisible), enabled state (isEnabled), fill (bgColor/bgOpacity), text color/font/alignment, and an ordered onClickAction list of effect tokens. isLocked is editor-only bookkeeping — the pure runtime ignores it. The kinds:

  • panel — a colored rect or circle, usually placed first (behind everything) as a background or a modal's dim backdrop. Can carry a sprite overlay and an optional linked animation.
  • label — text drawn from up to four sources, resolved every frame in priority order: a conditional message (condMsgs — a list of {condition, message} rows; the first row whose condition holds wins, built with the same condition picker as puzzle and hotspot rules), then a bound value (bind at a state path like party.count or settings.musicVol), then a self-rotating ticker (a list of messages that cycle on their own against the FX clock — secs per message, gapSecs of blank between them, and a blank entry is a valid pause beat; no rules or timers needed), and finally the static text, which doubles as the placeholder shown when nothing above applies. Ticker and conditional messages are authored monolingually like all content, each backed by its own lid for the Translation Editor.
  • sentence — the SCUMM sentence line ("Usar llave con puerta…"). Its content isn't authored here at all — it's computed every frame by core/guis/sentence.js from the hovered target and armed item. Its visibility follows what the line is for: with a modal open (the inventory, say) it stays readable over the modal's dim, because that's the feedback for the item you're about to pick; while a dialog is running it hides along with the verb buttons, because the choice list takes over that strip and the sentence would only be showing what you were doing before the conversation started.
  • button — the general-purpose control, specialized by which ref it carries (see below).
  • sprite — an image, with optional inline frame animations (anims) so a shipped build never needs to list a folder to animate it.
  • inventory — a cols × rows grid that fans out into one leaf cell per slot at layout time (autoFit derives slot size from the widget's rect); cells reflect state.inventory live and arm/un-arm/combine items on click. Cell painter picks how a slot is drawn: icons (the default) or text names, the SCUMM v5 look used by the classic9legacy bar. It changes the drawing only — the same cells, the same click targets, the same armed state. In the icon painter, Show names under the icon turns the caption on or off, so you can have an icons-only grid; it is on unless you say otherwise. The toggle is not offered for the text painter, where the name is the cell.
  • list — rows of text + onClickAction, used for things like a dialog choice list reused as a GUI.
  • textbox — an engine-bound text field (bind, placeholder, maxLength); used for the save-name field on the Save screen. Focus and typing are routed by shell/main.js (state.guiFocus), not by this widget itself.
  • slider — an engine-bound min/max/step control bound to a state path (used for the Options screen's volume/brightness/contrast sliders); drag handling lives in shell/main.js.
  • saveSlots — fans out into N save-slot cells at layout time; display content comes from persistence.listSlots() at draw time, not from the descriptor.

Button kinds#

A button widget's behavior comes from which reference field it carries — the editor's kind dropdown switches between them and clears the others so exactly one applies:

  • verbRef — resolves label/sprite from the VERBS registry; default action SELECTVERB:<id>. This is how the classic 9-verb bar is built — nine buttons, each verbRef pointing at one verb.
  • charRef — resolves name/sprite from the CHARACTERS registry; default action SWITCHCHAR:<id>.
  • partyRef — a party-roster toggle (add/remove a character from the active party); default action PARTYTOGGLE:<id>. If the character you pick isn't in the project's character pool (Hub Config → Project), the editor warns right there — a button that offers someone the pool never holds back at boot means they can end up on-screen before anyone chooses them.
  • partySlotRef — a committed-party slot button. It carries no character id at all — at runtime it resolves to chosenOrder(state)[N-1] (the Nth character in current selection order) and dispatches SWITCHPARTYSLOT:<N>. computeLayout() is pure and can't resolve "who's in slot 2 right now," so that resolution happens at draw/click time in the shell.
  • toggleRef — flips an arbitrary game flag; default action TOGGLEFLAG:<flag>. The flag field is an autocompleted free-text input (flags are an open, author-defined namespace, so it can't be a closed picker), listing every flag already used in the project plus the reserved engine flags — names the engine maintains itself, which no project scan can find and you would otherwise have to know by heart.
  • scrollRef'up'/'down' inventory paging. Its action is locked (INVENTORY_UP/INVENTORY_DOWN) — the editor won't let you override it.
  • plain — no ref at all: freeform label plus whatever onClickAction tokens you attach through the shared effect-token widget (the same one used by hotspot reactions, dialog choices, and cutscenes — every ID-bearing argument is a typed picker, never free text).

Layout and positioning#

Widget coordinates are absolute logical pixels at the project's canvas resolution (1920×1080 by default), not percentages — core/guis/constants.js holds the shared geometry (panel top, verb-grid cell size, inventory slot size…) that both the built-in templates and the renderer read from. A project at a non-default resolution applies it once at boot through applyCanvasResolution(), which moves those constants and rebuilds the four engine modals (Options, Save/Load, quit confirmation, inventory) centred on the real canvas — the editor surfaces them the same way, so materializing one never writes a widget off the edge of your game. The bar gets the same treatment through a different route: every bar the engine ships declares the canvas it was drawn for (designResolution) and is scaled to yours by one uniform factor, then docked to the bottom — which is how a single classic9legacy descriptor authored at 320×200 is also a 1280-wide bar at 1280×1024. A descriptor you authored is left exactly as you drew it, unless it carries a designResolution of its own — that field is how a materialized template keeps scaling after it becomes your content. It works on your modals too, where it means "re-centre me on the real canvas" (and shrink me only if I wouldn't fit): a dialog centred for a 1920 canvas is not too big for a 1280 one, it's in the wrong place, and that's a distinction only the declared canvas can make. Everything else you re-position by hand here. computeLayout() takes a descriptor and returns a flat list of leaf nodes (each with a resolved rect) — container widgets like inventory, list, and saveSlots fan out into one leaf per cell/row/slot with a composite id (${parentId}.${index}). hitTest() walks that flat list back-to-front and returns the first rect containing the click point, skipping anything invisible or disabled.

Button and verb labels auto-fit their cell the same way: a widget's fontSize is a literal in the renderer, so unlike the geometry above it doesn't scale on its own — an unscaled label at a small canvas can print wider than the cell that holds it (the classic 9-verb bar at 640×400 used to read "DARGARRUSAR"). fitFontSize() shrinks a label down until it fits its box on both axes, and ellipsizes rather than shrink past a readable floor; a label that already fits is left at its authored size, so a project that already looks right is unaffected. Verb cells fit as a group, not independently — the bar takes the smallest size that fits every cell sharing a base size, so you never end up with one verb readable at 22px next to another cramped at 15px; a cell an author gave a deliberately different fontSize sits outside that group. The sentence line (what the player's about to do) gets the same box-fit, measured against the font's own line-height rather than the actual ink, so its size doesn't shift every time the hovered description's length does.

Wiring widgets to game logic#

Every clickable widget's behavior is an ordered onClickAction array of the same effect tokens used everywhere else in the engine (SETFLAG, GIVEITEM:<item>|<char>, SAY…), run through applyEffects(). The GUI runtime layers a few of its own: OPENGUI:<id> / CLOSEGUI open and close a modal (gated by state.activeGui), SELECTVERB/SWITCHCHAR/PARTYTOGGLE/ SWITCHPARTYSLOT/TOGGLEFLAG are the default actions the ref-kinds above derive automatically, and SETLANG:es/SETLANG:en (used by the Options screen) switch the active language. A widget's own onClickAction, if set, always wins over a kind's derived default — the editor only re-syncs the default when the current action still is that default, so a genuine custom override is never clobbered.

Text and localization#

GUI widget text is authored monolingually, in the project's defaultLocale — the same content-i18n rule as everywhere else. There's no inline {es, en} object to fill in here (a few of the engine's built-in descriptors, like save.js and options.js, still carry legacy inline lang-maps from before this migration — the editor reads them fine via L(), but any edit through the editor's text field rewrites them as a plain string). What actually happens on save:

  • The literal you type is written into the descriptor as a plain string (the defaultLocale source of truth — this is what a locale falls back to if no translation exists).
  • The editor also upserts an address-derived lid into the project's strings.jsonguiTextLid(guiId, widgetId) for a widget's main text, and matching per-property lids for placeholders, tooltips, and list-row text. Verb-button labels are a special case: they overlay through the verb's own lid (verb_<id>_label) instead, so translating a verb once covers every bar/coin that references it.
  • At runtime, shell/canvas_render.js's _guiText() resolves through that lid first (falling back to the literal, then to L()), so the drawn text is never the raw descriptor string once a translation exists.
  • Those lids show up under the GUI category in the Translation Editor, where the actual Spanish/English (or any other configured locale) pairs get filled in — never inline, here.

GUI widget sprites follow the same overlay idea for art: a per-locale image variant (<base>.<lang>.<ext>) is preferred over the base PNG when one exists, for images that bake text into the pixels.

Button states#

A button can look different depending on what the player is doing with it: sitting there, hovered, held down, or selected (the verb currently chosen, the character being played, a party member, a toggle that's on). Two things can change per state, and they stack:

  • Art — one PNG per state. Fill in only the ones you need; a state you leave blank falls back to the default image, so a button with nothing but a default sprite is perfectly valid.
  • Color — a background fill and a text color per state, under colors by state in the properties panel. Same rule: blank means "keep the base color".

The auto button next to a hover or clicked color derives that shade from the base color — a lighter one for hover, a darker one for pressed — and writes it into the field, where you can still tune it by hand afterwards. It needs the base color to be a hex value (#rrggbb) to have something to work from.

The editor's canvas is for authoring, so it always draws buttons at rest. The 👁 next to a state draws the selected widget in that state instead — art and colors together — so you can check how a hover looks without launching the game. Click it again to go back.

The selected state only exists for buttons that can actually be selected: verb, character, party, and flag-toggle buttons. A plain button never enters it, so those fields aren't offered for one.

An inventory widget takes the same colors, authored once for the whole grid — each cell then picks its own state as the player moves around it: hovered is the cell under the cursor, pressed is the cell being held, and selected is the cell holding the item they picked up and haven't used yet. In the text names cell painter the colors are the whole story, since that mode has no box to tint — the name itself changes color for each state.

Animations#

A widget (sprite, button, or panel) can link an animation authored in the Anim Editor under its own GUI category (assets/guis/<guiId>/animations/<animName>/, tracked in animations.json). The linked animation's current frame overrides the widget's static PNG at draw time; clearing the link falls back to the static sprite. The toolbar's Animate toggle plays every linked animation live in the editor's canvas so you can check timing without leaving the tool.

Workflow#

  1. New GUI… and choose an id, a kind (modal / panel / coin), and a starting template — empty, or a clone of a built-in (classic9 the 9-verb bar, modern4, coinHud, or classic9legacy, the same nine verbs with no chrome at all: plain text, drawn for 320×200 and scaled up from there). A panel kind becomes the project's HUD bar; a modal kind gets a backdrop for OPENGUI; a coin kind materializes the radial verb-coin as editable content.
  2. + Add widget… and drag/resize it directly on the canvas; Snap aligns drags to a 4px grid.
  3. Set kind-specific fields in the right panel — a verbRef/charRef/partySlotRef/toggleRef for a button, bind for a label/textbox/slider, cols/rows/autoFit for an inventory grid.
  4. Wire clicks through the shared onClickAction token list — the same typed-picker widget used by hotspot reactions and dialog choices.
  5. Author widget text once, in your project's default language; translations are filled later in the Translation Editor.
  6. Link an animation from the Anim Editor's GUI category if the widget needs to play frames.
  7. Save to write the project's guis.json.

Deleting a GUI#

A single button in the toolbar handles removing a GUI, and it reads the currently-loaded descriptor to decide which of two very different things it does:

  • On one of the engine's five default slots (the main bar, the verb-coin, and the inventory/save/options modals) it reads Revert to default: taking it out of guis.json doesn't remove it from the game — those five rebuild themselves from the engine's own templates the moment the key is absent, so the bar or modal is never gone, only your saved changes to it are (and that loss can't be undone).
  • On a modal of your own it reads Delete GUI, in red, because there's no built-in fallback behind it: deleting it is permanent, and any OPENGUI:<id> left pointing at it is left dangling (the token is null-safe — it quietly does nothing — but the confirm dialog lists exactly which reactions, watchers, or GUI buttons still name it, so you can fix them first instead of finding out in play).
  • On a virtual entry — a default being offered for editing, or a brand-new GUI you haven't saved yet — the button is disabled: there's nothing on disk yet to remove. Save once and it activates.

Saving a main bar can offer to move the playable area#

roomViewport in project.json is where the room area stops — the band above the bar that rooms are drawn into. The wizard sets it to the bar's top edge when it creates a project, but nothing kept the two in step afterwards: move or materialize a main bar and the project's playable area still describes the old one, while the runtime believes the project file. Either the room draws under the bar and loses its bottom band, or a strip belongs to nobody — the room can't use it and the bar doesn't cover it.

So saving a panel (a main bar) compares the bar's top edge against the playable area, and if they disagree by more than a couple of pixels it offers to line them up, naming the numbers: this bar starts at y=740, but the room area runs to 1024 — the bar covers the last 284 px of every room. It is an offer, never automatic: leaving it alone is the default, and the check runs after the GUI is safely written, so declining costs you nothing. Accepting patches only roomViewport's height — an authored x/y/width stays exactly as it is — and once the two agree the offer stops appearing.

Widget ids are load-bearing for partySlotRef and every other ref field — but the id itself is just a label the widget owns, not a stable content key like a hotspot or item id. The values that actually get referenced elsewhere are the ref fields: partySlotRef's slot number resolves at runtime against chosenOrder(state), not against any fixed character — rename the character roster and the button keeps working. The one id that is load-bearing is the GUI's own id, since OPENGUI:<id> and the main-bar resolution both key off it directly; renaming a GUI after other content already opens it with OPENGUI:<oldId> breaks that link silently (the token is null-safe — it just does nothing — so the failure is quiet, not a crash).