add documentation

This commit is contained in:
2026-05-25 20:03:49 +02:00
parent f0b86871f6
commit e4a7cc9177
4 changed files with 417 additions and 876 deletions

216
DEMO.md Normal file
View File

@@ -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.<name>.go` with a `define<Name>(g *p.Game)`
function that calls `g.SceneManager.Register(...)`. Add a call in
`Build()`. If any hotspot uses `GoTo("<name>")`, `Validate()` will
cross-check the link for you.
- **New item**: same pattern, `item.<name>.go`, register into
`g.ItemManager`. Map keys inside `OnUseWith` must exactly match the
target hotspot's `Name` field.
- **New dialog**: `dialog.<name>.go``DialogueChoice.Show` accepts
anything that implements `Condition` (`Flag`, `HasItem`, `Not(...)`,
etc.).
- **New cutscene**: `script.<name>.go`, register into
`g.ScriptManager`, then fire it anywhere with
`p.RunScript("<name>")`.
## 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/<name>.png` or
`assets/spr/<name>.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).

8
LICENSE.md Normal file
View File

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

876
PLAN.md
View File

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

193
README.md Normal file
View File

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