From 2429ada0900f7d08fe0b4191522cbcf942bf3167 Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Sun, 30 Aug 2026 22:19:17 +0200 Subject: [PATCH] A widget declares its layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Registration order was the only thing deciding what a widget was drawn over, which forced a domain to keep one ordered list of every widget it owns — the one shape that cannot be split into a file per widget. A widget now says where it belongs: Layer, the eight LayerScene..LayerCursor constants, the optional Layered interface and LayerOf for wrappers. Draw runs from the bottom layer up, Tick from the top down, and registration order only breaks ties inside a layer. Every built-in declares its own; anything that stays quiet sits on LayerHUD, so existing HUDs come out where they were. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 50 ++++++++++++++++++++++++++++++++--------- ui.character_panel.go | 1 + ui.chat_log.go | 1 + ui.cursor.go | 1 + ui.dialog_box.go | 1 + ui.end_card.go | 1 + ui.hotspot_debug.go | 1 + ui.inventory.go | 1 + ui.manager.go | 27 ++++++++++++++-------- ui.panel.go | 1 + ui.speech.go | 1 + ui.status.go | 1 + ui.top_bar.go | 1 + ui.verb_bar.go | 1 + ui.verb_radial.go | 1 + ui.widget.go | 52 +++++++++++++++++++++++++++++++++++++++---- 16 files changed, 118 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index e65cdcc..b799a97 100644 --- a/README.md +++ b/README.md @@ -1018,6 +1018,24 @@ type Widget interface { Draw(dst *ebiten.Image, ctx *UICtx) } +type Layer int +const ( + LayerScene Layer = 0 // over the picture, under the HUD + LayerPanel Layer = 100 // the plate the HUD sits on + LayerHUD Layer = 200 // verbs, inventory, status line, top bar + LayerSpeech Layer = 300 // speech bubbles + LayerDialog Layer = 400 // the dialogue box + LayerMenu Layer = 500 // radial verbs, menus + LayerCurtain Layer = 600 // end cards, fades + LayerCursor Layer = 700 // the pointer +) + +type Layered interface { + Layer() Layer +} + +func LayerOf(w Widget) Layer // Layered, else LayerHUD + type UICtx struct { Game *Game DT float64 @@ -1045,13 +1063,23 @@ their own `Bounds` (or compute them dynamically, like `RadialVerbs`). ### 10.2 Z-order and input consumption -- **`Tick` runs in reverse registration order.** Widgets registered later - (drawn on top) get the click first. Each widget calls - `ctx.Game.Input.ConsumeLeft()` / `ConsumeRight()` to claim the event; - later widgets see `LeftClicked() == false`. -- **`Draw` runs in registration order** — registered last → painted on top. -- The `Cursor` widget is registered last by convention so it always wins - on visual layer (and effectively never claims clicks). +- **`Draw` runs from the bottom layer up** — a widget on a higher layer is + painted on top. Inside one layer, registration order decides. +- **`Tick` runs from the top layer down.** The widget drawn on top gets the + click first. Each widget calls `ctx.Game.Input.ConsumeLeft()` / + `ConsumeRight()` to claim the event; widgets below see + `LeftClicked() == false`. +- **A widget declares its layer**, it does not inherit one from the order it + was registered in. Every built-in implements `Layered`; a widget that does + not sits on `LayerHUD`. `Cursor` is on `LayerCursor`, so it always wins on + visual layer (and effectively never claims clicks) no matter when it was + registered. +- Layers are what let a domain register its widgets **one file at a time** — + an `init()` per widget, in whatever order the file names happen to fall — + without the HUD coming out shuffled. +- A wrapper widget that forwards to an inner one — a visibility gate, say — + should return `LayerOf(inner)` from its own `Layer`, so that wrapping does + not move the widget. After all widgets ticked, the engine offers the (possibly consumed) click to `handleSceneInput`, which is where hotspot interactions live. If a @@ -1575,7 +1603,7 @@ if scriptRunner != nil: return clear hoverLabel -for w in reversed(WidgetManager): +for w in WidgetManager, top layer down: w.Tick(uictx) // widgets consume input top-down handleSceneInput() // hotspot resolution + right-click reset @@ -1589,7 +1617,7 @@ fill Theme.SceneBackdrop (if any) draw scene background image for c in characters sorted by Y: drawCharacter(c) -for w in WidgetManager (registration order): +for w in WidgetManager (by layer, then registration order): w.Draw(screen, uictx) draw transition overlay ``` @@ -1844,8 +1872,8 @@ inkwell/ # module git.teletypegames.org/games/inkwell │ ├── input.def.go # Input (consume-on-use) │ -├── ui.widget.go # Widget interface, UICtx, Size, Align -├── ui.manager.go # WidgetManager alias + reversed/ordered iterators +├── ui.widget.go # Widget interface, Layer, UICtx, Size, Align +├── ui.manager.go # WidgetManager alias + layer-ordered iterators ├── ui.theme.go # Theme + ThemeManager ├── ui.theme_presets.go # 4 preset themes ├── ui.defaults.go # RegisterDefaultUI/RadialVerbUI/RichUI diff --git a/ui.character_panel.go b/ui.character_panel.go index 96cc29c..0b52b93 100644 --- a/ui.character_panel.go +++ b/ui.character_panel.go @@ -28,6 +28,7 @@ type CharacterPanel struct { } func (c *CharacterPanel) GetName() string { return c.Name } +func (c *CharacterPanel) Layer() Layer { return LayerHUD } func (c *CharacterPanel) Tick(ctx *UICtx) {} func (c *CharacterPanel) Draw(dst *ebiten.Image, ctx *UICtx) { diff --git a/ui.chat_log.go b/ui.chat_log.go index 9bab3f1..220d2b4 100644 --- a/ui.chat_log.go +++ b/ui.chat_log.go @@ -20,6 +20,7 @@ type ChatLog struct { } func (c *ChatLog) GetName() string { return c.Name } +func (c *ChatLog) Layer() Layer { return LayerHUD } func (c *ChatLog) Tick(ctx *UICtx) {} func (c *ChatLog) Draw(dst *ebiten.Image, ctx *UICtx) { diff --git a/ui.cursor.go b/ui.cursor.go index bb425ed..d9cb763 100644 --- a/ui.cursor.go +++ b/ui.cursor.go @@ -13,6 +13,7 @@ type Cursor struct { } func (c *Cursor) GetName() string { return c.Name } +func (c *Cursor) Layer() Layer { return LayerCursor } func (c *Cursor) Tick(ctx *UICtx) {} func (c *Cursor) Draw(dst *ebiten.Image, ctx *UICtx) { diff --git a/ui.dialog_box.go b/ui.dialog_box.go index 76eee20..10256f2 100644 --- a/ui.dialog_box.go +++ b/ui.dialog_box.go @@ -17,6 +17,7 @@ type DialogBox struct { } func (d *DialogBox) GetName() string { return d.Name } +func (d *DialogBox) Layer() Layer { return LayerDialog } func (d *DialogBox) Tick(ctx *UICtx) { g := ctx.Game diff --git a/ui.end_card.go b/ui.end_card.go index 235d49b..0cdf57d 100644 --- a/ui.end_card.go +++ b/ui.end_card.go @@ -12,6 +12,7 @@ type EndCard struct { } func (e *EndCard) GetName() string { return e.Name } +func (e *EndCard) Layer() Layer { return LayerCurtain } func (e *EndCard) Tick(ctx *UICtx) { if ctx.Game.endCard == "" { diff --git a/ui.hotspot_debug.go b/ui.hotspot_debug.go index 00af5da..e2c3e1f 100644 --- a/ui.hotspot_debug.go +++ b/ui.hotspot_debug.go @@ -16,6 +16,7 @@ type HotspotDebug struct { } func (h *HotspotDebug) GetName() string { return h.Name } +func (h *HotspotDebug) Layer() Layer { return LayerScene } func (h *HotspotDebug) Tick(ctx *UICtx) { key := h.ToggleKey diff --git a/ui.inventory.go b/ui.inventory.go index 2912747..2779534 100644 --- a/ui.inventory.go +++ b/ui.inventory.go @@ -20,6 +20,7 @@ type InventoryBar struct { } func (b *InventoryBar) GetName() string { return b.Name } +func (b *InventoryBar) Layer() Layer { return LayerHUD } func (b *InventoryBar) slotRect(idx int) Rectangle { cols := b.Cols diff --git a/ui.manager.go b/ui.manager.go index 4c538d5..5a75502 100644 --- a/ui.manager.go +++ b/ui.manager.go @@ -1,19 +1,28 @@ package inkwell +import "sort" + // WidgetManager registers Widget instances. Same shape as every other manager. type WidgetManager = Manager[Widget] -// reversedWidgets iterates a manager's contents in reverse registration -// order — used by the engine for top-down input dispatch (the widget -// drawn on top gets the click first). +// reversedWidgets iterates from the top layer down — used by the engine +// for top-down input dispatch (the widget drawn on top gets the click +// first). func reversedWidgets(m *WidgetManager) []Widget { - all := m.All() - out := make([]Widget, len(all)) - for i, w := range all { - out[len(all)-1-i] = w + ordered := orderedWidgets(m) + out := make([]Widget, len(ordered)) + for i, w := range ordered { + out[len(ordered)-1-i] = w } return out } -// orderedWidgets iterates in registration order — bottom-up draw. -func orderedWidgets(m *WidgetManager) []Widget { return m.All() } +// orderedWidgets iterates bottom-up draw order: by layer, and by +// registration order inside a layer. +func orderedWidgets(m *WidgetManager) []Widget { + all := m.All() + sort.SliceStable(all, func(i, j int) bool { + return LayerOf(all[i]) < LayerOf(all[j]) + }) + return all +} diff --git a/ui.panel.go b/ui.panel.go index 0c83106..ccad7e9 100644 --- a/ui.panel.go +++ b/ui.panel.go @@ -21,6 +21,7 @@ type Panel struct { } func (p *Panel) GetName() string { return p.Name } +func (p *Panel) Layer() Layer { return LayerPanel } func (p *Panel) Tick(ctx *UICtx) {} func (p *Panel) Draw(dst *ebiten.Image, ctx *UICtx) { diff --git a/ui.speech.go b/ui.speech.go index 3b15ff6..e5454e2 100644 --- a/ui.speech.go +++ b/ui.speech.go @@ -18,6 +18,7 @@ type SpeechBubble struct { } func (s *SpeechBubble) GetName() string { return s.Name } +func (s *SpeechBubble) Layer() Layer { return LayerSpeech } func (s *SpeechBubble) Tick(ctx *UICtx) {} func (s *SpeechBubble) Draw(dst *ebiten.Image, ctx *UICtx) { diff --git a/ui.status.go b/ui.status.go index a7efda7..9462529 100644 --- a/ui.status.go +++ b/ui.status.go @@ -13,6 +13,7 @@ type StatusLine struct { } func (s *StatusLine) GetName() string { return s.Name } +func (s *StatusLine) Layer() Layer { return LayerHUD } func (s *StatusLine) Tick(ctx *UICtx) { g := ctx.Game diff --git a/ui.top_bar.go b/ui.top_bar.go index d7d5a46..d0ee4a0 100644 --- a/ui.top_bar.go +++ b/ui.top_bar.go @@ -27,6 +27,7 @@ type TopBar struct { } func (t *TopBar) GetName() string { return t.Name } +func (t *TopBar) Layer() Layer { return LayerHUD } func (t *TopBar) Tick(ctx *UICtx) {} func (t *TopBar) Draw(dst *ebiten.Image, ctx *UICtx) { diff --git a/ui.verb_bar.go b/ui.verb_bar.go index 2902245..2c1bac0 100644 --- a/ui.verb_bar.go +++ b/ui.verb_bar.go @@ -19,6 +19,7 @@ type VerbBar struct { } func (v *VerbBar) GetName() string { return v.Name } +func (v *VerbBar) Layer() Layer { return LayerHUD } func (v *VerbBar) buttons(ctx *UICtx) []verbButton { cols := v.Cols diff --git a/ui.verb_radial.go b/ui.verb_radial.go index dfb183a..9851a21 100644 --- a/ui.verb_radial.go +++ b/ui.verb_radial.go @@ -43,6 +43,7 @@ type RadialVerbs struct { } func (r *RadialVerbs) GetName() string { return r.Name } +func (r *RadialVerbs) Layer() Layer { return LayerMenu } func (r *RadialVerbs) Tick(ctx *UICtx) { g := ctx.Game diff --git a/ui.widget.go b/ui.widget.go index d541e3c..f962409 100644 --- a/ui.widget.go +++ b/ui.widget.go @@ -9,17 +9,61 @@ import "github.com/hajimehoshi/ebiten/v2" // satisfy this interface. // // Lifecycle: -// - Tick runs once per frame in REVERSE registration order so the -// top-most widget gets a chance to consume input first via +// - Tick runs once per frame from the TOP layer down so the top-most +// widget gets a chance to consume input first via // ctx.Game.Input.ConsumeLeft / ConsumeRight. -// - Draw runs once per frame in REGISTRATION order, so widgets -// registered later are painted on top. +// - Draw runs once per frame from the BOTTOM layer up, so a widget on a +// higher layer is painted on top. +// +// Registration order only decides ties inside one layer — see Layer. type Widget interface { Named Tick(ctx *UICtx) Draw(dst *ebiten.Image, ctx *UICtx) } +// Layer is a widget's place in the paint order. A widget says where it +// belongs rather than depending on the order it happened to be registered +// in, which frees a domain to register its widgets one file at a time. +type Layer int + +const ( + // LayerScene is over the picture and under the HUD: hotspot outlines, + // cutscene bars, and the invisible widgets that only tick. + LayerScene Layer = 0 + // LayerPanel is the plate the HUD is drawn on. + LayerPanel Layer = 100 + // LayerHUD is everything mounted on that plate: verbs, inventory, + // status line, top bar. + LayerHUD Layer = 200 + // LayerSpeech is speech bubbles, which float over the scene. + LayerSpeech Layer = 300 + // LayerDialog is the dialogue box. + LayerDialog Layer = 400 + // LayerMenu is what opens on top of the game: radial verbs, menus. + LayerMenu Layer = 500 + // LayerCurtain is what covers the game: end cards, fades. + LayerCurtain Layer = 600 + // LayerCursor is the pointer, and nothing else belongs above it. + LayerCursor Layer = 700 +) + +// Layered is implemented by a widget that declares its own layer. Every +// built-in widget does. A widget that does not sits on LayerHUD. +type Layered interface { + Layer() Layer +} + +// LayerOf reports the layer a widget paints on. A wrapper widget that +// forwards to an inner one — a visibility gate, say — should return +// LayerOf(inner) so that wrapping does not move the widget. +func LayerOf(w Widget) Layer { + if l, ok := w.(Layered); ok { + return l.Layer() + } + return LayerHUD +} + // UICtx is the per-tick context handed to widgets. Game is the root // aggregate; DT is seconds since the previous frame. type UICtx struct {