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

Come back to the editor and it reopens the GUI you were last editing instead of the first one in the list. A link from another editor always wins over that memory; a link to a GUI that no longer exists falls back to that memory, and only then to the first. It's tracked per project.

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).
  • theme: optional. 'ember' gives the GUI the Ignitor amber look: text buttons, textboxes and save slots take the amber palette as their default (a color you set on a widget still wins). Absent means the classic blue look. It's the GUI style field (Classic (blue) / Ignitor amber) in the panel when nothing is selected. The engine's Options and Save/Load screens ship in amber; a project that already customized one keeps its own look until you switch it.
  • 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. A widget with no font of its own uses the project's default font, in the editor preview the same as in the game. 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. A new sprite starts with no image and shows a magenta box until you pick one.
  • 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. Style picks the look: classic, or Ignitor fader (style: 'fader'), a mixing-desk cap that slides along a sunken track with a solid amber fill. A fader can also fill from the center (bipolar), for values whose neutral point is the middle, like brightness or contrast; two ticks mark the center. A new slider starts with no bind: pick what it moves in the inspector (until you do, it sits at its minimum and dragging it changes nothing).
  • saveSlots: fans out into N save-slot cells at layout time; display content comes from persistence.listSlots() at draw time, not from the descriptor. The preview shows the slots empty, the way a player who hasn't saved yet sees them. Select the column to set the slot count, slot width and height, the gap between slots, font size and font. There's no color field: the slots take their colors from the GUI style.

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 five engine modals (Options, Save/Load, quit confirmation, inventory, pause menu) 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 and list fan out into one leaf per cell/row with a composite id (${parentId}.${index}), and saveSlots into one leaf per slot named saveSlot.${index} (the game routes a slot click by its number, not by the widget's id). 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.json: guiTextLid(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, plus a plain button with Shows active when set. That field takes a state path and a value (lang = es, saveGuiMode = load), and the button shows as selected while the path holds that value: it's how the Options screen lights the current language. An unbound plain button never enters the state, 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 (the field suggests the state paths that fit that widget: a slider offers the volume and calibration settings, a label or textbox the text values), cols/rows/autoFit for an inventory grid, slot count and size for saveSlots.
  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.

A main bar that hides at the edge#

A main bar can stay out of the way until it is wanted. The Slide in from the edge property has two values: Always visible, the default and what every bar did before, and Slides in at the edge. With the second one the bar lives hidden just past the edge it sits on (below the picture for a bar at the bottom, above it for a bar at the top) and slides in when the cursor reaches that edge, then slides back out a moment after the cursor leaves it. The section only appears on the main GUI: no other panel slides.

Three sliders tune it, and a blank field falls back to its default:

  • Edge strip (px): how thick the trigger band along the bar's edge is. 1-64, default 8. This is the band the cursor has to reach, not the bar's own height. Going past the edge counts too, so the black bars around the picture work as part of it.
  • Wait before hiding (s): how long the bar stays out after the cursor leaves. 0-5, default 1.5.
  • Slide duration (ms): how long the slide takes, each way. 0-1000, default 250; at 0 the bar snaps instead of sliding.

The mouse and a controller's stick both reach the edge, and cycling verbs with the shoulder buttons pops the bar up for a moment so the player sees the pick. Three things hold it open regardless of the cursor:

  • Touchscreens: there is no hover to bring to the edge, so the bar simply stays visible; an auto-hiding bar would be unreachable.
  • An item in hand: the "Use … with" sentence lives on the bar, so it stays up until the item is used or dropped.
  • A conversation: the choices are drawn on the bar, so it opens at once.

The bar is never hidden at a moment the player needs it. Hiding every GUI (the M key, or HIDEGUI) still wins over all of this. While the bar is away, the strip it covered belongs to the room again (clicks there walk and interact like anywhere else) as long as the playable area reaches that far. For the strip to show something, paint your room backgrounds to the full height of the screen.

An auto-hiding bar changes what the playable area should be. A bar that is always visible owns the bottom band of the screen, so the room area stops above it. A bar that hides does not. With it out of the way that band sits empty unless the room reaches into it. So saving an auto-hiding main bar makes the check described below run the other direction: instead of offering to pull the room area up to the bar, it offers to extend it to the full height, naming the pixels that would otherwise stay blank.

A main bar at the top#

The main bar doesn't have to live at the bottom. Drag its frame to the top of the canvas and the editor works out the edge from where you put it: a bar whose middle sits in the upper half of the screen is a top bar. There is no setting to flip, and moving the bar back down turns it back into a bottom bar.

The room keeps drawing from the top of the screen and the bar simply covers that band, so nothing in your rooms moves; keep what matters below the bar, or paint the band to sit behind it. Everything that used to stay above a bottom bar now stays below a top one:

  • Clicks: the bar's band belongs to the bar, everything under it is the room, including the bottom strip a bottom bar used to own.
  • Conversations: the choices are listed inside the bar, at its top.
  • Speech bubbles and the narrator's top caption stay under the bar instead of hiding behind it, and the verb coin opens below it.
  • Slide in from the edge: a top bar hides upward and slides in when the cursor reaches the top edge; the bottom edge doesn't open it.

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.

A bar that slides in from the edge flips the target. It covers the room on purpose and uncovers it when it hides, so the room area should run the full height of the screen; stopping it at the bar would leave an empty strip every time the bar goes away. For those bars the offer is the other one (extend the playable area to the full height) and the "the bar covers the room" warning never appears. A bar at the top gets the same offer for the same reason: it draws over the top of the room, so the room area should run all the way down.

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