From e4a7cc917741235f0f4a14351f54c05d5bfaa1fe Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Mon, 25 May 2026 20:03:49 +0200 Subject: [PATCH] add documentation --- DEMO.md | 216 +++++++++++++ LICENSE.md | 8 + PLAN.md | 876 ----------------------------------------------------- README.md | 193 ++++++++++++ 4 files changed, 417 insertions(+), 876 deletions(-) create mode 100644 DEMO.md create mode 100644 LICENSE.md delete mode 100644 PLAN.md create mode 100644 README.md diff --git a/DEMO.md b/DEMO.md new file mode 100644 index 0000000..fbda31f --- /dev/null +++ b/DEMO.md @@ -0,0 +1,216 @@ +# DEMO.md — "Morning Coffee" + +The `domain/` folder contains a 5-minute mini-game that exercises every +important `pncdsl` feature in one sitting — without the demo itself +outgrowing the library. The code is a good place to learn from, because +each mechanic appears in **exactly one small file**. + +## Story + +You've just woken up in your bedroom. The cat (Whiskers) is already +meowing in the kitchen, and you'd better make coffee before she tears +the whole apartment apart. + +## Running it + +```bash +go run . +``` + +A window opens at 1280×800 (internal resolution 320×200, 4× scale). No +asset files are needed — every graphic is a colored placeholder / +stylized humanoid until you replace it with real art (see +[`GFX.md`](GFX.md)). + +Controls: + +- **left click** on a hotspot/UI button: run the active verb +- **left click** on an inventory item: select it (the cursor picks it up) +- **left click** on a hotspot while an item is selected: use-with +- **right click**: deselect the held item, or reset the verb to "Look" +- click during dialog: advance + +## Walkthrough (spoilers) + +1. **Bedroom** — the `intro` script runs two `Say` lines from the + `player`. +2. Click the **nightstand** with the "Take" verb → you receive `key`. +3. Click the **door** → you switch to the kitchen. +4. **Kitchen** — Whiskers is sitting in the corner. Try "Talk" on her — + a conditional dialog opens; one of the branches is only available + while the cupboard hasn't been opened yet. +5. Click the **shelf** with "Take" → you receive `mug`. +6. Click the **cupboard** with "Use" (key in inventory) → it opens, you + automatically receive `beans`, and the key is consumed. *Or* select + the key in the inventory and click the cupboard — `OnUseWith` does + the same thing. +7. Click the **coffee machine** with `beans` → beans loaded. +8. Click the coffee machine with `mug` → mug in place. +9. Once both flags are set, the coffee machine fires the `victory` + script: two characters talk, a sound plays (stubbed), end card → done. + +## Scenes + +| ID | Background | Music | Contents | +|------------|------------------|--------------|-----------------------------------------------------------| +| `bedroom` | `bg/bedroom` | `mus/wakeup` | bed, nightstand (key), door | +| `kitchen` | `bg/kitchen` | `mus/calm` | cupboard, shelf, coffee machine, cat (NPC), door | + +## Items + +| ID | Acquired from | Used for | +|---------|----------------------------------------------|---------------------------------------------------------------------| +| `key` | `bedroom.nightstand` → Take | Use on `kitchen.cupboard`, or as a selected item via `OnUseWith` | +| `beans` | `kitchen.cupboard` (after unlocking) | Selected, used on `kitchen.coffee_machine` | +| `mug` | `kitchen.shelf` → Take | Selected, used on `kitchen.coffee_machine` | + +## Characters + +- **`player`** — the player character. Walks (`Walk`), talks (`Say`). + Size 28×62 (W×H), roughly the SCUMM-style 30% screen height. +- **`cat`** — Whiskers, the household pet. Present in the kitchen, only + responds to `Talk`, has a dialog tree. + +Until you provide real sprites, they're rendered as a stylized humanoid +(skin-tone head + colored torso + dark trousers + feet) and quadruped +(side-view with ears, head, eye, tail) placeholder shape. + +## Dialog (`dialog.cat_morning.go`) + +``` +[start] +cat: Meeeoooow. It's late. + > "Coffee's coming right up." → SetFlag(promised_coffee), end + > "Did you eat the beans?" (only while NOT cupboard_open) → goto beans + > "Leave me alone." → end + +[beans] +cat: They're in the cupboard. They're always in the cupboard. + > "Thanks." → end +``` + +The `Show: p.Not(p.Flag(FlagCupboardOpen))` condition makes the "Did +you eat the beans?" choice disappear once you've opened the cupboard +— this demonstrates conditional choice visibility +(`DialogueChoice.Show`). + +## Cutscenes + +**`script.intro.go`** — wired up to `OnStart`, runs in the bedroom: + +```go +p.Seq( + p.Wait(0.4), + p.Say("player", "Brr, it's cold."), + p.Say("player", "I need coffee before my head explodes."), + p.Walk("player", p.Point{X: 200, Y: 145}), +) +``` + +**`script.victory.go`** — fired when the coffee machine sees both flags: + +```go +p.Seq( + p.Say("player", "All done!"), + p.PlaySound("snd/coffee_done"), + p.Wait(0.6), + p.Say("cat", "Mrrrrow. *content cat noises*"), + p.Say("player", "Morning coffee: saved."), + p.ShowEnd("The End — thanks for playing!"), +) +``` + +## Flag/var table (`domain.flag.go`) + +```go +const ( + FlagKeyTaken = "key_taken" + FlagCupboardOpen = "cupboard_open" + FlagMugTaken = "mug_taken" + FlagCoffeeHasBeans = "coffee_has_beans" + FlagCoffeeHasMug = "coffee_has_mug" + FlagPromisedCoffee = "promised_coffee" +) +``` + +Naming the flags as constants is worth it because the type system then +catches typos — `pncdsl.Flag("cupbord_open")` would otherwise silently +return `false` forever. + +## What the demo exercises + +| Library feature | Where it shows up | +|------------------------------------|----------------------------------------------------------------| +| `Scene` + `GoTo` | bedroom ↔ kitchen door, with fade transition | +| `Hotspot` with every verb | each scene has multiple hotspots: Look / Use / Talk / Take | +| `Inventory` + `Give` / `TakeAway` | picking up and consuming key, beans, mug | +| `RequireItem` semantics | cupboard checks `HasItem("key")` | +| `OnUseWith` (from the hotspot) | cupboard + key | +| `OnUseWith` (from the item) | coffee machine + beans / mug | +| `Flag` + `If` | cupboard "already open", shelf "already took one" | +| `Dialogue` + `Choice.Show` | cat's dialog tree, vanishing branch driven by a flag | +| `Script` / cutscene | intro (walk+say) + victory (multi-actor, sound, end card) | +| `Walk` with straight-line stepping | every interaction is preceded by a walk target | +| `ShowEnd` | last action of the victory script | +| `Validate()` | `domain/build_test.go` CI-friendly smoke test | + +## Files in `domain/` + +``` +domain/ +├── game.go # Build() — calls every define*() in order +├── domain.asset.go # registerAssets(g) — all assets in one place +├── domain.flag.go # flag-name constants +│ +├── character.player.go # definePlayer(g) +├── character.cat.go # defineCat(g) +│ +├── item.key.go # defineKey(g) — OnUseWith: {"cupboard": ...} +├── item.beans.go # defineBeans(g) +├── item.mug.go # defineMug(g) +│ +├── dialog.cat_morning.go # defineCatMorning(g) — 2 nodes, 3+1 choices +│ +├── scene.bedroom.go # defineBedroom(g) +├── scene.kitchen.go # defineKitchen(g) +│ +├── script.intro.go # defineIntroScript(g) +├── script.victory.go # defineVictoryScript(g) +│ +└── build_test.go # Build() + Validate() smoke test +``` + +Adding a new scene/item: one new file + one line in `Build()` inside +`game.go`. There's no central registry list to edit. + +## How to extend it + +- **New scene**: create `scene..go` with a `define(g *p.Game)` + function that calls `g.SceneManager.Register(...)`. Add a call in + `Build()`. If any hotspot uses `GoTo("")`, `Validate()` will + cross-check the link for you. +- **New item**: same pattern, `item..go`, register into + `g.ItemManager`. Map keys inside `OnUseWith` must exactly match the + target hotspot's `Name` field. +- **New dialog**: `dialog..go` — `DialogueChoice.Show` accepts + anything that implements `Condition` (`Flag`, `HasItem`, `Not(...)`, + etc.). +- **New cutscene**: `script..go`, register into + `g.ScriptManager`, then fire it anywhere with + `p.RunScript("")`. + +## Graphics + +The library substitutes a **deterministic colored rectangle** for every +missing image and renders characters as a stylized humanoid/quadruped +shape. The game therefore runs without any asset on disk. When you drop +a generated image into `assets/bg/.png` or +`assets/spr/.png`, the library switches to it immediately — no +code change required. + +Prompts and positioning tips for an image AI: [`GFX.md`](GFX.md). + +## Back to the library + +For the framework structure and the Manager pattern itself, see +[`README.md`](README.md) and [`PLAN.md`](PLAN.md). diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..5afdf5f --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,8 @@ +The MIT License (MIT) +Copyright © 2026 Teletype Games + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/PLAN.md b/PLAN.md deleted file mode 100644 index 223f0ae..0000000 --- a/PLAN.md +++ /dev/null @@ -1,876 +0,0 @@ -# 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..3f2f74c --- /dev/null +++ b/README.md @@ -0,0 +1,193 @@ +# pncdsl + +A point-and-click adventure game framework for Go, built on top of +[Ebitengine](https://ebitengine.org/). You describe your game with +**declarative struct literals**: every entity (item, scene, character, +dialogue, script, asset, verb) is registered through its matching +`XxxManager.Register(...)` call, and the library handles the rest — +input, rendering, state, dialog trees, cutscenes. + +## What it is + +Inspired by the classic LucasArts SCUMM era (Maniac Mansion, Monkey +Island, Day of the Tentacle). The framework doesn't try to be a generic +"engine" — it's a thin Go-level DSL layer on top of Ebitengine that gives +you the fixed skeleton of the adventure genre: + +- scenes with hotspots and click zones, +- a verb-bar UI: Look / Use / Talk / Take, +- inventory, item-on-item and item-on-hotspot interactions, +- dialog trees with conditional choices, +- a script/cutscene system with composite actions (`Seq`, `Par`, `If`, `Wait`), +- world state (flags, vars), validation. + +The approach deliberately collapses to **one single pattern**: every +piece is registered via `Manager.Register(Entity{Name: "..."})`. No +builders, no fluent chains, no registry sprawl. + +## Quick start + +Prerequisites: Go 1.24+ (for generic type aliases) and a working OpenGL +context. + +```bash +git clone +cd pncdsl +go run . +``` + +Opens an Ebiten window at 1280×800 (internal resolution 320×200, scaled +4×). No asset files are required — the library generates deterministic +colored placeholders for anything missing under `assets/`. + +## One-minute example + +```go +// main.go +package main + +import ( + "log" + "pncdsl/pncdsl" +) + +func main() { + g := pncdsl.NewGame("Sample", 320, 200) + + g.AssetManager.Register(pncdsl.Asset{Name: "bg/kitchen", Path: "assets/bg/kitchen.png", Kind: pncdsl.AssetImage}) + + g.ItemManager.Register(pncdsl.Item{ + Name: "key", Sprite: "spr/key", Description: "a rusty key", + }) + + g.SceneManager.Register(pncdsl.Scene{ + Name: "kitchen", + Background: "bg/kitchen", + Hotspots: []pncdsl.Hotspot{ + { + Name: "drawer", + Area: pncdsl.Rect(40, 80, 60, 40), + Label: "drawer", + OnLook: pncdsl.Say("player", "A drawer. Wonder what's inside?"), + OnUse: pncdsl.Seq(pncdsl.Give("key"), pncdsl.Say("player", "A key!")), + }, + }, + }) + + g.CharacterManager.Register(pncdsl.Character{Name: "player", W: 28, H: 62}) + + g.StartAt("kitchen") + if err := pncdsl.Run(g); err != nil { + log.Fatal(err) + } +} +``` + +## The central pattern — `Manager` + +Every name-addressable entity goes into the game through one shared type: + +```go +type Named interface { GetName() string } + +type Manager[T Named] struct { /* ... */ } +func (m *Manager[T]) Register(v T) +func (m *Manager[T]) Get(name string) (T, bool) +func (m *Manager[T]) MustGet(name string) T +func (m *Manager[T]) Has(name string) bool +func (m *Manager[T]) Names() []string +``` + +Each entity kind gets a generic alias (`ItemManager = Manager[Item]`, +`SceneManager = Manager[Scene]`, …) so usage is uniform: + +```go +g.ItemManager.Register(pncdsl.Item{Name: "key", ...}) +g.SceneManager.Register(pncdsl.Scene{Name: "kitchen", ...}) +g.AssetManager.Register(pncdsl.Asset{Name: "bg/kitchen", Path: "..."}) +``` + +A duplicate or empty `Name` panics — that's a construction-time bug, +not a runtime error. + +Full design rationale: [`PLAN.md`](PLAN.md). + +## File layout + +``` +pncdsl/ +├── main.go # entry point: pncdsl.Run(domain.Build()) +├── pncdsl/ # the library — theme-prefixed file names +│ ├── core.*.go # Game, engine, manager, dsl, errors +│ ├── scene.*.go # Scene, Hotspot, Trigger, Camera, Transition +│ ├── item.*.go # Item, Inventory +│ ├── actor.*.go # Character, Animation +│ ├── dialog.*.go # Dialogue, DialogBox +│ ├── action.*.go # Action/Runner, built-in actions, Condition +│ ├── state.*.go # State, Save (stub) +│ ├── ui.*.go # VerbBar, Cursor, SpeechBubble +│ ├── asset.*.go # Registry, lazy load, Audio (stub), Text +│ └── util.*.go # geometry, timer, log +├── domain/ # the concrete game — one file per entity +│ ├── game.go # Build() +│ ├── scene.bedroom.go # ↔ g.SceneManager.Register(...Name: "bedroom"...) +│ ├── item.key.go # ↔ g.ItemManager.Register(...Name: "key"...) +│ └── ... +├── PLAN.md # detailed design document +├── DEMO.md # demo game overview ◀──────── +├── GFX.md # asset prompts for image-AI +└── README.md # this file +``` + +Files under `domain/` always follow `theme.identifier.go` (e.g. +`scene.kitchen.go`, `character.player.go`) — `ls domain/scene.*` +instantly lists every location. + +## The demo + +The repo ships with a mini-game (`domain/`) titled **"Morning Coffee"**. +Two scenes, three items, one NPC with a dialog, one intro and one ending +cutscene — just enough to exercise every library feature without the +demo outgrowing the library. + +Full walkthrough and file mapping: [`DEMO.md`](DEMO.md). + +If you want to generate art for the game, prompts for image AIs are in +[`GFX.md`](GFX.md). + +## Current status + +| Area | State | +|-------------------------------|----------------------------------------------------------| +| Manager registries | ✅ complete (Item, Scene, Character, Dialogue, Script, Asset, Verb) | +| Hotspot + verb interaction | ✅ | +| Inventory, use-with-item | ✅ (both `hotspot.OnUseWith` and `item.OnUseWith`) | +| Dialog tree + conditional choices | ✅ (`Show` condition, `Once` flag) | +| `Action`/`Runner` engine | ✅ Seq / Par / If / Wait / Say / Walk / Give / GoTo / … | +| Condition library | ✅ Flag, HasItem, SelectedItem, InScene, VarEq, Not/And/Or | +| Scene transitions (fade) | ✅ on scene change | +| Asset placeholders | ✅ deterministic color for missing files | +| Stylized character placeholder | ✅ humanoid / quadruped shape when no sprite art | +| Audio (PlayMusic/PlaySound) | 🟡 log-only — Ebiten audio backend not wired up yet | +| Animation (sprite sheet) | 🟡 `AnimationClip` field exists, rendering not yet | +| Walkbox + A\* | 🟡 straight-line movement only | +| Save / Load (JSON) | 🟡 API in place, implementation stubbed | +| Trigger processing | 🟡 struct exists, engine doesn't run them yet | + +## Testing + +```bash +go test ./... +``` + +`domain/build_test.go` is a headless smoke test: it calls `Build()` and +then `Validate()` to cross-check every name reference between managers. +No Ebiten window is opened. + +## Further reading + +- [`DEMO.md`](DEMO.md) — walkthrough of the "Morning Coffee" demo + +## License + +MIT — see [`LICENSE.md`](LICENSE.md). Copyright © 2026 Teletype Games.