Files
pncdsl/PLAN.md
2026-05-25 18:09:51 +02:00

32 KiB

PLAN.md — pncdsl point-and-click adventure library

Cél

Egy Go nyelvű, Ebitengine 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:

// 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
}
// 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"),
            },
        },
    })
}
// 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.

// 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.gog.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).

// 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:

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+):

// 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:

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:

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)

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)

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)

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)

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)

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)

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)

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)

type Script struct {
    Name    string
    Actions Action                     // tipikusan Seq(...)
}
func (s Script) GetName() string { return s.Name }

Verb (pncdsl/ui.verb.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)

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)

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)

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)

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)

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

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:

// 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:

// 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)

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ázGame, 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 inventoryVerbBar, Inventory, Item, UseOn. SCUMM-szerű flow.
  3. Karakterek és walkboxokCharacter, Walkbox, A* az útkereséshez.
  4. Dialógus rendszerDialogue, DialogBox, DialogueChoice, Once.
  5. Cutscene / ScriptAction 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.