This commit is contained in:
2026-05-25 20:35:04 +02:00
parent e4a7cc9177
commit 9fa1d200ef
26 changed files with 1826 additions and 516 deletions

504
UIPLAN.md Normal file
View File

@@ -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.<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:
```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.

View File

@@ -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
}

View File

@@ -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
}

View File

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

View File

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

View File

@@ -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 }

View File

@@ -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 {

View File

@@ -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
}
}

View File

@@ -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)
}

View File

@@ -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:<name>", "inv:<i>", "" 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 ""
}

51
pncdsl/ui.defaults.go Normal file
View File

@@ -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"})
}

134
pncdsl/ui.dialog_box.go Normal file
View File

@@ -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)
}
}

38
pncdsl/ui.end_card.go Normal file
View File

@@ -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)
}

View File

@@ -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)
}
}
}

148
pncdsl/ui.inventory.go Normal file
View File

@@ -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)
}

26
pncdsl/ui.manager.go Normal file
View File

@@ -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
}

44
pncdsl/ui.panel.go Normal file
View File

@@ -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)
}
}

View File

@@ -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)
}
}

54
pncdsl/ui.status.go Normal file
View File

@@ -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)
}

46
pncdsl/ui.theme.go Normal file
View File

@@ -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]

125
pncdsl/ui.theme_presets.go Normal file
View File

@@ -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,
}
}

View File

@@ -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"},

111
pncdsl/ui.verb_bar.go Normal file
View File

@@ -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)
}

View File

@@ -1,3 +0,0 @@
package pncdsl
type VerbManager = Manager[Verb]

168
pncdsl/ui.verb_radial.go Normal file
View File

@@ -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)
}

51
pncdsl/ui.widget.go Normal file
View File

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