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 {