commit df7219677e3cd43fb0090f14d05516236e1cd794 Author: Zsolt Tasnadi Date: Mon May 25 18:09:51 2026 +0200 initial commit diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..223f0ae --- /dev/null +++ b/PLAN.md @@ -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. diff --git a/domain/build_test.go b/domain/build_test.go new file mode 100644 index 0000000..c96cbb4 --- /dev/null +++ b/domain/build_test.go @@ -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") + } +} diff --git a/domain/character.cat.go b/domain/character.cat.go new file mode 100644 index 0000000..04945f7 --- /dev/null +++ b/domain/character.cat.go @@ -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, + }) +} diff --git a/domain/character.player.go b/domain/character.player.go new file mode 100644 index 0000000..0699e78 --- /dev/null +++ b/domain/character.player.go @@ -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, + }) +} diff --git a/domain/dialog.cat_morning.go b/domain/dialog.cat_morning.go new file mode 100644 index 0000000..208ed33 --- /dev/null +++ b/domain/dialog.cat_morning.go @@ -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()}, + }, + }, + }, + }, + }) +} diff --git a/domain/domain.asset.go b/domain/domain.asset.go new file mode 100644 index 0000000..e2724dd --- /dev/null +++ b/domain/domain.asset.go @@ -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, + }) + } +} diff --git a/domain/domain.flag.go b/domain/domain.flag.go new file mode 100644 index 0000000..e924f36 --- /dev/null +++ b/domain/domain.flag.go @@ -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" +) diff --git a/domain/game.go b/domain/game.go new file mode 100644 index 0000000..39aead9 --- /dev/null +++ b/domain/game.go @@ -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 +} diff --git a/domain/item.beans.go b/domain/item.beans.go new file mode 100644 index 0000000..7fe9bd5 --- /dev/null +++ b/domain/item.beans.go @@ -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", + }) +} diff --git a/domain/item.key.go b/domain/item.key.go new file mode 100644 index 0000000..3531808 --- /dev/null +++ b/domain/item.key.go @@ -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."), + ), + }, + }) +} diff --git a/domain/item.mug.go b/domain/item.mug.go new file mode 100644 index 0000000..3a3058f --- /dev/null +++ b/domain/item.mug.go @@ -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", + }) +} diff --git a/domain/scene.bedroom.go b/domain/scene.bedroom.go new file mode 100644 index 0000000..5dc0d8e --- /dev/null +++ b/domain/scene.bedroom.go @@ -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"), + ), + }, + }, + }) +} diff --git a/domain/scene.kitchen.go b/domain/scene.kitchen.go new file mode 100644 index 0000000..ef598b1 --- /dev/null +++ b/domain/scene.kitchen.go @@ -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"), + ), + }, + }, + }) +} diff --git a/domain/script.intro.go b/domain/script.intro.go new file mode 100644 index 0000000..72d0f1e --- /dev/null +++ b/domain/script.intro.go @@ -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}), + ), + }) +} diff --git a/domain/script.victory.go b/domain/script.victory.go new file mode 100644 index 0000000..ab6f5e7 --- /dev/null +++ b/domain/script.victory.go @@ -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!"), + ), + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..824c43e --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..07593fd --- /dev/null +++ b/go.sum @@ -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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..e59e75c --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/pncdsl/action.condition.go b/pncdsl/action.condition.go new file mode 100644 index 0000000..0d6f263 --- /dev/null +++ b/pncdsl/action.condition.go @@ -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 ¬Cond{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 } diff --git a/pncdsl/action.def.go b/pncdsl/action.def.go new file mode 100644 index 0000000..864f4c8 --- /dev/null +++ b/pncdsl/action.def.go @@ -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 +} diff --git a/pncdsl/action.manager.go b/pncdsl/action.manager.go new file mode 100644 index 0000000..6873102 --- /dev/null +++ b/pncdsl/action.manager.go @@ -0,0 +1,3 @@ +package pncdsl + +type ScriptManager = Manager[Script] diff --git a/pncdsl/action.script.go b/pncdsl/action.script.go new file mode 100644 index 0000000..560a88a --- /dev/null +++ b/pncdsl/action.script.go @@ -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" } diff --git a/pncdsl/actor.animation.go b/pncdsl/actor.animation.go new file mode 100644 index 0000000..1defbcf --- /dev/null +++ b/pncdsl/actor.animation.go @@ -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 +} diff --git a/pncdsl/actor.def.go b/pncdsl/actor.def.go new file mode 100644 index 0000000..c3db040 --- /dev/null +++ b/pncdsl/actor.def.go @@ -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" } diff --git a/pncdsl/actor.manager.go b/pncdsl/actor.manager.go new file mode 100644 index 0000000..0a87a58 --- /dev/null +++ b/pncdsl/actor.manager.go @@ -0,0 +1,3 @@ +package pncdsl + +type CharacterManager = Manager[Character] diff --git a/pncdsl/asset.audio.go b/pncdsl/asset.audio.go new file mode 100644 index 0000000..f957d9e --- /dev/null +++ b/pncdsl/asset.audio.go @@ -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) +} diff --git a/pncdsl/asset.def.go b/pncdsl/asset.def.go new file mode 100644 index 0000000..f9cc370 --- /dev/null +++ b/pncdsl/asset.def.go @@ -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" } diff --git a/pncdsl/asset.manager.go b/pncdsl/asset.manager.go new file mode 100644 index 0000000..9171c3f --- /dev/null +++ b/pncdsl/asset.manager.go @@ -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 +} diff --git a/pncdsl/asset.text.go b/pncdsl/asset.text.go new file mode 100644 index 0000000..09f3fed --- /dev/null +++ b/pncdsl/asset.text.go @@ -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 +} diff --git a/pncdsl/core.doc.go b/pncdsl/core.doc.go new file mode 100644 index 0000000..b7b8c06 --- /dev/null +++ b/pncdsl/core.doc.go @@ -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 diff --git a/pncdsl/core.dsl.go b/pncdsl/core.dsl.go new file mode 100644 index 0000000..2d13bfc --- /dev/null +++ b/pncdsl/core.dsl.go @@ -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) } diff --git a/pncdsl/core.engine.go b/pncdsl/core.engine.go new file mode 100644 index 0000000..f661cc4 --- /dev/null +++ b/pncdsl/core.engine.go @@ -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) +} diff --git a/pncdsl/core.errors.go b/pncdsl/core.errors.go new file mode 100644 index 0000000..f86d7fe --- /dev/null +++ b/pncdsl/core.errors.go @@ -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") +) diff --git a/pncdsl/core.game.go b/pncdsl/core.game.go new file mode 100644 index 0000000..1fade69 --- /dev/null +++ b/pncdsl/core.game.go @@ -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 } diff --git a/pncdsl/core.manager.go b/pncdsl/core.manager.go new file mode 100644 index 0000000..9f4b8cb --- /dev/null +++ b/pncdsl/core.manager.go @@ -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" +} diff --git a/pncdsl/dialog.box.go b/pncdsl/dialog.box.go new file mode 100644 index 0000000..3967c15 --- /dev/null +++ b/pncdsl/dialog.box.go @@ -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 + } +} diff --git a/pncdsl/dialog.def.go b/pncdsl/dialog.def.go new file mode 100644 index 0000000..4e150d2 --- /dev/null +++ b/pncdsl/dialog.def.go @@ -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 +} diff --git a/pncdsl/dialog.manager.go b/pncdsl/dialog.manager.go new file mode 100644 index 0000000..7e35d6b --- /dev/null +++ b/pncdsl/dialog.manager.go @@ -0,0 +1,3 @@ +package pncdsl + +type DialogueManager = Manager[Dialogue] diff --git a/pncdsl/input.def.go b/pncdsl/input.def.go new file mode 100644 index 0000000..0678e56 --- /dev/null +++ b/pncdsl/input.def.go @@ -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 +} diff --git a/pncdsl/item.def.go b/pncdsl/item.def.go new file mode 100644 index 0000000..1beccc9 --- /dev/null +++ b/pncdsl/item.def.go @@ -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" } diff --git a/pncdsl/item.inventory.go b/pncdsl/item.inventory.go new file mode 100644 index 0000000..9d239cb --- /dev/null +++ b/pncdsl/item.inventory.go @@ -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...) } diff --git a/pncdsl/item.manager.go b/pncdsl/item.manager.go new file mode 100644 index 0000000..718adbe --- /dev/null +++ b/pncdsl/item.manager.go @@ -0,0 +1,3 @@ +package pncdsl + +type ItemManager = Manager[Item] diff --git a/pncdsl/scene.camera.go b/pncdsl/scene.camera.go new file mode 100644 index 0000000..ef06b68 --- /dev/null +++ b/pncdsl/scene.camera.go @@ -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} } diff --git a/pncdsl/scene.def.go b/pncdsl/scene.def.go new file mode 100644 index 0000000..688c532 --- /dev/null +++ b/pncdsl/scene.def.go @@ -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 +} diff --git a/pncdsl/scene.hotspot.go b/pncdsl/scene.hotspot.go new file mode 100644 index 0000000..862e599 --- /dev/null +++ b/pncdsl/scene.hotspot.go @@ -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 +} diff --git a/pncdsl/scene.manager.go b/pncdsl/scene.manager.go new file mode 100644 index 0000000..173d8fd --- /dev/null +++ b/pncdsl/scene.manager.go @@ -0,0 +1,3 @@ +package pncdsl + +type SceneManager = Manager[Scene] diff --git a/pncdsl/scene.transition.go b/pncdsl/scene.transition.go new file mode 100644 index 0000000..6b12b7d --- /dev/null +++ b/pncdsl/scene.transition.go @@ -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) +} diff --git a/pncdsl/scene.trigger.go b/pncdsl/scene.trigger.go new file mode 100644 index 0000000..ecb5a15 --- /dev/null +++ b/pncdsl/scene.trigger.go @@ -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 +} diff --git a/pncdsl/state.def.go b/pncdsl/state.def.go new file mode 100644 index 0000000..4c9f03e --- /dev/null +++ b/pncdsl/state.def.go @@ -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]++ } diff --git a/pncdsl/state.save.go b/pncdsl/state.save.go new file mode 100644 index 0000000..8b027b6 --- /dev/null +++ b/pncdsl/state.save.go @@ -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 } diff --git a/pncdsl/ui.cursor.go b/pncdsl/ui.cursor.go new file mode 100644 index 0000000..6bcf0f5 --- /dev/null +++ b/pncdsl/ui.cursor.go @@ -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) +} diff --git a/pncdsl/ui.def.go b/pncdsl/ui.def.go new file mode 100644 index 0000000..5847c9a --- /dev/null +++ b/pncdsl/ui.def.go @@ -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:", "inv:", "" 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 "" +} diff --git a/pncdsl/ui.speech.go b/pncdsl/ui.speech.go new file mode 100644 index 0000000..ecc0271 --- /dev/null +++ b/pncdsl/ui.speech.go @@ -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) + } +} diff --git a/pncdsl/ui.verb.go b/pncdsl/ui.verb.go new file mode 100644 index 0000000..05172f0 --- /dev/null +++ b/pncdsl/ui.verb.go @@ -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"}, + } +} diff --git a/pncdsl/ui.verb_manager.go b/pncdsl/ui.verb_manager.go new file mode 100644 index 0000000..5bc4f00 --- /dev/null +++ b/pncdsl/ui.verb_manager.go @@ -0,0 +1,3 @@ +package pncdsl + +type VerbManager = Manager[Verb] diff --git a/pncdsl/util.geometry.go b/pncdsl/util.geometry.go new file mode 100644 index 0000000..6131ec8 --- /dev/null +++ b/pncdsl/util.geometry.go @@ -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} +} diff --git a/pncdsl/util.log.go b/pncdsl/util.log.go new file mode 100644 index 0000000..00f5b3f --- /dev/null +++ b/pncdsl/util.log.go @@ -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...) +} diff --git a/pncdsl/util.timer.go b/pncdsl/util.timer.go new file mode 100644 index 0000000..7973c84 --- /dev/null +++ b/pncdsl/util.timer.go @@ -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 }