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.
Manager.Register-nek adsz át.Seq, Par, If, Wait, Say, Walk, … lazy condition-ökkel flag-ekre, változókra és az inventory-ra.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.
Go 1.24+ (generikus típusaliasok) és OpenGL-kontextus kell hozzá.
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:
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:
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)
}
}
Minden név szerint címezhető entitás egyetlen generikus típuson megy át:
// 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:
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.
// 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:
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.
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:
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.
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).
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
// 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.
// 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.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
}
OnEnter akkor kerül sorba, amikor a jelenet aktuálissá válik (a beúszás után), az OnLeave pedig kifelé menet.Actors mondja meg, mely regisztrált karakterek állnak a jelenetben, és hol kezdődik a lábuk.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.Exits a jelenet kapcsolatai a többi jelenethez, adatként leírva. A motor mindegyiket hotspottá alakítja — lásd alább.// 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:
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ó.
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á.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.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.// 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.
// 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.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:
hotspot.OnUseWith[item.Name]item.OnUseWith[hotspot.Name]// 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.
// 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
}
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."walk", amíg útvonal aktív, különben "idle". Ha csak egy van regisztrálva, mindkét állapotban az megy.FrameTime <= 0 vagy az üres Frames kikapcsol egy klipet; ilyenkor a teljes sprite (vagy a helyőrző) rajzolódik ki.SubImage hívással másoljuk, és W×H méretre skálázzuk. A Loop: false az utolsó képkockán fagy meg.// 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.
// 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.
// 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.
// 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.
| 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. |
Hosszan futó állapothoz (animáció, tween, lekérdezés) valósítsd meg mindkét interfészt:
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:
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
})
// 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.
// 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 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.
// 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).
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.Draw regisztrációs sorrendben fut, tehát az utoljára regisztrált kerül legfelülre.Cursor konvenció szerint utoljára regisztrálódik: mindig felül van, és soha nem veszi birtokba a kattintást.handleSceneInput kapja meg (hotspot-interakciók). Egy már felhasznált kattintás esetén ez nem csinál semmit.Mindegyik saját fájlban, ui.<név>.go néven. A nulla értékű mezők ésszerű alapértékekre esnek vissza.
// 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:
SpeechBubble szövegszíne: Character.SpeechColor, ennek híján Theme.SpeechDefaultText.DialogBox rámutatási színe a Theme.DialogChoiceHover; a Once választások az első kiválasztás után eltűnnek.HotspotDebug futásidőben az F1 billentyűvel kapcsolható.TopBar kihagyja az üres mezőket, így két- vagy egyszakaszos elrendezésre esik vissza.CharacterPanel és a ChatLog a helyőrző portrékat és a téma csevegésnapló-színeit használja újra.ChatLog esetén állítsd a Game.MaxLogLines értékét nagyjából 64-re.Azok a widgetek, amelyeknek fix területük van, ne engedjék, hogy egy RadialVerbs menü felugorjon föléjük:
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.
// 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.
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.
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)})
// 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ó:
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.
Az AssetManager.Register csak a leírást tárolja. Az első képkérésnél:
Asset.Path fájlt,fnv32a(name) értékből, és megjelöli a bejegyzést,*ebiten.Image objektumot.// 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.
// 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.
PlayMusic nem csinál semmit, így nyugodtan hívható az OnEnter eseményből minden látogatáskor.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.
// 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.
// core.dsl.go
func Run(g *Game) error // same as g.Run()
A Run sorrendben:
g.Audio.attach(g.AssetManager) — bekötni a hanglejátszót abba az asset-regiszterbe, amelyet a játék addigra tart.g.Validate().RegisterDefaultUI(g), ha a UIManager üres.State.NoteVisit értékét, pozicionálja a szereplőket, elindítja a zenét.Seq(scene.OnEnter, OnStart script, OnFinale script) sorozatot első akcióként.ebiten.SetWindowSize(Width*4, Height*4), ebiten.SetWindowTitle(g.Title), ebiten.RunGame(&engine{g}).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:
selectedVerb értéket "look" értékre állítja vissza.hotspot.OnUseWith[item], majd item.OnUseWith[hotspot], különben felvillan a „Nem ehhez.".hotspot.handler(selectedVerb), majd az ige Default akciója, különben felvillan a „Semmi említésre méltó.".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.
A Game.changeScene(name) 0,25 másodperces feketébe áttűnést indít. A felezőpontban:
prevScene.OnLeave akciót,currentScene értéket,State.NoteVisit(name),SceneActor.At pozícióikban,Music elemet,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.
func (g *Game) Validate() error
A Run hívja meg, de tesztből is használható. Azt ellenőrzi, hogy:
StartAt regisztrált jelenetet nevez meg,Background mezője, amely regisztrált assetre mutat,Scene.Music regisztrált assetre mutat,SceneActor.CharacterName regisztrálva van,Scene.Exit.To regisztrált jelenetet nevez meg (üresen is jó — az azt jelenti, "vissza, amerről jöttél"),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.
// 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:
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;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.
// 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.
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.
func TestBuildValidates(t *testing.T) {
g := domain.Build()
if err := g.Validate(); err != nil {
t.Fatalf("validate: %v", err)
}
}
Debug-kapcsolók:
inkwell.DebugLog = true // script queue, audio, scene change -> stderr
&inkwell.HotspotDebug{Enabled: true} // start with the F1 overlay on
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.
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.