ui
The title card, the instruction form, the in-game HUD, the pause sheet and the two end cards — plus the `speak` contract that keeps a spoken line alive when its audio does not arrive.
game.ui — Lanternwake's screens
The title card, the instruction form, the in-game HUD, the pause sheet and the
two end cards — plus the speak contract that keeps a spoken line alive when
its audio does not arrive.
Everything is laid out for 1080 x 1440, portrait. Compositions stack down the frame: no bar across the top, no row of buttons across the foot, no two-column card.
Files
theme.luau | The design system: colours derived from Core.PALETTE, the two faces, the type scale, the paper and night surfaces, the rules, and the pressable line of type a choice is made of. |
art.luau | Every mark that is not type — the lantern, the three sigil seals, the dread ring, the engraved vignette, the Vicar plate, the small marks. Built from boxes, not from inline SVG. |
screens.luau | The six screens, each a pure function of a state snapshot. |
init.luau | The wiring: what is on the glass, when, and what a click does. |
What another slice can do with it
Nothing here needs to be required. Everything below is on the Core bus.
Put a line on the screen — the contract
Core.emit("speak", "vane_arrival", "Every door in this village is standing open.")
The sound slice plays the voice for the key; this slice prints the text,
picks the speaker from the key's first word (vane_* → Dr. Vane,
thackeray_* → Rev. Thackeray, register_*, ledger_*, letter_*,
note_*), and holds it long enough to read. A line whose generation failed is
still delivered, in full, in type.
Core.state.subtitle is adopted too, so a line staging writes directly is not
lost between the two routes.
Put a prompt under the reticle
Core.emit("prompt", "Read the parish register", "E") -- show it
Core.emit("prompt", "") -- clear it
Correct the key legend on the instruction form
Rules owns the bindings, so rules owns what the form says about them:
Core.emit("controls", { { "Walk", "W A S D" }, { "Lantern", "F" } })
Write the end cards' closing line
Core.emit("endText", "won", "…what the mystery slice decided actually happened.")
Core.emit("endText", "caught", "…and how the parish recorded her.")
Everything else is automatic
sigilSolved, clueFound, cryptOpen, vicarSpotted, vicarLost,
playerCaught and lanternToggled are all listened for and struck on screen
as announcements; phaseChanged decides which screen is up.
Which screen is up
| phase | screen |
|---|---|
boot, title | the playbill |
intro | the HUD, in its cinematic form — readouts stood down, the spoken line kept |
explore | the HUD |
caught, lost | the caught card |
won | the escaped card |
The instruction form and the pause sheet are overlays this slice raises itself, from the title card's Instructions, the HUD's pause mark, and Esc.
Core.state.uiScreen is the one Core field this slice writes.
Phase requests
Rules owns phase. A button here asks for a transition on the bus —
uiBegin, uiAbandon, uiRestart — and gives rules a short grace period to
answer. If nothing answers, this slice makes the move itself rather than leave
a control that does nothing; it logs when it does. Handle those three events in
rules and the fallback never runs.
Looking at a screen
Edit mode never ticks update, so this is how any screen is put on the glass
to be photographed:
require("game.ui").preview("title")
require("game.ui").preview("hud", { fear = 0.7, sigils = 2, subtitle = "…" })
require("game.ui").preview("endw")
require("game.ui").preview(nil) -- back to whatever the phase says
preview takes title · howto · hud · pause · endc · endw, and any
snapshot field can be overridden for the shot. require("game.ui").debug()
reports the live surface, the registered screens and the last validation.
Two things about the renderer these screens are built around
- A size value strictly between 0 and 1 is a FRACTION of the parent, not a
pixel count. A 0.96 px hairline becomes a bar 96% of the parent's height.
Every computed weight in
art.luauis clamped above 1 for that reason. - A wrapping label inside a centre-aligned column hugs its content and
wraps to one word a line. Paragraphs go inside a
column(width, …), which setsalignItems = "stretch", and the width lives on the container.
Scoped to this part · feeds back into the world's score.