initial commit

This commit is contained in:
2026-05-25 18:09:51 +02:00
commit df7219677e
58 changed files with 3646 additions and 0 deletions

876
PLAN.md Normal file
View File

@@ -0,0 +1,876 @@
# PLAN.md — `pncdsl` point-and-click adventure library
## Cél
Egy Go nyelvű, [Ebitengine](https://ebitengine.org/) tetejére épülő könyvtár, amellyel
klasszikus point-and-click kalandjátékok (Lucasarts/Sierra stílus) gyorsan, **deklaratív
struct literal**-ekkel összerakhatók. A library a `pncdsl` modulban él, a konkrét játék
pedig a `domain` package-ben a regisztráló hívásokon keresztül definiálódik. A `main.go`
mindössze behúzza a domaint, példányosítja a `pncdsl.Game`-et és elindítja.
**Vezérlő minta — Manager-regisztráció.** Minden névvel hivatkozható entitás (item,
scene, character, dialogue, script, asset, verb) egy-egy globális (Game-en lógó)
**`XxxManager`**-be kerül a **`Register(...)`** metódussal, és a `Get(name)` adja
vissza. Például `g.ItemManager.Register(pncdsl.Item{Name: "key", ...})` és onnantól
`g.ItemManager.Get("key")`. Mindegyik manager pontosan ugyanúgy viselkedik —
egységes konvenció, nincs entitásonkénti egyedi API.
## Felhasználói élmény (DSL ízelítő)
A `domain/` minden építőkövét **külön fájlba** tesszük, a fájlnév az adott entitás
típusát és azonosítóját tükrözi (`téma.azonosító.go`). A `domain/game.go` csak
behúzza az építő függvényeket egy `Build()`-be:
```go
// domain/game.go
package domain
import p "pncdsl/pncdsl"
func Build() *p.Game {
g := p.NewGame("Reggeli Kávé", 320, 200)
// assetek (a domain.asset.go-ban felsorolva)
registerAssets(g)
// karakterek
definePlayer(g)
defineCat(g)
// itemek
defineKey(g)
defineBeans(g)
defineMug(g)
// dialógusok
defineCatMorning(g)
// scenek
defineBedroom(g)
defineKitchen(g)
// scriptek / cutscene-ek
defineIntroScript(g)
defineVictoryScript(g)
g.StartAt("bedroom").OnStart(p.RunScript("intro"))
return g
}
```
```go
// domain/scene.kitchen.go
package domain
import p "pncdsl/pncdsl"
func defineKitchen(g *p.Game) {
g.SceneManager.Register(p.Scene{
Name: "kitchen",
Background: "bg/kitchen",
Music: "mus/calm",
Hotspots: []p.Hotspot{
{
Name: "cupboard",
Area: p.Rect(40, 60, 80, 120),
Label: "szekrény",
OnLook: p.Say("player", "Egy konyhaszekrény. Zárva."),
OnUse: p.If(p.Flag("cupboard_open"),
p.Say("player", "Már nyitva van."),
p.Seq(
p.RequireItem("key"),
p.Say("player", "Klikk."),
p.SetFlag("cupboard_open"),
p.Give("beans"),
p.TakeAway("key"),
),
),
},
{
Name: "cat",
Area: p.Rect(200, 130, 30, 30),
Label: "Cirmos",
OnTalk: p.RunDialogue("cat_morning"),
},
{
Name: "door",
Area: p.Rect(280, 60, 30, 120),
OnUse: p.GoTo("bedroom"),
},
},
})
}
```
```go
// domain/item.key.go
package domain
import p "pncdsl/pncdsl"
func defineKey(g *p.Game) {
g.ItemManager.Register(p.Item{
Name: "key",
Sprite: "spr/key",
Description: "rozsdás kulcs",
})
}
```
Az építőkövek tehát **plain struct literal**-ek, amiket a megfelelő `*Manager`-be
regisztrálunk. Nincs fluent builder, nincs `.Done()` — a struct mezői önmagukban
deklaratívak, az IDE autocomplete pedig kvázi-DSL-szerű élményt ad.
```go
// main.go
package main
import (
"pncdsl/domain"
"pncdsl/pncdsl"
)
func main() {
pncdsl.Run(domain.Build())
}
```
## Mappa- és fájlstruktúra
**Konvenció**: minden Go-fájl neve `téma.azonosító.go`. A pont szeparátor szándékos —
így `ls`-ben a fájlok témánként szépen csoportosulnak. A library oldalon a téma a
komponens funkcióját jelöli (`core`, `scene`, `actor`, `dialog`, `action`, `state`,
`ui`, `input`, `asset`, `util`); a domain oldalon a téma az entitás típusa (`scene`,
`item`, `character`, `dialog`, `script`), az azonosító pedig pontosan az a string,
amivel a DSL-ben hivatkozunk rá (`scene.kitchen.go``g.DefineScene("kitchen")`).
```
pncdsl/
├── go.mod
├── PLAN.md
├── main.go # belépési pont
├── assets/ # képek, hangok, fontok (futásidőben olvasva)
│ ├── bg/ spr/ snd/ mus/ fnt/
├── pncdsl/ # maga a library
│ ├── core.doc.go # package docs, design rationale
│ ├── core.game.go # Game (root aggregátum), Run()
│ ├── core.engine.go # Ebiten adapter: Update/Draw/Layout
│ ├── core.manager.go # Manager[T Named] — KÖZPONTI minta
│ ├── core.dsl.go # toplevel helperek (Rect, Seq, Par, Say...)
│ ├── core.errors.go # tipizált hibák, sentinel értékek
│ │
│ ├── scene.def.go # Scene struct (Name + mezők)
│ ├── scene.manager.go # SceneManager (alias: Manager[Scene])
│ ├── scene.hotspot.go # Hotspot struct (Scene gyermeke)
│ ├── scene.trigger.go # Trigger (Scene gyermeke)
│ ├── scene.transition.go # Fade, Cut, Wipe a scenek között
│ ├── scene.camera.go # Camera (pan/follow/shake)
│ │
│ ├── item.def.go # Item struct (Name + mezők)
│ ├── item.manager.go # ItemManager (alias: Manager[Item])
│ ├── item.inventory.go # Inventory, slotok, drag&drop
│ │
│ ├── actor.def.go # Character struct (Name + mezők)
│ ├── actor.manager.go # CharacterManager (alias: Manager[Character])
│ ├── actor.animation.go # SpriteSheet, AnimationClip, Animator
│ │
│ ├── dialog.def.go # Dialogue, DialogueNode, Choice
│ ├── dialog.manager.go # DialogueManager (alias: Manager[Dialogue])
│ ├── dialog.box.go # DialogBox UI a Dialogue megjelenítéshez
│ │
│ ├── action.def.go # Action interface + beépített konstruktorok
│ ├── action.condition.go # Condition interface + beépítettek
│ ├── action.script.go # Script struct (Name + Actions)
│ ├── action.manager.go # ScriptManager (alias: Manager[Script])
│ │
│ ├── state.def.go # World state, Flags, Vars, History
│ ├── state.save.go # SaveSlot, serialize/restore
│ │
│ ├── ui.def.go # UI root, layout
│ ├── ui.verb.go # Verb struct + VerbBar
│ ├── ui.verb_manager.go # VerbManager (alias: Manager[Verb])
│ ├── ui.cursor.go # Cursor sprite-ek és állapotok
│ ├── ui.speech.go # SpeechBubble renderelés
│ │
│ ├── input.def.go # Egér, billentyű, hover/click resolve
│ │
│ ├── asset.def.go # Asset struct (Name, Path, Kind)
│ ├── asset.manager.go # AssetManager (alias: Manager[Asset]) + lazy load
│ ├── asset.audio.go # AudioPlayer, Music, Sfx mixing
│ ├── asset.text.go # FontLoader, TextRenderer, Wrapper
│ │
│ ├── util.geometry.go # Point, Rect, Polygon, Path
│ ├── util.timer.go # Tickek, Tween, Delay
│ └── util.log.go # opcionális debug overlay
└── domain/ # a konkrét játék tartalma — lásd Demo szakasz
├── game.go # Build() — sorban hívja a define*() függvényeket
├── domain.asset.go # registerAssets(g) — minden asset egy helyen
├── domain.flag.go # flag/var konstansok (string típusos enum)
├── scene.bedroom.go # defineBedroom(g)
├── scene.kitchen.go # defineKitchen(g)
├── item.key.go # defineKey(g)
├── item.beans.go # defineBeans(g)
├── item.mug.go # defineMug(g)
├── character.player.go # definePlayer(g)
├── character.cat.go # defineCat(g)
├── dialog.cat_morning.go # defineCatMorning(g)
├── script.intro.go # defineIntroScript(g)
└── script.victory.go # defineVictoryScript(g)
```
A domain-konvenció előnye: amikor új scene-t/itemet teszek hozzá, **csak egy új fájlt
hozok létre + egy sort a `Build()`-ben**. Nincs egyetlen ezer soros `items.go`. Ha a
jövőben többszáz entitás lesz, a fájllista önmagában dokumentáció: `ls domain/scene.*`
megmutatja az összes helyszínt.
## Központi struktúrák
### `Manager[T]` — a regisztráló minta (`pncdsl/core.manager.go`)
Minden névvel hivatkozható entitást egyetlen generikus `Manager` kezel. A library
egyetlen helyen definiálja, és típus-aliasokkal kap nevet entitásonként — így minden
manager egységesen viselkedik (`Register`, `Get`, `MustGet`, `Has`, `Names`, `Each`).
```go
// pncdsl/core.manager.go
package pncdsl
type Named interface {
GetName() string
}
type Manager[T Named] struct {
items map[string]T
}
func NewManager[T Named]() *Manager[T] {
return &Manager[T]{items: make(map[string]T)}
}
func (m *Manager[T]) Register(v T) {
name := v.GetName()
if name == "" {
panic("pncdsl: Register: empty Name")
}
if _, dup := m.items[name]; dup {
panic("pncdsl: Register: duplicate name: " + name)
}
m.items[name] = v
}
func (m *Manager[T]) Get(name string) (T, bool) {
v, ok := m.items[name]
return v, ok
}
func (m *Manager[T]) MustGet(name string) T {
v, ok := m.items[name]
if !ok {
panic("pncdsl: MustGet: unknown name: " + name)
}
return v
}
func (m *Manager[T]) Has(name string) bool { _, ok := m.items[name]; return ok }
func (m *Manager[T]) Names() []string { /* sorted keys */ }
func (m *Manager[T]) Each(fn func(T)) { /* iterate */ }
```
Minden entitás implementálja a `Named` interfészt egyetlen kis metódussal:
```go
type Item struct {
Name string
Sprite string
Description string
// ... handlerek
}
func (i Item) GetName() string { return i.Name }
```
Entitásonkénti **nevesített manager-típus** (generic alias, Go 1.24+):
```go
// pncdsl/item.manager.go
type ItemManager = Manager[Item]
// pncdsl/scene.manager.go
type SceneManager = Manager[Scene]
// pncdsl/actor.manager.go
type CharacterManager = Manager[Character]
// pncdsl/dialog.manager.go
type DialogueManager = Manager[Dialogue]
// pncdsl/action.manager.go
type ScriptManager = Manager[Script]
// pncdsl/asset.manager.go
type AssetManager = Manager[Asset]
// pncdsl/ui.verb_manager.go
type VerbManager = Manager[Verb]
```
A használat ezért minden entitáson **pontosan ugyanígy néz ki**:
```go
g.ItemManager.Register(p.Item{Name: "key", Sprite: "spr/key"})
g.ItemManager.Get("key") // (Item, bool)
g.ItemManager.MustGet("key") // Item, panic ha nincs
g.SceneManager.Register(p.Scene{Name: "kitchen", ...})
g.AssetManager.Register(p.Asset{Name: "bg/kitchen", Path: "assets/bg/kitchen.png"})
```
**Manager-listája:** `ItemManager`, `SceneManager`, `CharacterManager`,
`DialogueManager`, `ScriptManager`, `AssetManager`, `VerbManager`. (A `Hotspot`,
`Trigger`, `Walkbox`, `DialogueNode` nem kap saját managert — ezek mindig egy
parent struct mezőiként élnek, scoped a Scene-hez ill. a Dialogue-hoz.)
### `Game` — a gyökér aggregátum (`pncdsl/core.game.go`)
A `Game` az összes managert mezőként hordozza, plusz a futásidejű állapotot:
```go
type Game struct {
Title string
Width, Height int
// regisztrálható entitások — minden mező egyforma alakú
ItemManager *ItemManager
SceneManager *SceneManager
CharacterManager *CharacterManager
DialogueManager *DialogueManager
ScriptManager *ScriptManager
AssetManager *AssetManager
VerbManager *VerbManager
// futásidejű állapot
State *State
Inventory *Inventory
UI *UI
Audio *AudioPlayer
Camera *Camera
Cursor *Cursor
current *Scene
startID string
onStart Action
}
func NewGame(title string, w, h int) *Game {
return &Game{
Title: title,
Width: w,
Height: h,
ItemManager: NewManager[Item](),
SceneManager: NewManager[Scene](),
CharacterManager: NewManager[Character](),
DialogueManager: NewManager[Dialogue](),
ScriptManager: NewManager[Script](),
AssetManager: NewManager[Asset](),
VerbManager: NewManager[Verb](),
State: NewState(),
Inventory: NewInventory(),
// ...
}
}
```
A `Game` többi metódusa minimális — nem builder, csak konfig:
- `func NewGame(title string, w, h int) *Game`
- `func (g *Game) StartAt(sceneName string) *Game`
- `func (g *Game) OnStart(a Action) *Game`
- `func (g *Game) Validate() error`
- `func (g *Game) Run() error`
- `func (g *Game) Save(slot int) error` / `func (g *Game) Load(slot int) error`
A toplevel `pncdsl.Run(g *Game) error` annyi, hogy `g.Validate()` után belép az
Ebiten ciklusba.
### `Asset` (`pncdsl/asset.def.go`)
```go
type Asset struct {
Name string // pl. "bg/kitchen"
Path string // pl. "assets/bg/kitchen.png"
Kind AssetKind // Image | Audio | Font
}
func (a Asset) GetName() string { return a.Name }
```
Az `AssetManager` lazy: `Register` csak metaadatot tárol, az első tényleges
hozzáférésnél tölti be és cache-eli (a betöltött resource az `AssetManager`
belső térképében, nem az `Asset` structban — utóbbi marad immutable konfig).
### `Scene` (`pncdsl/scene.def.go`)
```go
type Scene struct {
Name string
Background string // Asset.Name
Music string // Asset.Name
Hotspots []Hotspot
Walkboxes []Polygon
Triggers []Trigger
Actors []SceneActor // { CharacterName string; At Point }
OnEnter Action
OnLeave Action
}
func (s Scene) GetName() string { return s.Name }
```
Hotspot / Trigger / Walkbox értékek inline, ugyanabban a struct literalban —
nem kell külön regisztrálni őket; egy Hotspot eleve scene-scoped.
### `Hotspot` (`pncdsl/scene.hotspot.go`)
```go
type Hotspot struct {
Name string
Area Shape // Rect | Polygon
Label string // a kurzor melletti felirat hover-kor
Cursor CursorKind
// verbenkénti handler — egyszerű mezők, nem map
OnLook Action
OnUse Action
OnTalk Action
OnTake Action
OnGive Action
// egyéni verbek (`g.VerbManager.Register(Verb{Name: "push"})`-hoz)
OnVerb map[string]Action
}
```
### `Item` (`pncdsl/item.def.go`)
```go
type Item struct {
Name string
Sprite string // Asset.Name
Description string
OnUseSelf Action // pl. „nem tudom mit kezdjek vele"
OnUseWith map[string]Action // targetName -> action (hotspot vagy másik item)
}
func (i Item) GetName() string { return i.Name }
```
### `Inventory` (`pncdsl/item.inventory.go`)
```go
type Inventory struct {
items []string
capacity int
selected string
}
func (i *Inventory) Add(name string)
func (i *Inventory) Remove(name string)
func (i *Inventory) Has(name string) bool
func (i *Inventory) Select(name string)
func (i *Inventory) Selected() string
```
(Az `Inventory` nem manager — futásidejű állapot, nem regisztráció.)
### `Character` (`pncdsl/actor.def.go`)
```go
type Character struct {
Name string
Sprite string // Asset.Name
Animations map[string]AnimationClip // idle, walk_left, walk_right, talk
Speed float64
SpeechColor color.Color
Start Point // alappozíció (scene-en belül felülírhatja Actor)
}
func (c Character) GetName() string { return c.Name }
```
Futásidejű mozgás-API a `Game`-en keresztül érhető el (nem a Character struct
módosításán át, hiszen az regisztrált konfig):
`g.WalkCharacter("player", Point{...})`, `g.FaceCharacter("player", dir)`.
### `Dialogue` (`pncdsl/dialog.def.go`)
```go
type Dialogue struct {
Name string
Start string // kezdő node Name
Nodes []DialogueNode // mezőként, nem manager — Dialogue-scoped
}
func (d Dialogue) GetName() string { return d.Name }
type DialogueNode struct {
Name string
Lines []DialogueLine // ki mit mond a node belépésén
Choices []DialogueChoice
}
type DialogueLine struct {
Speaker string // Character.Name
Text string
}
type DialogueChoice struct {
Text string
Show Condition // nil = mindig látszik
Once bool
Actions []Action
}
```
### `Script` (`pncdsl/action.script.go`)
```go
type Script struct {
Name string
Actions Action // tipikusan Seq(...)
}
func (s Script) GetName() string { return s.Name }
```
### `Verb` (`pncdsl/ui.verb.go`)
```go
type Verb struct {
Name string // "look", "use", "talk", "take", ...
Label string // megjelenő szöveg a VerbBar-on
Default Action // ha a hotspot nem definiál erre handlert
}
func (v Verb) GetName() string { return v.Name }
```
### `Action` interface (`pncdsl/action.def.go`)
```go
type Action interface {
Run(ctx *Ctx) Status // Running | Done | Failed
}
type Ctx struct {
Game *Game
Scene *Scene
Hotspot *Hotspot
Item *Item
dt float64
}
```
Beépített actionök (sima konstruktorfüggvények, hogy DSL-ből szépen olvasódjon):
- `Say(speakerID, text string)`
- `Wait(seconds float64)`
- `GoTo(sceneID string)`
- `Walk(charID string, to Point)`
- `Give(itemID string)` / `TakeAway(itemID string)`
- `SetFlag(name string)` / `ClearFlag(name string)` / `SetVar(name string, v any)`
- `PlaySound(assetID string)` / `PlayMusic(assetID string)` / `StopMusic()`
- `RunDialogue(dialogueID string)` / `EndDialogue()` / `GotoNode(nodeID string)`
- `RunScript(scriptID string)`
- `Seq(a ...Action)` — sorban (default builder-szemantika)
- `Par(a ...Action)` — párhuzamosan
- `If(c Condition, then Action, else_ ...Action)`
- `RequireItem(itemID string)` — short-circuit ha nincs nála (visszadob egy "nem tudom használni"-t)
- `Custom(fn func(*Ctx) Status)` — escape hatch a domainnek
### `Condition` (`pncdsl/action.condition.go`)
```go
type Condition interface{ Eval(*Ctx) bool }
```
Beépítettek: `HasItem(name)`, `Flag(name)`, `Not(c)`, `And(c...)`, `Or(c...)`,
`VarEq(name, v)`, `InScene(name)`, `SelectedItem(name)`.
### `State` (`pncdsl/state.def.go`)
```go
type State struct {
flags map[string]bool
vars map[string]any
visited map[string]int // scene name -> hányszor lépett be
talked map[string]int // dialogue node -> hányszor futott le (a `Once` choice-okhoz)
}
```
Metódusok: `Flag/SetFlag/ClearFlag`, `Var/SetVar`, `Visited(name)`, `TalkedTo(name)`,
`Snapshot()`, `Restore(snap)`. (Ez sem manager — runtime állapot.)
### `UI` (`pncdsl/ui.def.go`, `pncdsl/dialog.box.go`)
```go
type UI struct {
verbBar *VerbBar
invBar *InventoryBar
speech *SpeechBubble
dialogBox *DialogBox
hoverLabel string
}
```
Konfigurálható: layout, font, színek. Default a SCUMM-szerű alsó verbsáv + jobboldali
inventory; ki is kapcsolható ha valaki modern verb-coin variánst akar.
### `Engine` — Ebiten adapter (`pncdsl/core.engine.go`)
```go
type engine struct {
g *Game
}
func (e *engine) Update() error
func (e *engine) Draw(screen *ebiten.Image)
func (e *engine) Layout(outW, outH int) (int, int)
```
Az `Update` belerajzol:
1. input → resolve (melyik hotspoton van a kurzor, milyen verb aktív)
2. aktív cutscene/dialogue lépése
3. karakterek mozgása, animation tick
4. trigger feltételek
5. UI állapot frissítése
A `Draw` rétegei alulról felfele: háttér → walkbox debug (csak ha bekapcsolva) →
karakterek (y-sorted) → speech bubble-k → UI → cursor → tranzíció overlay.
## Asset és audio kezelés
Az `AssetManager` egy hagyományos `Manager[Asset]` — a `Register` csak az
útvonalat és típust tárolja, az első `Load(name)` hívásra olvas be a diszkről
és cache-el (a betöltött `*ebiten.Image` / audio buffer egy belső térképben él,
az `Asset` struct maga immutable konfig marad).
```go
g.AssetManager.Register(p.Asset{Name: "bg/kitchen", Path: "assets/bg/kitchen.png", Kind: p.AssetImage})
g.AssetManager.Register(p.Asset{Name: "mus/calm", Path: "assets/mus/calm.ogg", Kind: p.AssetAudio})
```
`AudioPlayer` egy zenecsatornát + N sfx csatornát kezel, fade-in/out támogatással
a `PlayMusic` action-höz.
## Save / Load
`SaveSlot` JSON-be sorosít:
- aktuális scene `Name`-je
- karakterek pozíciói
- `State` (flags, vars, visited, talked)
- inventory tartalom (item `Name`-ek listája)
- futó script PC (ha cutscene közben menteni szabad — opcionálisan tilthatóan)
A managerek **tartalmát nem mentjük** — azok a `Build()`-ből minden indításkor
újra előállnak. Csak a runtime-állapotot persistáljuk.
API: `Game.Save(slot int) error`, `Game.Load(slot int) error`.
## Hibakezelés és validáció
`Game.Validate() error` a `Run()` előtt fut. Ellenőrzi a managerek közötti
név-hivatkozások konzisztenciáját:
- minden `Scene.Background`/`Music` és `Character.Sprite` szerepel-e az `AssetManager`-ben,
- minden `GoTo(name)` célja él-e a `SceneManager`-ben,
- `RunDialogue(name)` és `Dialogue` `Choice.Actions`-ben szereplő `GotoNode` ill.
`RunScript(name)` célok léteznek-e,
- minden `Hotspot.OnUseWith` map kulcs (item/hotspot név) feloldható,
- `StartAt(name)` scene létezik,
- nincs két azonos nevű regisztráció (ezt amúgy a `Register` panic-cal megfogja).
Hibák tipizáltak: `ErrUnknownAsset`, `ErrUnknownScene`, `ErrUnknownDialogue`,
`ErrUnknownDialogueNode`, `ErrUnknownScript`, `ErrUnknownItem`, `ErrDuplicateName`.
## Tesztelési stratégia
- A `pncdsl/*.go` minden nem-render részére táblavezérelt unit teszt
(`action_test.go`, `dialogue_test.go`, `state_test.go`).
- A `domain/`-hoz egy headless smoke test, ami `Build()` után `Validate()`-et hív —
így a játék statikus integritását CI-ben is le lehet csekkolni Ebiten ablak nélkül.
- A renderhez `ebiten/ebitenutil` segítségével offscreen image diff — opcionális,
csak ha sok regressziónk lenne.
## Demo játék: „Reggeli Kávé"
A library kipróbálására egy szándékosan apró, ~5 perces játék, ami a fontos
mechanikákat **egyenként** gyakorolja anélkül, hogy a domain túlnőne a libraryn.
### Sztori egy mondatban
Frissen ébredsz, és muszáj kávét főznöd, mielőtt a macskád szétszedné a konyhát.
### Helyszínek
| ID | Háttér | Zene | Tartalom |
|------------|---------------------|-------------|------------------------------------------------------------|
| `bedroom` | `bg/bedroom.png` | `mus/wakeup`| ágy, éjjeliszekrény, ajtó |
| `kitchen` | `bg/kitchen.png` | `mus/calm` | szekrény, polc, kávéfőző, macska, ajtó |
### Itemek
| ID | Sprite | Honnan szerezhető | Mire jó |
|---------|-----------------|------------------------------------------|-------------------------------------------|
| `key` | `spr/key.png` | `bedroom.nightstand` → Take | `kitchen.cupboard` kinyitása |
| `beans` | `spr/beans.png` | `kitchen.cupboard` (kinyitás után) | `kitchen.coffee_machine`-be tölteni |
| `mug` | `spr/mug.png` | `kitchen.shelf` → Take | `kitchen.coffee_machine`-re tenni |
### Karakterek
- **`player`** — az általunk irányított PC. `WalkTo` célokra mozog, beszél (`Say`).
- **`cat`** — az NPC, a konyhában ül. Csak `Talk` interakció, dialog-fa.
### Dialógus (`dialog.cat_morning.go`)
```
[start]
cat: Mióóóóóóóóu. Késő van.
> „Mindjárt megy a kávé." → SetFlag("promised_coffee") → end
> „Te etted meg a babot?" → goto cat_beans
> „Hagyj békén." → end
[cat_beans] (csak ha NEM flag `cupboard_open`)
cat: A szekrényben van. Mindig ott szokott lenni.
> „Köszi." → end
```
### Hotspot-tábla
**`scene.bedroom.go`**
| Hotspot | Look | Use |
|--------------|-----------------------------------|------------------------------------------------------------------|
| `bed` | „A párnám még meleg." | `Say("player", "Most keltem fel.")` |
| `nightstand` | „Az éjjeliszekrényemen egy kulcs." | egyszer: `Give("key")` + `SetFlag("key_taken")`; utána: „Üres." |
| `door` | „Az ajtó a konyhába vezet." | `GoTo("kitchen")` |
**`scene.kitchen.go`**
| Hotspot | Look | Use |
|------------------|-------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------|
| `cupboard` | „Konyhaszekrény. Zárva." | ha `cupboard_open`: „Már nyitva."; különben `RequireItem("key")``SetFlag("cupboard_open")` + `Give("beans")` + `TakeAway("key")` |
| `shelf` | „A polc tele bögrékkel." | egyszer: `Give("mug")`; utána: „Tiszta már mind." |
| `coffee_machine` | „A jó öreg kávéfőző." | progresszív: 0/2 → kell `beans` és `mug`; 1/2 → kell a másik; 2/2 → `RunScript("victory")` |
| `cat` (NPC) | „Cirmos, a házikedvencem." | (Talk) `RunDialogue("cat_morning")` |
| `door` | „Vissza a hálóba." | `GoTo("bedroom")` |
A „progresszív" használat egy kis state-trükkel: a kávéfőző két flaget figyel
(`coffee_has_beans`, `coffee_has_mug`); a `Use` ág `If` action-ökkel szétágazik
aszerint, hogy melyik item van kiválasztva az inventory-ban (`SelectedItem`
condition), és csak a megfelelőt fogadja el. Konkrétan így néz ki a hotspot
regisztrációja:
```go
// domain/scene.kitchen.go (részlet)
{
Name: "coffee_machine",
Area: p.Rect(140, 70, 50, 60),
Label: "kávéfőző",
OnLook: p.Say("player", "A jó öreg kávéfőző."),
OnUse: p.Seq(
p.If(p.And(p.SelectedItem("beans"), p.Not(p.Flag("coffee_has_beans"))),
p.Seq(p.SetFlag("coffee_has_beans"), p.TakeAway("beans"),
p.Say("player", "Bab betöltve."))),
p.If(p.And(p.SelectedItem("mug"), p.Not(p.Flag("coffee_has_mug"))),
p.Seq(p.SetFlag("coffee_has_mug"), p.TakeAway("mug"),
p.Say("player", "Bögre a helyén."))),
p.If(p.And(p.Flag("coffee_has_beans"), p.Flag("coffee_has_mug")),
p.RunScript("victory")),
),
}
```
És így néz ki egy item regisztrációja, ami egy másik item-re vagy hotspotra reagál:
```go
// domain/item.key.go
func defineKey(g *p.Game) {
g.ItemManager.Register(p.Item{
Name: "key",
Sprite: "spr/key",
Description: "rozsdás kulcs",
OnUseWith: map[string]p.Action{
"cupboard": p.Seq(p.SetFlag("cupboard_open"), p.Give("beans"),
p.TakeAway("key"), p.Say("player", "Klikk.")),
},
})
}
```
### Scriptek
**`script.intro.go`** — az `OnStart`-ra kötve, `bedroom` scene-ben:
```
Wait(0.5) → fade in →
Say("player", "Brr, hideg van.") →
Say("player", "Egy kávé kéne...") →
Walk("player", Point{160, 140})
```
**`script.victory.go`** — amikor a `coffee_machine` mindkét flaget látja:
```
Say("player", "Készen is van!") →
PlaySound("snd/coffee_done") →
Wait(0.8) →
Say("cat", "Mióóóóu. *boldog macska*") →
Say("player", "Reggeli kávé: megmentve.") →
fade out → ShowEnd("Vége — kösz hogy játszottál!")
```
### Flag/var táblázat (`domain.flag.go`)
```go
const (
FlagKeyTaken = "key_taken"
FlagCupboardOpen = "cupboard_open"
FlagCoffeeHasBeans = "coffee_has_beans"
FlagCoffeeHasMug = "coffee_has_mug"
FlagPromisedCoffee = "promised_coffee"
)
```
### Mit gyakorol a demo
| Library képesség | Hol jelenik meg a demoban |
|--------------------------|------------------------------------------------------------|
| `Scene` + `GoTo` | bedroom ↔ kitchen ajtó |
| `Hotspot` + verbek | minden helyszínen több hotspot, Look/Use/Talk |
| `Inventory` + `Give` | key, beans, mug felvétele |
| `RequireItem` | szekrény nyitása csak kulccsal |
| Use-with-item | kávéfőző két különböző itemet kér, progresszíven |
| `Flag` + `If` | szekrény „már nyitva", éjjeliszekrény „már üres" |
| `Dialogue` + `Choice` | macska dialógusfája feltételes ággal |
| `Script` / cutscene | intro (mozgás+beszéd) és victory (több actor) |
| `Walk` + walkbox | player A* a kattintott pontig |
| `PlayMusic` / `PlaySound`| szobánkénti zene, victory hangeffekt |
| `Save` / `Load` | mid-game mentés, pl. a szekrény kinyitása után |
| `Validate()` | CI smoke test a `Build()` outputra |
Ami **nincs benne** szándékosan: több aktor egyszerre mozogva, kamera-pan,
inventory-tárgyak kombinálása (item+item). Ezeket egy esetleges 2. demo (pl.
"A pajta") gyakorolná, de a libraryban már első körben benne van a kapacitás.
## Implementációs ütemterv
1. **Csontváz**`Game`, `Scene`, `Hotspot`, `AssetRegistry`, Ebiten `engine`, egér +
háttér + egy darab Look kattintás. Egy szoba, három hotspot, működő `Say`.
2. **Verbek és inventory**`VerbBar`, `Inventory`, `Item`, `UseOn`. SCUMM-szerű flow.
3. **Karakterek és walkboxok**`Character`, `Walkbox`, A* az útkereséshez.
4. **Dialógus rendszer**`Dialogue`, `DialogBox`, `DialogueChoice`, `Once`.
5. **Cutscene / Script**`Action` interface, `Seq`/`Par`/`If`, `RunScript`.
6. **State, Flags, Save/Load** — perzisztencia.
7. **Polish** — tranzíciók, audio fade, font wrapping, debug overlay, validáció.
Minden lépés végén egy minimális `domain/` példajáték validálja, hogy a DSL valóban
kényelmes-e arra, amire ki van találva.

29
domain/build_test.go Normal file
View File

@@ -0,0 +1,29 @@
package domain
import "testing"
// TestBuildValidates is the headless smoke test described in PLAN.md:
// it composes the full game and asks the library to cross-check every
// name reference between managers. No window opens.
func TestBuildValidates(t *testing.T) {
g := Build()
if err := g.Validate(); err != nil {
t.Fatalf("validate: %v", err)
}
for _, name := range []string{"bedroom", "kitchen"} {
if !g.SceneManager.Has(name) {
t.Errorf("missing scene %q", name)
}
}
for _, name := range []string{"key", "beans", "mug"} {
if !g.ItemManager.Has(name) {
t.Errorf("missing item %q", name)
}
}
if !g.DialogueManager.Has("cat_morning") {
t.Errorf("missing dialogue cat_morning")
}
if !g.ScriptManager.Has("intro") || !g.ScriptManager.Has("victory") {
t.Errorf("missing intro/victory script")
}
}

18
domain/character.cat.go Normal file
View File

@@ -0,0 +1,18 @@
package domain
import (
"image/color"
p "pncdsl/pncdsl"
)
func defineCat(g *p.Game) {
g.CharacterManager.Register(p.Character{
Name: "cat",
Sprite: "spr/cat",
Speed: 40,
SpeechColor: color.RGBA{200, 200, 255, 255},
W: 18,
H: 14,
})
}

View File

@@ -0,0 +1,18 @@
package domain
import (
"image/color"
p "pncdsl/pncdsl"
)
func definePlayer(g *p.Game) {
g.CharacterManager.Register(p.Character{
Name: "player",
Sprite: "spr/player",
Speed: 80,
SpeechColor: color.RGBA{255, 230, 160, 255},
W: 14,
H: 28,
})
}

View File

@@ -0,0 +1,45 @@
package domain
import p "pncdsl/pncdsl"
func defineCatMorning(g *p.Game) {
g.DialogueManager.Register(p.Dialogue{
Name: "cat_morning",
Start: "start",
Nodes: []p.DialogueNode{
{
Name: "start",
Lines: []p.DialogueLine{
{Speaker: "cat", Text: "Mióóóóóóóóu. Késő van."},
},
Choices: []p.DialogueChoice{
{
Text: "Mindjárt megy a kávé.",
Actions: []p.Action{p.SetFlag(FlagPromisedCoffee), p.EndDialogue()},
},
{
Text: "Te etted meg a babot?",
Show: p.Not(p.Flag(FlagCupboardOpen)),
Actions: []p.Action{p.GotoNode("beans")},
},
{
Text: "Hagyj békén.",
Actions: []p.Action{p.EndDialogue()},
},
},
},
{
Name: "beans",
Lines: []p.DialogueLine{
{Speaker: "cat", Text: "A szekrényben van. Mindig ott szokott lenni."},
},
Choices: []p.DialogueChoice{
{
Text: "Köszi.",
Actions: []p.Action{p.EndDialogue()},
},
},
},
},
})
}

31
domain/domain.asset.go Normal file
View File

@@ -0,0 +1,31 @@
package domain
import p "pncdsl/pncdsl"
// registerAssets declares every asset the game references. Files don't
// need to exist on disk — the library will substitute deterministic
// colored placeholders for anything missing.
func registerAssets(g *p.Game) {
imgs := []string{
"bg/bedroom", "bg/kitchen",
"spr/key", "spr/beans", "spr/mug",
"spr/player", "spr/cat",
}
for _, name := range imgs {
g.AssetManager.Register(p.Asset{
Name: name,
Path: "assets/" + name + ".png",
Kind: p.AssetImage,
})
}
audios := []string{
"mus/wakeup", "mus/calm", "snd/coffee_done",
}
for _, name := range audios {
g.AssetManager.Register(p.Asset{
Name: name,
Path: "assets/" + name + ".ogg",
Kind: p.AssetAudio,
})
}
}

10
domain/domain.flag.go Normal file
View File

@@ -0,0 +1,10 @@
package domain
const (
FlagKeyTaken = "key_taken"
FlagCupboardOpen = "cupboard_open"
FlagMugTaken = "mug_taken"
FlagCoffeeHasBeans = "coffee_has_beans"
FlagCoffeeHasMug = "coffee_has_mug"
FlagPromisedCoffee = "promised_coffee"
)

30
domain/game.go Normal file
View File

@@ -0,0 +1,30 @@
package domain
import p "pncdsl/pncdsl"
// Build assembles the full Reggeli Kávé game by registering every entity
// into its matching manager on a fresh *Game. The order matters only for
// readability — Validate() runs after Build to cross-check references.
func Build() *p.Game {
g := p.NewGame("Reggeli Kávé", 320, 200)
registerAssets(g)
definePlayer(g)
defineCat(g)
defineKey(g)
defineBeans(g)
defineMug(g)
defineCatMorning(g)
defineIntroScript(g)
defineVictoryScript(g)
defineBedroom(g)
defineKitchen(g)
g.StartAt("bedroom").OnStart(p.RunScript("intro"))
return g
}

11
domain/item.beans.go Normal file
View File

@@ -0,0 +1,11 @@
package domain
import p "pncdsl/pncdsl"
func defineBeans(g *p.Game) {
g.ItemManager.Register(p.Item{
Name: "beans",
Sprite: "spr/beans",
Description: "őrölt kávébab",
})
}

20
domain/item.key.go Normal file
View File

@@ -0,0 +1,20 @@
package domain
import p "pncdsl/pncdsl"
func defineKey(g *p.Game) {
g.ItemManager.Register(p.Item{
Name: "key",
Sprite: "spr/key",
Description: "rozsdás kulcs",
OnUseWith: map[string]p.Action{
"cupboard": p.Seq(
p.Say("player", "Klikk."),
p.SetFlag(FlagCupboardOpen),
p.Give("beans"),
p.TakeAway("key"),
p.Say("player", "Bab van benne. Pont, ami kell."),
),
},
})
}

11
domain/item.mug.go Normal file
View File

@@ -0,0 +1,11 @@
package domain
import p "pncdsl/pncdsl"
func defineMug(g *p.Game) {
g.ItemManager.Register(p.Item{
Name: "mug",
Sprite: "spr/mug",
Description: "kedvenc bögréje",
})
}

60
domain/scene.bedroom.go Normal file
View File

@@ -0,0 +1,60 @@
package domain
import p "pncdsl/pncdsl"
func defineBedroom(g *p.Game) {
g.SceneManager.Register(p.Scene{
Name: "bedroom",
Background: "bg/bedroom",
Music: "mus/wakeup",
Actors: []p.SceneActor{
{CharacterName: "player", At: p.Point{X: 160, Y: 120}},
},
Hotspots: []p.Hotspot{
{
Name: "bed",
Area: p.Rect(20, 70, 90, 60),
Label: "ágy",
OnLook: p.Say("player", "A párnám még meleg."),
OnUse: p.Say("player", "Most keltem fel. Nem alszom vissza."),
},
{
Name: "nightstand",
Area: p.Rect(115, 80, 35, 50),
Label: "éjjeliszekrény",
OnLook: p.If(p.Flag(FlagKeyTaken),
p.Say("player", "Üres a tetején."),
p.Say("player", "Egy rozsdás kulcs az éjjeliszekrényemen."),
),
OnTake: p.If(p.Flag(FlagKeyTaken),
p.Say("player", "Már elvittem."),
p.Seq(
p.Walk("player", p.Point{X: 130, Y: 120}),
p.Give("key"),
p.SetFlag(FlagKeyTaken),
p.Say("player", "Pont jól jöhet."),
),
),
OnUse: p.If(p.Flag(FlagKeyTaken),
p.Say("player", "Már elvittem a kulcsot."),
p.Seq(
p.Walk("player", p.Point{X: 130, Y: 120}),
p.Give("key"),
p.SetFlag(FlagKeyTaken),
p.Say("player", "Megvan a kulcs."),
),
),
},
{
Name: "door",
Area: p.Rect(260, 50, 40, 90),
Label: "ajtó",
OnLook: p.Say("player", "Az ajtó a konyhába vezet."),
OnUse: p.Seq(
p.Walk("player", p.Point{X: 260, Y: 130}),
p.GoTo("kitchen"),
),
},
},
})
}

114
domain/scene.kitchen.go Normal file
View File

@@ -0,0 +1,114 @@
package domain
import p "pncdsl/pncdsl"
func defineKitchen(g *p.Game) {
g.SceneManager.Register(p.Scene{
Name: "kitchen",
Background: "bg/kitchen",
Music: "mus/calm",
Actors: []p.SceneActor{
{CharacterName: "player", At: p.Point{X: 60, Y: 130}},
{CharacterName: "cat", At: p.Point{X: 210, Y: 132}},
},
Hotspots: []p.Hotspot{
{
Name: "cupboard",
Area: p.Rect(30, 40, 80, 80),
Label: "szekrény",
OnLook: p.Say("player", "Konyhaszekrény. Zárva."),
OnUse: p.If(p.Flag(FlagCupboardOpen),
p.Say("player", "Már nyitva van."),
p.If(p.HasItem("key"),
p.Seq(
p.Walk("player", p.Point{X: 70, Y: 130}),
p.Say("player", "Klikk."),
p.SetFlag(FlagCupboardOpen),
p.Give("beans"),
p.TakeAway("key"),
),
p.Say("player", "Zárva. Kéne egy kulcs."),
),
),
OnUseWith: map[string]p.Action{
"key": p.Seq(
p.Walk("player", p.Point{X: 70, Y: 130}),
p.Say("player", "Klikk."),
p.SetFlag(FlagCupboardOpen),
p.Give("beans"),
p.TakeAway("key"),
),
},
},
{
Name: "shelf",
Area: p.Rect(115, 45, 35, 50),
Label: "polc",
OnLook: p.Say("player", "A polc tele bögrékkel."),
OnTake: p.If(p.Flag(FlagMugTaken),
p.Say("player", "Tiszta már mind elfogyott."),
p.Seq(
p.Walk("player", p.Point{X: 125, Y: 130}),
p.Give("mug"),
p.SetFlag(FlagMugTaken),
p.Say("player", "Egy bögre, kérem."),
),
),
OnUse: p.If(p.Flag(FlagMugTaken),
p.Say("player", "Már elvettem egyet."),
p.Seq(
p.Walk("player", p.Point{X: 125, Y: 130}),
p.Give("mug"),
p.SetFlag(FlagMugTaken),
p.Say("player", "Egy bögre, kérem."),
),
),
},
{
Name: "coffee_machine",
Area: p.Rect(160, 75, 50, 55),
Label: "kávéfőző",
OnLook: p.Say("player", "A jó öreg kávéfőző."),
OnUse: p.If(p.And(p.Flag(FlagCoffeeHasBeans), p.Flag(FlagCoffeeHasMug)),
p.RunScript("victory"),
p.Say("player", "Még hiányzik valami. Bab? Bögre?"),
),
OnUseWith: map[string]p.Action{
"beans": p.Seq(
p.Walk("player", p.Point{X: 180, Y: 130}),
p.SetFlag(FlagCoffeeHasBeans),
p.TakeAway("beans"),
p.Say("player", "Bab a helyén."),
p.If(p.And(p.Flag(FlagCoffeeHasBeans), p.Flag(FlagCoffeeHasMug)),
p.RunScript("victory")),
),
"mug": p.Seq(
p.Walk("player", p.Point{X: 180, Y: 130}),
p.SetFlag(FlagCoffeeHasMug),
p.TakeAway("mug"),
p.Say("player", "Bögre a helyén."),
p.If(p.And(p.Flag(FlagCoffeeHasBeans), p.Flag(FlagCoffeeHasMug)),
p.RunScript("victory")),
),
},
},
{
Name: "cat",
Area: p.Rect(195, 118, 35, 22),
Label: "Cirmos",
OnLook: p.Say("player", "Cirmos, a házikedvencem."),
OnTalk: p.RunDialogue("cat_morning"),
},
{
Name: "door",
Area: p.Rect(0, 40, 22, 90),
Label: "ajtó",
OnLook: p.Say("player", "Vissza a hálóba."),
OnUse: p.Seq(
p.Walk("player", p.Point{X: 20, Y: 130}),
p.GoTo("bedroom"),
),
},
},
})
}

15
domain/script.intro.go Normal file
View File

@@ -0,0 +1,15 @@
package domain
import p "pncdsl/pncdsl"
func defineIntroScript(g *p.Game) {
g.ScriptManager.Register(p.Script{
Name: "intro",
Actions: p.Seq(
p.Wait(0.4),
p.Say("player", "Brr, hideg van."),
p.Say("player", "Egy kávé kéne, mielőtt szétszakad a fejem."),
p.Walk("player", p.Point{X: 160, Y: 130}),
),
})
}

17
domain/script.victory.go Normal file
View File

@@ -0,0 +1,17 @@
package domain
import p "pncdsl/pncdsl"
func defineVictoryScript(g *p.Game) {
g.ScriptManager.Register(p.Script{
Name: "victory",
Actions: p.Seq(
p.Say("player", "Készen is van!"),
p.PlaySound("snd/coffee_done"),
p.Wait(0.6),
p.Say("cat", "Mióóóóu. *boldog macska*"),
p.Say("player", "Reggeli kávé: megmentve."),
p.ShowEnd("Vége — kösz, hogy játszottál!"),
),
})
}

13
go.mod Normal file
View File

@@ -0,0 +1,13 @@
module pncdsl
go 1.26.3
require (
github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 // indirect
github.com/ebitengine/hideconsole v1.0.0 // indirect
github.com/ebitengine/purego v0.9.0 // indirect
github.com/hajimehoshi/ebiten/v2 v2.9.9 // indirect
github.com/jezek/xgb v1.1.1 // indirect
golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.36.0 // indirect
)

14
go.sum Normal file
View File

@@ -0,0 +1,14 @@
github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 h1:+kz5iTT3L7uU+VhlMfTb8hHcxLO3TlaELlX8wa4XjA0=
github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1/go.mod h1:lKJoeixeJwnFmYsBny4vvCJGVFc3aYDalhuDsfZzWHI=
github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE=
github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A=
github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k=
github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/hajimehoshi/ebiten/v2 v2.9.9 h1:JdDag6Ndj12iD4lxQGG8kbsrh7ssj4Sbzth6r929H/M=
github.com/hajimehoshi/ebiten/v2 v2.9.9/go.mod h1:DAt4tnkYYpCvu3x9i1X/nK/vOruNXIlYq/tBXxnhrXM=
github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4=
github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=

14
main.go Normal file
View File

@@ -0,0 +1,14 @@
package main
import (
"log"
"pncdsl/domain"
"pncdsl/pncdsl"
)
func main() {
if err := pncdsl.Run(domain.Build()); err != nil {
log.Fatal(err)
}
}

View File

@@ -0,0 +1,66 @@
package pncdsl
type Condition interface {
Eval(ctx *Ctx) bool
}
// ----- combinators ------------------------------------------------------
type notCond struct{ c Condition }
func Not(c Condition) Condition { return &notCond{c: c} }
func (n *notCond) Eval(ctx *Ctx) bool { return !n.c.Eval(ctx) }
type andCond struct{ cs []Condition }
func And(cs ...Condition) Condition { return &andCond{cs: cs} }
func (a *andCond) Eval(ctx *Ctx) bool {
for _, c := range a.cs {
if !c.Eval(ctx) {
return false
}
}
return true
}
type orCond struct{ cs []Condition }
func Or(cs ...Condition) Condition { return &orCond{cs: cs} }
func (o *orCond) Eval(ctx *Ctx) bool {
for _, c := range o.cs {
if c.Eval(ctx) {
return true
}
}
return false
}
// ----- state predicates -------------------------------------------------
type flagCond struct{ name string }
func Flag(name string) Condition { return &flagCond{name: name} }
func (f *flagCond) Eval(ctx *Ctx) bool { return ctx.Game.State.Flag(f.name) }
type hasItemCond struct{ name string }
func HasItem(name string) Condition { return &hasItemCond{name: name} }
func (h *hasItemCond) Eval(ctx *Ctx) bool { return ctx.Game.Inventory.Has(h.name) }
type selectedItemCond struct{ name string }
func SelectedItem(name string) Condition { return &selectedItemCond{name: name} }
func (s *selectedItemCond) Eval(ctx *Ctx) bool { return ctx.Game.Inventory.Selected() == s.name }
type inSceneCond struct{ name string }
func InScene(name string) Condition { return &inSceneCond{name: name} }
func (i *inSceneCond) Eval(ctx *Ctx) bool { return ctx.Scene != nil && ctx.Scene.Name == i.name }
type varEqCond struct {
name string
v any
}
func VarEq(name string, v any) Condition { return &varEqCond{name: name, v: v} }
func (e *varEqCond) Eval(ctx *Ctx) bool { return ctx.Game.State.Var(e.name) == e.v }

450
pncdsl/action.def.go Normal file
View File

@@ -0,0 +1,450 @@
package pncdsl
// Status is the outcome of one tick of a Runner.
type Status int
const (
StatusRunning Status = iota
StatusDone
StatusFailed
)
// Ctx is the per-tick context passed to running actions.
type Ctx struct {
Game *Game
DT float64
Scene *Scene
Hotspot *Hotspot
Item *Item
}
// Action is an immutable spec of work; Start makes a fresh Runner with
// state. Storing an Action in a struct field (e.g. Hotspot.OnUse) is safe
// because the engine always calls Start before ticking, so two invocations
// can never share mutable state.
type Action interface {
Start() Runner
}
// Runner is one in-flight execution of an Action. Tick advances it one
// frame and returns whether it's still running, done, or failed.
type Runner interface {
Tick(ctx *Ctx) Status
}
// ----- immediate action helper -----------------------------------------
type immediateAction struct {
fn func(*Ctx) Status
}
func (a *immediateAction) Start() Runner { return a }
func (a *immediateAction) Tick(ctx *Ctx) Status {
if a.fn == nil {
return StatusDone
}
return a.fn(ctx)
}
// Custom wraps a user function as an Action. Returns Done on first tick
// unless the function itself returns StatusRunning.
func Custom(fn func(*Ctx) Status) Action { return &immediateAction{fn: fn} }
// ----- Seq --------------------------------------------------------------
type seqAction struct{ children []Action }
type seqRunner struct {
spec *seqAction
idx int
current Runner
}
func Seq(actions ...Action) Action {
return &seqAction{children: flattenSeq(actions)}
}
func flattenSeq(in []Action) []Action {
out := make([]Action, 0, len(in))
for _, a := range in {
if a == nil {
continue
}
if s, ok := a.(*seqAction); ok {
out = append(out, s.children...)
} else {
out = append(out, a)
}
}
return out
}
func (a *seqAction) Start() Runner { return &seqRunner{spec: a} }
func (r *seqRunner) Tick(ctx *Ctx) Status {
for {
if r.idx >= len(r.spec.children) {
return StatusDone
}
if r.current == nil {
r.current = r.spec.children[r.idx].Start()
}
s := r.current.Tick(ctx)
switch s {
case StatusDone:
r.idx++
r.current = nil
// keep going only if the just-finished action consumed no time
// (zero-dt would loop forever otherwise — that's fine here since
// immediate actions complete in one Tick call).
continue
case StatusFailed:
return StatusFailed
default:
return StatusRunning
}
}
}
// ----- Par --------------------------------------------------------------
type parAction struct{ children []Action }
type parRunner struct {
runners []Runner
done []bool
}
func Par(actions ...Action) Action { return &parAction{children: actions} }
func (a *parAction) Start() Runner {
rs := make([]Runner, len(a.children))
for i, c := range a.children {
rs[i] = c.Start()
}
return &parRunner{runners: rs, done: make([]bool, len(rs))}
}
func (r *parRunner) Tick(ctx *Ctx) Status {
allDone := true
for i, rn := range r.runners {
if r.done[i] {
continue
}
s := rn.Tick(ctx)
if s == StatusFailed {
return StatusFailed
}
if s == StatusDone {
r.done[i] = true
continue
}
allDone = false
}
if allDone {
return StatusDone
}
return StatusRunning
}
// ----- If ---------------------------------------------------------------
type ifAction struct {
cond Condition
then Action
els Action
}
func If(cond Condition, then Action, els ...Action) Action {
var e Action
if len(els) > 0 {
e = Seq(els...)
}
return &ifAction{cond: cond, then: then, els: e}
}
func (a *ifAction) Start() Runner { return &ifRunner{spec: a} }
type ifRunner struct {
spec *ifAction
started bool
inner Runner
}
func (r *ifRunner) Tick(ctx *Ctx) Status {
if !r.started {
r.started = true
take := r.spec.then
if r.spec.cond == nil || !r.spec.cond.Eval(ctx) {
take = r.spec.els
}
if take == nil {
return StatusDone
}
r.inner = take.Start()
}
if r.inner == nil {
return StatusDone
}
return r.inner.Tick(ctx)
}
// ----- Wait -------------------------------------------------------------
type waitAction struct{ seconds float64 }
type waitRunner struct {
spec *waitAction
elapsed float64
}
func Wait(seconds float64) Action { return &waitAction{seconds: seconds} }
func (a *waitAction) Start() Runner { return &waitRunner{spec: a} }
func (r *waitRunner) Tick(ctx *Ctx) Status {
r.elapsed += ctx.DT
if r.elapsed >= r.spec.seconds {
return StatusDone
}
return StatusRunning
}
// ----- Say --------------------------------------------------------------
type sayAction struct{ speaker, text string }
type sayRunner struct {
spec *sayAction
elapsed float64
duration float64
started bool
}
func Say(speaker, text string) Action { return &sayAction{speaker: speaker, text: text} }
func (a *sayAction) Start() Runner { return &sayRunner{spec: a} }
func (r *sayRunner) Tick(ctx *Ctx) Status {
if !r.started {
r.started = true
// duration scales with text length, with a 1.2s floor
r.duration = 1.2 + float64(len(r.spec.text))*0.05
ctx.Game.UI.SetSpeech(r.spec.speaker, r.spec.text)
}
r.elapsed += ctx.DT
// skip on click
if ctx.Game.input.consumedClick() {
r.elapsed = r.duration
}
if r.elapsed >= r.duration {
ctx.Game.UI.ClearSpeech()
return StatusDone
}
return StatusRunning
}
// ----- GoTo -------------------------------------------------------------
type gotoAction struct{ scene string }
func GoTo(scene string) Action { return &gotoAction{scene: scene} }
func (a *gotoAction) Start() Runner { return a }
func (a *gotoAction) Tick(ctx *Ctx) Status {
ctx.Game.changeScene(a.scene)
return StatusDone
}
// ----- inventory --------------------------------------------------------
type giveAction struct{ item string }
func Give(item string) Action { return &giveAction{item: item} }
func (a *giveAction) Start() Runner { return a }
func (a *giveAction) Tick(ctx *Ctx) Status {
ctx.Game.Inventory.Add(a.item)
return StatusDone
}
type takeAwayAction struct{ item string }
func TakeAway(item string) Action { return &takeAwayAction{item: item} }
func (a *takeAwayAction) Start() Runner { return a }
func (a *takeAwayAction) Tick(ctx *Ctx) Status {
ctx.Game.Inventory.Remove(a.item)
return StatusDone
}
// RequireItem fails silently with a generic line if the player doesn't have
// the named item selected or in inventory. Used at the top of Use handlers.
type requireItemAction struct{ item string }
func RequireItem(item string) Action { return &requireItemAction{item: item} }
func (a *requireItemAction) Start() Runner { return a }
func (a *requireItemAction) Tick(ctx *Ctx) Status {
if ctx.Game.Inventory.Has(a.item) {
return StatusDone
}
ctx.Game.UI.FlashLine("Ehhez kell egy " + a.item + ".")
return StatusFailed
}
// ----- flags / vars -----------------------------------------------------
type setFlagAction struct{ name string }
func SetFlag(name string) Action { return &setFlagAction{name: name} }
func (a *setFlagAction) Start() Runner { return a }
func (a *setFlagAction) Tick(ctx *Ctx) Status { ctx.Game.State.SetFlag(a.name); return StatusDone }
type clearFlagAction struct{ name string }
func ClearFlag(name string) Action { return &clearFlagAction{name: name} }
func (a *clearFlagAction) Start() Runner { return a }
func (a *clearFlagAction) Tick(ctx *Ctx) Status {
ctx.Game.State.ClearFlag(a.name)
return StatusDone
}
type setVarAction struct {
name string
v any
}
func SetVar(name string, v any) Action { return &setVarAction{name: name, v: v} }
func (a *setVarAction) Start() Runner { return a }
func (a *setVarAction) Tick(ctx *Ctx) Status {
ctx.Game.State.SetVar(a.name, a.v)
return StatusDone
}
// ----- audio ------------------------------------------------------------
type playMusicAction struct{ name string }
func PlayMusic(name string) Action { return &playMusicAction{name: name} }
func (a *playMusicAction) Start() Runner { return a }
func (a *playMusicAction) Tick(ctx *Ctx) Status {
ctx.Game.Audio.PlayMusic(a.name)
return StatusDone
}
type stopMusicAction struct{}
func StopMusic() Action { return &stopMusicAction{} }
func (a *stopMusicAction) Start() Runner { return a }
func (a *stopMusicAction) Tick(ctx *Ctx) Status { ctx.Game.Audio.StopMusic(); return StatusDone }
type playSoundAction struct{ name string }
func PlaySound(name string) Action { return &playSoundAction{name: name} }
func (a *playSoundAction) Start() Runner { return a }
func (a *playSoundAction) Tick(ctx *Ctx) Status {
ctx.Game.Audio.PlaySound(a.name)
return StatusDone
}
// ----- dialogue ---------------------------------------------------------
type runDialogueAction struct{ name string }
func RunDialogue(name string) Action { return &runDialogueAction{name: name} }
func (a *runDialogueAction) Start() Runner { return &runDialogueRunner{spec: a} }
type runDialogueRunner struct {
spec *runDialogueAction
started bool
}
func (r *runDialogueRunner) Tick(ctx *Ctx) Status {
if !r.started {
r.started = true
ctx.Game.startDialogue(r.spec.name)
}
if ctx.Game.dialogueActive() {
return StatusRunning
}
return StatusDone
}
type endDialogueAction struct{}
func EndDialogue() Action { return &endDialogueAction{} }
func (a *endDialogueAction) Start() Runner { return a }
func (a *endDialogueAction) Tick(ctx *Ctx) Status {
ctx.Game.endDialogue()
return StatusDone
}
type gotoNodeAction struct{ node string }
func GotoNode(node string) Action { return &gotoNodeAction{node: node} }
func (a *gotoNodeAction) Start() Runner { return a }
func (a *gotoNodeAction) Tick(ctx *Ctx) Status {
ctx.Game.gotoDialogueNode(a.node)
return StatusDone
}
// ----- scripts ----------------------------------------------------------
type runScriptAction struct{ name string }
func RunScript(name string) Action { return &runScriptAction{name: name} }
func (a *runScriptAction) Start() Runner { return &runScriptRunner{spec: a} }
type runScriptRunner struct {
spec *runScriptAction
inner Runner
}
func (r *runScriptRunner) Tick(ctx *Ctx) Status {
if r.inner == nil {
s, ok := ctx.Game.ScriptManager.Get(r.spec.name)
if !ok || s.Actions == nil {
return StatusFailed
}
r.inner = s.Actions.Start()
}
return r.inner.Tick(ctx)
}
// ----- character movement ----------------------------------------------
type walkAction struct {
character string
to Point
}
func Walk(character string, to Point) Action { return &walkAction{character: character, to: to} }
func (a *walkAction) Start() Runner { return &walkRunner{spec: a} }
type walkRunner struct {
spec *walkAction
started bool
}
func (r *walkRunner) Tick(ctx *Ctx) Status {
if !r.started {
r.started = true
ctx.Game.walkCharacter(r.spec.character, r.spec.to)
}
if ctx.Game.characterMoving(r.spec.character) {
return StatusRunning
}
return StatusDone
}
// ----- misc -------------------------------------------------------------
type showEndAction struct{ text string }
func ShowEnd(text string) Action { return &showEndAction{text: text} }
func (a *showEndAction) Start() Runner { return &showEndRunner{spec: a} }
type showEndRunner struct {
spec *showEndAction
started bool
}
func (r *showEndRunner) Tick(ctx *Ctx) Status {
if !r.started {
r.started = true
ctx.Game.showEndCard(r.spec.text)
}
return StatusRunning // never finishes; player closes the window
}

3
pncdsl/action.manager.go Normal file
View File

@@ -0,0 +1,3 @@
package pncdsl
type ScriptManager = Manager[Script]

9
pncdsl/action.script.go Normal file
View File

@@ -0,0 +1,9 @@
package pncdsl
type Script struct {
Name string
Actions Action
}
func (s Script) GetName() string { return s.Name }
func (s Script) TypeLabel() string { return "script" }

10
pncdsl/actor.animation.go Normal file
View File

@@ -0,0 +1,10 @@
package pncdsl
// AnimationClip is a placeholder for sprite-sheet animation data. Not used
// for rendering yet — characters draw as flat colored rectangles in this
// milestone — but the field exists so domain code can reference it.
type AnimationClip struct {
Frames []Rectangle // source rects on the sprite sheet
FrameTime float64
Loop bool
}

17
pncdsl/actor.def.go Normal file
View File

@@ -0,0 +1,17 @@
package pncdsl
import "image/color"
type Character struct {
Name string
Sprite string // Asset.Name (placeholder if missing)
Animations map[string]AnimationClip
Speed float64
SpeechColor color.Color
Start Point
// Size hints used when the sprite is a placeholder rectangle.
W, H float64
}
func (c Character) GetName() string { return c.Name }
func (c Character) TypeLabel() string { return "character" }

3
pncdsl/actor.manager.go Normal file
View File

@@ -0,0 +1,3 @@
package pncdsl
type CharacterManager = Manager[Character]

30
pncdsl/asset.audio.go Normal file
View File

@@ -0,0 +1,30 @@
package pncdsl
// AudioPlayer is a stub for music/sfx playback. The action constructors
// (PlayMusic/PlaySound/StopMusic) call through here; on this milestone we
// just log the request so the game runs without an audio device.
type AudioPlayer struct {
currentMusic string
}
func NewAudioPlayer() *AudioPlayer { return &AudioPlayer{} }
func (a *AudioPlayer) PlayMusic(name string) {
if a.currentMusic == name {
return
}
a.currentMusic = name
logf("audio.PlayMusic %q", name)
}
func (a *AudioPlayer) StopMusic() {
if a.currentMusic == "" {
return
}
logf("audio.StopMusic (was %q)", a.currentMusic)
a.currentMusic = ""
}
func (a *AudioPlayer) PlaySound(name string) {
logf("audio.PlaySound %q", name)
}

18
pncdsl/asset.def.go Normal file
View File

@@ -0,0 +1,18 @@
package pncdsl
type AssetKind int
const (
AssetImage AssetKind = iota
AssetAudio
AssetFont
)
type Asset struct {
Name string
Path string
Kind AssetKind
}
func (a Asset) GetName() string { return a.Name }
func (a Asset) TypeLabel() string { return "asset" }

78
pncdsl/asset.manager.go Normal file
View File

@@ -0,0 +1,78 @@
package pncdsl
import (
"hash/fnv"
"image"
"image/color"
_ "image/jpeg"
_ "image/png"
"os"
"github.com/hajimehoshi/ebiten/v2"
)
type AssetManager = Manager[Asset]
// loadedAssets is the runtime cache for decoded image/audio resources.
// The Asset structs in the manager remain immutable spec; this is where
// the actual *ebiten.Image bytes live, lazily decoded on first access.
type loadedAssets struct {
images map[string]*ebiten.Image
defaultW, defaultH int
}
func newLoadedAssets(w, h int) *loadedAssets {
return &loadedAssets{
images: make(map[string]*ebiten.Image),
defaultW: w,
defaultH: h,
}
}
func (la *loadedAssets) image(am *AssetManager, name string) *ebiten.Image {
if img, ok := la.images[name]; ok {
return img
}
a, ok := am.Get(name)
if !ok {
img := placeholderImage(name, la.defaultW, la.defaultH)
la.images[name] = img
return img
}
img := loadImageFile(a.Path)
if img == nil {
img = placeholderImage(name, la.defaultW, la.defaultH)
}
la.images[name] = img
return img
}
func loadImageFile(path string) *ebiten.Image {
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
src, _, err := image.Decode(f)
if err != nil {
return nil
}
return ebiten.NewImageFromImage(src)
}
// placeholderImage creates a deterministic colored rectangle for missing
// assets so the game still runs without art on disk.
func placeholderImage(seed string, w, h int) *ebiten.Image {
img := ebiten.NewImage(w, h)
h32 := fnv.New32a()
_, _ = h32.Write([]byte(seed))
sum := h32.Sum32()
c := color.RGBA{
R: 60 + uint8(sum&0x7F),
G: 60 + uint8((sum>>8)&0x7F),
B: 60 + uint8((sum>>16)&0x7F),
A: 255,
}
img.Fill(c)
return img
}

72
pncdsl/asset.text.go Normal file
View File

@@ -0,0 +1,72 @@
package pncdsl
import (
"image/color"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
)
// drawText is a minimal text draw using ebiten's built-in debug font.
// The glyphs are 6×16-ish; good enough for a SCUMM-style 320×200 demo.
func drawText(dst *ebiten.Image, s string, x, y int, c color.Color) {
if s == "" {
return
}
// ebitenutil.DebugPrintAt only draws white; for color we render to a
// tiny offscreen, then ColorScale-tint when blitting. Simpler: just
// use the white draw and skip color. (TODO: text/v2 once needed.)
_ = c
ebitenutil.DebugPrintAt(dst, s, x, y)
}
// textWidth is a coarse pixel-width estimate, used for centering.
func textWidth(s string) int { return len(s) * 6 }
// wrapText splits s at word boundaries into lines no wider than maxPx.
func wrapText(s string, maxPx int) []string {
if maxPx <= 0 || textWidth(s) <= maxPx {
return []string{s}
}
var (
out []string
line string
)
flush := func() {
if line != "" {
out = append(out, line)
line = ""
}
}
word := ""
for _, r := range s {
if r == ' ' || r == '\n' {
if line == "" {
line = word
} else if textWidth(line+" "+word) <= maxPx {
line += " " + word
} else {
flush()
line = word
}
word = ""
if r == '\n' {
flush()
}
continue
}
word += string(r)
}
if word != "" {
if line == "" {
line = word
} else if textWidth(line+" "+word) <= maxPx {
line += " " + word
} else {
flush()
line = word
}
}
flush()
return out
}

6
pncdsl/core.doc.go Normal file
View File

@@ -0,0 +1,6 @@
// Package pncdsl is a point-and-click adventure game library built on top of
// Ebitengine. Games are composed declaratively by registering plain struct
// literals into per-entity *Manager registries hanging off the *Game root.
//
// See PLAN.md in the repo root for the full design notes.
package pncdsl

42
pncdsl/core.dsl.go Normal file
View File

@@ -0,0 +1,42 @@
package pncdsl
import (
"github.com/hajimehoshi/ebiten/v2"
)
// Run validates the game, then enters the ebiten main loop. The window is
// sized to 4× the internal resolution.
func Run(g *Game) error {
if err := g.Validate(); err != nil {
return err
}
g.UI.rebuildVerbButtons()
// Initial scene goes in directly (no transition) so OnEnter / OnStart
// run cleanly as one composed sequence on the first script tick.
s := g.SceneManager.MustGet(g.startID)
g.currentScene = g.startID
g.State.NoteVisit(g.startID)
g.placeActors(g.startID)
if s.Music != "" {
g.Audio.PlayMusic(s.Music)
}
var seq []Action
if s.OnEnter != nil {
seq = append(seq, s.OnEnter)
}
if g.onStart != nil {
seq = append(seq, g.onStart)
}
if len(seq) > 0 {
g.queueAction(Seq(seq...), "init")
}
ebiten.SetWindowSize(g.Width*4, g.Height*4)
ebiten.SetWindowTitle(g.Title)
ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
return ebiten.RunGame(&engine{g: g})
}
// (Game.Run is provided here for convenience; some callers prefer it.)
func (g *Game) Run() error { return Run(g) }

292
pncdsl/core.engine.go Normal file
View File

@@ -0,0 +1,292 @@
package pncdsl
import (
"image/color"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
)
// engine is the ebiten.Game adapter. It owns the per-frame Update/Draw flow
// and delegates everything stateful to *Game.
type engine struct {
g *Game
}
func (e *engine) Layout(outsideWidth, outsideHeight int) (int, int) {
return e.g.Width, e.g.Height
}
func (e *engine) Update() error {
g := e.g
dt := 1.0 / 60.0
g.input.poll()
g.UI.tick(dt)
g.transition.update(dt)
if g.transition.active && g.transition.out {
// during fade-out, freeze input
return nil
}
if g.UI.endCard != "" {
return nil
}
if g.scriptRunner != nil {
ctx := g.scriptCtx
ctx.DT = dt
// keep Scene reference fresh in case the script changed it
if g.currentScene != "" {
s := g.SceneManager.MustGet(g.currentScene)
ctx.Scene = &s
}
s := g.scriptRunner.Tick(ctx)
if s != StatusRunning {
g.scriptRunner = nil
g.scriptCtx = nil
}
g.tickCharacters(dt)
return nil
}
// dialog click handling
if g.dialog != nil {
if g.input.LeftClicked() {
g.input.ConsumeLeft()
ctx := g.makeCtx()
picked := g.dialog.handleClick(ctx, g.input.Point())
if picked != nil {
// record once-tracking
if picked.Once {
g.State.NoteTalked(g.dialog.node.Name + ":" + picked.Text)
}
// run the choice's actions as a synthetic script
if len(picked.Actions) > 0 {
g.queueAction(Seq(picked.Actions...), "choice")
}
}
}
g.tickCharacters(dt)
return nil
}
// free interaction
e.handleFreeInput()
g.tickCharacters(dt)
return nil
}
func (e *engine) handleFreeInput() {
g := e.g
g.UI.rebuildVerbButtons()
mp := g.input.Point()
// hover label resolution
g.UI.hoverLabel = ""
if mp.Y < uiPanelY-4 {
if h := e.hotspotAt(mp); h != nil {
if h.Label != "" {
g.UI.hoverLabel = h.Label
} else {
g.UI.hoverLabel = h.Name
}
}
} else {
// hovering UI: show item description when over an inv slot
if hit := g.UI.hitTest(mp); len(hit) > 4 && hit[:4] == "inv:" {
name := hit[4:]
if it, ok := g.ItemManager.Get(name); ok && it.Description != "" {
g.UI.hoverLabel = it.Description
} else {
g.UI.hoverLabel = name
}
}
}
if g.input.RightClicked() {
g.input.ConsumeRight()
// right click: deselect item / reset verb
if g.Inventory.Selected() != "" {
g.Inventory.Select("")
} else {
g.selectedVerb = "look"
}
}
if !g.input.LeftClicked() {
return
}
// UI hits first
if hit := g.UI.hitTest(mp); hit != "" {
g.input.ConsumeLeft()
switch hit[:4] {
case "verb":
g.selectedVerb = hit[5:]
case "inv:":
name := hit[4:]
if g.selectedVerb == "look" {
if it, ok := g.ItemManager.Get(name); ok && it.Description != "" {
g.queueAction(Say("player", it.Description), "inv-look")
return
}
}
if g.Inventory.Selected() == name {
g.Inventory.Select("")
} else {
g.Inventory.Select(name)
}
}
return
}
// game-world click
g.input.ConsumeLeft()
h := e.hotspotAt(mp)
if h == nil {
return
}
sel := g.Inventory.Selected()
if sel != "" {
// use-with: first check hotspot's OnUseWith, then item's
if h.OnUseWith != nil {
if a, ok := h.OnUseWith[sel]; ok && a != nil {
g.queueAction(a, "useWith hotspot")
g.Inventory.Select("")
return
}
}
if it, ok := g.ItemManager.Get(sel); ok && it.OnUseWith != nil {
if a, ok := it.OnUseWith[h.Name]; ok && a != nil {
g.queueAction(a, "useWith item")
g.Inventory.Select("")
return
}
}
g.UI.FlashLine("Nem ehhez.")
g.Inventory.Select("")
return
}
a := h.handler(g.selectedVerb)
if a == nil {
if v, ok := g.VerbManager.Get(g.selectedVerb); ok && v.Default != nil {
a = v.Default
}
}
if a == nil {
g.UI.FlashLine("Semmi említésre méltó.")
return
}
g.queueAction(a, "hotspot "+g.selectedVerb+" "+h.Name)
}
func (e *engine) hotspotAt(p Point) *Hotspot {
g := e.g
if g.currentScene == "" {
return nil
}
s := g.SceneManager.MustGet(g.currentScene)
for i := range s.Hotspots {
h := &s.Hotspots[i]
if h.Area != nil && h.Area.Contains(p) {
return h
}
}
return nil
}
func (e *engine) Draw(screen *ebiten.Image) {
g := e.g
// scene background
if g.currentScene != "" {
s := g.SceneManager.MustGet(g.currentScene)
img := g.loaded.image(g.AssetManager, s.Background)
op := &ebiten.DrawImageOptions{}
bw, bh := img.Bounds().Dx(), img.Bounds().Dy()
if bw > 0 && bh > 0 {
op.GeoM.Scale(float64(g.Width)/float64(bw), float64(g.Height)/float64(bh))
}
screen.DrawImage(img, op)
// hotspot debug outlines
if DebugLog {
for _, h := range s.Hotspots {
if r, ok := h.Area.(Rectangle); ok {
vector.StrokeRect(screen, float32(r.X), float32(r.Y), float32(r.W), float32(r.H), 1, color.RGBA{255, 255, 0, 200}, false)
}
}
}
} else {
screen.Fill(color.RGBA{0, 0, 0, 255})
}
// characters (y-sorted)
for _, c := range sortedChars(g) {
drawCharacter(screen, g, c)
}
// speech bubble
g.UI.speech.draw(screen, g)
// dialog box (if active)
if g.dialog != nil {
g.dialog.draw(screen, g)
} else {
g.UI.drawHUD(screen)
}
// transition overlay
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 {
out := make([]*runtimeChar, 0, len(g.chars))
for _, c := range g.chars {
out = append(out, c)
}
// insertion sort by Y (small N)
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j].pos.Y < out[j-1].pos.Y; j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
return out
}
func drawCharacter(dst *ebiten.Image, g *Game, c *runtimeChar) {
w := c.def.W
h := c.def.H
if w == 0 {
w = 14
}
if h == 0 {
h = 26
}
if c.def.Sprite != "" {
img := g.loaded.image(g.AssetManager, c.def.Sprite)
sw, sh := img.Bounds().Dx(), img.Bounds().Dy()
if sw > 0 && sh > 0 {
op := &ebiten.DrawImageOptions{}
op.GeoM.Scale(w/float64(sw), h/float64(sh))
op.GeoM.Translate(c.pos.X-w/2, c.pos.Y-h)
dst.DrawImage(img, op)
return
}
}
col := color.RGBA{200, 200, 200, 255}
if rgba, ok := c.def.SpeechColor.(color.RGBA); ok {
col = rgba
}
vector.DrawFilledRect(dst, float32(c.pos.X-w/2), float32(c.pos.Y-h), float32(w), float32(h), col, false)
}

17
pncdsl/core.errors.go Normal file
View File

@@ -0,0 +1,17 @@
package pncdsl
import "errors"
var (
ErrUnknownAsset = errors.New("pncdsl: unknown asset")
ErrUnknownScene = errors.New("pncdsl: unknown scene")
ErrUnknownItem = errors.New("pncdsl: unknown item")
ErrUnknownCharacter = errors.New("pncdsl: unknown character")
ErrUnknownDialogue = errors.New("pncdsl: unknown dialogue")
ErrUnknownDialogueNode = errors.New("pncdsl: unknown dialogue node")
ErrUnknownScript = errors.New("pncdsl: unknown script")
ErrUnknownVerb = errors.New("pncdsl: unknown verb")
ErrDuplicateName = errors.New("pncdsl: duplicate name")
ErrNoStartScene = errors.New("pncdsl: StartAt not set or unknown scene")
ErrSceneMissingBackground = errors.New("pncdsl: scene has no background")
)

259
pncdsl/core.game.go Normal file
View File

@@ -0,0 +1,259 @@
package pncdsl
import (
"fmt"
)
// Game is the root aggregate. It carries every Manager plus the runtime
// state. A domain package builds one via NewGame, registers entities, then
// calls Run (or hands it to pncdsl.Run).
type Game struct {
Title string
Width, Height int
ItemManager *ItemManager
SceneManager *SceneManager
CharacterManager *CharacterManager
DialogueManager *DialogueManager
ScriptManager *ScriptManager
AssetManager *AssetManager
VerbManager *VerbManager
State *State
Inventory *Inventory
UI *UI
Audio *AudioPlayer
Camera *Camera
startID string
onStart Action
// runtime
loaded *loadedAssets
input *Input
currentScene string
chars map[string]*runtimeChar
scriptRunner Runner
scriptCtx *Ctx // for click consumption
transition *transition
dialog *dialogBox
activeDialog string
selectedVerb string
}
// NewGame initializes a game with empty managers and the SCUMM-style verb set.
func NewGame(title string, w, h int) *Game {
g := &Game{
Title: title,
Width: w,
Height: h,
ItemManager: NewManager[Item](),
SceneManager: NewManager[Scene](),
CharacterManager: NewManager[Character](),
DialogueManager: NewManager[Dialogue](),
ScriptManager: NewManager[Script](),
AssetManager: NewManager[Asset](),
VerbManager: NewManager[Verb](),
State: NewState(),
Inventory: NewInventory(),
Audio: NewAudioPlayer(),
Camera: NewCamera(),
loaded: newLoadedAssets(w, h),
input: newInput(),
chars: make(map[string]*runtimeChar),
transition: &transition{},
selectedVerb: "look",
}
g.UI = newUI(g)
for _, v := range defaultVerbs() {
g.VerbManager.Register(v)
}
return g
}
func (g *Game) StartAt(name string) *Game { g.startID = name; return g }
func (g *Game) OnStart(a Action) *Game { g.onStart = a; return g }
// Validate cross-checks name references between managers.
func (g *Game) Validate() error {
if !g.SceneManager.Has(g.startID) {
return fmt.Errorf("%w: %q", ErrNoStartScene, g.startID)
}
for _, name := range g.SceneManager.Names() {
s := g.SceneManager.MustGet(name)
if s.Background == "" {
return fmt.Errorf("%w: scene %q", ErrSceneMissingBackground, name)
}
if s.Background != "" && !g.AssetManager.Has(s.Background) {
return fmt.Errorf("%w: scene %q background %q", ErrUnknownAsset, name, s.Background)
}
if s.Music != "" && !g.AssetManager.Has(s.Music) {
return fmt.Errorf("%w: scene %q music %q", ErrUnknownAsset, name, s.Music)
}
for _, a := range s.Actors {
if !g.CharacterManager.Has(a.CharacterName) {
return fmt.Errorf("%w: scene %q actor %q", ErrUnknownCharacter, name, a.CharacterName)
}
}
}
return nil
}
// ----- runtime helpers ---------------------------------------------------
type runtimeChar struct {
def Character
pos Point
target Point
moving bool
}
func (g *Game) makeCtx() *Ctx {
c := &Ctx{Game: g, DT: 1.0 / 60.0}
if g.currentScene != "" {
s := g.SceneManager.MustGet(g.currentScene)
c.Scene = &s
}
return c
}
func (g *Game) changeScene(name string) {
if !g.SceneManager.Has(name) {
logf("changeScene: unknown %q", name)
return
}
prev := g.currentScene
g.transition.start(func() {
if prev != "" {
old := g.SceneManager.MustGet(prev)
if old.OnLeave != nil {
g.queueAction(old.OnLeave, "OnLeave")
}
}
g.currentScene = name
g.State.NoteVisit(name)
g.placeActors(name)
s := g.SceneManager.MustGet(name)
if s.Music != "" {
g.Audio.PlayMusic(s.Music)
}
if s.OnEnter != nil {
g.queueAction(s.OnEnter, "OnEnter")
}
})
}
func (g *Game) placeActors(sceneName string) {
s := g.SceneManager.MustGet(sceneName)
for _, a := range s.Actors {
def := g.CharacterManager.MustGet(a.CharacterName)
rc, ok := g.chars[def.Name]
if !ok {
rc = &runtimeChar{def: def, pos: a.At}
g.chars[def.Name] = rc
} else {
rc.def = def
rc.pos = a.At
rc.moving = false
}
}
}
func (g *Game) runtimeChar(name string) (*runtimeChar, bool) {
c, ok := g.chars[name]
return c, ok
}
func (g *Game) walkCharacter(name string, to Point) {
c, ok := g.chars[name]
if !ok {
return
}
c.target = to
c.moving = true
}
func (g *Game) characterMoving(name string) bool {
c, ok := g.chars[name]
return ok && c.moving
}
func (g *Game) tickCharacters(dt float64) {
for _, c := range g.chars {
if !c.moving {
continue
}
dx := c.target.X - c.pos.X
dy := c.target.Y - c.pos.Y
d := c.pos.Dist(c.target)
speed := c.def.Speed
if speed <= 0 {
speed = 60
}
step := speed * dt
if d <= step {
c.pos = c.target
c.moving = false
continue
}
c.pos.X += dx / d * step
c.pos.Y += dy / d * step
}
}
// ----- script & dialog plumbing -----------------------------------------
func (g *Game) queueAction(a Action, label string) {
if a == nil {
return
}
if g.scriptRunner != nil {
logf("queueAction: %s ignored, runner busy", label)
return
}
g.scriptRunner = a.Start()
g.scriptCtx = g.makeCtx()
logf("queueAction %s", label)
}
func (g *Game) startDialogue(name string) {
d, ok := g.DialogueManager.Get(name)
if !ok {
logf("startDialogue: unknown %q", name)
return
}
startNode := d.Start
if startNode == "" && len(d.Nodes) > 0 {
startNode = d.Nodes[0].Name
}
node, ok := d.Node(startNode)
if !ok {
return
}
g.activeDialog = name
g.dialog = newDialogBox(&d, &node)
}
func (g *Game) endDialogue() {
g.activeDialog = ""
g.dialog = nil
}
func (g *Game) gotoDialogueNode(name string) {
if g.dialog == nil || g.activeDialog == "" {
return
}
d := g.DialogueManager.MustGet(g.activeDialog)
node, ok := d.Node(name)
if !ok {
logf("gotoNode: unknown %q in %q", name, g.activeDialog)
return
}
g.dialog.node = &node
g.dialog.lineIdx = 0
g.dialog.choiceHits = nil
}
func (g *Game) dialogueActive() bool { return g.dialog != nil }
func (g *Game) showEndCard(text string) { g.UI.endCard = text }

74
pncdsl/core.manager.go Normal file
View File

@@ -0,0 +1,74 @@
package pncdsl
import "sort"
// Named is implemented by every entity that can be registered into a Manager.
type Named interface {
GetName() string
}
// Manager is the single registry shape used by every entity type. Each entity
// kind gets a named alias (ItemManager = Manager[Item], etc.).
type Manager[T Named] struct {
items map[string]T
}
func NewManager[T Named]() *Manager[T] {
return &Manager[T]{items: make(map[string]T)}
}
// Register adds v to the registry. Panics on empty or duplicate name —
// these are construction-time bugs, not runtime conditions.
func (m *Manager[T]) Register(v T) {
name := v.GetName()
if name == "" {
panic("pncdsl: Register: empty Name on " + typeName(v))
}
if _, dup := m.items[name]; dup {
panic("pncdsl: Register: duplicate " + typeName(v) + " name: " + name)
}
m.items[name] = v
}
func (m *Manager[T]) Get(name string) (T, bool) {
v, ok := m.items[name]
return v, ok
}
func (m *Manager[T]) MustGet(name string) T {
v, ok := m.items[name]
if !ok {
panic("pncdsl: MustGet: unknown name: " + name)
}
return v
}
func (m *Manager[T]) Has(name string) bool {
_, ok := m.items[name]
return ok
}
func (m *Manager[T]) Len() int { return len(m.items) }
func (m *Manager[T]) Names() []string {
names := make([]string, 0, len(m.items))
for n := range m.items {
names = append(names, n)
}
sort.Strings(names)
return names
}
func (m *Manager[T]) Each(fn func(T)) {
for _, n := range m.Names() {
fn(m.items[n])
}
}
func typeName(v any) string {
type namer interface{ TypeLabel() string }
if n, ok := v.(namer); ok {
return n.TypeLabel()
}
return "entity"
}

97
pncdsl/dialog.box.go Normal file
View File

@@ -0,0 +1,97 @@
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
}
}

37
pncdsl/dialog.def.go Normal file
View File

@@ -0,0 +1,37 @@
package pncdsl
type Dialogue struct {
Name string
Start string
Nodes []DialogueNode
}
func (d Dialogue) GetName() string { return d.Name }
func (d Dialogue) TypeLabel() string { return "dialogue" }
func (d Dialogue) Node(name string) (DialogueNode, bool) {
for _, n := range d.Nodes {
if n.Name == name {
return n, true
}
}
return DialogueNode{}, false
}
type DialogueNode struct {
Name string
Lines []DialogueLine
Choices []DialogueChoice
}
type DialogueLine struct {
Speaker string
Text string
}
type DialogueChoice struct {
Text string
Show Condition // nil = always
Once bool
Actions []Action
}

3
pncdsl/dialog.manager.go Normal file
View File

@@ -0,0 +1,3 @@
package pncdsl
type DialogueManager = Manager[Dialogue]

44
pncdsl/input.def.go Normal file
View File

@@ -0,0 +1,44 @@
package pncdsl
import (
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
)
// Input is read once per Update() and exposes a tiny "consume on use" API
// so that one click can be authoritative — e.g. clicking on a hotspot
// while a Say is active should only skip the Say, not also fire the
// hotspot underneath.
type Input struct {
mouseX, mouseY int
leftPressed bool
leftConsumed bool
rightPressed bool
rightConsumed bool
}
func newInput() *Input { return &Input{} }
func (i *Input) poll() {
i.mouseX, i.mouseY = ebiten.CursorPosition()
i.leftPressed = inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonLeft)
i.leftConsumed = false
i.rightPressed = inpututil.IsMouseButtonJustPressed(ebiten.MouseButtonRight)
i.rightConsumed = false
}
func (i *Input) Pos() (int, int) { return i.mouseX, i.mouseY }
func (i *Input) Point() Point { return Point{X: float64(i.mouseX), Y: float64(i.mouseY)} }
func (i *Input) LeftClicked() bool { return i.leftPressed && !i.leftConsumed }
func (i *Input) RightClicked() bool { return i.rightPressed && !i.rightConsumed }
func (i *Input) ConsumeLeft() { i.leftConsumed = true }
func (i *Input) ConsumeRight() { i.rightConsumed = true }
// consumedClick is the Say-style "did the user click to skip?" probe.
func (i *Input) consumedClick() bool {
if i.LeftClicked() {
i.ConsumeLeft()
return true
}
return false
}

14
pncdsl/item.def.go Normal file
View File

@@ -0,0 +1,14 @@
package pncdsl
type Item struct {
Name string
Sprite string // Asset.Name
Description string
OnUseSelf Action
// OnUseWith: targetName -> action. Target may be a hotspot Name or another item Name.
OnUseWith map[string]Action
}
func (i Item) GetName() string { return i.Name }
func (i Item) TypeLabel() string { return "item" }

46
pncdsl/item.inventory.go Normal file
View File

@@ -0,0 +1,46 @@
package pncdsl
type Inventory struct {
items []string
selected string
}
func NewInventory() *Inventory { return &Inventory{} }
func (i *Inventory) Add(name string) {
if i.Has(name) {
return
}
i.items = append(i.items, name)
}
func (i *Inventory) Remove(name string) {
for k, n := range i.items {
if n == name {
i.items = append(i.items[:k], i.items[k+1:]...)
if i.selected == name {
i.selected = ""
}
return
}
}
}
func (i *Inventory) Has(name string) bool {
for _, n := range i.items {
if n == name {
return true
}
}
return false
}
func (i *Inventory) Select(name string) {
if name != "" && !i.Has(name) {
return
}
i.selected = name
}
func (i *Inventory) Selected() string { return i.selected }
func (i *Inventory) Items() []string { return append([]string(nil), i.items...) }

3
pncdsl/item.manager.go Normal file
View File

@@ -0,0 +1,3 @@
package pncdsl
type ItemManager = Manager[Item]

10
pncdsl/scene.camera.go Normal file
View File

@@ -0,0 +1,10 @@
package pncdsl
// Camera is a stub identity transform — no scrolling in this milestone.
type Camera struct {
Offset Point
}
func NewCamera() *Camera { return &Camera{} }
func (c *Camera) Apply(p Point) Point { return Point{p.X - c.Offset.X, p.Y - c.Offset.Y} }

22
pncdsl/scene.def.go Normal file
View File

@@ -0,0 +1,22 @@
package pncdsl
type Scene struct {
Name string
Background string // Asset.Name
Music string // Asset.Name (optional)
Hotspots []Hotspot
Walkboxes []Polygon
Triggers []Trigger
Actors []SceneActor
OnEnter Action
OnLeave Action
}
func (s Scene) GetName() string { return s.Name }
func (s Scene) TypeLabel() string { return "scene" }
// SceneActor places a registered Character at a starting position inside a scene.
type SceneActor struct {
CharacterName string
At Point
}

52
pncdsl/scene.hotspot.go Normal file
View File

@@ -0,0 +1,52 @@
package pncdsl
type CursorKind int
const (
CursorDefault CursorKind = iota
CursorLook
CursorUse
CursorTalk
CursorTake
CursorExit
)
type Hotspot struct {
Name string
Area Shape
Label string
Cursor CursorKind
OnLook Action
OnUse Action
OnTalk Action
OnTake Action
OnGive Action
// OnUseWith: when the player has an item selected and clicks this hotspot,
// the engine looks up the item's Name here first.
OnUseWith map[string]Action
// OnVerb: custom verbs registered via g.VerbManager.
OnVerb map[string]Action
}
// handler returns the action bound to verb v for this hotspot, or nil.
func (h Hotspot) handler(v string) Action {
switch v {
case "look":
return h.OnLook
case "use":
return h.OnUse
case "talk":
return h.OnTalk
case "take":
return h.OnTake
case "give":
return h.OnGive
}
if h.OnVerb != nil {
return h.OnVerb[v]
}
return nil
}

3
pncdsl/scene.manager.go Normal file
View File

@@ -0,0 +1,3 @@
package pncdsl
type SceneManager = Manager[Scene]

View File

@@ -0,0 +1,61 @@
package pncdsl
import (
"image/color"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
)
// transition is a fade-to-black overlay used between scene swaps.
type transition struct {
active bool
t float64
duration float64
out bool // true: fading to black; false: fading from black
onMid func()
}
func (t *transition) start(onMid func()) {
t.active = true
t.out = true
t.t = 0
t.duration = 0.25
t.onMid = onMid
}
func (t *transition) update(dt float64) {
if !t.active {
return
}
t.t += dt
if t.t >= t.duration {
if t.out {
if t.onMid != nil {
t.onMid()
t.onMid = nil
}
t.out = false
t.t = 0
return
}
t.active = false
}
}
func (t *transition) draw(dst *ebiten.Image, w, h int) {
if !t.active {
return
}
alpha := t.t / t.duration
if !t.out {
alpha = 1 - alpha
}
if alpha < 0 {
alpha = 0
} else if alpha > 1 {
alpha = 1
}
c := color.RGBA{0, 0, 0, uint8(alpha * 255)}
vector.DrawFilledRect(dst, 0, 0, float32(w), float32(h), c, false)
}

10
pncdsl/scene.trigger.go Normal file
View File

@@ -0,0 +1,10 @@
package pncdsl
// Trigger fires Do when its When condition first becomes true after the
// trigger arms (on scene enter). One-shot per scene visit by default.
type Trigger struct {
Name string
When Condition
Do Action
Once bool
}

27
pncdsl/state.def.go Normal file
View File

@@ -0,0 +1,27 @@
package pncdsl
type State struct {
flags map[string]bool
vars map[string]any
visited map[string]int
talked map[string]int
}
func NewState() *State {
return &State{
flags: make(map[string]bool),
vars: make(map[string]any),
visited: make(map[string]int),
talked: make(map[string]int),
}
}
func (s *State) Flag(name string) bool { return s.flags[name] }
func (s *State) SetFlag(name string) { s.flags[name] = true }
func (s *State) ClearFlag(name string) { delete(s.flags, name) }
func (s *State) Var(name string) any { return s.vars[name] }
func (s *State) SetVar(name string, v any) { s.vars[name] = v }
func (s *State) Visited(name string) int { return s.visited[name] }
func (s *State) NoteVisit(name string) { s.visited[name]++ }
func (s *State) Talked(node string) int { return s.talked[node] }
func (s *State) NoteTalked(node string) { s.talked[node]++ }

6
pncdsl/state.save.go Normal file
View File

@@ -0,0 +1,6 @@
package pncdsl
// Save/Load are stubbed out in this milestone — the runtime state machine
// is in place, but JSON serialization will land alongside the polish pass.
func (g *Game) Save(slot int) error { _ = slot; return nil }
func (g *Game) Load(slot int) error { _ = slot; return nil }

30
pncdsl/ui.cursor.go Normal file
View File

@@ -0,0 +1,30 @@
package pncdsl
import (
"image/color"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
)
// drawCursor renders a simple crosshair at the mouse position. When an
// inventory item is selected, the item sprite is drawn instead.
func drawCursor(dst *ebiten.Image, g *Game) {
x, y := g.input.Pos()
if sel := g.Inventory.Selected(); sel != "" {
if it, ok := g.ItemManager.Get(sel); ok {
img := g.loaded.image(g.AssetManager, it.Sprite)
sw, sh := img.Bounds().Dx(), img.Bounds().Dy()
if sw > 0 && sh > 0 {
op := &ebiten.DrawImageOptions{}
op.GeoM.Scale(16/float64(sw), 16/float64(sh))
op.GeoM.Translate(float64(x-8), float64(y-8))
dst.DrawImage(img, op)
return
}
}
}
c := color.RGBA{255, 255, 255, 255}
vector.StrokeLine(dst, float32(x-3), float32(y), float32(x+4), float32(y), 1, c, false)
vector.StrokeLine(dst, float32(x), float32(y-3), float32(x), float32(y+4), 1, c, false)
}

187
pncdsl/ui.def.go Normal file
View File

@@ -0,0 +1,187 @@
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 ""
}

74
pncdsl/ui.speech.go Normal file
View File

@@ -0,0 +1,74 @@
package pncdsl
import (
"image/color"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
)
// speechBubble holds the currently-spoken line. The Say action sets it and
// the engine draws it above the speaker (or centered at screen top if the
// speaker is unknown).
type speechBubble struct {
speaker string
lines []string
pos Point
active bool
}
func (s *speechBubble) set(g *Game, speaker, text string) {
s.speaker = speaker
s.lines = wrapText(text, 200)
s.active = true
// position above the speaker, if we know where they are
if c, ok := g.runtimeChar(speaker); ok {
s.pos = Point{X: c.pos.X, Y: c.pos.Y - c.def.H - 4}
} else {
s.pos = Point{X: 160, Y: 14}
}
}
func (s *speechBubble) clear() {
s.active = false
s.speaker = ""
s.lines = nil
}
func (s *speechBubble) tick(dt float64) { _ = dt }
func (s *speechBubble) draw(dst *ebiten.Image, g *Game) {
if !s.active || len(s.lines) == 0 {
return
}
maxW := 0
for _, ln := range s.lines {
if w := textWidth(ln); w > maxW {
maxW = w
}
}
pad := 3
w := maxW + pad*2
h := len(s.lines)*16 + pad*2
x := int(s.pos.X) - w/2
y := int(s.pos.Y) - h
if x < 2 {
x = 2
}
if x+w > 318 {
x = 318 - w
}
if y < 2 {
y = 2
}
vector.DrawFilledRect(dst, float32(x), float32(y), float32(w), float32(h), color.RGBA{0, 0, 0, 200}, false)
speechColor := color.RGBA{255, 255, 255, 255}
if c, ok := g.CharacterManager.Get(s.speaker); ok {
if sc, ok := c.SpeechColor.(color.RGBA); ok {
speechColor = sc
}
}
for k, ln := range s.lines {
drawText(dst, ln, x+pad, y+pad+k*16-2, speechColor)
}
}

21
pncdsl/ui.verb.go Normal file
View File

@@ -0,0 +1,21 @@
package pncdsl
type Verb struct {
Name string
Label string
Default Action
}
func (v Verb) GetName() string { return v.Name }
func (v Verb) TypeLabel() string { return "verb" }
// defaultVerbs returns the built-in SCUMM-style verb set. A game can replace
// or extend these by registering its own Verbs after NewGame.
func defaultVerbs() []Verb {
return []Verb{
{Name: "look", Label: "Nézd"},
{Name: "use", Label: "Használd"},
{Name: "talk", Label: "Beszélj"},
{Name: "take", Label: "Vedd fel"},
}
}

View File

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

79
pncdsl/util.geometry.go Normal file
View File

@@ -0,0 +1,79 @@
package pncdsl
import "math"
type Point struct {
X, Y float64
}
func (p Point) Add(q Point) Point { return Point{p.X + q.X, p.Y + q.Y} }
func (p Point) Sub(q Point) Point { return Point{p.X - q.X, p.Y - q.Y} }
func (p Point) Dist(q Point) float64 {
dx, dy := p.X-q.X, p.Y-q.Y
return math.Sqrt(dx*dx + dy*dy)
}
// Shape is a hotspot area. Rectangle and Polygon both satisfy it.
type Shape interface {
Contains(p Point) bool
Bounds() Rectangle
}
type Rectangle struct {
X, Y, W, H float64
}
func Rect(x, y, w, h float64) Rectangle { return Rectangle{X: x, Y: y, W: w, H: h} }
func (r Rectangle) Contains(p Point) bool {
return p.X >= r.X && p.X < r.X+r.W && p.Y >= r.Y && p.Y < r.Y+r.H
}
func (r Rectangle) Bounds() Rectangle { return r }
func (r Rectangle) Center() Point { return Point{X: r.X + r.W/2, Y: r.Y + r.H/2} }
type Polygon struct {
Points []Point
}
func Poly(pts ...Point) Polygon { return Polygon{Points: pts} }
func (g Polygon) Contains(pt Point) bool {
n := len(g.Points)
if n < 3 {
return false
}
inside := false
for i, j := 0, n-1; i < n; j, i = i, i+1 {
pi, pj := g.Points[i], g.Points[j]
if (pi.Y > pt.Y) != (pj.Y > pt.Y) &&
pt.X < (pj.X-pi.X)*(pt.Y-pi.Y)/(pj.Y-pi.Y)+pi.X {
inside = !inside
}
}
return inside
}
func (g Polygon) Bounds() Rectangle {
if len(g.Points) == 0 {
return Rectangle{}
}
minX, maxX := g.Points[0].X, g.Points[0].X
minY, maxY := g.Points[0].Y, g.Points[0].Y
for _, p := range g.Points[1:] {
if p.X < minX {
minX = p.X
}
if p.X > maxX {
maxX = p.X
}
if p.Y < minY {
minY = p.Y
}
if p.Y > maxY {
maxY = p.Y
}
}
return Rectangle{X: minX, Y: minY, W: maxX - minX, H: maxY - minY}
}

15
pncdsl/util.log.go Normal file
View File

@@ -0,0 +1,15 @@
package pncdsl
import (
"fmt"
"os"
)
var DebugLog = false
func logf(format string, args ...any) {
if !DebugLog {
return
}
fmt.Fprintf(os.Stderr, "[pncdsl] "+format+"\n", args...)
}

10
pncdsl/util.timer.go Normal file
View File

@@ -0,0 +1,10 @@
package pncdsl
// Timer accumulates seconds. Tick advances it; Reset zeroes it.
type Timer struct {
Elapsed float64
}
func (t *Timer) Tick(dt float64) { t.Elapsed += dt }
func (t *Timer) Reset() { t.Elapsed = 0 }
func (t *Timer) Done(after float64) bool { return t.Elapsed >= after }