From 9fa1d200ef30610050397ba0a140efa277f82b95 Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Mon, 25 May 2026 20:35:04 +0200 Subject: [PATCH] widgets --- UIPLAN.md | 504 +++++++++++++++++++++++++++++++++++++ domain/game.go | 5 + pncdsl/action.def.go | 8 +- pncdsl/core.dsl.go | 6 +- pncdsl/core.engine.go | 198 +++++---------- pncdsl/core.game.go | 122 +++++++-- pncdsl/core.manager.go | 33 ++- pncdsl/dialog.box.go | 97 ------- pncdsl/ui.cursor.go | 27 +- pncdsl/ui.def.go | 187 -------------- pncdsl/ui.defaults.go | 51 ++++ pncdsl/ui.dialog_box.go | 134 ++++++++++ pncdsl/ui.end_card.go | 38 +++ pncdsl/ui.hotspot_debug.go | 47 ++++ pncdsl/ui.inventory.go | 148 +++++++++++ pncdsl/ui.manager.go | 26 ++ pncdsl/ui.panel.go | 44 ++++ pncdsl/ui.speech.go | 100 ++++---- pncdsl/ui.status.go | 54 ++++ pncdsl/ui.theme.go | 46 ++++ pncdsl/ui.theme_presets.go | 125 +++++++++ pncdsl/ui.verb.go | 9 +- pncdsl/ui.verb_bar.go | 111 ++++++++ pncdsl/ui.verb_manager.go | 3 - pncdsl/ui.verb_radial.go | 168 +++++++++++++ pncdsl/ui.widget.go | 51 ++++ 26 files changed, 1826 insertions(+), 516 deletions(-) create mode 100644 UIPLAN.md delete mode 100644 pncdsl/dialog.box.go delete mode 100644 pncdsl/ui.def.go create mode 100644 pncdsl/ui.defaults.go create mode 100644 pncdsl/ui.dialog_box.go create mode 100644 pncdsl/ui.end_card.go create mode 100644 pncdsl/ui.hotspot_debug.go create mode 100644 pncdsl/ui.inventory.go create mode 100644 pncdsl/ui.manager.go create mode 100644 pncdsl/ui.panel.go create mode 100644 pncdsl/ui.status.go create mode 100644 pncdsl/ui.theme.go create mode 100644 pncdsl/ui.theme_presets.go create mode 100644 pncdsl/ui.verb_bar.go delete mode 100644 pncdsl/ui.verb_manager.go create mode 100644 pncdsl/ui.verb_radial.go create mode 100644 pncdsl/ui.widget.go diff --git a/UIPLAN.md b/UIPLAN.md new file mode 100644 index 0000000..6a6ebc7 --- /dev/null +++ b/UIPLAN.md @@ -0,0 +1,504 @@ +# UIPLAN.md — dinamikus widget-alapú UI réteg + +## Cél + +A `pncdsl` jelenlegi kezelőfelülete hard-kódolt komponensek halmaza: van egy +fix verb-bar, fix inventory-bar, fix dialog-box, fix speech-bubble. Cserélni, +átalakítani őket csak az `ui.*.go` fájlok átírásával lehet. + +Át kell térni egy **dinamikus widget-rendszerre**, ami: + +- minden HUD-elemet egységesen, **`Widget` interfészként** kezel, +- engedi a `domain`-nak hogy **saját widget-eket adjon hozzá** (pl. egy chat + panel) anélkül, hogy a library kódjához nyúlna, +- támogatja az alternatív input-módokat — pl. **verb-coin** (radiális menü + másodlagos kattintásra) a SCUMM-szerű verb-bar helyett, +- a jelenlegi „look and feel"-t **preset Theme-ekként** csomagolja, amik + egyetlen sorral aktiválhatók, +- mindezt a meglévő **`Manager.Register(...)` mintával** integrálja, hogy ne + legyen idegen test a libraryban. + +## Az alapfogalom: `Widget` + +Egy widget bármi, ami: + +1. névvel hivatkozható (lookup, ki/be-kapcsolás), +2. frame-enként frissít magán állapotot és input-ot (Tick), +3. frame-enként rajzol magát a képernyőre (Draw). + +```go +// pncdsl/ui.widget.go +type Widget interface { + Named // GetName() string + Tick(ctx *UICtx) // állapot + input feldolgozás + Draw(dst *ebiten.Image, ctx *UICtx) +} + +type UICtx struct { + Game *Game + DT float64 +} +``` + +A widget **maga felel a saját geometriájáért** — nincs külső layout-rendszer, +ami pozícionálja. Egy widget lehet: + +- **statikus** (mindig ugyanazon a fix téglalapon van, pl. `VerbBar`), +- **dinamikus** (a bounds-ot futás közben számolja, pl. a `RadialVerbs` + a click-pozícióból, vagy a `SpeechBubble` a beszélő karakter feje fölött), +- **modális** (csak bizonyos állapotban látszik / fogad inputot, + pl. `DialogBox` amikor egy `Dialogue` aktív). + +A bounds tehát **method, nem mező** — ha kellene, a widget egy `Bounds()` +implementál belsőleg, de a library nem követeli meg. Ez a dynamic layout DSL +lényege: nincs nagy `UILayout` struct fix mezőkkel; a layout **a widget-fa +maga**. + +## `UIManager`: a megszokott regiszter-minta + +A többi entitáshoz hasonlóan a widgetek is egy generic Manager-ben élnek: + +```go +// pncdsl/ui.manager.go +type UIManager = Manager[Widget] +``` + +```go +g.UIManager.Register(&pncdsl.VerbBar{Name: "verbs"}) +g.UIManager.Register(&pncdsl.InventoryBar{Name: "inventory"}) +g.UIManager.Register(&pncdsl.RadialVerbs{Name: "verb_coin", Trigger: pncdsl.MouseButtonRight}) +g.UIManager.Register(&domain.ChatPanel{Name: "chat", Bounds: pncdsl.Rect(220, 4, 96, 130)}) +``` + +Hideg újdonság a `Manager`-ben: a `Widget` egy **interfész**, nem konkrét +struct. A `Manager[T Named]` ezt eddig is kezelte (T = `Widget`); csak most +használjuk ki először. + +**Z-order**: a regisztráció sorrendje. Korábban regisztrált widget hátrább van. + +- `Draw` **regisztráció sorrendben** fut → későbbi widget rajzol felülre. +- `Tick` **fordított sorrendben** fut → felül lévő widget kapja először az + inputot. Ha a top-most widget `ctx.Game.input.ConsumeLeft()`-ot hív, az + alatta lévők `input.LeftClicked()` hívása már `false`-ot ad — így a + click „elnyelődik". Ez ugyanaz a consume-on-use pattern, mint amit a + `Say` action és a hotspot-klikk-resolve használ. + +## Beépített widget-ek + +A library a következő `Widget` implementációkat szállítja (mindegyik egy-egy +`ui..go` fájlban): + +| Widget | Mit csinál | +|-----------------|------------------------------------------------------------------| +| `Panel` | Generic konténer — háttér-szín, opcionális keret. Csoportosításhoz. | +| `StatusLine` | Hover-felirat + 2s-os flash üzenetek központozva. | +| `VerbBar` | SCUMM-szerű verb-gomb grid (mai default). | +| `RadialVerbs` | Verb-coin: másodlagos kattintásra felugró radiális ige-menü. | +| `InventoryBar` | Item slotok, sorba/grid-be rendezve. | +| `SpeechBubble` | A `Say` action által beállított beszéd buborék a beszélő fölött. | +| `DialogBox` | Az aktív `Dialogue` választói + sorai (modális). | +| `EndCard` | A `ShowEnd` által beállított befejező overlay (modális). | +| `Cursor` | Egér-kurzor, kiválasztott item sprite-jával ha van. | +| `HotspotDebug` | Hotspot-téglalapok overlay-je, fejlesztéshez (`Enabled` toggle). | + +Mindegyik **konfigurálható mezőkkel** (struct literal) és **felülírható** — +ha a domain saját `VerbBar`-t akar, nem regisztrálja a beépítettet, hanem a +sajátját. + +Példa egy beépített widget mezőire: + +```go +type VerbBar struct { + Name string + Origin Point + Cols int + Button Size + Gap Point + // belső állapot — nem kell érintened + buttons []verbButton +} +func (v *VerbBar) GetName() string { return v.Name } +func (v *VerbBar) Tick(ctx *UICtx) { /* hit-test + verb-szelekció */ } +func (v *VerbBar) Draw(dst *ebiten.Image, c *UICtx) { /* gombrajzolás */ } +``` + +### A `RadialVerbs` widget + +Az új verb-coin működése: + +```go +type RadialVerbs struct { + Name string + Trigger MouseButton // alap: MouseButtonRight + Radius float64 // alap: 40 + // belső állapot + visible bool + center Point +} +``` + +Lifecycle: + +1. Tick-ben figyel a `Trigger`-re. Ha a játékos a scene-területen (nem UI-on) + triggert nyom, megnyitja: `visible = true`, `center = mouse.Pos`. Konzumálja + a kattintást, hogy ne menjen tovább a hotspot-resolve-ra. +2. Amíg `visible`: minden frame megrajzolja a `VerbManager` ige-listáját + `len(verbs)` szelet formájában a `center` körül `Radius` sugárral. +3. Bal-klikk egy szeletre: kiválasztja az adott igét (`g.selectedVerb = ...`), + `visible = false`. Klikk kívülre: csak bezár. +4. ESC ugyanígy bezár. + +A radiális menü tehát **dinamikus pozíciójú widget** — minden frame +újraszámolt szelet-bounds-ok, a domain nem kell hogy pozícionálja. + +## Custom widget — `ChatPanel` példa + +A domain a `Widget` interfész implementálásával bármilyen Ebiten-eszközt be +tud kötni. Egy chat-panel kb. 40 sorból: + +```go +// domain/widget.chat.go +package domain + +import ( + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" + p "pncdsl/pncdsl" +) + +type ChatPanel struct { + Name string + Bounds p.Rectangle + MaxLines int + + lines []string +} + +func (c *ChatPanel) GetName() string { return c.Name } + +func (c *ChatPanel) Push(line string) { + c.lines = append(c.lines, line) + if len(c.lines) > c.MaxLines { + c.lines = c.lines[len(c.lines)-c.MaxLines:] + } +} + +func (c *ChatPanel) Tick(ctx *p.UICtx) { + // pl. scrollozás egér-kerékkel — szabadon +} + +func (c *ChatPanel) Draw(dst *ebiten.Image, ctx *p.UICtx) { + th := ctx.Game.Theme() // aktív téma + vector.DrawFilledRect(dst, + float32(c.Bounds.X), float32(c.Bounds.Y), + float32(c.Bounds.W), float32(c.Bounds.H), + th.PanelBG, false, + ) + for i, ln := range c.lines { + ctx.Game.DrawText(dst, ln, + int(c.Bounds.X)+2, + int(c.Bounds.Y)+2+i*14, + th.StatusText, + ) + } +} +``` + +Bejegyzés a `domain/game.go` `Build()`-jébe: + +```go +g.UIManager.Register(&ChatPanel{ + Name: "chat", + Bounds: p.Rect(220, 4, 96, 130), + MaxLines: 8, +}) +``` + +A chat tartalmát egy új action vagy a domain-kód push-olja: + +```go +g.UIManager.MustGet("chat").(*ChatPanel).Push("[server] Welcome!") +``` + +Vagy egy action-konstruktor: `p.Custom(func(ctx) { ... })`. + +## Theme rendszer + +A jelenlegi színek/színskála a `Theme` struct-ba kerül. **A téma egy +manager-be regisztrált entitás** — több téma lehet regisztrálva, és egyik +aktív: + +```go +// pncdsl/ui.theme.go +type Theme struct { + Name string + + PanelBG color.Color + StatusText color.Color + FlashText color.Color + + VerbButtonBG color.Color + VerbButtonSelectedBG color.Color + VerbButtonText color.Color + + InventorySlotBG color.Color + InventorySlotSelectedBG color.Color + + SpeechBubbleBG color.Color + SpeechDefaultText color.Color + + DialogBG color.Color + DialogBorder color.Color + DialogChoiceBG color.Color + DialogChoiceHover color.Color + DialogSpeaker color.Color + DialogText color.Color + + EndCardBG color.Color + EndCardText color.Color + + CursorColor color.Color + HotspotOutline color.Color +} + +func (t Theme) GetName() string { return t.Name } +func (t Theme) TypeLabel() string { return "theme" } + +type ThemeManager = Manager[Theme] +``` + +A `Game` mezője: + +```go +type Game struct { + // ... + ThemeManager *ThemeManager + activeTheme string +} + +func (g *Game) Theme() Theme { return g.ThemeManager.MustGet(g.activeTheme) } +func (g *Game) UseTheme(name string) { g.activeTheme = name } +``` + +A widgetek a `ctx.Game.Theme()` hívásból olvassák ki a színeket — **minden +frame friss**, így futás közbeni téma-csere zökkenőmentes. + +### Szállított preset-ek + +A library a következő témákat regisztrálja induláskor (vagy a domain hívja +őket `pncdsl.RegisterPresetThemes(g)`-vel): + +| Preset | Mit reprezentál | +|-----------------------|------------------------------------------------------------------------| +| `classic-scumm` | A jelenlegi sötét-kékes paletta, SCUMM-szerű, alapértelmezett. | +| `sierra-coin` | Sötétebb, a verb-coin használathoz hangolt (nincs alsó verb-bar). | +| `paper-notebook` | Világos, krémszínű, mint egy papír — modern indie-stílus. | +| `terminal-green` | Klasszikus zöld-fekete terminál-look, retro hacker hangulat. | + +A `classic-scumm` **pontosan a jelenlegi színekkel** indul, így a meglévő +demo változtatás nélkül ugyanúgy néz ki, csak már a Theme-rendszeren át. + +```go +// pncdsl/ui.theme_presets.go — a built-in presetek +func RegisterPresetThemes(g *Game) { + g.ThemeManager.Register(Theme{Name: "classic-scumm", /* … */ }) + g.ThemeManager.Register(Theme{Name: "sierra-coin", /* … */ }) + // … +} +``` + +`NewGame()` automatikusan hívja, és `UseTheme("classic-scumm")`-ot állít be — +backwards-compatible default. + +## Lifecycle — pontosabban + +```go +// engine.Update +func (e *engine) Update() error { + g.input.poll() + g.transition.update(dt) + if g.transition.active && g.transition.out { return nil } + if g.scriptRunner != nil { /* tick runner */ } + + uictx := &UICtx{Game: g, DT: dt} + + // 1) Top-down input feldolgozás. A felül lévő widget (reverse reg-order) + // kapja először az inputot; a saját Tick-jében dönthet a Consume-ról. + for _, w := range g.UIManager.reversed() { + w.Tick(uictx) + } + + // 2) Ha az input még nincs konzumálva, a scene/hotspot kap esélyt: + e.handleSceneInput() +} + +// engine.Draw +func (e *engine) Draw(screen *ebiten.Image) { + // háttér + karakterek + g.UIManager.Each(func(w Widget) { w.Draw(screen, uictx) }) + g.transition.draw(screen, ...) +} +``` + +A `Cursor` widget mindig **utolsóként regisztrálva** — biztosan a tetejére +rajzol és előbb kap inputot, mint bármi más, ha kell. + +A modális widgetek (`DialogBox`, `EndCard`) belsőleg figyelik az aktív +állapotot (`g.dialogueActive()`, `g.UI.endCard != ""`), és csak akkor +fogadnak inputot / rajzolnak. Ha aktívak, **konzumálnak minden inputot**, +hogy a háttér widgetek és a scene-input ne reagáljon. + +## Mit kapcsol ki / mire vált ki + +A jelenlegi `pncdsl/ui.def.go` központi `UI` struct **megszűnik mint +adattároló** — minden ami benne volt (`hoverLabel`, `flash`, `speech`, +`dialog`, `endCard`, `verbButtons`) **átköltözik az adott widgetbe** mint +belső állapot. + +A `Game.UI` mező marad mint convenience-handle (pl. `g.UI.FlashLine(...)` +továbbra is hívható — átdelegál a `StatusLine` widgetnek a manager-en +keresztül), de ez egy vékony adapter, nem központi state. + +## Fájlok + +``` +pncdsl/ +├── ui.widget.go # új — Widget interface, UICtx, MouseButton, Size +├── ui.manager.go # új — UIManager alias, reversed() iterátor +├── ui.theme.go # új — Theme struct, ThemeManager alias +├── ui.theme_presets.go # új — classic-scumm, sierra-coin, paper-notebook, terminal-green +│ +├── ui.panel.go # új — Panel widget (generic container) +├── ui.status.go # új — StatusLine widget (volt: ui.def hover/flash) +├── ui.verb_bar.go # átnevezve: ui.verb.go → ui.verb_bar.go (most widget) +├── ui.verb_radial.go # új — RadialVerbs (verb-coin) +├── ui.inventory.go # új — InventoryBar widget (volt: ui.def-ben) +├── ui.speech.go # → SpeechBubble widget interface-é alakítva +├── ui.dialog_box.go # átnevezve: dialog.box.go (most widget) +├── ui.end_card.go # új — EndCard widget +├── ui.cursor.go # → Cursor widget interface-é alakítva +├── ui.hotspot_debug.go # új — HotspotDebug widget (toggle-elhető) +│ +└── ui.def.go # zsugorodik egy vékony Game.UI adapterre vagy törölhető +``` + +Az `ui.verb_manager.go` (verb-manager alias) **változatlan marad** — a +`RadialVerbs` és a `VerbBar` is ugyanazt a `VerbManager`-t olvassa. + +## Domain-oldali használat (a végén így néz ki) + +```go +// domain/game.go +func Build() *p.Game { + g := p.NewGame("Reggeli Kávé", 320, 200) + + registerAssets(g) + definePlayer(g); defineCat(g) + // … itemek, dialógusok, scriptek, scene-ek … + + // SCUMM-szerű alapértelmezett UI: + g.UIManager.Register(&p.HotspotDebug{Name: "debug"}) // legalul (z=0) + g.UIManager.Register(&p.VerbBar{Name: "verbs", + Origin: p.Point{X: 4, Y: 152}, Cols: 2, + Button: p.Size{W: 60, H: 14}}) + g.UIManager.Register(&p.InventoryBar{Name: "inventory", + Origin: p.Point{X: 132, Y: 152}, Slots: 8, Cols: 8, SlotSize: 22, Gap: 2}) + g.UIManager.Register(&p.StatusLine{Name: "status", Y: 142}) + g.UIManager.Register(&p.SpeechBubble{Name: "speech"}) + g.UIManager.Register(&p.DialogBox{Name: "dialog"}) + g.UIManager.Register(&p.EndCard{Name: "endcard"}) + g.UIManager.Register(&p.Cursor{Name: "cursor"}) // legfelül + + g.UseTheme("classic-scumm") + g.StartAt("bedroom").OnStart(p.RunScript("intro")) + return g +} +``` + +Ha verb-coin kell verb-bar helyett, ennyi a változás: + +```go +// töröld a VerbBar regisztrációt, helyette: +g.UIManager.Register(&p.RadialVerbs{Name: "verbs", Radius: 40}) +g.UseTheme("sierra-coin") +``` + +És ha chat is kell: + +```go +g.UIManager.Register(&ChatPanel{Name: "chat", + Bounds: p.Rect(220, 4, 96, 130), MaxLines: 8}) +``` + +## A „minden alapból regisztrálva" rövidítés + +A fenti boilerplate cseppet sok ahhoz, hogy minden domain újra leírja. A +`pncdsl.RegisterDefaultUI(g)` egysoros hívás regisztrálja a SCUMM-szerű +default készletet: + +```go +g := p.NewGame(...) +p.RegisterDefaultUI(g) // verbs + inventory + status + speech + dialog + endcard + cursor + +// a domain csak felülír / hozzáad: +g.UIManager.MustGet("verbs").(*p.VerbBar).Cols = 4 +g.UIManager.Register(&ChatPanel{...}) +``` + +A `NewGame` **nem** hívja automatikusan — explicit kell, hogy a domain +lássa hova kerülnek a default widgetek (és könnyen kivehessen onnan). + +## Validáció + +`Game.Validate()` kiegészül: + +- Minden regisztrált `Widget.GetName()` egyedi (ezt amúgy a `Register` + panic-cal kiszűri). +- `ActiveTheme` létezik a `ThemeManager`-ben. +- Legalább egy `SpeechBubble` és egy `DialogBox` regisztrálva, mert a + `Say` és `RunDialogue` action ezekre számít. (Vagy: ezek a built-in actionök + graceful no-op-pá válnak ha hiányoznak — eldöntendő.) + +## Lépésenkénti implementáció + +1. **Widget interface + UIManager + Theme + ThemeManager** — csontváz, még + üres widget-ekkel. NewGame regisztrálja a manager-eket; a régi UI + párhuzamosan él tovább. +2. **`Panel`, `StatusLine`, `SpeechBubble`, `Cursor`** — refaktoráld át + ezeket widgetekre. A régi `UI.SetSpeech`/`UI.FlashLine` delegál a + widgetekhez. +3. **`VerbBar` widget** — a régi `rebuildVerbButtons` + draw + hit-test + kerül bele. Az engine `handleFreeInput` ezt használja. +4. **`InventoryBar`, `DialogBox`, `EndCard`, `HotspotDebug`** — a maradék + beépített widgetek. +5. **`Theme` preset-ek** — minden hard-kódolt szín kikerül a Theme-be. + `RegisterPresetThemes` regisztrálja a négyet. +6. **`RadialVerbs`** — új widget, verb-coin működéssel. +7. **`RegisterDefaultUI`** convenience + dokumentáció. +8. **Demo átállítása** — `domain/game.go` használja az új API-t. Smoke test + marad zöld. +9. **Régi `ui.def.go` központi state kitörlése** vagy zsugorítása vékony + adapterre. + +Egy-egy lépés után a játék fut, a smoke test zöld — semmi atomi nagy commit. + +## Out of scope (most) + +- **Egyéni font** — a `Game.DrawText` továbbra is ebitenutil debug fontot + használ. A Theme-ben lesz `Font` mező placeholder, de a swap később. +- **Animált widgetek** — pl. tweenelt megjelenés a `DialogBox`-nak. Felület + van rá (Tick), implementáció későbbre. +- **Drag and drop az inventory-ban** — most csak klikk. +- **Több aktív theme egyszerre** (pl. UI overlay külön téma). Egy aktív téma. +- **Layout-szerver / declarative layout DSL** (mint a CSS flexbox). A widget + maga felel a saját pozíciójáért. Ha kell csoportosítás, `Panel` widget + + children API jöhet a 2. iterációban. + +## Nyitott kérdés + +A `Game.UI` (jelenlegi convenience-handle, `g.UI.FlashLine` stb.) maradjon, +vagy minden hívás közvetlenül `g.UIManager.MustGet("status").(*StatusLine).Flash(...)`? + +Javaslom: maradjon mint **shortcut**, de mögötte a widget-rendszer fut. A +hivatkozást egy `(g *Game) Status() *StatusLine` típusú getter is megteheti +a publikus API-n, hogy ne kelljen type-assertelni a domain kódjában. diff --git a/domain/game.go b/domain/game.go index 39aead9..cb449a2 100644 --- a/domain/game.go +++ b/domain/game.go @@ -25,6 +25,11 @@ func Build() *p.Game { defineBedroom(g) defineKitchen(g) + // SCUMM-style HUD. Swap for p.RegisterRadialVerbUI(g) to try the + // verb-coin variant, or build a fully custom widget set by calling + // g.UIManager.Register(...) directly. + p.RegisterDefaultUI(g) + g.StartAt("bedroom").OnStart(p.RunScript("intro")) return g } diff --git a/pncdsl/action.def.go b/pncdsl/action.def.go index 864f4c8..b9054a9 100644 --- a/pncdsl/action.def.go +++ b/pncdsl/action.def.go @@ -224,15 +224,15 @@ func (r *sayRunner) Tick(ctx *Ctx) Status { r.started = true // duration scales with text length, with a 1.2s floor r.duration = 1.2 + float64(len(r.spec.text))*0.05 - ctx.Game.UI.SetSpeech(r.spec.speaker, r.spec.text) + ctx.Game.SetSpeech(r.spec.speaker, r.spec.text) } r.elapsed += ctx.DT // skip on click - if ctx.Game.input.consumedClick() { + if ctx.Game.Input.consumedClick() { r.elapsed = r.duration } if r.elapsed >= r.duration { - ctx.Game.UI.ClearSpeech() + ctx.Game.ClearSpeech() return StatusDone } return StatusRunning @@ -279,7 +279,7 @@ func (a *requireItemAction) Tick(ctx *Ctx) Status { if ctx.Game.Inventory.Has(a.item) { return StatusDone } - ctx.Game.UI.FlashLine("Ehhez kell egy " + a.item + ".") + ctx.Game.FlashLine("Ehhez kell egy " + a.item + ".") return StatusFailed } diff --git a/pncdsl/core.dsl.go b/pncdsl/core.dsl.go index 2d13bfc..7ad4b12 100644 --- a/pncdsl/core.dsl.go +++ b/pncdsl/core.dsl.go @@ -10,7 +10,11 @@ func Run(g *Game) error { if err := g.Validate(); err != nil { return err } - g.UI.rebuildVerbButtons() + // If the domain didn't register any widgets, fall back to the SCUMM + // preset so the game is still playable. + if g.UIManager.Len() == 0 { + RegisterDefaultUI(g) + } // Initial scene goes in directly (no transition) so OnEnter / OnStart // run cleanly as one composed sequence on the first script tick. diff --git a/pncdsl/core.engine.go b/pncdsl/core.engine.go index 4b53dc7..d5e56f4 100644 --- a/pncdsl/core.engine.go +++ b/pncdsl/core.engine.go @@ -7,8 +7,10 @@ import ( "github.com/hajimehoshi/ebiten/v2/vector" ) -// engine is the ebiten.Game adapter. It owns the per-frame Update/Draw flow -// and delegates everything stateful to *Game. +// engine is the ebiten.Game adapter. Each frame it polls input, advances +// the active script (if any), then lets every registered Widget tick +// (reverse registration order, so the top widget claims input first). +// Whatever survives is offered to the scene as a hotspot click. type engine struct { g *Game } @@ -20,29 +22,24 @@ func (e *engine) Layout(outsideWidth, outsideHeight int) (int, int) { func (e *engine) Update() error { g := e.g dt := 1.0 / 60.0 - g.input.poll() - g.UI.tick(dt) + + g.Input.poll() g.transition.update(dt) if g.transition.active && g.transition.out { - // during fade-out, freeze input - return nil - } - - if g.UI.endCard != "" { return nil } + // Active script consumes the frame. if g.scriptRunner != nil { ctx := g.scriptCtx ctx.DT = dt - // keep Scene reference fresh in case the script changed it if g.currentScene != "" { s := g.SceneManager.MustGet(g.currentScene) ctx.Scene = &s } - s := g.scriptRunner.Tick(ctx) - if s != StatusRunning { + st := g.scriptRunner.Tick(ctx) + if st != StatusRunning { g.scriptRunner = nil g.scriptCtx = nil } @@ -50,106 +47,57 @@ func (e *engine) Update() error { return nil } - // dialog click handling - if g.dialog != nil { - if g.input.LeftClicked() { - g.input.ConsumeLeft() - ctx := g.makeCtx() - picked := g.dialog.handleClick(ctx, g.input.Point()) - if picked != nil { - // record once-tracking - if picked.Once { - g.State.NoteTalked(g.dialog.node.Name + ":" + picked.Text) - } - // run the choice's actions as a synthetic script - if len(picked.Actions) > 0 { - g.queueAction(Seq(picked.Actions...), "choice") - } - } - } - g.tickCharacters(dt) - return nil + uictx := &UICtx{Game: g, DT: dt} + + // Clear per-frame transient state that widgets / scene-resolution write. + g.SetHoverLabel("") + + // Top-down input: the widget drawn last (= registered last) gets the + // click first, then the next-to-last, etc. A widget signals "I took it" + // via g.Input.ConsumeLeft / ConsumeRight. + for _, w := range reversedWidgets(g.UIManager) { + w.Tick(uictx) } - // free interaction - e.handleFreeInput() + // Anything still unconsumed is a scene-world interaction. + e.handleSceneInput() g.tickCharacters(dt) return nil } -func (e *engine) handleFreeInput() { +// handleSceneInput is the legacy "click on hotspot with active verb" flow, +// running only when no widget has claimed the input. +func (e *engine) handleSceneInput() { g := e.g - g.UI.rebuildVerbButtons() - mp := g.input.Point() - - // hover label resolution - g.UI.hoverLabel = "" - if mp.Y < uiPanelY-4 { - if h := e.hotspotAt(mp); h != nil { - if h.Label != "" { - g.UI.hoverLabel = h.Label - } else { - g.UI.hoverLabel = h.Name - } - } - } else { - // hovering UI: show item description when over an inv slot - if hit := g.UI.hitTest(mp); len(hit) > 4 && hit[:4] == "inv:" { - name := hit[4:] - if it, ok := g.ItemManager.Get(name); ok && it.Description != "" { - g.UI.hoverLabel = it.Description - } else { - g.UI.hoverLabel = name - } + mp := g.Input.Point() + // hover label resolution: pointer over a hotspot in the current scene + if h := e.hotspotAt(mp); h != nil { + if h.Label != "" { + g.SetHoverLabel(h.Label) + } else { + g.SetHoverLabel(h.Name) } } - if g.input.RightClicked() { - g.input.ConsumeRight() - // right click: deselect item / reset verb + if g.Input.RightClicked() { + g.Input.ConsumeRight() if g.Inventory.Selected() != "" { g.Inventory.Select("") } else { - g.selectedVerb = "look" + g.SetSelectedVerb("look") } } - if !g.input.LeftClicked() { + if !g.Input.LeftClicked() { return } - - // UI hits first - if hit := g.UI.hitTest(mp); hit != "" { - g.input.ConsumeLeft() - switch hit[:4] { - case "verb": - g.selectedVerb = hit[5:] - case "inv:": - name := hit[4:] - if g.selectedVerb == "look" { - if it, ok := g.ItemManager.Get(name); ok && it.Description != "" { - g.queueAction(Say("player", it.Description), "inv-look") - return - } - } - if g.Inventory.Selected() == name { - g.Inventory.Select("") - } else { - g.Inventory.Select(name) - } - } - return - } - - // game-world click - g.input.ConsumeLeft() + g.Input.ConsumeLeft() h := e.hotspotAt(mp) if h == nil { return } sel := g.Inventory.Selected() if sel != "" { - // use-with: first check hotspot's OnUseWith, then item's if h.OnUseWith != nil { if a, ok := h.OnUseWith[sel]; ok && a != nil { g.queueAction(a, "useWith hotspot") @@ -164,21 +112,21 @@ func (e *engine) handleFreeInput() { return } } - g.UI.FlashLine("Nem ehhez.") + g.FlashLine("Nem ehhez.") g.Inventory.Select("") return } - a := h.handler(g.selectedVerb) + a := h.handler(g.SelectedVerb()) if a == nil { - if v, ok := g.VerbManager.Get(g.selectedVerb); ok && v.Default != nil { + if v, ok := g.VerbManager.Get(g.SelectedVerb()); ok && v.Default != nil { a = v.Default } } if a == nil { - g.UI.FlashLine("Semmi említésre méltó.") + g.FlashLine("Semmi említésre méltó.") return } - g.queueAction(a, "hotspot "+g.selectedVerb+" "+h.Name) + g.queueAction(a, "hotspot "+g.SelectedVerb()+" "+h.Name) } func (e *engine) hotspotAt(p Point) *Hotspot { @@ -198,24 +146,22 @@ func (e *engine) hotspotAt(p Point) *Hotspot { func (e *engine) Draw(screen *ebiten.Image) { g := e.g + th := g.Theme() + + // optional backdrop (helps when the background image doesn't fill the screen) + if th.SceneBackdrop != nil { + screen.Fill(th.SceneBackdrop) + } + // scene background if g.currentScene != "" { s := g.SceneManager.MustGet(g.currentScene) img := g.loaded.image(g.AssetManager, s.Background) - op := &ebiten.DrawImageOptions{} bw, bh := img.Bounds().Dx(), img.Bounds().Dy() if bw > 0 && bh > 0 { + op := &ebiten.DrawImageOptions{} op.GeoM.Scale(float64(g.Width)/float64(bw), float64(g.Height)/float64(bh)) - } - screen.DrawImage(img, op) - - // hotspot debug outlines - if DebugLog { - for _, h := range s.Hotspots { - if r, ok := h.Area.(Rectangle); ok { - vector.StrokeRect(screen, float32(r.X), float32(r.Y), float32(r.W), float32(r.H), 1, color.RGBA{255, 255, 0, 200}, false) - } - } + screen.DrawImage(img, op) } } else { screen.Fill(color.RGBA{0, 0, 0, 255}) @@ -226,28 +172,16 @@ func (e *engine) Draw(screen *ebiten.Image) { drawCharacter(screen, g, c) } - // speech bubble - g.UI.speech.draw(screen, g) - - // dialog box (if active) - if g.dialog != nil { - g.dialog.draw(screen, g) - } else { - g.UI.drawHUD(screen) + // widgets in registration order + uictx := &UICtx{Game: g, DT: 1.0 / 60.0} + for _, w := range orderedWidgets(g.UIManager) { + w.Draw(screen, uictx) } - // transition overlay + // transition overlay on top of everything except cursor (cursor is the + // last widget, so it has already drawn above the transition? no — the + // fade should cover the cursor too; draw it after the widgets). g.transition.draw(screen, g.Width, g.Height) - - // end card - if g.UI.endCard != "" { - vector.DrawFilledRect(screen, 0, 0, float32(g.Width), float32(g.Height), color.RGBA{0, 0, 0, 230}, false) - w := textWidth(g.UI.endCard) - drawText(screen, g.UI.endCard, (g.Width-w)/2, g.Height/2-8, color.White) - } - - // cursor on top - drawCursor(screen, g) } func sortedChars(g *Game) []*runtimeChar { @@ -255,7 +189,6 @@ func sortedChars(g *Game) []*runtimeChar { for _, c := range g.chars { out = append(out, c) } - // insertion sort by Y (small N) for i := 1; i < len(out); i++ { for j := i; j > 0 && out[j].pos.Y < out[j-1].pos.Y; j-- { out[j], out[j-1] = out[j-1], out[j] @@ -273,8 +206,6 @@ func drawCharacter(dst *ebiten.Image, g *Game, c *runtimeChar) { if h == 0 { h = 56 } - // Try the real sprite first. Only fall through to the stylized - // placeholder when no actual art is on disk. if c.def.Sprite != "" && !g.loaded.isPlaceholder(c.def.Sprite) { img := g.loaded.image(g.AssetManager, c.def.Sprite) sw, sh := img.Bounds().Dx(), img.Bounds().Dy() @@ -286,7 +217,6 @@ func drawCharacter(dst *ebiten.Image, g *Game, c *runtimeChar) { return } } - // Touch the asset cache so isPlaceholder is populated for later frames. if c.def.Sprite != "" { _ = g.loaded.image(g.AssetManager, c.def.Sprite) } @@ -311,23 +241,19 @@ func drawHumanoidPlaceholder(dst *ebiten.Image, x, y, w, h float64, body color.R headR := h * 0.14 headCY := y + headR + 1 - // pants / legs — two narrow strips pantsTop := y + h*0.62 pantsH := h - (pantsTop - y) - 1 legW := w * 0.36 vector.DrawFilledRect(dst, float32(x+w*0.08), float32(pantsTop), float32(legW), float32(pantsH), pants, false) vector.DrawFilledRect(dst, float32(x+w*0.56), float32(pantsTop), float32(legW), float32(pantsH), pants, false) - // torso — full-width rect, shirt color torsoTop := headCY + headR torsoH := pantsTop - torsoTop vector.DrawFilledRect(dst, float32(x), float32(torsoTop), float32(w), float32(torsoH), body, false) vector.StrokeRect(dst, float32(x), float32(torsoTop), float32(w), float32(torsoH), 1, outline, false) - // head — circle in skin tone vector.DrawFilledCircle(dst, float32(x+w/2), float32(headCY), float32(headR), skin, true) - // feet — short dark blobs at the very bottom footH := 2.0 vector.DrawFilledRect(dst, float32(x+w*0.05), float32(y+h-footH), float32(legW+2), float32(footH), outline, false) vector.DrawFilledRect(dst, float32(x+w*0.55), float32(y+h-footH), float32(legW+2), float32(footH), outline, false) @@ -335,34 +261,26 @@ func drawHumanoidPlaceholder(dst *ebiten.Image, x, y, w, h float64, body color.R func drawQuadrupedPlaceholder(dst *ebiten.Image, x, y, w, h float64, body color.RGBA) { outline := color.RGBA{20, 20, 30, 255} - dark := color.RGBA{ - R: body.R / 2, G: body.G / 2, B: body.B / 2, A: 255, - } + dark := color.RGBA{R: body.R / 2, G: body.G / 2, B: body.B / 2, A: 255} - // body bodyY := y + h*0.30 bodyH := h * 0.65 vector.DrawFilledRect(dst, float32(x), float32(bodyY), float32(w*0.78), float32(bodyH), body, false) - // head — circle on the right headR := h * 0.30 headCX := x + w - headR headCY := y + h*0.50 vector.DrawFilledCircle(dst, float32(headCX), float32(headCY), float32(headR), body, true) - // ears — two triangles approximated as small rects earW := w * 0.07 earH := h * 0.30 vector.DrawFilledRect(dst, float32(headCX-headR*0.7), float32(y), float32(earW), float32(earH), body, false) vector.DrawFilledRect(dst, float32(headCX+headR*0.4), float32(y), float32(earW), float32(earH), body, false) - // eye — dark dot vector.DrawFilledCircle(dst, float32(headCX+headR*0.25), float32(headCY-1), 1.2, outline, true) - // tail — small dark line stub on the left vector.DrawFilledRect(dst, float32(x-w*0.04), float32(bodyY+1), float32(w*0.08), 2, dark, false) - // legs — two short bars at the bottom legW := w * 0.07 vector.DrawFilledRect(dst, float32(x+w*0.10), float32(y+h-3), float32(legW), 3, dark, false) vector.DrawFilledRect(dst, float32(x+w*0.55), float32(y+h-3), float32(legW), 3, dark, false) diff --git a/pncdsl/core.game.go b/pncdsl/core.game.go index 1fade69..0360ee0 100644 --- a/pncdsl/core.game.go +++ b/pncdsl/core.game.go @@ -2,11 +2,14 @@ package pncdsl import ( "fmt" + "image/color" + + "github.com/hajimehoshi/ebiten/v2" ) // Game is the root aggregate. It carries every Manager plus the runtime -// state. A domain package builds one via NewGame, registers entities, then -// calls Run (or hands it to pncdsl.Run). +// state. A domain package builds one via NewGame, registers entities, +// optionally calls RegisterDefaultUI / UseTheme, then hands it to Run. type Game struct { Title string Width, Height int @@ -18,30 +21,58 @@ type Game struct { ScriptManager *ScriptManager AssetManager *AssetManager VerbManager *VerbManager + UIManager *UIManager + ThemeManager *ThemeManager State *State Inventory *Inventory - UI *UI Audio *AudioPlayer Camera *Camera + Input *Input - startID string - onStart Action + startID string + onStart Action + activeTheme string // runtime loaded *loadedAssets - input *Input currentScene string chars map[string]*runtimeChar scriptRunner Runner - scriptCtx *Ctx // for click consumption + scriptCtx *Ctx transition *transition - dialog *dialogBox - activeDialog string selectedVerb string + + // UI runtime state (read by widgets, written by actions / engine) + hoverLabel string + flash string + flashTimer float64 + endCard string + speech speechState + dialog *runtimeDialog + activeDialog string } -// NewGame initializes a game with empty managers and the SCUMM-style verb set. +// speechState is what the SpeechBubble widget renders. Mutated by Say. +type speechState struct { + Active bool + Speaker string + Text string +} + +// runtimeDialog holds the in-flight Dialogue state. Mutated by the +// startDialogue/gotoDialogueNode/endDialogue plumbing and read by the +// DialogBox widget. +type runtimeDialog struct { + Dialogue *Dialogue + Node *DialogueNode + LineIdx int + ChoiceHits []Rectangle +} + +// NewGame initializes a game with empty entity managers, the SCUMM-style +// verb set, all preset themes, and "classic-scumm" selected. Widgets are +// NOT auto-registered — call RegisterDefaultUI(g) explicitly. func NewGame(title string, w, h int) *Game { g := &Game{ Title: title, @@ -54,27 +85,77 @@ func NewGame(title string, w, h int) *Game { ScriptManager: NewManager[Script](), AssetManager: NewManager[Asset](), VerbManager: NewManager[Verb](), + UIManager: NewManager[Widget](), + ThemeManager: NewManager[Theme](), State: NewState(), Inventory: NewInventory(), Audio: NewAudioPlayer(), Camera: NewCamera(), + Input: newInput(), loaded: newLoadedAssets(w, h), - input: newInput(), chars: make(map[string]*runtimeChar), transition: &transition{}, selectedVerb: "look", } - g.UI = newUI(g) for _, v := range defaultVerbs() { g.VerbManager.Register(v) } + RegisterPresetThemes(g) + g.UseTheme("classic-scumm") return g } func (g *Game) StartAt(name string) *Game { g.startID = name; return g } func (g *Game) OnStart(a Action) *Game { g.onStart = a; return g } -// Validate cross-checks name references between managers. +// ----- theme + UI conveniences ------------------------------------------ + +func (g *Game) Theme() Theme { return g.ThemeManager.MustGet(g.activeTheme) } +func (g *Game) UseTheme(name string) { g.activeTheme = name } +func (g *Game) SelectedVerb() string { return g.selectedVerb } +func (g *Game) SetSelectedVerb(s string) { g.selectedVerb = s } +func (g *Game) HoverLabel() string { return g.hoverLabel } +func (g *Game) SetHoverLabel(s string) { g.hoverLabel = s } + +// SetSpeech / ClearSpeech are called by the Say action; widgets render +// whatever the current state says. +func (g *Game) SetSpeech(speaker, text string) { + g.speech = speechState{Active: true, Speaker: speaker, Text: text} +} +func (g *Game) ClearSpeech() { g.speech = speechState{} } + +// FlashLine shows a status-line message for ~2 seconds. +func (g *Game) FlashLine(text string) { + g.flash = text + g.flashTimer = 2.0 +} + +// DrawText is a thin wrapper that lets widgets accept a Theme-supplied color +// today and switch to text/v2 later without changing call sites. +func (g *Game) DrawText(dst *ebiten.Image, s string, x, y int, c color.Color) { + drawText(dst, s, x, y, c) +} + +// HoverHint composes the "verb + target" string used by the StatusLine. +func (g *Game) HoverHint() string { + verbLabel := "" + if v, ok := g.VerbManager.Get(g.selectedVerb); ok { + verbLabel = v.Label + } + if sel := g.Inventory.Selected(); sel != "" { + if g.hoverLabel != "" { + return verbLabel + " " + sel + " ezen: " + g.hoverLabel + } + return verbLabel + " " + sel + } + if g.hoverLabel == "" { + return "" + } + return verbLabel + " " + g.hoverLabel +} + +// ----- validation ------------------------------------------------------- + func (g *Game) Validate() error { if !g.SceneManager.Has(g.startID) { return fmt.Errorf("%w: %q", ErrNoStartScene, g.startID) @@ -96,10 +177,13 @@ func (g *Game) Validate() error { } } } + if g.activeTheme == "" || !g.ThemeManager.Has(g.activeTheme) { + return fmt.Errorf("pncdsl: no active theme (got %q)", g.activeTheme) + } return nil } -// ----- runtime helpers --------------------------------------------------- +// ----- runtime: characters & scenes ------------------------------------- type runtimeChar struct { def Character @@ -231,7 +315,7 @@ func (g *Game) startDialogue(name string) { return } g.activeDialog = name - g.dialog = newDialogBox(&d, &node) + g.dialog = &runtimeDialog{Dialogue: &d, Node: &node, LineIdx: 0} } func (g *Game) endDialogue() { @@ -249,11 +333,11 @@ func (g *Game) gotoDialogueNode(name string) { logf("gotoNode: unknown %q in %q", name, g.activeDialog) return } - g.dialog.node = &node - g.dialog.lineIdx = 0 - g.dialog.choiceHits = nil + g.dialog.Node = &node + g.dialog.LineIdx = 0 + g.dialog.ChoiceHits = nil } func (g *Game) dialogueActive() bool { return g.dialog != nil } -func (g *Game) showEndCard(text string) { g.UI.endCard = text } +func (g *Game) showEndCard(text string) { g.endCard = text } diff --git a/pncdsl/core.manager.go b/pncdsl/core.manager.go index 9f4b8cb..51c9a4a 100644 --- a/pncdsl/core.manager.go +++ b/pncdsl/core.manager.go @@ -8,9 +8,11 @@ type Named interface { } // Manager is the single registry shape used by every entity type. Each entity -// kind gets a named alias (ItemManager = Manager[Item], etc.). +// kind gets a named alias (ItemManager = Manager[Item], etc.). Insertion +// order is preserved — Names() returns it. SortedNames() returns alphabetical. type Manager[T Named] struct { items map[string]T + order []string } func NewManager[T Named]() *Manager[T] { @@ -28,6 +30,7 @@ func (m *Manager[T]) Register(v T) { panic("pncdsl: Register: duplicate " + typeName(v) + " name: " + name) } m.items[name] = v + m.order = append(m.order, name) } func (m *Manager[T]) Get(name string) (T, bool) { @@ -50,21 +53,39 @@ func (m *Manager[T]) Has(name string) bool { func (m *Manager[T]) Len() int { return len(m.items) } +// Names returns the registered names in insertion order. Use SortedNames +// when iteration order needs to be stable across runs. func (m *Manager[T]) Names() []string { - names := make([]string, 0, len(m.items)) - for n := range m.items { - names = append(names, n) - } + return append([]string(nil), m.order...) +} + +func (m *Manager[T]) SortedNames() []string { + names := append([]string(nil), m.order...) sort.Strings(names) return names } func (m *Manager[T]) Each(fn func(T)) { - for _, n := range m.Names() { + for _, n := range m.order { fn(m.items[n]) } } +// Remove drops a registered entry. Used by hot-reload / dynamic UI changes. +// Silently no-ops on unknown names. +func (m *Manager[T]) Remove(name string) { + if _, ok := m.items[name]; !ok { + return + } + delete(m.items, name) + for i, n := range m.order { + if n == name { + m.order = append(m.order[:i], m.order[i+1:]...) + return + } + } +} + func typeName(v any) string { type namer interface{ TypeLabel() string } if n, ok := v.(namer); ok { diff --git a/pncdsl/dialog.box.go b/pncdsl/dialog.box.go deleted file mode 100644 index 3967c15..0000000 --- a/pncdsl/dialog.box.go +++ /dev/null @@ -1,97 +0,0 @@ -package pncdsl - -import ( - "image/color" - - "github.com/hajimehoshi/ebiten/v2" - "github.com/hajimehoshi/ebiten/v2/vector" -) - -// dialogBox is the active-conversation UI: it shows the current node's -// lines (one at a time, advanced on click) followed by the visible choices. -type dialogBox struct { - dialogue *Dialogue - node *DialogueNode - lineIdx int // -1 = lines done, showing choices - choiceHits []Rectangle -} - -func newDialogBox(d *Dialogue, n *DialogueNode) *dialogBox { - return &dialogBox{dialogue: d, node: n, lineIdx: 0} -} - -// resolveChoices returns the list of choices currently visible to the player. -func (db *dialogBox) resolveChoices(ctx *Ctx) []DialogueChoice { - out := make([]DialogueChoice, 0, len(db.node.Choices)) - for _, c := range db.node.Choices { - if c.Show != nil && !c.Show.Eval(ctx) { - continue - } - if c.Once && ctx.Game.State.Talked(db.node.Name+":"+c.Text) > 0 { - continue - } - out = append(out, c) - } - return out -} - -// handleClick advances the dialog state. Returns the picked choice (if any). -func (db *dialogBox) handleClick(ctx *Ctx, p Point) (picked *DialogueChoice) { - if db.lineIdx >= 0 && db.lineIdx < len(db.node.Lines) { - db.lineIdx++ - if db.lineIdx >= len(db.node.Lines) { - db.lineIdx = -1 - db.choiceHits = nil - } - return nil - } - for i, r := range db.choiceHits { - if r.Contains(p) { - ch := db.resolveChoices(ctx)[i] - return &ch - } - } - return nil -} - -func (db *dialogBox) currentLine() (DialogueLine, bool) { - if db.lineIdx >= 0 && db.lineIdx < len(db.node.Lines) { - return db.node.Lines[db.lineIdx], true - } - return DialogueLine{}, false -} - -func (db *dialogBox) draw(dst *ebiten.Image, g *Game) { - // box across the bottom 60 px - vector.DrawFilledRect(dst, 0, 140, 320, 60, color.RGBA{15, 15, 25, 240}, false) - vector.StrokeRect(dst, 0, 140, 320, 60, 1, color.RGBA{80, 80, 110, 255}, false) - - if ln, ok := db.currentLine(); ok { - speakerCol := color.RGBA{220, 220, 120, 255} - drawText(dst, ln.Speaker+":", 6, 142, speakerCol) - lines := wrapText(ln.Text, 300) - for i, l := range lines { - drawText(dst, l, 6, 158+i*14, color.White) - } - return - } - - // choices - ctx := g.makeCtx() - choices := db.resolveChoices(ctx) - db.choiceHits = db.choiceHits[:0] - for i, c := range choices { - y := 144 + i*14 - r := Rect(6, float64(y), 308, 13) - db.choiceHits = append(db.choiceHits, r) - mx, my := g.input.Pos() - hover := r.Contains(Point{X: float64(mx), Y: float64(my)}) - bg := color.RGBA{30, 30, 45, 255} - if hover { - bg = color.RGBA{70, 70, 100, 255} - } - vector.DrawFilledRect(dst, float32(r.X), float32(r.Y), float32(r.W), float32(r.H), bg, false) - drawText(dst, c.Text, int(r.X)+2, int(r.Y)-1, color.White) - _ = i - } -} diff --git a/pncdsl/ui.cursor.go b/pncdsl/ui.cursor.go index 6bcf0f5..ee3bcc3 100644 --- a/pncdsl/ui.cursor.go +++ b/pncdsl/ui.cursor.go @@ -1,21 +1,28 @@ package pncdsl import ( - "image/color" - "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/vector" ) -// drawCursor renders a simple crosshair at the mouse position. When an -// inventory item is selected, the item sprite is drawn instead. -func drawCursor(dst *ebiten.Image, g *Game) { - x, y := g.input.Pos() +// Cursor renders the mouse cursor. When the player has an inventory item +// selected, the item's sprite is drawn at the cursor instead of the +// default crosshair. Registered last so it draws on top. +type Cursor struct { + Name string +} + +func (c *Cursor) GetName() string { return c.Name } +func (c *Cursor) Tick(ctx *UICtx) {} + +func (c *Cursor) Draw(dst *ebiten.Image, ctx *UICtx) { + g := ctx.Game + x, y := g.Input.Pos() if sel := g.Inventory.Selected(); sel != "" { if it, ok := g.ItemManager.Get(sel); ok { img := g.loaded.image(g.AssetManager, it.Sprite) sw, sh := img.Bounds().Dx(), img.Bounds().Dy() - if sw > 0 && sh > 0 { + if sw > 0 && sh > 0 && !g.loaded.isPlaceholder(it.Sprite) { op := &ebiten.DrawImageOptions{} op.GeoM.Scale(16/float64(sw), 16/float64(sh)) op.GeoM.Translate(float64(x-8), float64(y-8)) @@ -24,7 +31,7 @@ func drawCursor(dst *ebiten.Image, g *Game) { } } } - c := color.RGBA{255, 255, 255, 255} - vector.StrokeLine(dst, float32(x-3), float32(y), float32(x+4), float32(y), 1, c, false) - vector.StrokeLine(dst, float32(x), float32(y-3), float32(x), float32(y+4), 1, c, false) + col := g.Theme().CursorColor + vector.StrokeLine(dst, float32(x-3), float32(y), float32(x+4), float32(y), 1, col, false) + vector.StrokeLine(dst, float32(x), float32(y-3), float32(x), float32(y+4), 1, col, false) } diff --git a/pncdsl/ui.def.go b/pncdsl/ui.def.go deleted file mode 100644 index 5847c9a..0000000 --- a/pncdsl/ui.def.go +++ /dev/null @@ -1,187 +0,0 @@ -package pncdsl - -import ( - "image/color" - - "github.com/hajimehoshi/ebiten/v2" - "github.com/hajimehoshi/ebiten/v2/vector" -) - -// Layout constants in the internal 320×200 coordinate space. -const ( - uiSceneTop = 0 - uiSceneBottom = 140 - uiStatusY = 142 - uiPanelY = 152 - uiPanelH = 48 - uiVerbsX = 4 - uiVerbsW = 120 - uiInvX = 132 - uiInvW = 184 - uiInvSlotW = 22 -) - -// UI is the per-game UI state. It's a simple bag of fields the engine -// writes into and the renderer reads. No event/signal layer — the engine -// drives everything top-down each tick. -type UI struct { - g *Game - - hoverLabel string - flash string - flashTimer float64 - - speech *speechBubble - dialog *dialogBox - endCard string - - verbButtons []verbButton -} - -func newUI(g *Game) *UI { - return &UI{ - g: g, - speech: &speechBubble{}, - } -} - -func (u *UI) rebuildVerbButtons() { - verbs := u.g.VerbManager.Names() - u.verbButtons = u.verbButtons[:0] - cols := 2 - bw := uiVerbsW / cols - bh := 14 - for i, name := range verbs { - col := i % cols - row := i / cols - x := uiVerbsX + col*bw - y := uiPanelY + row*bh - u.verbButtons = append(u.verbButtons, verbButton{ - Name: name, - Label: u.g.VerbManager.MustGet(name).Label, - Bounds: Rect(float64(x), float64(y), float64(bw-2), float64(bh-2)), - }) - } -} - -type verbButton struct { - Name string - Label string - Bounds Rectangle -} - -// SetSpeech / ClearSpeech are called from the Say action. -func (u *UI) SetSpeech(speaker, text string) { - u.speech.set(u.g, speaker, text) -} - -func (u *UI) ClearSpeech() { u.speech.clear() } - -// FlashLine shows a short status string (e.g. "Ehhez kell a kulcs.") for ~2s. -func (u *UI) FlashLine(text string) { - u.flash = text - u.flashTimer = 2.0 -} - -func (u *UI) tick(dt float64) { - if u.flashTimer > 0 { - u.flashTimer -= dt - if u.flashTimer <= 0 { - u.flash = "" - } - } - u.speech.tick(dt) -} - -// drawHUD paints the bottom UI panel (status line + verbs + inventory). -// Dialog box, if active, is drawn separately by the engine on top. -func (u *UI) drawHUD(dst *ebiten.Image) { - // status / hover / flash line - status := u.flash - if status == "" { - status = u.composedHoverLabel() - } - if status != "" { - w := textWidth(status) - drawText(dst, status, (320-w)/2, uiStatusY, color.White) - } - - // panel background - vector.DrawFilledRect(dst, 0, float32(uiPanelY-2), 320, float32(uiPanelH+2), color.RGBA{20, 20, 30, 255}, false) - - // verb buttons - for _, b := range u.verbButtons { - bg := color.RGBA{50, 50, 70, 255} - if b.Name == u.g.selectedVerb { - bg = color.RGBA{120, 90, 40, 255} - } - vector.DrawFilledRect(dst, float32(b.Bounds.X), float32(b.Bounds.Y), float32(b.Bounds.W), float32(b.Bounds.H), bg, false) - drawText(dst, b.Label, int(b.Bounds.X)+2, int(b.Bounds.Y)-1, color.White) - } - - // inventory slots - items := u.g.Inventory.Items() - for k := 0; k < 8; k++ { - x := uiInvX + k*uiInvSlotW - y := uiPanelY - bg := color.RGBA{40, 40, 50, 255} - if k < len(items) && items[k] == u.g.Inventory.Selected() { - bg = color.RGBA{120, 90, 40, 255} - } - vector.DrawFilledRect(dst, float32(x), float32(y), float32(uiInvSlotW-2), float32(uiInvSlotW-2), bg, false) - if k < len(items) { - it, ok := u.g.ItemManager.Get(items[k]) - if ok { - img := u.g.loaded.image(u.g.AssetManager, it.Sprite) - op := &ebiten.DrawImageOptions{} - sw, sh := img.Bounds().Dx(), img.Bounds().Dy() - if sw == 0 || sh == 0 { - continue - } - slotPx := float64(uiInvSlotW - 4) - sx := slotPx / float64(sw) - sy := slotPx / float64(sh) - op.GeoM.Scale(sx, sy) - op.GeoM.Translate(float64(x+1), float64(y+1)) - dst.DrawImage(img, op) - } - } - } -} - -func (u *UI) composedHoverLabel() string { - verb := "" - if v, ok := u.g.VerbManager.Get(u.g.selectedVerb); ok { - verb = v.Label - } - target := u.hoverLabel - if sel := u.g.Inventory.Selected(); sel != "" { - if target != "" { - return verb + " " + sel + " ezen: " + target - } - return verb + " " + sel - } - if target == "" { - return "" - } - return verb + " " + target -} - -// hitTestUI returns the click target (verbButton, inventory slot index) or "". -// "verb:", "inv:", "" for none. -func (u *UI) hitTest(p Point) string { - for _, b := range u.verbButtons { - if b.Bounds.Contains(p) { - return "verb:" + b.Name - } - } - items := u.g.Inventory.Items() - for k := 0; k < len(items); k++ { - x := uiInvX + k*uiInvSlotW - r := Rect(float64(x), float64(uiPanelY), float64(uiInvSlotW-2), float64(uiInvSlotW-2)) - if r.Contains(p) { - return "inv:" + items[k] - } - } - return "" -} diff --git a/pncdsl/ui.defaults.go b/pncdsl/ui.defaults.go new file mode 100644 index 0000000..7f508f6 --- /dev/null +++ b/pncdsl/ui.defaults.go @@ -0,0 +1,51 @@ +package pncdsl + +// RegisterDefaultUI installs the SCUMM-style HUD widgets into g.UIManager +// in the conventional Z-order (HotspotDebug at the back, Cursor on top). +// Domains that want a different layout call this and then either tweak +// the registered widgets in place or replace them entirely. +// +// Layout assumes the default 320×200 internal resolution. +func RegisterDefaultUI(g *Game) { + g.UIManager.Register(&HotspotDebug{Name: "hotspot_debug"}) + g.UIManager.Register(&VerbBar{ + Name: "verbs", + Origin: Point{X: 4, Y: 152}, + Cols: 2, + Button: Size{W: 60, H: 14}, + }) + g.UIManager.Register(&InventoryBar{ + Name: "inventory", + Origin: Point{X: 132, Y: 152}, + Slots: 8, + Cols: 8, + SlotSize: 22, + Gap: 2, + }) + g.UIManager.Register(&StatusLine{Name: "status", Y: 142, Align: AlignCenter}) + g.UIManager.Register(&SpeechBubble{Name: "speech", MaxWidth: 200, Padding: 3, OffsetY: 4, FallbackY: 14}) + g.UIManager.Register(&DialogBox{Name: "dialog", Bounds: Rect(0, 140, float64(g.Width), 60), LineHeight: 14, Padding: 6}) + g.UIManager.Register(&EndCard{Name: "endcard"}) + g.UIManager.Register(&Cursor{Name: "cursor"}) +} + +// RegisterRadialVerbUI installs an alternative HUD that swaps the +// permanent verb-bar for a verb-coin (right-click radial menu). Inventory, +// dialog, speech and cursor stay the same. +func RegisterRadialVerbUI(g *Game) { + g.UIManager.Register(&HotspotDebug{Name: "hotspot_debug"}) + g.UIManager.Register(&InventoryBar{ + Name: "inventory", + Origin: Point{X: 4, Y: 178}, + Slots: 14, + Cols: 14, + SlotSize: 22, + Gap: 0, + }) + g.UIManager.Register(&StatusLine{Name: "status", Y: 168, Align: AlignCenter}) + g.UIManager.Register(&SpeechBubble{Name: "speech", MaxWidth: 200, Padding: 3, OffsetY: 4, FallbackY: 14}) + g.UIManager.Register(&DialogBox{Name: "dialog", Bounds: Rect(0, 140, float64(g.Width), 60), LineHeight: 14, Padding: 6}) + g.UIManager.Register(&RadialVerbs{Name: "verbs", Trigger: MouseButtonRight, Radius: 40}) + g.UIManager.Register(&EndCard{Name: "endcard"}) + g.UIManager.Register(&Cursor{Name: "cursor"}) +} diff --git a/pncdsl/ui.dialog_box.go b/pncdsl/ui.dialog_box.go new file mode 100644 index 0000000..1bc65ff --- /dev/null +++ b/pncdsl/ui.dialog_box.go @@ -0,0 +1,134 @@ +package pncdsl + +import ( + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" +) + +// DialogBox renders the active conversation: one line at a time (advanced +// on click) followed by the player's choices. The widget is dormant +// unless ctx.Game.dialogueActive() — and while active it consumes input +// so nothing underneath reacts. +type DialogBox struct { + Name string + Bounds Rectangle + LineHeight int + Padding int +} + +func (d *DialogBox) GetName() string { return d.Name } + +func (d *DialogBox) Tick(ctx *UICtx) { + g := ctx.Game + if !g.dialogueActive() { + return + } + // while a dialog is open, ALL clicks belong to it + if g.Input.RightClicked() { + g.Input.ConsumeRight() + } + if !g.Input.LeftClicked() { + return + } + g.Input.ConsumeLeft() + mp := g.Input.Point() + + dlg := g.dialog + if dlg.LineIdx >= 0 && dlg.LineIdx < len(dlg.Node.Lines) { + dlg.LineIdx++ + if dlg.LineIdx >= len(dlg.Node.Lines) { + dlg.LineIdx = -1 + dlg.ChoiceHits = nil + } + return + } + // pick the clicked choice + for i, r := range dlg.ChoiceHits { + if r.Contains(mp) { + choice := d.resolveChoices(ctx)[i] + if choice.Once { + g.State.NoteTalked(dlg.Node.Name + ":" + choice.Text) + } + if len(choice.Actions) > 0 { + g.queueAction(Seq(choice.Actions...), "choice") + } + return + } + } +} + +func (d *DialogBox) resolveChoices(ctx *UICtx) []DialogueChoice { + g := ctx.Game + dlg := g.dialog + out := make([]DialogueChoice, 0, len(dlg.Node.Choices)) + for _, c := range dlg.Node.Choices { + gctx := g.makeCtx() + if c.Show != nil && !c.Show.Eval(gctx) { + continue + } + if c.Once && g.State.Talked(dlg.Node.Name+":"+c.Text) > 0 { + continue + } + out = append(out, c) + } + return out +} + +// BlocksClickAt — when a dialog is open, every click is ours. +func (d *DialogBox) BlocksClickAt(p Point) bool { + // without ctx we can't know if a dialog is active; conservatively block + // when the cursor is in the dialog bounds (used only by RadialVerbs). + b := d.Bounds + if b.W == 0 { + return false + } + return b.Contains(p) +} + +func (d *DialogBox) Draw(dst *ebiten.Image, ctx *UICtx) { + g := ctx.Game + if !g.dialogueActive() { + return + } + th := g.Theme() + b := d.Bounds + if b.W == 0 { + b = Rect(0, 140, float64(g.Width), 60) + } + pad := d.Padding + if pad <= 0 { + pad = 6 + } + lh := d.LineHeight + if lh <= 0 { + lh = 14 + } + + vector.DrawFilledRect(dst, float32(b.X), float32(b.Y), float32(b.W), float32(b.H), th.DialogBG, false) + vector.StrokeRect(dst, float32(b.X), float32(b.Y), float32(b.W), float32(b.H), 1, th.DialogBorder, false) + + dlg := g.dialog + if dlg.LineIdx >= 0 && dlg.LineIdx < len(dlg.Node.Lines) { + ln := dlg.Node.Lines[dlg.LineIdx] + g.DrawText(dst, ln.Speaker+":", int(b.X)+pad, int(b.Y)+2, th.DialogSpeaker) + lines := wrapText(ln.Text, int(b.W)-pad*2) + for i, l := range lines { + g.DrawText(dst, l, int(b.X)+pad, int(b.Y)+18+i*lh, th.DialogText) + } + return + } + + choices := d.resolveChoices(ctx) + dlg.ChoiceHits = dlg.ChoiceHits[:0] + mp := g.Input.Point() + for i, c := range choices { + r := Rect(b.X+float64(pad), b.Y+4+float64(i*lh), b.W-float64(pad)*2, float64(lh-1)) + dlg.ChoiceHits = append(dlg.ChoiceHits, r) + bg := th.DialogChoiceBG + if r.Contains(mp) { + bg = th.DialogChoiceHover + } + vector.DrawFilledRect(dst, float32(r.X), float32(r.Y), float32(r.W), float32(r.H), bg, false) + g.DrawText(dst, c.Text, int(r.X)+2, int(r.Y)-1, th.DialogText) + } +} diff --git a/pncdsl/ui.end_card.go b/pncdsl/ui.end_card.go new file mode 100644 index 0000000..315e3bc --- /dev/null +++ b/pncdsl/ui.end_card.go @@ -0,0 +1,38 @@ +package pncdsl + +import ( + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" +) + +// EndCard is a fullscreen overlay shown when the ShowEnd action fires. +// While active it consumes every input so nothing underneath reacts. +type EndCard struct { + Name string +} + +func (e *EndCard) GetName() string { return e.Name } + +func (e *EndCard) Tick(ctx *UICtx) { + if ctx.Game.endCard == "" { + return + } + // swallow every click while the end card is up + if ctx.Game.Input.LeftClicked() { + ctx.Game.Input.ConsumeLeft() + } + if ctx.Game.Input.RightClicked() { + ctx.Game.Input.ConsumeRight() + } +} + +func (e *EndCard) Draw(dst *ebiten.Image, ctx *UICtx) { + g := ctx.Game + if g.endCard == "" { + return + } + th := g.Theme() + vector.DrawFilledRect(dst, 0, 0, float32(g.Width), float32(g.Height), th.EndCardBG, false) + w := textWidth(g.endCard) + g.DrawText(dst, g.endCard, (g.Width-w)/2, g.Height/2-8, th.EndCardText) +} diff --git a/pncdsl/ui.hotspot_debug.go b/pncdsl/ui.hotspot_debug.go new file mode 100644 index 0000000..dda07bd --- /dev/null +++ b/pncdsl/ui.hotspot_debug.go @@ -0,0 +1,47 @@ +package pncdsl + +import ( + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/inpututil" + "github.com/hajimehoshi/ebiten/v2/vector" +) + +// HotspotDebug overlays a colored outline on every hotspot of the current +// scene. F1 toggles it at runtime; the default is off. +type HotspotDebug struct { + Name string + Enabled bool + // ToggleKey, if non-zero, overrides the default F1 toggle. + ToggleKey ebiten.Key +} + +func (h *HotspotDebug) GetName() string { return h.Name } + +func (h *HotspotDebug) Tick(ctx *UICtx) { + key := h.ToggleKey + if key == 0 { + key = ebiten.KeyF1 + } + if inpututil.IsKeyJustPressed(key) { + h.Enabled = !h.Enabled + } +} + +func (h *HotspotDebug) Draw(dst *ebiten.Image, ctx *UICtx) { + if !h.Enabled { + return + } + g := ctx.Game + if g.currentScene == "" { + return + } + col := g.Theme().HotspotOutline + s := g.SceneManager.MustGet(g.currentScene) + for _, hs := range s.Hotspots { + b := hs.Area.Bounds() + vector.StrokeRect(dst, float32(b.X), float32(b.Y), float32(b.W), float32(b.H), 1, col, false) + if hs.Label != "" { + g.DrawText(dst, hs.Label, int(b.X)+1, int(b.Y)+1, col) + } + } +} diff --git a/pncdsl/ui.inventory.go b/pncdsl/ui.inventory.go new file mode 100644 index 0000000..1789d3b --- /dev/null +++ b/pncdsl/ui.inventory.go @@ -0,0 +1,148 @@ +package pncdsl + +import ( + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" +) + +// InventoryBar shows the player's items as clickable slots. Left-click on +// a slot under the "look" verb prints the item's description; under any +// other verb, it toggles selection (the cursor "picks up" the item). +type InventoryBar struct { + Name string + Origin Point + Slots int + Cols int + SlotSize int + Gap int + + PanelBG bool +} + +func (b *InventoryBar) GetName() string { return b.Name } + +func (b *InventoryBar) slotRect(idx int) Rectangle { + cols := b.Cols + if cols <= 0 { + cols = b.Slots + } + step := b.SlotSize + b.Gap + col := idx % cols + row := idx / cols + x := b.Origin.X + float64(col*step) + y := b.Origin.Y + float64(row*step) + return Rect(x, y, float64(b.SlotSize), float64(b.SlotSize)) +} + +func (b *InventoryBar) Tick(ctx *UICtx) { + g := ctx.Game + mp := g.Input.Point() + items := g.Inventory.Items() + + // hover label: when over a slot with an item, show its description + for i := 0; i < min(b.Slots, len(items)); i++ { + if b.slotRect(i).Contains(mp) { + if it, ok := g.ItemManager.Get(items[i]); ok && it.Description != "" { + g.SetHoverLabel(it.Description) + } else { + g.SetHoverLabel(items[i]) + } + break + } + } + + if !g.Input.LeftClicked() { + return + } + for i := 0; i < min(b.Slots, len(items)); i++ { + if b.slotRect(i).Contains(mp) { + g.Input.ConsumeLeft() + name := items[i] + if g.SelectedVerb() == "look" { + if it, ok := g.ItemManager.Get(name); ok && it.Description != "" { + g.queueAction(Say("player", it.Description), "inv-look") + return + } + } + if g.Inventory.Selected() == name { + g.Inventory.Select("") + } else { + g.Inventory.Select(name) + } + return + } + } +} + +func (b *InventoryBar) Draw(dst *ebiten.Image, ctx *UICtx) { + g := ctx.Game + th := g.Theme() + items := g.Inventory.Items() + + if b.PanelBG { + last := b.slotRect(b.Slots - 1) + first := b.slotRect(0) + x := first.X - 2 + y := first.Y - 2 + w := (last.X + last.W) - first.X + 4 + h := (last.Y + last.H) - first.Y + 4 + vector.DrawFilledRect(dst, float32(x), float32(y), float32(w), float32(h), th.PanelBG, false) + } + + for i := 0; i < b.Slots; i++ { + r := b.slotRect(i) + bg := th.InventorySlotBG + if i < len(items) && items[i] == g.Inventory.Selected() { + bg = th.InventorySlotSelectedBG + } + vector.DrawFilledRect(dst, float32(r.X), float32(r.Y), float32(r.W), float32(r.H), bg, false) + if i < len(items) { + it, ok := g.ItemManager.Get(items[i]) + if !ok { + continue + } + img := g.loaded.image(g.AssetManager, it.Sprite) + sw, sh := img.Bounds().Dx(), img.Bounds().Dy() + if sw == 0 || sh == 0 { + continue + } + if g.loaded.isPlaceholder(it.Sprite) { + // draw a small colored square as a stand-in + inset := float32(2) + vector.DrawFilledRect(dst, + float32(r.X)+inset, float32(r.Y)+inset, + float32(r.W)-2*inset, float32(r.H)-2*inset, + th.VerbButtonSelectedBG, false) + continue + } + op := &ebiten.DrawImageOptions{} + inset := 1.0 + tgtW := r.W - 2*inset + tgtH := r.H - 2*inset + op.GeoM.Scale(tgtW/float64(sw), tgtH/float64(sh)) + op.GeoM.Translate(r.X+inset, r.Y+inset) + dst.DrawImage(img, op) + } + } +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} + +// BlocksClickAt — clickBlocker contract for the RadialVerbs widget. +func (b *InventoryBar) BlocksClickAt(p Point) bool { + if b.Slots <= 0 { + return false + } + first := b.slotRect(0) + last := b.slotRect(b.Slots - 1) + x := first.X + y := first.Y + w := last.X + last.W - first.X + h := last.Y + last.H - first.Y + return Rect(x, y, w, h).Contains(p) +} diff --git a/pncdsl/ui.manager.go b/pncdsl/ui.manager.go new file mode 100644 index 0000000..067c202 --- /dev/null +++ b/pncdsl/ui.manager.go @@ -0,0 +1,26 @@ +package pncdsl + +// UIManager registers Widget instances. Same shape as every other manager. +type UIManager = 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). +func reversedWidgets(m *UIManager) []Widget { + names := m.Names() + out := make([]Widget, len(names)) + for i, n := range names { + out[len(names)-1-i] = m.MustGet(n) + } + return out +} + +// orderedWidgets iterates in registration order — bottom-up draw. +func orderedWidgets(m *UIManager) []Widget { + names := m.Names() + out := make([]Widget, len(names)) + for i, n := range names { + out[i] = m.MustGet(n) + } + return out +} diff --git a/pncdsl/ui.panel.go b/pncdsl/ui.panel.go new file mode 100644 index 0000000..4c8d4ed --- /dev/null +++ b/pncdsl/ui.panel.go @@ -0,0 +1,44 @@ +package pncdsl + +import ( + "image/color" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" +) + +// Panel is a generic background widget — a colored rectangle. Useful as a +// backdrop for grouping other widgets or as a chat/notebook frame. +type Panel struct { + Name string + Bounds Rectangle + // BG, if nil, falls back to the active Theme.PanelBG. + BG color.Color + // Border thickness in pixels (0 = no border). + Border int + // BorderColor, if nil, falls back to Theme.DialogBorder. + BorderColor color.Color +} + +func (p *Panel) GetName() string { return p.Name } +func (p *Panel) Tick(ctx *UICtx) {} + +func (p *Panel) Draw(dst *ebiten.Image, ctx *UICtx) { + bg := p.BG + if bg == nil { + bg = ctx.Game.Theme().PanelBG + } + vector.DrawFilledRect(dst, + float32(p.Bounds.X), float32(p.Bounds.Y), + float32(p.Bounds.W), float32(p.Bounds.H), bg, false) + if p.Border > 0 { + bc := p.BorderColor + if bc == nil { + bc = ctx.Game.Theme().DialogBorder + } + vector.StrokeRect(dst, + float32(p.Bounds.X), float32(p.Bounds.Y), + float32(p.Bounds.W), float32(p.Bounds.H), + float32(p.Border), bc, false) + } +} diff --git a/pncdsl/ui.speech.go b/pncdsl/ui.speech.go index ecc0271..2c68bf5 100644 --- a/pncdsl/ui.speech.go +++ b/pncdsl/ui.speech.go @@ -7,68 +7,74 @@ import ( "github.com/hajimehoshi/ebiten/v2/vector" ) -// speechBubble holds the currently-spoken line. The Say action sets it and -// the engine draws it above the speaker (or centered at screen top if the -// speaker is unknown). -type speechBubble struct { - speaker string - lines []string - pos Point - active bool +// SpeechBubble renders the line set by the Say action above the speaking +// character (or at FallbackY if the speaker has no on-screen position). +type SpeechBubble struct { + Name string + MaxWidth int + Padding int + OffsetY int + FallbackY int } -func (s *speechBubble) set(g *Game, speaker, text string) { - s.speaker = speaker - s.lines = wrapText(text, 200) - s.active = true - // position above the speaker, if we know where they are - if c, ok := g.runtimeChar(speaker); ok { - s.pos = Point{X: c.pos.X, Y: c.pos.Y - c.def.H - 4} - } else { - s.pos = Point{X: 160, Y: 14} - } -} +func (s *SpeechBubble) GetName() string { return s.Name } +func (s *SpeechBubble) Tick(ctx *UICtx) {} -func (s *speechBubble) clear() { - s.active = false - s.speaker = "" - s.lines = nil -} - -func (s *speechBubble) tick(dt float64) { _ = dt } - -func (s *speechBubble) draw(dst *ebiten.Image, g *Game) { - if !s.active || len(s.lines) == 0 { +func (s *SpeechBubble) Draw(dst *ebiten.Image, ctx *UICtx) { + g := ctx.Game + sp := g.speech + if !sp.Active { return } - maxW := 0 - for _, ln := range s.lines { - if w := textWidth(ln); w > maxW { - maxW = w + maxW := s.MaxWidth + if maxW <= 0 { + maxW = 200 + } + pad := s.Padding + if pad <= 0 { + pad = 3 + } + lines := wrapText(sp.Text, maxW) + w := 0 + for _, ln := range lines { + if t := textWidth(ln); t > w { + w = t } } - pad := 3 - w := maxW + pad*2 - h := len(s.lines)*16 + pad*2 - x := int(s.pos.X) - w/2 - y := int(s.pos.Y) - h + w += pad * 2 + h := len(lines)*16 + pad*2 + + // position above speaker if known + cx, cy := g.Width/2, s.FallbackY + if cy == 0 { + cy = 14 + } + if c, ok := g.runtimeChar(sp.Speaker); ok { + cx = int(c.pos.X) + cy = int(c.pos.Y-c.def.H) - s.OffsetY + } + x := cx - w/2 + y := cy - h if x < 2 { x = 2 } - if x+w > 318 { - x = 318 - w + if x+w > g.Width-2 { + x = g.Width - 2 - w } if y < 2 { y = 2 } - vector.DrawFilledRect(dst, float32(x), float32(y), float32(w), float32(h), color.RGBA{0, 0, 0, 200}, false) - speechColor := color.RGBA{255, 255, 255, 255} - if c, ok := g.CharacterManager.Get(s.speaker); ok { - if sc, ok := c.SpeechColor.(color.RGBA); ok { - speechColor = sc + + th := g.Theme() + vector.DrawFilledRect(dst, float32(x), float32(y), float32(w), float32(h), th.SpeechBubbleBG, false) + + textCol := th.SpeechDefaultText + if c, ok := g.CharacterManager.Get(sp.Speaker); ok { + if rgba, ok := c.SpeechColor.(color.RGBA); ok && rgba.A > 0 { + textCol = rgba } } - for k, ln := range s.lines { - drawText(dst, ln, x+pad, y+pad+k*16-2, speechColor) + for i, ln := range lines { + g.DrawText(dst, ln, x+pad, y+pad+i*16-2, textCol) } } diff --git a/pncdsl/ui.status.go b/pncdsl/ui.status.go new file mode 100644 index 0000000..7df1398 --- /dev/null +++ b/pncdsl/ui.status.go @@ -0,0 +1,54 @@ +package pncdsl + +import "github.com/hajimehoshi/ebiten/v2" + +// StatusLine renders a single line: either the active flash message (set +// by FlashLine) or the hover-hint (verb + target under the cursor). +type StatusLine struct { + Name string + Y int + Align Align + // ScreenWidth, if 0, uses Game.Width. + ScreenWidth int +} + +func (s *StatusLine) GetName() string { return s.Name } + +func (s *StatusLine) Tick(ctx *UICtx) { + g := ctx.Game + if g.flashTimer > 0 { + g.flashTimer -= ctx.DT + if g.flashTimer <= 0 { + g.flash = "" + } + } +} + +func (s *StatusLine) Draw(dst *ebiten.Image, ctx *UICtx) { + g := ctx.Game + th := g.Theme() + w := s.ScreenWidth + if w == 0 { + w = g.Width + } + text := g.flash + col := th.FlashText + if text == "" { + text = g.HoverHint() + col = th.StatusText + } + if text == "" { + return + } + tw := textWidth(text) + var x int + switch s.Align { + case AlignLeft: + x = 2 + case AlignRight: + x = w - tw - 2 + default: + x = (w - tw) / 2 + } + g.DrawText(dst, text, x, s.Y, col) +} diff --git a/pncdsl/ui.theme.go b/pncdsl/ui.theme.go new file mode 100644 index 0000000..5dee324 --- /dev/null +++ b/pncdsl/ui.theme.go @@ -0,0 +1,46 @@ +package pncdsl + +import "image/color" + +// Theme is a registry-stored entity carrying every color the built-in +// widgets read. Multiple themes can be registered; the active one is +// addressed by name on the Game. +type Theme struct { + Name string + + PanelBG color.Color + StatusText color.Color + FlashText color.Color + + VerbButtonBG color.Color + VerbButtonSelectedBG color.Color + VerbButtonText color.Color + + InventorySlotBG color.Color + InventorySlotSelectedBG color.Color + + SpeechBubbleBG color.Color + SpeechDefaultText color.Color + + DialogBG color.Color + DialogBorder color.Color + DialogChoiceBG color.Color + DialogChoiceHover color.Color + DialogSpeaker color.Color + DialogText color.Color + + EndCardBG color.Color + EndCardText color.Color + + CursorColor color.Color + HotspotOutline color.Color + + // SceneBackdrop is painted before the scene background. Useful for + // letterboxing or filling areas not covered by a smaller backdrop. + SceneBackdrop color.Color +} + +func (t Theme) GetName() string { return t.Name } +func (t Theme) TypeLabel() string { return "theme" } + +type ThemeManager = Manager[Theme] diff --git a/pncdsl/ui.theme_presets.go b/pncdsl/ui.theme_presets.go new file mode 100644 index 0000000..dc11934 --- /dev/null +++ b/pncdsl/ui.theme_presets.go @@ -0,0 +1,125 @@ +package pncdsl + +import "image/color" + +// RegisterPresetThemes installs the four built-in themes. NewGame calls +// this automatically and selects "classic-scumm". +func RegisterPresetThemes(g *Game) { + g.ThemeManager.Register(classicScummTheme()) + g.ThemeManager.Register(sierraCoinTheme()) + g.ThemeManager.Register(paperNotebookTheme()) + g.ThemeManager.Register(terminalGreenTheme()) +} + +func classicScummTheme() Theme { + return Theme{ + Name: "classic-scumm", + PanelBG: color.RGBA{20, 20, 30, 255}, + StatusText: color.White, + FlashText: color.RGBA{255, 220, 120, 255}, + VerbButtonBG: color.RGBA{50, 50, 70, 255}, + VerbButtonSelectedBG: color.RGBA{120, 90, 40, 255}, + VerbButtonText: color.White, + InventorySlotBG: color.RGBA{40, 40, 50, 255}, + InventorySlotSelectedBG: color.RGBA{120, 90, 40, 255}, + SpeechBubbleBG: color.RGBA{0, 0, 0, 200}, + SpeechDefaultText: color.White, + DialogBG: color.RGBA{15, 15, 25, 240}, + DialogBorder: color.RGBA{80, 80, 110, 255}, + DialogChoiceBG: color.RGBA{30, 30, 45, 255}, + DialogChoiceHover: color.RGBA{70, 70, 100, 255}, + DialogSpeaker: color.RGBA{220, 220, 120, 255}, + DialogText: color.White, + EndCardBG: color.RGBA{0, 0, 0, 230}, + EndCardText: color.White, + CursorColor: color.RGBA{255, 255, 255, 255}, + HotspotOutline: color.RGBA{255, 255, 0, 200}, + SceneBackdrop: color.RGBA{0, 0, 0, 255}, + } +} + +func sierraCoinTheme() Theme { + return Theme{ + Name: "sierra-coin", + PanelBG: color.RGBA{15, 10, 25, 255}, + StatusText: color.RGBA{220, 220, 240, 255}, + FlashText: color.RGBA{255, 200, 100, 255}, + VerbButtonBG: color.RGBA{40, 30, 70, 255}, + VerbButtonSelectedBG: color.RGBA{170, 110, 60, 255}, + VerbButtonText: color.RGBA{240, 240, 255, 255}, + InventorySlotBG: color.RGBA{30, 20, 50, 255}, + InventorySlotSelectedBG: color.RGBA{170, 110, 60, 255}, + SpeechBubbleBG: color.RGBA{10, 10, 20, 220}, + SpeechDefaultText: color.RGBA{240, 240, 255, 255}, + DialogBG: color.RGBA{10, 5, 20, 245}, + DialogBorder: color.RGBA{120, 60, 180, 255}, + DialogChoiceBG: color.RGBA{25, 15, 45, 255}, + DialogChoiceHover: color.RGBA{60, 40, 100, 255}, + DialogSpeaker: color.RGBA{200, 180, 240, 255}, + DialogText: color.RGBA{240, 240, 255, 255}, + EndCardBG: color.RGBA{0, 0, 0, 240}, + EndCardText: color.RGBA{220, 200, 255, 255}, + CursorColor: color.RGBA{255, 200, 140, 255}, + HotspotOutline: color.RGBA{200, 140, 255, 180}, + SceneBackdrop: color.RGBA{0, 0, 0, 255}, + } +} + +func paperNotebookTheme() Theme { + cream := color.RGBA{245, 235, 210, 255} + ink := color.RGBA{50, 40, 30, 255} + return Theme{ + Name: "paper-notebook", + PanelBG: cream, + StatusText: ink, + FlashText: color.RGBA{180, 50, 50, 255}, + VerbButtonBG: color.RGBA{225, 215, 190, 255}, + VerbButtonSelectedBG: color.RGBA{180, 150, 80, 255}, + VerbButtonText: ink, + InventorySlotBG: color.RGBA{230, 220, 195, 255}, + InventorySlotSelectedBG: color.RGBA{180, 150, 80, 255}, + SpeechBubbleBG: color.RGBA{255, 255, 245, 235}, + SpeechDefaultText: ink, + DialogBG: color.RGBA{250, 240, 215, 250}, + DialogBorder: ink, + DialogChoiceBG: color.RGBA{235, 225, 200, 255}, + DialogChoiceHover: color.RGBA{220, 200, 160, 255}, + DialogSpeaker: color.RGBA{120, 70, 30, 255}, + DialogText: ink, + EndCardBG: color.RGBA{245, 235, 210, 240}, + EndCardText: ink, + CursorColor: ink, + HotspotOutline: color.RGBA{120, 70, 30, 200}, + SceneBackdrop: cream, + } +} + +func terminalGreenTheme() Theme { + black := color.RGBA{8, 12, 8, 255} + green := color.RGBA{120, 220, 120, 255} + dim := color.RGBA{60, 120, 60, 255} + return Theme{ + Name: "terminal-green", + PanelBG: black, + StatusText: green, + FlashText: color.RGBA{220, 220, 100, 255}, + VerbButtonBG: color.RGBA{12, 22, 12, 255}, + VerbButtonSelectedBG: dim, + VerbButtonText: green, + InventorySlotBG: color.RGBA{12, 22, 12, 255}, + InventorySlotSelectedBG: dim, + SpeechBubbleBG: color.RGBA{0, 16, 0, 220}, + SpeechDefaultText: green, + DialogBG: color.RGBA{0, 16, 0, 240}, + DialogBorder: green, + DialogChoiceBG: color.RGBA{12, 22, 12, 255}, + DialogChoiceHover: dim, + DialogSpeaker: color.RGBA{180, 255, 180, 255}, + DialogText: green, + EndCardBG: color.RGBA{0, 0, 0, 250}, + EndCardText: green, + CursorColor: green, + HotspotOutline: dim, + SceneBackdrop: black, + } +} diff --git a/pncdsl/ui.verb.go b/pncdsl/ui.verb.go index 05172f0..40fb2ad 100644 --- a/pncdsl/ui.verb.go +++ b/pncdsl/ui.verb.go @@ -1,5 +1,8 @@ package pncdsl +// Verb is a registered "action word" (Look, Use, Talk, Take, …). The +// VerbBar / RadialVerbs widgets read the VerbManager to know which verbs +// to render; Hotspot.handler(name) looks up the bound action by Verb.Name. type Verb struct { Name string Label string @@ -9,8 +12,10 @@ type Verb struct { func (v Verb) GetName() string { return v.Name } func (v Verb) TypeLabel() string { return "verb" } -// defaultVerbs returns the built-in SCUMM-style verb set. A game can replace -// or extend these by registering its own Verbs after NewGame. +type VerbManager = Manager[Verb] + +// defaultVerbs returns the built-in SCUMM-style verb set. NewGame +// registers them so the demo works out of the box. func defaultVerbs() []Verb { return []Verb{ {Name: "look", Label: "Nézd"}, diff --git a/pncdsl/ui.verb_bar.go b/pncdsl/ui.verb_bar.go new file mode 100644 index 0000000..d319cfb --- /dev/null +++ b/pncdsl/ui.verb_bar.go @@ -0,0 +1,111 @@ +package pncdsl + +import ( + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" +) + +// VerbBar is the SCUMM-style permanent verb panel. It reads the +// VerbManager every frame so newly-registered verbs show up automatically. +type VerbBar struct { + Name string + Origin Point + Cols int + Button Size + Gap Point + + // PanelBG, if non-nil, paints a backdrop behind the buttons. nil = use Theme.PanelBG. + PanelBG bool +} + +func (v *VerbBar) GetName() string { return v.Name } + +func (v *VerbBar) buttons(ctx *UICtx) []verbButton { + cols := v.Cols + if cols <= 0 { + cols = 2 + } + names := ctx.Game.VerbManager.Names() + out := make([]verbButton, 0, len(names)) + for i, n := range names { + col := i % cols + row := i / cols + x := int(v.Origin.X) + col*(v.Button.W+int(v.Gap.X)) + y := int(v.Origin.Y) + row*(v.Button.H+int(v.Gap.Y)) + verb := ctx.Game.VerbManager.MustGet(n) + out = append(out, verbButton{ + Name: n, + Label: verb.Label, + Bounds: Rect(float64(x), float64(y), float64(v.Button.W), float64(v.Button.H)), + }) + } + return out +} + +func (v *VerbBar) Tick(ctx *UICtx) { + g := ctx.Game + if !g.Input.LeftClicked() { + return + } + mp := g.Input.Point() + for _, b := range v.buttons(ctx) { + if b.Bounds.Contains(mp) { + g.SetSelectedVerb(b.Name) + g.Input.ConsumeLeft() + return + } + } +} + +func (v *VerbBar) Draw(dst *ebiten.Image, ctx *UICtx) { + g := ctx.Game + th := g.Theme() + if v.PanelBG { + // rough bounding box of the buttons + buttons := v.buttons(ctx) + if len(buttons) > 0 { + x, y := buttons[0].Bounds.X, buttons[0].Bounds.Y + rx, ry := x, y + for _, b := range buttons { + if b.Bounds.X+b.Bounds.W > rx { + rx = b.Bounds.X + b.Bounds.W + } + if b.Bounds.Y+b.Bounds.H > ry { + ry = b.Bounds.Y + b.Bounds.H + } + } + vector.DrawFilledRect(dst, float32(x-2), float32(y-2), float32(rx-x+4), float32(ry-y+4), th.PanelBG, false) + } + } + for _, b := range v.buttons(ctx) { + bg := th.VerbButtonBG + if b.Name == g.SelectedVerb() { + bg = th.VerbButtonSelectedBG + } + vector.DrawFilledRect(dst, float32(b.Bounds.X), float32(b.Bounds.Y), float32(b.Bounds.W), float32(b.Bounds.H), bg, false) + g.DrawText(dst, b.Label, int(b.Bounds.X)+2, int(b.Bounds.Y)-1, th.VerbButtonText) + } +} + +type verbButton struct { + Name string + Label string + Bounds Rectangle +} + +// BlocksClickAt is part of the clickBlocker contract — keeps the +// RadialVerbs widget from popping up over the verb bar. +func (v *VerbBar) BlocksClickAt(p Point) bool { + cols := v.Cols + if cols <= 0 { + cols = 2 + } + // rough bounding box: rows = ceil(verbCount/cols), but without a Game + // reference we approximate by assuming the bar is small (at most 2 rows + // in the default SCUMM layout). + x := v.Origin.X + y := v.Origin.Y + w := float64(cols*(v.Button.W+int(v.Gap.X)) - int(v.Gap.X)) + h := float64(2*(v.Button.H+int(v.Gap.Y)) - int(v.Gap.Y)) + return Rect(x, y, w, h).Contains(p) +} diff --git a/pncdsl/ui.verb_manager.go b/pncdsl/ui.verb_manager.go deleted file mode 100644 index 5bc4f00..0000000 --- a/pncdsl/ui.verb_manager.go +++ /dev/null @@ -1,3 +0,0 @@ -package pncdsl - -type VerbManager = Manager[Verb] diff --git a/pncdsl/ui.verb_radial.go b/pncdsl/ui.verb_radial.go new file mode 100644 index 0000000..c8f891e --- /dev/null +++ b/pncdsl/ui.verb_radial.go @@ -0,0 +1,168 @@ +package pncdsl + +import ( + "image/color" + "math" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" +) + +// RadialVerbs is the verb-coin alternative to VerbBar: secondary-click on +// the scene opens a radial menu of every registered verb, click on a +// slice (or close to its center) picks that verb. +// +// To use it instead of a VerbBar, register RadialVerbs and skip VerbBar. +// Both can coexist if you really want. +type RadialVerbs struct { + Name string + Trigger MouseButton + Radius float64 + + // runtime + visible bool + center Point +} + +func (r *RadialVerbs) GetName() string { return r.Name } + +func (r *RadialVerbs) Tick(ctx *UICtx) { + g := ctx.Game + if r.Radius <= 0 { + r.Radius = 40 + } + + if !r.visible { + if !r.triggerPressed(g) { + return + } + mp := g.Input.Point() + // Don't open over a widget that would also claim this click — + // e.g. an InventoryBar. Each widget can declare its dead-zone + // via the optional clickBlocker interface. + if r.blockedAt(ctx, mp) { + return + } + r.consumeTrigger(g) + r.center = mp + r.visible = true + return + } + // modal: swallow secondary clicks (close without picking) + if r.triggerPressed(g) { + r.consumeTrigger(g) + r.visible = false + return + } + if !g.Input.LeftClicked() { + return + } + g.Input.ConsumeLeft() + idx := r.hitTest(g, g.Input.Point()) + if idx >= 0 { + names := g.VerbManager.Names() + g.SetSelectedVerb(names[idx]) + } + r.visible = false +} + +func (r *RadialVerbs) triggerPressed(g *Game) bool { + if r.Trigger == MouseButtonLeft { + return g.Input.LeftClicked() + } + return g.Input.RightClicked() +} + +func (r *RadialVerbs) consumeTrigger(g *Game) { + if r.Trigger == MouseButtonLeft { + g.Input.ConsumeLeft() + return + } + g.Input.ConsumeRight() +} + +// blockedAt walks the registered widgets and asks any clickBlocker +// whether its area covers the cursor — so the radial menu doesn't open +// over an InventoryBar that would also want this click. +func (r *RadialVerbs) blockedAt(ctx *UICtx, p Point) bool { + for _, name := range ctx.Game.UIManager.Names() { + if name == r.Name { + continue + } + w := ctx.Game.UIManager.MustGet(name) + if b, ok := w.(clickBlocker); ok && b.BlocksClickAt(p) { + return true + } + } + return false +} + +// clickBlocker is the optional widget contract for "if the cursor is on +// me, claim the click — don't let modal pop-ups open over me." Implemented +// by VerbBar, InventoryBar, DialogBox by default. +type clickBlocker interface { + BlocksClickAt(p Point) bool +} + +func (r *RadialVerbs) hitTest(g *Game, p Point) int { + verbs := g.VerbManager.Names() + n := len(verbs) + if n == 0 { + return -1 + } + dx := p.X - r.center.X + dy := p.Y - r.center.Y + dist := math.Hypot(dx, dy) + if dist < r.Radius*0.25 || dist > r.Radius*1.4 { + return -1 + } + // angle 0 = right, going counter-clockwise; map to slice index + a := math.Atan2(dy, dx) + if a < 0 { + a += 2 * math.Pi + } + slice := 2 * math.Pi / float64(n) + idx := int(a / slice) + if idx >= n { + idx = n - 1 + } + return idx +} + +func (r *RadialVerbs) Draw(dst *ebiten.Image, ctx *UICtx) { + if !r.visible { + return + } + g := ctx.Game + th := g.Theme() + verbs := g.VerbManager.Names() + n := len(verbs) + if n == 0 { + return + } + radius := r.Radius + // translucent backdrop disk + vector.DrawFilledCircle(dst, float32(r.center.X), float32(r.center.Y), float32(radius*1.15), color.RGBA{0, 0, 0, 140}, true) + + hover := r.hitTest(g, g.Input.Point()) + slice := 2 * math.Pi / float64(n) + for i, name := range verbs { + a := slice*float64(i) + slice/2 + x := r.center.X + math.Cos(a)*radius + y := r.center.Y + math.Sin(a)*radius + bg := th.VerbButtonBG + if i == hover { + bg = th.VerbButtonSelectedBG + } + bw, bh := 34, 14 + bx := int(x) - bw/2 + by := int(y) - bh/2 + vector.DrawFilledRect(dst, float32(bx), float32(by), float32(bw), float32(bh), bg, false) + verb := g.VerbManager.MustGet(name) + // center label as best we can + tw := textWidth(verb.Label) + g.DrawText(dst, verb.Label, bx+(bw-tw)/2, by-1, th.VerbButtonText) + } + // center dot + vector.DrawFilledCircle(dst, float32(r.center.X), float32(r.center.Y), 2, th.CursorColor, true) +} diff --git a/pncdsl/ui.widget.go b/pncdsl/ui.widget.go new file mode 100644 index 0000000..c96f9e2 --- /dev/null +++ b/pncdsl/ui.widget.go @@ -0,0 +1,51 @@ +package pncdsl + +import "github.com/hajimehoshi/ebiten/v2" + +// Widget is the minimal surface a HUD element must implement to participate +// in the per-frame loop. The library ships several built-in widgets +// (VerbBar, InventoryBar, DialogBox, etc.) and the domain can register +// arbitrary new ones — chat panels, minimaps, hotbars — as long as they +// 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 +// ctx.Game.Input.ConsumeLeft / ConsumeRight. +// - Draw runs once per frame in REGISTRATION order, so widgets +// registered later are painted on top. +type Widget interface { + Named + Tick(ctx *UICtx) + Draw(dst *ebiten.Image, ctx *UICtx) +} + +// UICtx is the per-tick context handed to widgets. Game is the root +// aggregate; DT is seconds since the previous frame. +type UICtx struct { + Game *Game + DT float64 +} + +// MouseButton identifies which mouse button a widget binds to (e.g. the +// RadialVerbs widget uses MouseButtonRight by default). +type MouseButton int + +const ( + MouseButtonLeft MouseButton = iota + MouseButtonRight +) + +// Size is a {Width, Height} pair in screen-space pixels. +type Size struct { + W, H int +} + +// Align controls horizontal text alignment for widgets that draw a single line. +type Align int + +const ( + AlignLeft Align = iota + AlignCenter + AlignRight +)