widgets
This commit is contained in:
504
UIPLAN.md
Normal file
504
UIPLAN.md
Normal 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.
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
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.UI.hoverLabel = h.Label
|
||||
g.SetHoverLabel(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
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
} 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)
|
||||
|
||||
@@ -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
|
||||
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 }
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
187
pncdsl/ui.def.go
187
pncdsl/ui.def.go
@@ -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
51
pncdsl/ui.defaults.go
Normal 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
134
pncdsl/ui.dialog_box.go
Normal 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
38
pncdsl/ui.end_card.go
Normal 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)
|
||||
}
|
||||
47
pncdsl/ui.hotspot_debug.go
Normal file
47
pncdsl/ui.hotspot_debug.go
Normal 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
148
pncdsl/ui.inventory.go
Normal 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
26
pncdsl/ui.manager.go
Normal 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
44
pncdsl/ui.panel.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
54
pncdsl/ui.status.go
Normal 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
46
pncdsl/ui.theme.go
Normal 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
125
pncdsl/ui.theme_presets.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -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
111
pncdsl/ui.verb_bar.go
Normal 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)
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
package pncdsl
|
||||
|
||||
type VerbManager = Manager[Verb]
|
||||
168
pncdsl/ui.verb_radial.go
Normal file
168
pncdsl/ui.verb_radial.go
Normal 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
51
pncdsl/ui.widget.go
Normal 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
|
||||
)
|
||||
Reference in New Issue
Block a user