game skeleton

This commit is contained in:
2026-08-29 19:07:32 +02:00
parent a04a61ddd0
commit 0d8918e6f5
39 changed files with 2540 additions and 0 deletions
+204
View File
@@ -0,0 +1,204 @@
package world
// The game's own actions, and the domain action pump.
//
// inkwell's queueAction is unexported, so domain widgets (TapeSlots) cannot put
// an action on the engine's queue — the built-in widgets only manage it because
// they live inside the package. inkwell.Ctx, however, is exported with exported
// fields, so the domain can drive a Runner itself. That is what the pump does:
// it runs HUD-originated actions (starting a dialogue, a tape line) alongside
// the engine's own script runner. See README, "Engine workarounds".
import (
inkwell "git.teletypegames.org/engines/inkwell"
"github.com/hajimehoshi/ebiten/v2"
)
// ----- pump -------------------------------------------------------------
// Do queues a HUD-originated action.
func (w *World) Do(a inkwell.Action) {
if a != nil {
w.queue = append(w.queue, a)
}
}
// PumpTick advances the pump. The ActionPump widget calls it every frame.
func (w *World) PumpTick(dt float64) {
// The TopBar's left section: location plus a single word for a stopped
// world. No pause overlay, no blur — one word says time has stopped.
if w.top != nil {
title := w.sceneTitle()
if w.paused {
title += " — paused"
}
w.top.LeftText = title
}
if w.running == nil {
if len(w.queue) == 0 {
return
}
w.running = w.queue[0].Start()
w.queue = w.queue[1:]
}
ctx := &inkwell.Ctx{Game: w.G, DT: dt}
if s, ok := w.G.SceneManager.Get(w.scene); ok {
ctx.Scene = &s
}
if w.running.Tick(ctx) != inkwell.StatusRunning {
w.running = nil
}
}
func (w *World) sceneTitle() string {
s, ok := w.G.SceneManager.Get(w.scene)
if !ok {
return ""
}
if s.Title != "" {
return s.Title
}
return s.Name
}
// ActionPump is an invisible widget that advances the pump. The HUD registers
// it; the world itself knows nothing about the widget tree.
type ActionPump struct {
Name string
W *World
}
func (p *ActionPump) GetName() string { return p.Name }
func (p *ActionPump) Tick(ctx *inkwell.UICtx) { p.W.PumpTick(ctx.DT) }
func (p *ActionPump) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {}
// ----- helpers ----------------------------------------------------------
type fnAction struct{ fn func(*inkwell.Ctx) }
func (a *fnAction) Start() inkwell.Runner { return &fnRunner{spec: a} }
type fnRunner struct{ spec *fnAction }
func (r *fnRunner) Tick(ctx *inkwell.Ctx) inkwell.Status {
r.spec.fn(ctx)
return inkwell.StatusDone
}
// Fn turns a function into a one-shot action that completes immediately.
func Fn(f func(*inkwell.Ctx)) inkwell.Action { return &fnAction{fn: f} }
// UseTheme switches the theme at runtime. This is the single line that makes
// the Nokia-punk texture break in during the finale. inkwell saves ActiveTheme,
// so the switch survives a save without needing a flag of its own.
func UseTheme(name string) inkwell.Action {
return Fn(func(ctx *inkwell.Ctx) { ctx.Game.UseTheme(name) })
}
// SetMode switches the HUD mode (play / cutscene / menu).
func SetMode(w *World, m Mode) inkwell.Action {
return Fn(func(*inkwell.Ctx) { w.SetMode(m) })
}
// EnterScene tells the domain which scene we are in. inkwell exposes no
// CurrentScene accessor, and the pump needs Ctx.Scene.
func EnterScene(w *World, name string) inkwell.Action {
return Fn(func(*inkwell.Ctx) { w.scene = name })
}
// ----- TapeSay ----------------------------------------------------------
// TapeSay is a line spoken by a tape.
//
// It is deliberately not inkwell.Say: Say puts a SpeechBubble above the
// speaker's head, but tapes have no body in the scene — they speak into your
// ear. This is the two-channel rule enforced in code: a tape can only reach the
// tape channel, never the scene.
func TapeSay(speaker, text string) inkwell.Action {
return &tapeSayAction{speaker: speaker, text: text}
}
type tapeSayAction struct{ speaker, text string }
func (a *tapeSayAction) Start() inkwell.Runner { return &tapeSayRunner{spec: a} }
type tapeSayRunner struct {
spec *tapeSayAction
started bool
elapsed float64
duration float64
}
func (r *tapeSayRunner) Tick(ctx *inkwell.Ctx) inkwell.Status {
if !r.started {
r.started = true
// Same pacing as Say, so the two breathe together inside a Seq.
r.duration = 1.2 + float64(len([]rune(r.spec.text)))*0.05
ctx.Game.LogResponse(r.spec.speaker, r.spec.text)
}
r.elapsed += ctx.DT
if r.elapsed >= r.duration {
return inkwell.StatusDone
}
return inkwell.StatusRunning
}
// ----- TapeOffer --------------------------------------------------------
// TapeOffer offers a tape line during a cutscene: the Letterbox shows that a
// tape would speak, but it only speaks if the player asks. A tape never
// interrupts — the floor is the player's.
func TapeOffer(w *World, line inkwell.Action) inkwell.Action {
return Fn(func(*inkwell.Ctx) {
w.tapeLine = line
w.tapeWaiting = true
})
}
// TakeTapeOffer queues the offered line and clears the prompt.
func (w *World) TakeTapeOffer() {
if !w.tapeWaiting {
return
}
w.tapeWaiting = false
line := w.tapeLine
w.tapeLine = nil
w.Do(line)
}
// ----- Paused -----------------------------------------------------------
// Paused marks the world as stopped for the duration of the wrapped action.
// inkwell exposes no dialogueActive accessor, so we mark it on the content
// side: every dialogue start is wrapped in this.
func Paused(w *World, inner inkwell.Action) inkwell.Action {
return &pausedAction{w: w, inner: inner}
}
type pausedAction struct {
w *World
inner inkwell.Action
}
func (a *pausedAction) Start() inkwell.Runner {
return &pausedRunner{spec: a, inner: a.inner.Start()}
}
type pausedRunner struct {
spec *pausedAction
inner inkwell.Runner
started bool
}
func (r *pausedRunner) Tick(ctx *inkwell.Ctx) inkwell.Status {
if !r.started {
r.started = true
r.spec.w.paused = true
}
s := r.inner.Tick(ctx)
if s != inkwell.StatusRunning {
r.spec.w.paused = false
}
return s
}
+98
View File
@@ -0,0 +1,98 @@
// Package world is the domain aggregate: a thin layer around the inkwell Game
// holding the runtime state that must NOT be saved, plus the game's own
// actions and the action pump.
//
// This package knows nothing about the HUD or the content, so ui and content
// can both build on it without creating an import cycle.
package world
import (
inkwell "git.teletypegames.org/engines/inkwell"
"realworld/internal/names"
)
// Mode is the HUD mode.
//
// IMPORTANT: this is deliberately NOT a State.Var. inkwell's state.save.go
// serialises State.Vars but drops the in-flight script ("saves capture state at
// idle/ready boundaries"), so a save taken mid-cutscene would reload into a
// permanently letterboxed, HUD-less game with no script running. The mode is
// therefore an unsaved runtime field.
type Mode string
const (
ModePlay Mode = "play" // full HUD
ModeCutscene Mode = "cutscene" // letterbox, HUD hidden
ModeMenu Mode = "menu" // main menu, HUD hidden
)
// World holds the runtime state that does not belong in a save file.
type World struct {
G *inkwell.Game
mode Mode
scene string // current scene — inkwell exposes no accessor for it
paused bool // is the world stopped (during dialogue), for the TopBar suffix
pending string // the tape currently being loaded into slot 2
queue []inkwell.Action // HUD-originated actions waiting to run
running inkwell.Runner
top *inkwell.TopBar // the location bar, so the pump can write its suffix
// During a cutscene a tape signals but never interrupts: the line waits
// here until the player asks for it with SPACE.
tapeWaiting bool
tapeLine inkwell.Action
}
// New wraps an existing inkwell game.
func New(g *inkwell.Game) *World {
return &World{G: g, mode: ModePlay}
}
// Mode returns the current HUD mode.
func (w *World) Mode() Mode { return w.mode }
// SetMode switches the HUD mode.
func (w *World) SetMode(m Mode) { w.mode = m }
// HUDVisible reports whether the HUD should draw: only in play mode.
func (w *World) HUDVisible() bool { return w.mode == ModePlay }
// Scene returns the name of the current scene.
func (w *World) Scene() string { return w.scene }
// Paused reports whether the world is stopped (during dialogue).
func (w *World) Paused() bool { return w.paused }
// SetTopBar hands over the location bar so the pump can write its suffix.
func (w *World) SetTopBar(t *inkwell.TopBar) { w.top = t }
// Slot2 returns the item name in the second tape slot, empty if there is none.
//
// Unlike Mode, this is REAL game state and lives in State.Var so that it is
// saved.
func (w *World) Slot2() string {
if v, ok := w.G.State.Var(names.VarSlot2).(string); ok {
return v
}
return ""
}
// SetSlot2 sets the contents of the second tape slot.
func (w *World) SetSlot2(item string) { w.G.State.SetVar(names.VarSlot2, item) }
// SetPending marks which tape is going into slot 2. The tape-insert script
// performs the actual load; the widget only marks.
func (w *World) SetPending(item string) { w.pending = item }
// TakePending returns and clears the tape waiting to be loaded.
func (w *World) TakePending() string {
item := w.pending
w.pending = ""
return item
}
// TapeWaiting reports whether a tape line has been offered but not yet spoken.
func (w *World) TapeWaiting() bool { return w.tapeWaiting }
+45
View File
@@ -0,0 +1,45 @@
package world_test
import (
"testing"
inkwell "git.teletypegames.org/engines/inkwell"
"realworld/internal/names"
"realworld/internal/world"
)
// The mode must not leak into saved state: inkwell saves State.Vars but drops
// the in-flight script, so a save taken mid-cutscene would otherwise reload
// into a letterboxed, HUD-less game with nothing running.
func TestModeIsNotSavedState(t *testing.T) {
w := world.New(inkwell.NewGame("t", 320, 200))
w.SetMode(world.ModeCutscene)
for _, k := range []string{"ui.mode", "mode"} {
if v := w.G.State.Var(k); v != nil {
t.Errorf("mode leaked into State.Vars (%q = %v)", k, v)
}
}
// Slot 2, by contrast, is real game state and must be saved.
w.SetSlot2(names.ItemMysteryTape)
if w.G.State.Var(names.VarSlot2) != names.ItemMysteryTape {
t.Error("slot 2 is not in State.Vars")
}
if got := w.Slot2(); got != names.ItemMysteryTape {
t.Errorf("Slot2() = %q", got)
}
}
// The widget marks a tape as pending; the script consumes it — once.
func TestPendingIsConsumedOnce(t *testing.T) {
w := world.New(inkwell.NewGame("t", 320, 200))
w.SetPending(names.ItemMysteryTape)
if got := w.TakePending(); got != names.ItemMysteryTape {
t.Fatalf("TakePending() = %q", got)
}
if got := w.TakePending(); got != "" {
t.Errorf("pending was not cleared: %q", got)
}
}