EN /HU | Belépés

Inkwell

Az Inkwell point-and-click kalandjáték-keretrendszer Go-hoz, Ebitengine alapokon. Sima struct literal-okat regisztrálsz be manager-ekbe; a library kezeli az inputot, a renderelést, a dialog tree-ket, a cutscene-eket, a HUD-ot, a theme-eket és a lazy asset-betöltést. A kódod csak tartalmat deklarál.

Amit kapsz

  • SCUMM-korszakbeli váz — jelenetek hotspot-okkal és walkbox-okkal, verb-alapú interakció, inventory és item-kombók, dialog tree-k, cutscene-ek.
  • Egy pattern — minden elnevezett entitás egy struct literal, amit a Manager.Register-nek adsz át.
  • Komponálható action-ökSeq, Par, If, Wait, Say, Walk, … lazy condition-ökkel flag-ekre, változókra és az inventory-ra.
  • Widget-fa alapú HUD — verb bar, verb coin, inventory, dialog box, speech bubble-ök, top bar, character panel-ek, chat log. Bármi, amit az Ebitengine ki tud rajzolni, érvényes widget.
  • Theme-ek és placeholder-ek — négy színtéma és determinisztikus placeholder grafika, így a játék nulla asset mellett is elindul.
  • Save, load, validate — JSON save slot-ok a futásidejű állapotnak, plusz induláskor validálja az összes névhivatkozást.

Modulútvonal: git.teletypegames.org/engines/inkwell

A „Morning Coffee" demójáték az inkwell-demo repóban él, és sima Go-modulként használja a könyvtárat.


Referenciakézikönyv

Tartalomjegyzék

  1. Gyorsindítás
  2. A Manager-minta
  3. A Game aggregátum
  4. Geometria
  5. Entitásreferencia
  6. Az akciórendszer
  7. Feltételek
  8. Világállapot
  9. A widgetrendszer
  10. Témák
  11. Asset-pipeline
  12. Hang
  13. Bevitel
  14. A játékciklus
  15. Jelenetváltások
  16. Validáció
  17. Mentés és betöltés
  18. Hibák
  19. Tesztelés
  20. Projektszerkezet

Gyorsindítás

Go 1.24+ (generikus típusaliasok) és OpenGL-kontextus kell hozzá.

BASH
go mod init my-game
go get git.teletypegames.org/engines/inkwell

Ha a HTTPS nem megy a privát hoszthoz, használj SSH-t:

BASH
git config --global \
    url."ssh://git@git.teletypegames.org:2222/".insteadOf "https://git.teletypegames.org/"
export GOPRIVATE=git.teletypegames.org/*

Egy minimális játék:

GO
package main

import (
    "log"
    "git.teletypegames.org/engines/inkwell"
)

func main() {
    g := inkwell.NewGame("Sample", 320, 200)

    g.AssetManager.Register(inkwell.Asset{
        Name: "bg/room", Path: "assets/bg/room.png", Kind: inkwell.AssetImage,
    })
    g.CharacterManager.Register(inkwell.Character{Name: "player", W: 28, H: 62})
    g.SceneManager.Register(inkwell.Scene{
        Name:       "room",
        Background: "bg/room",
        Actors:     []inkwell.SceneActor{{CharacterName: "player", At: inkwell.Point{X: 160, Y: 140}}},
        Hotspots: []inkwell.Hotspot{{
            Name:   "door",
            Area:   inkwell.Rect(260, 50, 40, 90),
            Label:  "door",
            OnLook: inkwell.Say("player", "A wooden door."),
        }},
    })

    g.StartAt("room")
    inkwell.RegisterDefaultUI(g) // optional; Run calls it if no widgets are registered
    if err := inkwell.Run(g); err != nil {
        log.Fatal(err)
    }
}

A Manager-minta

Minden név szerint címezhető entitás egyetlen generikus típuson megy át:

GO
// core.manager.go

type Named interface {
    GetName() string
}

type Manager[T Named] struct { /* ... */ }

type ItemManager      = Manager[Item]
type SceneManager     = Manager[Scene]
type CharacterManager = Manager[Character]
type DialogueManager  = Manager[Dialogue]
type ScriptManager    = Manager[Script]
type AssetManager     = Manager[Asset]
type VerbManager      = Manager[Verb]
type UIManager        = Manager[Widget]
type ThemeManager     = Manager[Theme]
Metódus Viselkedés
Register(v T) Hozzáadja a v elemet. Üres vagy duplikált Name esetén pánikol.
Set(v T) Helyben cseréli a bejegyzést, megtartva a helyét; új név esetén regisztrálja.
Get(name) (T, bool) Keresés; false, ha nincs.
MustGet(name) T Ugyanaz, de hiányzó névnél pánikol.
Has(name) bool Igaz, ha regisztrálva van.
Len() int A bejegyzések száma.
Names() []string Nevek beszúrási sorrendben (a widgetek Z-sorrendje ezt használja).
All() []T Minden bejegyzés beszúrási sorrendben.
SortedNames() []string Nevek ábécérendben.
Each(fn func(T)) Bejárás beszúrási sorrendben.
Remove(name string) Egy regisztráció eldobása; ismeretlen névnél nem csinál semmit.

Minden regisztrálható struct megvalósítja a GetName() metódust. Egy opcionális TypeLabel() érthetőbbé teszi a pánikszövegeket:

GO
func (i Item) GetName() string   { return i.Name }
func (i Item) TypeLabel() string { return "item" }

A duplikátumok szándékosan pánikolnak: a duplikált regisztráció összeállítás-idejű hiba. Ha csendben elfogadnánk, futásidőben elárnyékolna egy entitást.

Amikor egy regisztrációt valóban felül kell írni — egy utólag kitöltött származtatott mező, egy hot-reloadolt entitás —, a Set a szándékos felülírás. Megtartja a bejegyzés helyét a beszúrási sorrendben, így a widgetek Z-sorrendje és minden más sorrendérzékeny bejárás túléli a felülírást.


A Game aggregátum

GO
// core.game.go

type Game struct {
    Title         string
    Width, Height int

    ItemManager      *ItemManager
    SceneManager     *SceneManager
    CharacterManager *CharacterManager
    DialogueManager  *DialogueManager
    ScriptManager    *ScriptManager
    AssetManager     *AssetManager
    VerbManager      *VerbManager
    UIManager        *UIManager
    ThemeManager     *ThemeManager

    State     *State
    Inventory *Inventory
    Audio     *AudioPlayer
    Camera    *Camera
    Input     *Input

    SceneRect Rectangle          // az ablak azon része, amit a kép elfoglal
    ExitLook  func(Exit) Action  // alap nézés-válasz a generált kijáratokhoz
    ExitTake  func(Exit) Action  // alap elvétel-válasz a generált kijáratokhoz

    MaxLogLines int
}

A NewGame(title, w, h) üres menedzsereket hoz létre, regisztrálja a négy SCUMM-igét (look, use, talk, take) és mind a négy előre elkészített témát, majd a classic-scumm témát választja. A widgetek nem regisztrálódnak automatikusan — hívd meg a RegisterDefaultUI(g) függvényt, vagy hagyd, hogy a Run tegye meg, ha a UIManager üres.

A menedzser-mezők sima *Manager értékek, így az a domain, amelyik saját regisztereket tart, egyszerűen ráállíthatja őket a játékra, ahelyett hogy minden bejegyzést átmásolna:

GO
g := inkwell.NewGame("Real World", 640, 380)
g.SceneManager = mySceneManager
g.AssetManager = myAssetManager

A Run előtt kell megtenni — ott köti be a motor azokat a részeket, amelyek közvetlenül tartják a regisztert. A ThemeManager és a VerbManager esetén érdemes kétszer meggondolni: ezek a preset témákkal, illetve a SCUMM-igékkel érkeznek, és a csere eldobja őket. Ha nem ez a szándék, inkább regisztrálj beléjük.

GO
func (g *Game) StartAt(name string) *Game     // entry scene
func (g *Game) OnStart(script string) *Game    // script run after the scene's OnEnter
func (g *Game) OnFinale(script string) *Game  // closing script, queued after OnStart
func (g *Game) Validate() error               // cross-check name references
func (g *Game) Run() error                    // same as inkwell.Run(g)

func (g *Game) CurrentScene() string          // ahol a játékos van
func (g *Game) PreviousScene() string         // ahonnan jött; ide vezet a Back()
func (g *Game) SceneArea() Rectangle          // SceneRect, vagy a teljes ablak
func (g *Game) SceneHotspots(name string) []Hotspot

func (g *Game) Theme() Theme
func (g *Game) UseTheme(name string)          // runtime switch, next frame uses it

Az OnStart és az OnFinale regisztrált scriptet nevez meg, nem akciót vár, így a játék nyitánya is ugyanolyan tartalom, mint bármi más — egy Script a ScriptManager-ben, név szerint elérhető és a huzalozás érintése nélkül szerkeszthető. A Validate elutasítja a nem regisztrált nevet. Az OnFinale közvetlenül a kezdőscript után áll sorba, ami épp az, amit egy „indulj rögtön a befejezésbe” debug-kapcsoló akar:

GO
g.StartAt(start)
g.OnStart(ScriptOpening)
if finale {
    g.OnFinale(ScriptFinale)
}

Mindhárom *Game értéket ad vissza, így láncolhatók a Build végén.

A SceneHotspots a jelenet saját hotspotjai, utánuk kijáratonként egy, jelenetenként gyorsítótárazva; a kézzel megírt hotspotok jönnek előre, tehát a megfestett dolog megelőzi azt az élsávot, amin a kijárat ül. A CurrentScene és a PreviousScene is túléli a mentést.

A felület futásidejű állapota a Game objektumon él: az akciók és a motor írják, a widgetek olvassák.

GO
func (g *Game) SelectedVerb() string
func (g *Game) SetSelectedVerb(s string)

func (g *Game) HoverLabel() string            // raw target label
func (g *Game) SetHoverLabel(s string)
func (g *Game) HoverHint() string             // composed "verb + target"

func (g *Game) SetSpeech(speaker, text string)
func (g *Game) ClearSpeech()

func (g *Game) FlashLine(text string)         // ~2s status banner

func (g *Game) CharacterInScene(name string) bool   // CharacterPanel auto-hide
func (g *Game) DrawText(dst *ebiten.Image, s string, x, y int, c color.Color)

A DrawText egyetlen indirekció, így a könyvtár később átállhat a text/v2 csomagra anélkül, hogy a widgetekhez nyúlna.

A csevegésnapló gyűrűpuffer; a Game.MaxLogLines értékkel korlátozható (0 = korlátlan).

GO
type LogKind int
const (
    LogAction   // "> look at door" — player commands
    LogResponse // in-world replies, Say output
    LogSystem   // meta
)

type LogMessage struct {
    Speaker string
    Text    string
    Kind    LogKind
}

func (g *Game) LogAction(text string)
func (g *Game) LogResponse(speaker, text string)
func (g *Game) LogSystem(text string)
func (g *Game) Messages() []LogMessage

Geometria

GO
// util.geometry.go

type Point struct{ X, Y float64 }

func (p Point) Add(q Point) Point
func (p Point) Sub(q Point) Point
func (p Point) Dist(q Point) float64

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
func (r Rectangle) Contains(p Point) bool
func (r Rectangle) Bounds() Rectangle
func (r Rectangle) Center() Point

type Polygon struct{ Points []Point }

func Poly(pts ...Point) Polygon
func (g Polygon) Contains(pt Point) bool      // ray-casting
func (g Polygon) Bounds() Rectangle           // axis-aligned bbox

Mindkét alakzat használható Hotspot.Area értékként. A hotspotokat minden képkockában a Shape.Contains metódussal teszteljük.


Entitásreferencia

Asset

GO
// asset.def.go

type AssetKind int
const (
    AssetImage AssetKind = iota
    AssetAudio
    AssetFont
)

type Asset struct {
    Name string  // logical id, "bg/kitchen"
    Path string  // "assets/bg/kitchen.png"
    Kind AssetKind
}

g.AssetManager.Register(inkwell.Asset{
    Name: "bg/kitchen", Path: "assets/bg/kitchen.png", Kind: inkwell.AssetImage,
})

A Register csak a leírást tárolja el. A fájl az első használatkor nyílik meg, a hiányzó fájlból pedig determinisztikus, színes helyőrző lesz — így a játék grafika nélkül is fut.

Scene

GO
// scene.def.go

type Scene struct {
    Name       string
    Title      string         // human-readable, read by TopBar
    Background string         // Asset.Name
    Music      string         // Asset.Name, optional
    Hotspots   []Hotspot
    Exits      []Exit         // kapcsolatok más jelenetekhez
    Walkboxes  []Polygon
    Triggers   []Trigger
    Actors     []SceneActor
    OnEnter    Action
    OnLeave    Action
}

type SceneActor struct {
    CharacterName string
    At            Point
}
  • Az OnEnter akkor kerül sorba, amikor a jelenet aktuálissá válik (a beúszás után), az OnLeave pedig kifelé menet.
  • Az Actors mondja meg, mely regisztrált karakterek állnak a jelenetben, és hol kezdődik a lábuk.
  • A Walkboxes korlátozza a mozgást. Járófelületekkel a Walk szélességi kereséssel útvonalaz a poligonszomszédsági gráfon (a közös éllel rendelkező poligonok szomszédok, a közös él felezőpontja útpont), a minden dobozon kívüli célpontok pedig a legközelebbi határra vágódnak. Járófelületek nélkül a karakter egyenesen megy.
  • Az Exits a jelenet kapcsolatai a többi jelenethez, adatként leírva. A motor mindegyiket hotspottá alakítja — lásd alább.

Exit

GO
// scene.exit.go

type Exit struct {
    To      string      // a céljelenet; üresen "vissza, amerről jöttél"
    Label   string      // ahogy az állapotsor nevezi
    Side    ExitSide    // hol helyezkedik el, ha az Area nil
    Area    Shape       // felülírja a Side adta élsávot
    Needs   string      // zászló, aminek állnia kell, mielőtt kinyílik
    Blocked Action      // ami addig történik, amíg nem áll
    OnLook  Action      // felülírja a Game.ExitLook-ot erre a kijáratra
    OnTake  Action      // felülírja a Game.ExitTake-et erre a kijáratra
}

type ExitSide int
const (
    ExitLeft  ExitSide = iota // a bal szélen ki
    ExitRight                 // a jobb szélen ki
    ExitBack                  // a kép mélyébe
    ExitNear                  // a kamera felé kifelé
)

func ExitName(to string) string

A kapcsolat ahhoz a jelenethez tartozik, amelyikből kivezet, ezért annak a jelenetnek a saját literáljában van leírva, nem egy külön tartott térképen:

GO
Scene{
    Name: "alley",
    Exits: []Exit{
        {To: "noodle_house", Label: "vissza az utcára", Side: ExitLeft},
        {To: "", Label: "a tűzlétra", Side: ExitBack,
         Needs: "has_ladder", Blocked: Say("paul", "Nem érem el.")},
    },
}

A motor minden kijáratot Hotspot-tá bont ki: Cursor: CursorExit, a kijárat Label-je, és az OnUse a GoTo(To)-ra kötve — vagy a Back()-re, ha a To üres. Ha a Needs be van állítva, az egész utazás If(Flag(Needs), travel, Blocked)-be kerül, tehát a kijárat így is, úgy is látszik: egy zárt ajtó is megmondja, hogy ajtó.

  • Elhelyezés. Area nélkül a kijárat egy sáv a kép egyik széle mentén, a Game.SceneArea() arányában méretezve — ez az a konvenció, amit minden 1990-es évekbeli point & click használt, és egy mezőnyi változtatás valódi ajtóra irányítani, ha a rajz már ki van mérve. Az a játék, amelynek a HUD-ja eltakarja az ablak alját, beállítja a Game.SceneRect-et, így a sávok a képen belülre esnek, nem a HUD alá.
  • A megfogalmazás egy helyen. A Game.ExitLook és a Game.ExitTake adja a nézés- és elvétel-választ minden generált kijárathoz, így ezek nem ismétlődnek kijáratonként.
  • A gráf olvasható marad. A generált hotspot neve ExitName(To)"exit:noodle_house", illetve "exit:back" —, tehát a helyszíngráf visszaolvasható a regisztrált jelenetekből, a Validate pedig elutasítja azt a kijáratot, ami nem regisztrált jelenetet nevez meg.

Hotspot

GO
// scene.hotspot.go

type Hotspot struct {
    Name   string
    Area   Shape           // Rectangle or Polygon
    Label  string          // hover hint
    Cursor CursorKind

    OnLook Action
    OnUse  Action
    OnTalk Action
    OnTake Action
    OnGive Action

    OnUseWith map[string]Action   // item Name -> action
    OnVerb    map[string]Action   // custom verb Name -> action
}

type CursorKind int
const (
    CursorDefault CursorKind = iota
    CursorLook
    CursorUse
    CursorTalk
    CursorTake
    CursorExit
)

A hotspotok a jelenethez tartoznak, nem menedzserhez. Egy kattintás a hotspot.handler(verbName) hívással oldódik fel: a négy beépített ige az On* mezőkre képződik le, minden más az OnVerb[verbName] értékre esik vissza.

Trigger

GO
// scene.trigger.go

type Trigger struct {
    Name string
    When Condition
    Do   Action
    Once bool
}

A triggerek a jelenetbe lépéskor élesednek. Minden tétlen képkockában (amikor a szkripthely szabad) a motor mintát vesz a When feltételből, és felfutó élnél (hamis → igaz) sorba állítja a Do akciót. Az Once: true élesedésenként legfeljebb egyszer sül el. A nil értékű When vagy Do kimarad.

Átvezető vagy párbeszéd futása közben nem veszünk mintát a triggerekből. Az él túléli ezt az elfoglalt ablakot, és a következő tétlen képkockában észleljük. A betöltés „frissen élesített" állapotba állítja vissza a triggereket, ahogy a jelenetbe való újbóli belépés is.

Item

GO
// item.def.go

type Item struct {
    Name        string
    Sprite      string                  // Asset.Name
    Description string

    OnUseSelf Action
    OnUseWith map[string]Action         // target: hotspot Name or item Name
}

A Give("key") a tárgylistához fűzi a tárgyat. Ha kiválasztott tárggyal kattintasz egy hotspotra, a feloldás sorrendje:

  1. hotspot.OnUseWith[item.Name]
  2. item.OnUseWith[hotspot.Name]
  3. különben felvillan a „Nem ehhez." üzenet, és megszűnik a kijelölés

Inventory

GO
// item.inventory.go

func NewInventory() *Inventory
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)        // "" clears
func (i *Inventory) Selected() string
func (i *Inventory) Items() []string           // copy

Futásidejű állapot, amely a Game tulajdona, nem menedzseré. A Give / TakeAway és az InventoryBar kattintásai módosítják.

Character

GO
// actor.def.go

type Character struct {
    Name        string
    Sprite      string                       // Asset.Name
    Animations  map[string]AnimationClip
    Speed       float64                      // px/sec, default 60
    SpeechColor color.Color
    Start       Point
    W, H        float64                      // placeholder size hint
}

type AnimationClip struct {
    Frames    []Rectangle                    // source rects in the sprite sheet
    FrameTime float64
    Loop      bool
}
  • Ha nincs sprite a lemezen → stilizált helyőrző: humanoid, ha W < H, különben négylábú. Egy RGBA típusú SpeechColor a helyőrző testét és a buborék szövegét is színezi.
  • A klipek név szerint választódnak ki automatikusan: "walk", amíg útvonal aktív, különben "idle". Ha csak egy van regisztrálva, mindkét állapotban az megy.
  • A FrameTime <= 0 vagy az üres Frames kikapcsol egy klipet; ilyenkor a teljes sprite (vagy a helyőrző) rajzolódik ki.
  • A képkockákat SubImage hívással másoljuk, és W×H méretre skálázzuk. A Loop: false az utolsó képkockán fagy meg.

Dialogue

GO
// dialog.def.go

type Dialogue struct {
    Name  string
    Start string                        // initial node; "" = first in slice
    Nodes []DialogueNode
}

type DialogueNode struct {
    Name    string
    Lines   []DialogueLine              // one per click
    Choices []DialogueChoice
}

type DialogueLine struct {
    Speaker string                      // Character.Name
    Text    string
}

type DialogueChoice struct {
    Text    string
    Show    Condition                   // nil = always visible
    Once    bool                        // hide after the first pick
    Actions []Action
}

func (d Dialogue) Node(name string) (DialogueNode, bool)

A folyamat: a RunDialogue("name") blokkol, amíg a párbeszéd be nem zárul → a DialogBox kattintásonként egy sort mutat → az utolsó sor után megjelennek a látható Choices elemek → az egyik kiválasztása lefuttatja a Seq(choice.Actions...) sorozatot, és Once esetén rögzíti a State.NoteTalked hívással. A GotoNode("other") a párbeszéden belül ugrik, az EndDialogue() bezárja.

Script

GO
// action.script.go

type Script struct {
    Name    string
    Actions Action                      // usually Seq(...)
}

Elnevezett összetett akció, egy átvezető több hívási helyről való újrafelhasználásához. A RunScript("name") futtatja.

Verb

GO
// ui.verb.go

type Verb struct {
    Name    string                      // canonical id, "look"
    Label   string                      // display label, "Nézd"
    Default Action                      // runs when the hotspot has no handler
}

g.VerbManager.Register(inkwell.Verb{
    Name: "push", Label: "Lökd",
    Default: inkwell.Say("player", "Nem mozdul."),
})

Az igesáv és az igekerék minden képkockában a VerbManager.Names() listát olvassa, így a futásidőben hozzáadott ige azonnal megjelenik.


Az akciórendszer

GO
// action.def.go

type Action interface {
    Start() Runner
}

type Runner interface {
    Tick(ctx *Ctx) Status
}

type Status int
const (
    StatusRunning Status = iota
    StatusDone
    StatusFailed
)

type Ctx struct {
    Game    *Game
    DT      float64                     // seconds since last tick
    Scene   *Scene                      // current scene, may be nil
    Hotspot *Hotspot
    Item    *Item
}

Az Action megváltoztathatatlan leírás — nyugodtan tárolható egy Hotspot.OnLook mezőben, és minden kattintásnál újrahasználható. A Runner egyetlen végrehajtás a saját állapotával; a motor mindig frisset kér.

Egyszerre egy runner aktív (a „szkripthely"). A futás közben sorba állított akciók eldobódnak és naplózódnak. Párhuzamos munkához használj Par(...) hívást egyetlen akción belül.

A StatusDone lépteti a Seq sorozatot. A StatusFailed rövidre zárja az egész ágat — így szakítja meg a RequireItem a sorozatot.

Beépített akciók

Konstruktor Viselkedés
Seq(a ...Action) A gyerekek sorban. A beágyazott Seq kilapul, a nil kimarad.
Par(a ...Action) A gyerekek együtt; akkor kész, ha mind kész.
If(c Condition, then Action, els ...Action) then, ha a c igaz, különben Seq(els...).
Wait(seconds float64) Blokkol seconds másodpercig.
Say(speaker, text string) Sor a beszélő felett + csevegésnapló-bejegyzés. Kattintással átugorható. Az időtartam a hosszal skálázódik, 1,2 mp az alsó határ.
GoTo(scene string) Jelenetváltás áttűnéssel.
Back() Vissza a PreviousScene()-re. Ha nincs hova visszamenni, nem csinál semmit.
Walk(character string, to Point) Mozgás Character.Speed sebességgel; megérkezéskor kész.
Give(item string) Hozzáadás a tárgylistához.
TakeAway(item string) Eltávolítás a tárgylistából.
RequireItem(item string) Hiányzó tárgy → felvillan az „Ehhez kell egy …", és elbukik.
SetFlag(name) / ClearFlag(name) State.SetFlag / State.ClearFlag.
SetVar(name string, v any) State.SetVar.
PlayMusic(asset) / StopMusic() / PlaySound(asset) AudioPlayer-hívások.
RunDialogue(name string) Elindít egy párbeszédet; blokkol, amíg be nem zárul.
EndDialogue() Bezárja az aktív párbeszédet.
GotoNode(node string) Ugrás az aktív párbeszéden belül.
RunScript(name string) Lefuttat egy regisztrált Script elemet.
ShowEnd(text string) Zárókártya-overlay; soha nem fejeződik be.
Custom(fn func(*Ctx) Status) Vészkijárat tetszőleges visszahíváshoz.

Egyedi akciók

Hosszan futó állapothoz (animáció, tween, lekérdezés) valósítsd meg mindkét interfészt:

GO
type fadeAction struct{ target string; duration float64 }

func (a *fadeAction) Start() Runner { return &fadeRunner{spec: a} }

type fadeRunner struct {
    spec    *fadeAction
    elapsed float64
}

func (r *fadeRunner) Tick(ctx *Ctx) Status {
    r.elapsed += ctx.DT
    if r.elapsed >= r.spec.duration {
        return StatusDone
    }
    return StatusRunning
}

Egyszeri módosításhoz a Custom rövidebb:

GO
bumpScore := inkwell.Custom(func(ctx *inkwell.Ctx) inkwell.Status {
    cur := ctx.Game.State.Var("score").(int)
    ctx.Game.State.SetVar("score", cur+10)
    return inkwell.StatusDone
})

Feltételek

GO
// action.condition.go

type Condition interface {
    Eval(ctx *Ctx) bool
}
Konstruktor Jelentés
Not(c) !c
And(cs ...) mind igaz
Or(cs ...) legalább egy igaz
Flag(name) State.Flag(name)
HasItem(name) Inventory.Has(name)
SelectedItem(name) Inventory.Selected() == name
InScene(name) az aktuális Ctx.Scene egyezik
VarEq(name, v) State.Var(name) == v, Go-egyenlőség

A feltételek állapotmentesek, és lustán értékelődnek ki, az If és a DialogueChoice.Show belsejében is.


Világállapot

GO
// state.def.go

func NewState() *State

func (s *State) Flag(name string) bool
func (s *State) SetFlag(name string)
func (s *State) ClearFlag(name string)

func (s *State) Var(name string) any
func (s *State) SetVar(name string, v any)

func (s *State) Visited(name string) int
func (s *State) NoteVisit(name string)             // engine bumps on scene enter

func (s *State) Talked(node string) int
func (s *State) NoteTalked(node string)            // DialogBox bumps on a Once pick

A jelzők hordozzák a fejtörők állapotát ("cupboard_open"). A SetVar bármilyen értéket elfogad — sztringeket, pontszámokat, struct-hivatkozásokat. A TopBar név szerint olvassa a változókat a pontszámhoz és az időhöz. A Visited és a Talked áll a „Once" szemantika mögött.


A widgetrendszer

A HUD a g.UIManager menedzserbe regisztrált widgetek fája. A beépítettek lefedik a klasszikus kalandjáték-felületet; a sajátjaid a könyvtár érintése nélkül illeszkednek be.

GO
// ui.widget.go

type Widget interface {
    Named
    Tick(ctx *UICtx)
    Draw(dst *ebiten.Image, ctx *UICtx)
}

type UICtx struct {
    Game *Game
    DT   float64
}

type MouseButton int
const (
    MouseButtonLeft MouseButton = iota
    MouseButtonRight
)

type Size struct{ W, H int }

type Align int
const (
    AlignLeft Align = iota
    AlignCenter
    AlignRight
)

Egy widget maga kezeli a bevitelét, a találatvizsgálatát, az állapotát és a rajzolását. Elrendezési réteg nincs — a widgetek maguk viszik a Bounds értéküket (vagy kiszámolják, mint a RadialVerbs).

Z-sorrend és bevitel

  • A Tick fordított regisztrációs sorrendben fut, így a legfelső widget kapja meg elsőként a kattintást. Az eseményt a ctx.Game.Input.ConsumeLeft() / ConsumeRight() hívással veszi birtokba; a későbbi widgetek ekkor már LeftClicked() == false értéket látnak.
  • A Draw regisztrációs sorrendben fut, tehát az utoljára regisztrált kerül legfelülre.
  • A Cursor konvenció szerint utoljára regisztrálódik: mindig felül van, és soha nem veszi birtokba a kattintást.
  • Miután minden widget lefutott, a kattintást a handleSceneInput kapja meg (hotspot-interakciók). Egy már felhasznált kattintás esetén ez nem csinál semmit.

Beépített widgetek

Mindegyik saját fájlban, ui.<név>.go néven. A nulla értékű mezők ésszerű alapértékekre esnek vissza.

GO
// ui.panel.go — colored rectangle, optional border; backdrop for other widgets
type Panel struct {
    Name        string
    Bounds      Rectangle
    BG          color.Color    // nil -> Theme.PanelBG
    Border      int            // 0 = none
    BorderColor color.Color    // nil -> Theme.DialogBorder
}

// ui.status.go — one line: the active flash message, else the hover hint
type StatusLine struct {
    Name        string
    Y           int
    Align       Align          // default AlignCenter
    ScreenWidth int            // 0 -> Game.Width
}

// ui.verb_bar.go — SCUMM verb panel, Cols x ceil(N/Cols) grid from VerbManager.Names()
type VerbBar struct {
    Name    string
    Origin  Point
    Cols    int               // default 2
    Button  Size
    Gap     Point
    PanelBG bool
}

// ui.verb_radial.go — verb coin or permanent wheel
type RadialVerbs struct {
    Name    string
    Trigger MouseButton       // default MouseButtonRight
    Radius  float64           // default 40

    AlwaysVisible bool        // permanent wheel at Center; Trigger ignored
    Center        Point

    Labels map[string]string  // shorten a label so it fits the disk
}

// ui.inventory.go — item slots; hover sets the description, click toggles selection
type InventoryBar struct {
    Name     string
    Origin   Point
    Slots    int
    Cols     int             // 0 -> Slots (single row)
    SlotSize int
    Gap      int
    PanelBG  bool
}

// ui.speech.go — draws whatever Game.SetSpeech last set, above the speaker's head
type SpeechBubble struct {
    Name      string
    MaxWidth  int                // wrap width, default 200
    Padding   int                // default 3
    OffsetY   int                // lift above head
    FallbackY int                // anchor when the speaker is unknown
}

// ui.dialog_box.go — dormant unless a dialogue runs; then consumes every click
type DialogBox struct {
    Name       string
    Bounds     Rectangle      // default Rect(0, 140, Width, 60)
    LineHeight int            // default 14
    Padding    int            // default 6
}

// ui.end_card.go — fullscreen overlay from ShowEnd; consumes every click
type EndCard struct{ Name string }

// ui.cursor.go — crosshair, or the selected item's sprite when it has one
type Cursor struct{ Name string }

// ui.hotspot_debug.go — outlines every hotspot in the scene
type HotspotDebug struct {
    Name      string
    Enabled   bool
    ToggleKey ebiten.Key       // 0 -> ebiten.KeyF1
}

// ui.top_bar.go — left: LeftText or Scene.Title | center: score | right: time
type TopBar struct {
    Name     string
    Height   int                  // default 12
    LeftText string
    ScoreVar string                // State.Var key; "" = no score
    ScoreMax int                   // >0 -> "Score: X/MAX"
    TimeVar  string                // State.Var key; "" = no time
}

// ui.character_panel.go — portrait, role badge, stat rows;
// auto-hides when the character is not an actor in the current scene
type CharStat struct {
    Label  string
    VarKey string                  // State.Var(VarKey), fmt-printed
}

type CharacterPanel struct {
    Name      string
    Bounds    Rectangle
    Character string
    Title     string                // "PLAYER", "NPC", "GUIDE", …
    Stats     []CharStat
}

// ui.chat_log.go — Game.Messages() with per-kind colors, newest at the bottom
type ChatLog struct {
    Name       string
    Bounds     Rectangle
    LineHeight int                 // default 10
    Padding    int                 // default 3
    ShowBorder bool
}

Megjegyzések:

  • A SpeechBubble szövegszíne: Character.SpeechColor, ennek híján Theme.SpeechDefaultText.
  • A DialogBox rámutatási színe a Theme.DialogChoiceHover; a Once választások az első kiválasztás után eltűnnek.
  • A HotspotDebug futásidőben az F1 billentyűvel kapcsolható.
  • A TopBar kihagyja az üres mezőket, így két- vagy egyszakaszos elrendezésre esik vissza.
  • A CharacterPanel és a ChatLog a helyőrző portrékat és a téma csevegésnapló-színeit használja újra.
  • Kis ChatLog esetén állítsd a Game.MaxLogLines értékét nagyjából 64-re.

A clickBlocker szerződés

Azok a widgetek, amelyeknek fix területük van, ne engedjék, hogy egy RadialVerbs menü felugorjon föléjük:

GO
type clickBlocker interface {
    BlocksClickAt(p Point) bool
}

Megvalósítja a VerbBar, az InventoryBar, a DialogBox, a TopBar, a CharacterPanel, a ChatLog, és a RadialVerbs maga is, amíg látható. Valósítsd meg a saját widgetjeiden, hogy tömör beviteli zónaként jelöld meg őket.

Regisztrációs segédek

GO
// ui.defaults.go

func RegisterDefaultUI(g *Game)                            // verb bar + inventory
func RegisterRadialVerbUI(g *Game)                          // verb coin + wider inventory
func RegisterRichUI(g *Game, playerName, npcName string)    // top bar + panels + chat log + wheel

Adj át "" karakternevet, ha ki akarod hagyni az adott CharacterPanel elemet. Mindhárom a szokásos Z-sorrendben regisztrál: a debug-overlay hátul, a kurzor elöl.

Egyedi widgetek

Bármi működik, ami megvalósítja a Widget interfészt. Olvasd a ctx.Game objektumot, vedd birtokba a bevitelt a ctx.Game.Input hívásokkal, és rajzolj Ebitengine-nel.

GO
type Minimap struct {
    Name   string
    Bounds inkwell.Rectangle
}

func (m *Minimap) GetName() string          { return m.Name }
func (m *Minimap) Tick(ctx *inkwell.UICtx)  {}
func (m *Minimap) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
    // render scene thumbnail, mark NPCs, …
}

g.UIManager.Register(&Minimap{Name: "minimap", Bounds: inkwell.Rect(220, 4, 96, 56)})

Témák

GO
// ui.theme.go

type Theme struct {
    Name string

    PanelBG    color.Color
    StatusText color.Color
    FlashText  color.Color

    VerbButtonBG         color.Color
    VerbButtonSelectedBG color.Color
    VerbButtonText       color.Color

    InventorySlotBG         color.Color
    InventorySlotSelectedBG color.Color

    SpeechBubbleBG    color.Color
    SpeechDefaultText color.Color

    DialogBG          color.Color
    DialogBorder      color.Color
    DialogChoiceBG    color.Color
    DialogChoiceHover color.Color
    DialogSpeaker     color.Color
    DialogText        color.Color

    EndCardBG      color.Color
    EndCardText    color.Color
    CursorColor    color.Color
    HotspotOutline color.Color
    SceneBackdrop  color.Color

    TopBarBG     color.Color
    TopBarText   color.Color
    TopBarAccent color.Color

    ChatLogBG       color.Color
    ChatLogPrompt   color.Color
    ChatLogResponse color.Color
    ChatLogSystem   color.Color

    CharacterPanelBG     color.Color
    CharacterPanelBorder color.Color
    CharacterPanelTitle  color.Color
}

A NewGame regisztrálja az előre elkészített témákat, és a classic-scumm témát választja:

Név Hangulat
classic-scumm SCUMM-korabeli sötétkék meleg borostyánsárga kiemelésekkel (alapértelmezett).
sierra-coin Sötétebb lila és narancs, az igeérméhez tervezve.
paper-notebook Krémszínű papír, tinta, egy kis szépia.
terminal-green Retró foszforzöld feketén.

A saját témád csak egy újabb regisztráció:

GO
g.ThemeManager.Register(inkwell.Theme{
    Name:       "midnight-noir",
    PanelBG:    color.RGBA{6, 6, 12, 255},
    StatusText: color.White,
    // … the remaining color fields …
})
g.UseTheme("midnight-noir")

A widgetek minden képkockában olvassák az aktív témát, így egy futásidejű váltás a következő rajzoláskor látszik.


Asset-pipeline

Az AssetManager.Register csak a leírást tárolja. Az első képkérésnél:

  1. megnyitja az Asset.Path fájlt,
  2. hiányzó vagy olvashatatlan fájl esetén determinisztikus helyőrző színt generál az fnv32a(name) értékből, és megjelöli a bejegyzést,
  3. gyorsítótárazza a dekódolt *ebiten.Image objektumot.
GO
// internal
func (la *loadedAssets) image(am *AssetManager, name string) *ebiten.Image
func (la *loadedAssets) isPlaceholder(name string) bool

A widgetek az isPlaceholder alapján döntenek a stilizált helyőrző és a valódi sprite között.

Képformátumok: PNG és JPEG. Az AssetAudio elemeket az AudioPlayer dekódolja igény szerint. Az AssetFont osztozik a nyilvántartáson, de betöltése még nincs.


Hang

GO
// asset.audio.go

func NewAudioPlayer() *AudioPlayer
func (a *AudioPlayer) PlayMusic(name string)
func (a *AudioPlayer) StopMusic()
func (a *AudioPlayer) PlaySound(name string)

Az Ebiten audio csomagjára épül. A közös kontextus lustán, 48 kHz-en jön létre az első lejátszáskor; egy már létező audio.CurrentContext (például egy gazdafolyamatból) újrahasznosul. Az assetnek Kind: AssetAudio értékűnek kell lennie.

  • A zene lemezről streamel, és örökké ismétlődik. Az aktuális számon meghívott PlayMusic nem csinál semmit, így nyugodtan hívható az OnEnter eseményből minden látogatáskor.
  • A hangeffektek egyszer dekódolódnak, nyers PCM-ként gyorsítótárazódnak, és a NewPlayerFromBytes hívással játszódnak le újra, alacsony késleltetéssel. A befejezett lejátszók a következő PlaySound hívásnál takarodnak ki.

Formátumok kiterjesztés szerint: WAV, OGG Vorbis, MP3. Bármi más dekódolása elbukik, az asset megjelölődik, így a későbbi lejátszások nem csinálnak semmit.

Minden hiba (hiányzó fájl, nem támogatott kodek, nincs hangeszköz) egyetlen debug-naplósor — a játék hang nélkül fut tovább.


Bevitel

GO
// input.def.go

func (i *Input) Pos() (int, int)
func (i *Input) Point() Point
func (i *Input) LeftClicked() bool       // just-pressed AND not consumed
func (i *Input) RightClicked() bool
func (i *Input) ConsumeLeft()
func (i *Input) ConsumeRight()

A motor képkockánként egyszer kérdez le, és pillanatképet készít a kurzorról és mindkét éppen lenyomott állapotról. Használatkor felemésztjük: egy kattintás mérvadó, és az nyer, aki elsőként veszi birtokba.


A játékciklus

GO
// core.dsl.go

func Run(g *Game) error    // same as g.Run()

A Run sorrendben:

  1. g.Audio.attach(g.AssetManager) — bekötni a hanglejátszót abba az asset-regiszterbe, amelyet a játék addigra tart.
  2. g.Validate().
  3. RegisterDefaultUI(g), ha a UIManager üres.
  4. Elhelyezi a kezdőjelenetet átmenet nélkül, növeli a State.NoteVisit értékét, pozicionálja a szereplőket, elindítja a zenét.
  5. Sorba állítja a Seq(scene.OnEnter, OnStart script, OnFinale script) sorozatot első akcióként.
  6. ebiten.SetWindowSize(Width*4, Height*4), ebiten.SetWindowTitle(g.Title), ebiten.RunGame(&engine{g}).
TEXT
engine.Update:
    poll input
    update transition
    if fading out -> return

    if scriptRunner != nil:
        tick runner; tick characters; return

    clear hoverLabel
    for w in reversed(UIManager): w.Tick(uictx)   # top-down input
    handleSceneInput()                            # hotspots, right-click reset
    tick characters

engine.Draw:
    fill Theme.SceneBackdrop
    draw scene background
    for c in characters sorted by Y: drawCharacter(c)
    for w in UIManager (registration order): w.Draw(screen, uictx)
    draw transition overlay

A handleSceneInput azután fut, hogy minden widget megkapta az esélyét:

  1. Beállítja a rámutatási feliratot a kurzor alatti hotspotból.
  2. Jobb kattintás: megszünteti a fogott tárgy kijelölését, vagy a selectedVerb értéket "look" értékre állítja vissza.
  3. Bal kattintás tárggyal: hotspot.OnUseWith[item], majd item.OnUseWith[hotspot], különben felvillan a „Nem ehhez.".
  4. Bal kattintás tárgy nélkül: hotspot.handler(selectedVerb), majd az ige Default akciója, különben felvillan a „Semmi említésre méltó.".
  5. Minden sikeres kattintás betol egy LogAction sort.

Karaktermozgás: a tickCharacters az útpontsor elejét lépteti Speed px/mp sebességgel, megérkezéskor kiveszi, és a karakter tétlenné válik, amikor a sor kiürül. A sort a Walk építi, amely StatusRunning értéket ad vissza, amíg a karakter meg nem áll. A tickAnimation képkockánként a "walk" vagy az "idle" klipet választja, és kimásolja a klip forrás-téglalapját, vagy visszaesik a teljes sprite-ra / a helyőrzőre.


Jelenetváltások

A Game.changeScene(name) 0,25 másodperces feketébe áttűnést indít. A felezőpontban:

  1. sorba állítja a prevScene.OnLeave akciót,
  2. beállítja az új currentScene értéket,
  3. State.NoteVisit(name),
  4. elhelyezi a szereplőket a SceneActor.At pozícióikban,
  5. lejátssza az új Music elemet,
  6. sorba állítja a newScene.OnEnter akciót.

Aztán jön a visszaúszás. A kiúszás felében a bevitel be van fagyasztva. A StartAt jelenete átmenet nélkül áll be, így az introszkript azonnal lefut.


Validáció

GO
func (g *Game) Validate() error

A Run hívja meg, de tesztből is használható. Azt ellenőrzi, hogy:

  • a StartAt regisztrált jelenetet nevez meg,
  • minden jelenetnek van nem üres Background mezője, amely regisztrált assetre mutat,
  • a nem üres Scene.Music regisztrált assetre mutat,
  • minden SceneActor.CharacterName regisztrálva van,
  • minden Scene.Exit.To regisztrált jelenetet nevez meg (üresen is jó — az azt jelenti, "vissza, amerről jöttél"),
  • van kiválasztott és regisztrált aktív téma.

Az első hibát adja vissza, egy Err… őrértékbe csomagolva az errors.Is hívásokhoz. A duplikált neveket korábban, a Register pánikja kapja el.


Mentés és betöltés

GO
// state.save.go

func (g *Game) Save(slot int) error
func (g *Game) Load(slot int) error

Mentési helyenként egy JSON-fájl, slot<N>.json néven a g.SaveDir könyvtárban (alapérték saves/). Elmentve: az aktuális és az előző jelenet, az aktív ige, az aktív téma, minden karakter pozíciója/célpontja/mozgásjelzője, a teljes State (jelzők, változók, meglátogatott helyek, elmondott párbeszédek), valamint a tárgylista a kiválasztott hellyel együtt.

A statikus regisztrációk nincsenek elmentve — a domain.Build() minden indításkor újra létrehozza őket. A mentésben lévő hivatkozásokat az aktuális menedzserekkel ellenőrizzük, mielőtt bármihez hozzányúlnánk, így egy elavult mentés hibát ad ahelyett, hogy megrontaná az élő *Game objektumot.

Nincs elmentve:

  • a futó Runner és az esetleges aktív párbeszéd — a Load mindkettőt megszakítja, tehát a mentés gyakorlatilag tétlen határon készül;
  • az animációlejátszás állapota — a következő tétlen képkocka újraszármaztatja a klipet;
  • a járófelület-útvonalsorok — az éppen mozgó karakter egylépéses egyenes útvonalat kap a pos pontból a target pontba.

A Var értékek úgy mennek át az encoding/json csomagon, ahogy vannak: a számok float64 típusként jönnek vissza, a sztringek sztringként. A formátum verziózott, tehát egy újabb verzióból származó fájl hibát ad ahelyett, hogy némán elveszítene mezőket.


Hibák

GO
// core.errors.go

var (
    ErrUnknownAsset           = errors.New("inkwell: unknown asset")
    ErrUnknownScene           = errors.New("inkwell: unknown scene")
    ErrUnknownItem            = errors.New("inkwell: unknown item")
    ErrUnknownCharacter       = errors.New("inkwell: unknown character")
    ErrUnknownDialogue        = errors.New("inkwell: unknown dialogue")
    ErrUnknownDialogueNode    = errors.New("inkwell: unknown dialogue node")
    ErrUnknownScript          = errors.New("inkwell: unknown script")
    ErrUnknownVerb            = errors.New("inkwell: unknown verb")
    ErrDuplicateName          = errors.New("inkwell: duplicate name")
    ErrNoStartScene           = errors.New("inkwell: StartAt not set or unknown scene")
    ErrSceneMissingBackground = errors.New("inkwell: scene has no background")
)

A Register és a MustGet ezek visszaadása helyett pánikol: összeállítás-idejű hibákat jeleznek, amelyeknek hangosan kell összeomlaniuk.


Tesztelés

A könyvtár egyetlen csomag, egyelőre fán belüli egységtesztek nélkül. Az integrációs felületet egy headless füstpróba fedi le — másold be bármelyik projektbe, amely a könyvtárat használja. Nem nyit ablakot, és ezredmásodpercek alatt lefut, tehát olcsó CI-kapu.

GO
func TestBuildValidates(t *testing.T) {
    g := domain.Build()
    if err := g.Validate(); err != nil {
        t.Fatalf("validate: %v", err)
    }
}

Debug-kapcsolók:

GO
inkwell.DebugLog = true                 // script queue, audio, scene change -> stderr
&inkwell.HotspotDebug{Enabled: true}    // start with the F1 overlay on

Projektszerkezet

Minden forrás a repó gyökerében ül, téma.azonosító.go névvel, így az ls core.* vagy az ls ui.* csoportosítja őket.

TXT
inkwell/                            # module git.teletypegames.org/engines/inkwell
├── core.doc.go                    # package docs
├── core.manager.go                # Manager[T Named], Named, TypeLabel
├── core.game.go                   # Game aggregate, runtime state, helpers
├── core.engine.go                 # ebiten.Game adapter
├── core.dsl.go                    # Run()
├── core.errors.go                 # sentinel errors
├── util.geometry.go               # Point, Rectangle, Polygon, Shape
├── util.timer.go                  # Timer helper
├── util.log.go                    # DebugLog + logf
├── asset.def.go                   # Asset, AssetKind
├── asset.manager.go               # alias + lazy loader
├── asset.audio.go                 # AudioPlayer (WAV/OGG/MP3, looping music)
├── asset.text.go                  # drawText / wrapText
├── scene.def.go                   # Scene, SceneActor
├── scene.manager.go
├── scene.hotspot.go               # Hotspot, CursorKind
├── scene.exit.go                  # Exit, ExitSide + edge-strip geometry
├── scene.trigger.go               # Trigger + rising-edge sweep
├── scene.path.go                  # walkbox routing (BFS)
├── scene.transition.go            # fade overlay (internal)
├── scene.camera.go                # Camera (identity stub)
├── item.def.go                    # Item
├── item.manager.go
├── item.inventory.go              # Inventory
├── actor.def.go                   # Character
├── actor.manager.go
├── actor.animation.go             # AnimationClip + tickAnimation
├── dialog.def.go                  # Dialogue, DialogueNode, DialogueChoice
├── dialog.manager.go
├── action.def.go                  # Action, Runner, Ctx, Status + built-ins
├── action.condition.go            # Condition + combinators
├── action.script.go               # Script entity
├── action.manager.go
├── state.def.go                   # State (flags, vars, visited, talked)
├── state.save.go                  # Save/Load JSON
├── input.def.go                   # Input (consume-on-use)
├── ui.widget.go                   # Widget, UICtx, Size, Align
├── ui.manager.go                  # alias + ordered/reversed iterators
├── ui.theme.go                    # Theme + ThemeManager
├── ui.theme_presets.go            # 4 presets
├── ui.defaults.go                 # RegisterDefaultUI / RadialVerbUI / RichUI
├── ui.verb.go                     # Verb + VerbManager
├── ui.verb_bar.go
├── ui.verb_radial.go
├── ui.inventory.go
├── ui.status.go
├── ui.speech.go
├── ui.dialog_box.go
├── ui.end_card.go
├── ui.cursor.go
├── ui.hotspot_debug.go
├── ui.panel.go
├── ui.top_bar.go
├── ui.character_panel.go          # + CharStat
├── ui.chat_log.go
├── LICENSE.md                     # MIT
└── README.md

MIT licenc alatt. Copyright © 2026 Teletype Games.