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)
|
defineBedroom(g)
|
||||||
defineKitchen(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"))
|
g.StartAt("bedroom").OnStart(p.RunScript("intro"))
|
||||||
return g
|
return g
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -224,15 +224,15 @@ func (r *sayRunner) Tick(ctx *Ctx) Status {
|
|||||||
r.started = true
|
r.started = true
|
||||||
// duration scales with text length, with a 1.2s floor
|
// duration scales with text length, with a 1.2s floor
|
||||||
r.duration = 1.2 + float64(len(r.spec.text))*0.05
|
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
|
r.elapsed += ctx.DT
|
||||||
// skip on click
|
// skip on click
|
||||||
if ctx.Game.input.consumedClick() {
|
if ctx.Game.Input.consumedClick() {
|
||||||
r.elapsed = r.duration
|
r.elapsed = r.duration
|
||||||
}
|
}
|
||||||
if r.elapsed >= r.duration {
|
if r.elapsed >= r.duration {
|
||||||
ctx.Game.UI.ClearSpeech()
|
ctx.Game.ClearSpeech()
|
||||||
return StatusDone
|
return StatusDone
|
||||||
}
|
}
|
||||||
return StatusRunning
|
return StatusRunning
|
||||||
@@ -279,7 +279,7 @@ func (a *requireItemAction) Tick(ctx *Ctx) Status {
|
|||||||
if ctx.Game.Inventory.Has(a.item) {
|
if ctx.Game.Inventory.Has(a.item) {
|
||||||
return StatusDone
|
return StatusDone
|
||||||
}
|
}
|
||||||
ctx.Game.UI.FlashLine("Ehhez kell egy " + a.item + ".")
|
ctx.Game.FlashLine("Ehhez kell egy " + a.item + ".")
|
||||||
return StatusFailed
|
return StatusFailed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,11 @@ func Run(g *Game) error {
|
|||||||
if err := g.Validate(); err != nil {
|
if err := g.Validate(); err != nil {
|
||||||
return err
|
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
|
// Initial scene goes in directly (no transition) so OnEnter / OnStart
|
||||||
// run cleanly as one composed sequence on the first script tick.
|
// run cleanly as one composed sequence on the first script tick.
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ import (
|
|||||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||||
)
|
)
|
||||||
|
|
||||||
// engine is the ebiten.Game adapter. It owns the per-frame Update/Draw flow
|
// engine is the ebiten.Game adapter. Each frame it polls input, advances
|
||||||
// and delegates everything stateful to *Game.
|
// 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 {
|
type engine struct {
|
||||||
g *Game
|
g *Game
|
||||||
}
|
}
|
||||||
@@ -20,29 +22,24 @@ func (e *engine) Layout(outsideWidth, outsideHeight int) (int, int) {
|
|||||||
func (e *engine) Update() error {
|
func (e *engine) Update() error {
|
||||||
g := e.g
|
g := e.g
|
||||||
dt := 1.0 / 60.0
|
dt := 1.0 / 60.0
|
||||||
g.input.poll()
|
|
||||||
g.UI.tick(dt)
|
g.Input.poll()
|
||||||
g.transition.update(dt)
|
g.transition.update(dt)
|
||||||
|
|
||||||
if g.transition.active && g.transition.out {
|
if g.transition.active && g.transition.out {
|
||||||
// during fade-out, freeze input
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if g.UI.endCard != "" {
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Active script consumes the frame.
|
||||||
if g.scriptRunner != nil {
|
if g.scriptRunner != nil {
|
||||||
ctx := g.scriptCtx
|
ctx := g.scriptCtx
|
||||||
ctx.DT = dt
|
ctx.DT = dt
|
||||||
// keep Scene reference fresh in case the script changed it
|
|
||||||
if g.currentScene != "" {
|
if g.currentScene != "" {
|
||||||
s := g.SceneManager.MustGet(g.currentScene)
|
s := g.SceneManager.MustGet(g.currentScene)
|
||||||
ctx.Scene = &s
|
ctx.Scene = &s
|
||||||
}
|
}
|
||||||
s := g.scriptRunner.Tick(ctx)
|
st := g.scriptRunner.Tick(ctx)
|
||||||
if s != StatusRunning {
|
if st != StatusRunning {
|
||||||
g.scriptRunner = nil
|
g.scriptRunner = nil
|
||||||
g.scriptCtx = nil
|
g.scriptCtx = nil
|
||||||
}
|
}
|
||||||
@@ -50,106 +47,57 @@ func (e *engine) Update() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// dialog click handling
|
uictx := &UICtx{Game: g, DT: dt}
|
||||||
if g.dialog != nil {
|
|
||||||
if g.input.LeftClicked() {
|
// Clear per-frame transient state that widgets / scene-resolution write.
|
||||||
g.input.ConsumeLeft()
|
g.SetHoverLabel("")
|
||||||
ctx := g.makeCtx()
|
|
||||||
picked := g.dialog.handleClick(ctx, g.input.Point())
|
// Top-down input: the widget drawn last (= registered last) gets the
|
||||||
if picked != nil {
|
// click first, then the next-to-last, etc. A widget signals "I took it"
|
||||||
// record once-tracking
|
// via g.Input.ConsumeLeft / ConsumeRight.
|
||||||
if picked.Once {
|
for _, w := range reversedWidgets(g.UIManager) {
|
||||||
g.State.NoteTalked(g.dialog.node.Name + ":" + picked.Text)
|
w.Tick(uictx)
|
||||||
}
|
|
||||||
// run the choice's actions as a synthetic script
|
|
||||||
if len(picked.Actions) > 0 {
|
|
||||||
g.queueAction(Seq(picked.Actions...), "choice")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Anything still unconsumed is a scene-world interaction.
|
||||||
|
e.handleSceneInput()
|
||||||
g.tickCharacters(dt)
|
g.tickCharacters(dt)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// free interaction
|
// handleSceneInput is the legacy "click on hotspot with active verb" flow,
|
||||||
e.handleFreeInput()
|
// running only when no widget has claimed the input.
|
||||||
g.tickCharacters(dt)
|
func (e *engine) handleSceneInput() {
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (e *engine) handleFreeInput() {
|
|
||||||
g := e.g
|
g := e.g
|
||||||
g.UI.rebuildVerbButtons()
|
mp := g.Input.Point()
|
||||||
mp := g.input.Point()
|
// hover label resolution: pointer over a hotspot in the current scene
|
||||||
|
|
||||||
// hover label resolution
|
|
||||||
g.UI.hoverLabel = ""
|
|
||||||
if mp.Y < uiPanelY-4 {
|
|
||||||
if h := e.hotspotAt(mp); h != nil {
|
if h := e.hotspotAt(mp); h != nil {
|
||||||
if h.Label != "" {
|
if h.Label != "" {
|
||||||
g.UI.hoverLabel = h.Label
|
g.SetHoverLabel(h.Label)
|
||||||
} else {
|
} else {
|
||||||
g.UI.hoverLabel = h.Name
|
g.SetHoverLabel(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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if g.input.RightClicked() {
|
if g.Input.RightClicked() {
|
||||||
g.input.ConsumeRight()
|
g.Input.ConsumeRight()
|
||||||
// right click: deselect item / reset verb
|
|
||||||
if g.Inventory.Selected() != "" {
|
if g.Inventory.Selected() != "" {
|
||||||
g.Inventory.Select("")
|
g.Inventory.Select("")
|
||||||
} else {
|
} else {
|
||||||
g.selectedVerb = "look"
|
g.SetSelectedVerb("look")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !g.input.LeftClicked() {
|
if !g.Input.LeftClicked() {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
g.Input.ConsumeLeft()
|
||||||
// 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()
|
|
||||||
h := e.hotspotAt(mp)
|
h := e.hotspotAt(mp)
|
||||||
if h == nil {
|
if h == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
sel := g.Inventory.Selected()
|
sel := g.Inventory.Selected()
|
||||||
if sel != "" {
|
if sel != "" {
|
||||||
// use-with: first check hotspot's OnUseWith, then item's
|
|
||||||
if h.OnUseWith != nil {
|
if h.OnUseWith != nil {
|
||||||
if a, ok := h.OnUseWith[sel]; ok && a != nil {
|
if a, ok := h.OnUseWith[sel]; ok && a != nil {
|
||||||
g.queueAction(a, "useWith hotspot")
|
g.queueAction(a, "useWith hotspot")
|
||||||
@@ -164,21 +112,21 @@ func (e *engine) handleFreeInput() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
g.UI.FlashLine("Nem ehhez.")
|
g.FlashLine("Nem ehhez.")
|
||||||
g.Inventory.Select("")
|
g.Inventory.Select("")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
a := h.handler(g.selectedVerb)
|
a := h.handler(g.SelectedVerb())
|
||||||
if a == nil {
|
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
|
a = v.Default
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if a == nil {
|
if a == nil {
|
||||||
g.UI.FlashLine("Semmi említésre méltó.")
|
g.FlashLine("Semmi említésre méltó.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
g.queueAction(a, "hotspot "+g.selectedVerb+" "+h.Name)
|
g.queueAction(a, "hotspot "+g.SelectedVerb()+" "+h.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *engine) hotspotAt(p Point) *Hotspot {
|
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) {
|
func (e *engine) Draw(screen *ebiten.Image) {
|
||||||
g := e.g
|
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
|
// scene background
|
||||||
if g.currentScene != "" {
|
if g.currentScene != "" {
|
||||||
s := g.SceneManager.MustGet(g.currentScene)
|
s := g.SceneManager.MustGet(g.currentScene)
|
||||||
img := g.loaded.image(g.AssetManager, s.Background)
|
img := g.loaded.image(g.AssetManager, s.Background)
|
||||||
op := &ebiten.DrawImageOptions{}
|
|
||||||
bw, bh := img.Bounds().Dx(), img.Bounds().Dy()
|
bw, bh := img.Bounds().Dx(), img.Bounds().Dy()
|
||||||
if bw > 0 && bh > 0 {
|
if bw > 0 && bh > 0 {
|
||||||
|
op := &ebiten.DrawImageOptions{}
|
||||||
op.GeoM.Scale(float64(g.Width)/float64(bw), float64(g.Height)/float64(bh))
|
op.GeoM.Scale(float64(g.Width)/float64(bw), float64(g.Height)/float64(bh))
|
||||||
}
|
|
||||||
screen.DrawImage(img, op)
|
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 {
|
} else {
|
||||||
screen.Fill(color.RGBA{0, 0, 0, 255})
|
screen.Fill(color.RGBA{0, 0, 0, 255})
|
||||||
@@ -226,28 +172,16 @@ func (e *engine) Draw(screen *ebiten.Image) {
|
|||||||
drawCharacter(screen, g, c)
|
drawCharacter(screen, g, c)
|
||||||
}
|
}
|
||||||
|
|
||||||
// speech bubble
|
// widgets in registration order
|
||||||
g.UI.speech.draw(screen, g)
|
uictx := &UICtx{Game: g, DT: 1.0 / 60.0}
|
||||||
|
for _, w := range orderedWidgets(g.UIManager) {
|
||||||
// dialog box (if active)
|
w.Draw(screen, uictx)
|
||||||
if g.dialog != nil {
|
|
||||||
g.dialog.draw(screen, g)
|
|
||||||
} else {
|
|
||||||
g.UI.drawHUD(screen)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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)
|
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 {
|
func sortedChars(g *Game) []*runtimeChar {
|
||||||
@@ -255,7 +189,6 @@ func sortedChars(g *Game) []*runtimeChar {
|
|||||||
for _, c := range g.chars {
|
for _, c := range g.chars {
|
||||||
out = append(out, c)
|
out = append(out, c)
|
||||||
}
|
}
|
||||||
// insertion sort by Y (small N)
|
|
||||||
for i := 1; i < len(out); i++ {
|
for i := 1; i < len(out); i++ {
|
||||||
for j := i; j > 0 && out[j].pos.Y < out[j-1].pos.Y; j-- {
|
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]
|
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 {
|
if h == 0 {
|
||||||
h = 56
|
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) {
|
if c.def.Sprite != "" && !g.loaded.isPlaceholder(c.def.Sprite) {
|
||||||
img := g.loaded.image(g.AssetManager, c.def.Sprite)
|
img := g.loaded.image(g.AssetManager, c.def.Sprite)
|
||||||
sw, sh := img.Bounds().Dx(), img.Bounds().Dy()
|
sw, sh := img.Bounds().Dx(), img.Bounds().Dy()
|
||||||
@@ -286,7 +217,6 @@ func drawCharacter(dst *ebiten.Image, g *Game, c *runtimeChar) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Touch the asset cache so isPlaceholder is populated for later frames.
|
|
||||||
if c.def.Sprite != "" {
|
if c.def.Sprite != "" {
|
||||||
_ = g.loaded.image(g.AssetManager, 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
|
headR := h * 0.14
|
||||||
headCY := y + headR + 1
|
headCY := y + headR + 1
|
||||||
|
|
||||||
// pants / legs — two narrow strips
|
|
||||||
pantsTop := y + h*0.62
|
pantsTop := y + h*0.62
|
||||||
pantsH := h - (pantsTop - y) - 1
|
pantsH := h - (pantsTop - y) - 1
|
||||||
legW := w * 0.36
|
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.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)
|
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
|
torsoTop := headCY + headR
|
||||||
torsoH := pantsTop - torsoTop
|
torsoH := pantsTop - torsoTop
|
||||||
vector.DrawFilledRect(dst, float32(x), float32(torsoTop), float32(w), float32(torsoH), body, false)
|
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)
|
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)
|
vector.DrawFilledCircle(dst, float32(x+w/2), float32(headCY), float32(headR), skin, true)
|
||||||
|
|
||||||
// feet — short dark blobs at the very bottom
|
|
||||||
footH := 2.0
|
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.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)
|
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) {
|
func drawQuadrupedPlaceholder(dst *ebiten.Image, x, y, w, h float64, body color.RGBA) {
|
||||||
outline := color.RGBA{20, 20, 30, 255}
|
outline := color.RGBA{20, 20, 30, 255}
|
||||||
dark := color.RGBA{
|
dark := color.RGBA{R: body.R / 2, G: body.G / 2, B: body.B / 2, A: 255}
|
||||||
R: body.R / 2, G: body.G / 2, B: body.B / 2, A: 255,
|
|
||||||
}
|
|
||||||
|
|
||||||
// body
|
|
||||||
bodyY := y + h*0.30
|
bodyY := y + h*0.30
|
||||||
bodyH := h * 0.65
|
bodyH := h * 0.65
|
||||||
vector.DrawFilledRect(dst, float32(x), float32(bodyY), float32(w*0.78), float32(bodyH), body, false)
|
vector.DrawFilledRect(dst, float32(x), float32(bodyY), float32(w*0.78), float32(bodyH), body, false)
|
||||||
|
|
||||||
// head — circle on the right
|
|
||||||
headR := h * 0.30
|
headR := h * 0.30
|
||||||
headCX := x + w - headR
|
headCX := x + w - headR
|
||||||
headCY := y + h*0.50
|
headCY := y + h*0.50
|
||||||
vector.DrawFilledCircle(dst, float32(headCX), float32(headCY), float32(headR), body, true)
|
vector.DrawFilledCircle(dst, float32(headCX), float32(headCY), float32(headR), body, true)
|
||||||
|
|
||||||
// ears — two triangles approximated as small rects
|
|
||||||
earW := w * 0.07
|
earW := w * 0.07
|
||||||
earH := h * 0.30
|
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.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)
|
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)
|
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)
|
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
|
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.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)
|
vector.DrawFilledRect(dst, float32(x+w*0.55), float32(y+h-3), float32(legW), 3, dark, false)
|
||||||
|
|||||||
@@ -2,11 +2,14 @@ package pncdsl
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"image/color"
|
||||||
|
|
||||||
|
"github.com/hajimehoshi/ebiten/v2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Game is the root aggregate. It carries every Manager plus the runtime
|
// Game is the root aggregate. It carries every Manager plus the runtime
|
||||||
// state. A domain package builds one via NewGame, registers entities, then
|
// state. A domain package builds one via NewGame, registers entities,
|
||||||
// calls Run (or hands it to pncdsl.Run).
|
// optionally calls RegisterDefaultUI / UseTheme, then hands it to Run.
|
||||||
type Game struct {
|
type Game struct {
|
||||||
Title string
|
Title string
|
||||||
Width, Height int
|
Width, Height int
|
||||||
@@ -18,30 +21,58 @@ type Game struct {
|
|||||||
ScriptManager *ScriptManager
|
ScriptManager *ScriptManager
|
||||||
AssetManager *AssetManager
|
AssetManager *AssetManager
|
||||||
VerbManager *VerbManager
|
VerbManager *VerbManager
|
||||||
|
UIManager *UIManager
|
||||||
|
ThemeManager *ThemeManager
|
||||||
|
|
||||||
State *State
|
State *State
|
||||||
Inventory *Inventory
|
Inventory *Inventory
|
||||||
UI *UI
|
|
||||||
Audio *AudioPlayer
|
Audio *AudioPlayer
|
||||||
Camera *Camera
|
Camera *Camera
|
||||||
|
Input *Input
|
||||||
|
|
||||||
startID string
|
startID string
|
||||||
onStart Action
|
onStart Action
|
||||||
|
activeTheme string
|
||||||
|
|
||||||
// runtime
|
// runtime
|
||||||
loaded *loadedAssets
|
loaded *loadedAssets
|
||||||
input *Input
|
|
||||||
currentScene string
|
currentScene string
|
||||||
chars map[string]*runtimeChar
|
chars map[string]*runtimeChar
|
||||||
scriptRunner Runner
|
scriptRunner Runner
|
||||||
scriptCtx *Ctx // for click consumption
|
scriptCtx *Ctx
|
||||||
transition *transition
|
transition *transition
|
||||||
dialog *dialogBox
|
|
||||||
activeDialog string
|
|
||||||
selectedVerb 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 {
|
func NewGame(title string, w, h int) *Game {
|
||||||
g := &Game{
|
g := &Game{
|
||||||
Title: title,
|
Title: title,
|
||||||
@@ -54,27 +85,77 @@ func NewGame(title string, w, h int) *Game {
|
|||||||
ScriptManager: NewManager[Script](),
|
ScriptManager: NewManager[Script](),
|
||||||
AssetManager: NewManager[Asset](),
|
AssetManager: NewManager[Asset](),
|
||||||
VerbManager: NewManager[Verb](),
|
VerbManager: NewManager[Verb](),
|
||||||
|
UIManager: NewManager[Widget](),
|
||||||
|
ThemeManager: NewManager[Theme](),
|
||||||
State: NewState(),
|
State: NewState(),
|
||||||
Inventory: NewInventory(),
|
Inventory: NewInventory(),
|
||||||
Audio: NewAudioPlayer(),
|
Audio: NewAudioPlayer(),
|
||||||
Camera: NewCamera(),
|
Camera: NewCamera(),
|
||||||
|
Input: newInput(),
|
||||||
loaded: newLoadedAssets(w, h),
|
loaded: newLoadedAssets(w, h),
|
||||||
input: newInput(),
|
|
||||||
chars: make(map[string]*runtimeChar),
|
chars: make(map[string]*runtimeChar),
|
||||||
transition: &transition{},
|
transition: &transition{},
|
||||||
selectedVerb: "look",
|
selectedVerb: "look",
|
||||||
}
|
}
|
||||||
g.UI = newUI(g)
|
|
||||||
for _, v := range defaultVerbs() {
|
for _, v := range defaultVerbs() {
|
||||||
g.VerbManager.Register(v)
|
g.VerbManager.Register(v)
|
||||||
}
|
}
|
||||||
|
RegisterPresetThemes(g)
|
||||||
|
g.UseTheme("classic-scumm")
|
||||||
return g
|
return g
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Game) StartAt(name string) *Game { g.startID = name; 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 }
|
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 {
|
func (g *Game) Validate() error {
|
||||||
if !g.SceneManager.Has(g.startID) {
|
if !g.SceneManager.Has(g.startID) {
|
||||||
return fmt.Errorf("%w: %q", ErrNoStartScene, 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- runtime helpers ---------------------------------------------------
|
// ----- runtime: characters & scenes -------------------------------------
|
||||||
|
|
||||||
type runtimeChar struct {
|
type runtimeChar struct {
|
||||||
def Character
|
def Character
|
||||||
@@ -231,7 +315,7 @@ func (g *Game) startDialogue(name string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
g.activeDialog = name
|
g.activeDialog = name
|
||||||
g.dialog = newDialogBox(&d, &node)
|
g.dialog = &runtimeDialog{Dialogue: &d, Node: &node, LineIdx: 0}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Game) endDialogue() {
|
func (g *Game) endDialogue() {
|
||||||
@@ -249,11 +333,11 @@ func (g *Game) gotoDialogueNode(name string) {
|
|||||||
logf("gotoNode: unknown %q in %q", name, g.activeDialog)
|
logf("gotoNode: unknown %q in %q", name, g.activeDialog)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
g.dialog.node = &node
|
g.dialog.Node = &node
|
||||||
g.dialog.lineIdx = 0
|
g.dialog.LineIdx = 0
|
||||||
g.dialog.choiceHits = nil
|
g.dialog.ChoiceHits = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (g *Game) dialogueActive() bool { return g.dialog != 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
|
// 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 {
|
type Manager[T Named] struct {
|
||||||
items map[string]T
|
items map[string]T
|
||||||
|
order []string
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewManager[T Named]() *Manager[T] {
|
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)
|
panic("pncdsl: Register: duplicate " + typeName(v) + " name: " + name)
|
||||||
}
|
}
|
||||||
m.items[name] = v
|
m.items[name] = v
|
||||||
|
m.order = append(m.order, name)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager[T]) Get(name string) (T, bool) {
|
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) }
|
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 {
|
func (m *Manager[T]) Names() []string {
|
||||||
names := make([]string, 0, len(m.items))
|
return append([]string(nil), m.order...)
|
||||||
for n := range m.items {
|
|
||||||
names = append(names, n)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager[T]) SortedNames() []string {
|
||||||
|
names := append([]string(nil), m.order...)
|
||||||
sort.Strings(names)
|
sort.Strings(names)
|
||||||
return names
|
return names
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager[T]) Each(fn func(T)) {
|
func (m *Manager[T]) Each(fn func(T)) {
|
||||||
for _, n := range m.Names() {
|
for _, n := range m.order {
|
||||||
fn(m.items[n])
|
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 {
|
func typeName(v any) string {
|
||||||
type namer interface{ TypeLabel() string }
|
type namer interface{ TypeLabel() string }
|
||||||
if n, ok := v.(namer); ok {
|
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
|
package pncdsl
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"image/color"
|
|
||||||
|
|
||||||
"github.com/hajimehoshi/ebiten/v2"
|
"github.com/hajimehoshi/ebiten/v2"
|
||||||
"github.com/hajimehoshi/ebiten/v2/vector"
|
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||||
)
|
)
|
||||||
|
|
||||||
// drawCursor renders a simple crosshair at the mouse position. When an
|
// Cursor renders the mouse cursor. When the player has an inventory item
|
||||||
// inventory item is selected, the item sprite is drawn instead.
|
// selected, the item's sprite is drawn at the cursor instead of the
|
||||||
func drawCursor(dst *ebiten.Image, g *Game) {
|
// default crosshair. Registered last so it draws on top.
|
||||||
x, y := g.input.Pos()
|
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 sel := g.Inventory.Selected(); sel != "" {
|
||||||
if it, ok := g.ItemManager.Get(sel); ok {
|
if it, ok := g.ItemManager.Get(sel); ok {
|
||||||
img := g.loaded.image(g.AssetManager, it.Sprite)
|
img := g.loaded.image(g.AssetManager, it.Sprite)
|
||||||
sw, sh := img.Bounds().Dx(), img.Bounds().Dy()
|
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 := &ebiten.DrawImageOptions{}
|
||||||
op.GeoM.Scale(16/float64(sw), 16/float64(sh))
|
op.GeoM.Scale(16/float64(sw), 16/float64(sh))
|
||||||
op.GeoM.Translate(float64(x-8), float64(y-8))
|
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}
|
col := g.Theme().CursorColor
|
||||||
vector.StrokeLine(dst, float32(x-3), float32(y), float32(x+4), float32(y), 1, c, false)
|
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, c, 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"
|
"github.com/hajimehoshi/ebiten/v2/vector"
|
||||||
)
|
)
|
||||||
|
|
||||||
// speechBubble holds the currently-spoken line. The Say action sets it and
|
// SpeechBubble renders the line set by the Say action above the speaking
|
||||||
// the engine draws it above the speaker (or centered at screen top if the
|
// character (or at FallbackY if the speaker has no on-screen position).
|
||||||
// speaker is unknown).
|
type SpeechBubble struct {
|
||||||
type speechBubble struct {
|
Name string
|
||||||
speaker string
|
MaxWidth int
|
||||||
lines []string
|
Padding int
|
||||||
pos Point
|
OffsetY int
|
||||||
active bool
|
FallbackY int
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *speechBubble) set(g *Game, speaker, text string) {
|
func (s *SpeechBubble) GetName() string { return s.Name }
|
||||||
s.speaker = speaker
|
func (s *SpeechBubble) Tick(ctx *UICtx) {}
|
||||||
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) clear() {
|
func (s *SpeechBubble) Draw(dst *ebiten.Image, ctx *UICtx) {
|
||||||
s.active = false
|
g := ctx.Game
|
||||||
s.speaker = ""
|
sp := g.speech
|
||||||
s.lines = nil
|
if !sp.Active {
|
||||||
}
|
|
||||||
|
|
||||||
func (s *speechBubble) tick(dt float64) { _ = dt }
|
|
||||||
|
|
||||||
func (s *speechBubble) draw(dst *ebiten.Image, g *Game) {
|
|
||||||
if !s.active || len(s.lines) == 0 {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
maxW := 0
|
maxW := s.MaxWidth
|
||||||
for _, ln := range s.lines {
|
if maxW <= 0 {
|
||||||
if w := textWidth(ln); w > maxW {
|
maxW = 200
|
||||||
maxW = w
|
}
|
||||||
|
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 += pad * 2
|
||||||
w := maxW + pad*2
|
h := len(lines)*16 + pad*2
|
||||||
h := len(s.lines)*16 + pad*2
|
|
||||||
x := int(s.pos.X) - w/2
|
// position above speaker if known
|
||||||
y := int(s.pos.Y) - h
|
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 {
|
if x < 2 {
|
||||||
x = 2
|
x = 2
|
||||||
}
|
}
|
||||||
if x+w > 318 {
|
if x+w > g.Width-2 {
|
||||||
x = 318 - w
|
x = g.Width - 2 - w
|
||||||
}
|
}
|
||||||
if y < 2 {
|
if y < 2 {
|
||||||
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}
|
th := g.Theme()
|
||||||
if c, ok := g.CharacterManager.Get(s.speaker); ok {
|
vector.DrawFilledRect(dst, float32(x), float32(y), float32(w), float32(h), th.SpeechBubbleBG, false)
|
||||||
if sc, ok := c.SpeechColor.(color.RGBA); ok {
|
|
||||||
speechColor = sc
|
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 {
|
for i, ln := range lines {
|
||||||
drawText(dst, ln, x+pad, y+pad+k*16-2, speechColor)
|
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
|
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 {
|
type Verb struct {
|
||||||
Name string
|
Name string
|
||||||
Label string
|
Label string
|
||||||
@@ -9,8 +12,10 @@ type Verb struct {
|
|||||||
func (v Verb) GetName() string { return v.Name }
|
func (v Verb) GetName() string { return v.Name }
|
||||||
func (v Verb) TypeLabel() string { return "verb" }
|
func (v Verb) TypeLabel() string { return "verb" }
|
||||||
|
|
||||||
// defaultVerbs returns the built-in SCUMM-style verb set. A game can replace
|
type VerbManager = Manager[Verb]
|
||||||
// or extend these by registering its own Verbs after NewGame.
|
|
||||||
|
// defaultVerbs returns the built-in SCUMM-style verb set. NewGame
|
||||||
|
// registers them so the demo works out of the box.
|
||||||
func defaultVerbs() []Verb {
|
func defaultVerbs() []Verb {
|
||||||
return []Verb{
|
return []Verb{
|
||||||
{Name: "look", Label: "Nézd"},
|
{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