Files
pncdsl/UIPLAN.md
2026-05-25 20:35:04 +02:00

18 KiB

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).
// 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:

// pncdsl/ui.manager.go
type UIManager = Manager[Widget]
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.<név>.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:

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:

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:

// 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:

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:

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:

// 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:

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.

// 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

// 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)

// 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:

// 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:

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:

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ásadomain/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.