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