From 92fc36bdf5b8a37c9e6431cc8b80c1e08c6f3577 Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Sun, 30 Aug 2026 22:57:16 +0200 Subject: [PATCH] Nine things a domain kept having to invent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every change here started life as a workaround in a game and is really the same finding: a fact about an entity had nowhere to live, so the domain built machinery around the gap. Character.Label / Character.Voice — a tape is a character that speaks into the log, not a second spelling of Say. VoiceOf and CharacterLabel read them; Say obeys them. Game.Do — a real action queue, in order, so a widget can start an action. queueAction no longer drops what arrives while the runner is busy; it queues it. Widget.When + Gated + WidgetVisible — a widget declares when it is on screen. Nothing ticks, draws or blocks a click while its condition is false, so a HUD is hidden for a cutscene without a wrapper per widget. TopBar.NoteVar — the title was already discovered; the note beside it no longer needs a domain widget to push it in. Game.UseWithFail — the pair nobody authored is content, and belongs to the domain, exactly like ExitLook and ExitTake. Game.Player / Game.Walkboxes — a scene that names no cast and no floor means "the usual", instead of a defaults pass rewriting every scene. Game.WindowScale — Run's 4× is now a default, not a decision. SceneNav — the dev widget every game was writing. Colour text: DrawText paints in the colour it is handed, and GlyphW/H, TextWidth, WrapText and ClipText are the library's, not each game's. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 140 +++++++++++++++++++++++++++++++++++++----- action.def.go | 12 +++- actor.def.go | 31 +++++++++- asset.text.go | 123 +++++++++++++++++++++++++------------ core.dsl.go | 8 ++- core.engine.go | 22 +++++-- core.game.go | 87 +++++++++++++++++++++++--- ui.character_panel.go | 8 ++- ui.chat_log.go | 10 +-- ui.cursor.go | 8 ++- ui.dialog_box.go | 8 ++- ui.end_card.go | 8 ++- ui.hotspot_debug.go | 6 +- ui.inventory.go | 6 +- ui.panel.go | 8 ++- ui.scene_nav.go | 49 +++++++++++++++ ui.speech.go | 12 ++-- ui.status.go | 8 ++- ui.top_bar.go | 22 +++++-- ui.verb_bar.go | 6 +- ui.verb_radial.go | 11 +++- ui.widget.go | 19 ++++++ 22 files changed, 499 insertions(+), 113 deletions(-) create mode 100644 ui.scene_nav.go diff --git a/README.md b/README.md index b799a97..d661e07 100644 --- a/README.md +++ b/README.md @@ -250,6 +250,12 @@ type Game struct { ExitLook func(Exit) Action // default look response for generated exits ExitTake func(Exit) Action // default take response for generated exits + // domain defaults + UseWithFail func(item string, h *Hotspot) Action // unauthored use-with pair + Player string // actor a scene with none gets + Walkboxes []Polygon // floor a scene with none walks + WindowScale int // window = resolution × this (0 = 4) + // runtime services State *State Inventory *Inventory @@ -400,14 +406,38 @@ exit sits on wherever the two overlap. The expansion is cached per scene. `CharacterInScene` is used by `CharacterPanel` to auto-hide when its character isn't an actor in the current scene. -### 4.7 Text drawing hook +### 4.7 Text drawing and measuring ```go func (g *Game) DrawText(dst *ebiten.Image, s string, x, y int, c color.Color) + +const GlyphW, GlyphH = 6, 16 // the built-in font's cell + +func TextWidth(s string) int // pixel width, rune-counted +func WrapText(s string, maxPx int) []string // word wrap; \n breaks where it stands +func ClipText(s string, maxPx int) string // cut to width, ellipsis when cut ``` -Single indirection so widgets can hand a `color.Color` today and the -library can swap to `text/v2` later without changing call sites. +`DrawText` renders in the colour it is given: `ebitenutil.DebugPrintAt` +only paints white, so the glyphs go onto a reused offscreen and are +blitted back tinted with a `ColorScale`. Every `Theme` text colour is +therefore live, in the built-in widgets and in a domain's own. + +The three measuring helpers are what the built-in widgets lay out with, +and every vertical measurement in a HUD should derive from `GlyphH` +rather than a number that happens to look right. + +### 4.8 The action queue + +```go +func (g *Game) Do(a Action) +``` + +Queues an action. Queued actions run **one at a time and in order** — the +engine starts the next one on the first frame the previous is done, so a +widget, a trigger and a hotspot click can all hand work in without any of +them being dropped or racing the others. The engine's own hotspot and +`OnEnter` actions go through the same queue. --- @@ -508,9 +538,14 @@ type SceneActor struct { `OnEnter` is queued the moment the scene becomes current (after a fade-in); `OnLeave` runs on the transition out. `Actors` lists which registered -characters are placed in the scene and where their feet start. +characters are placed in the scene and where their feet start. A `nil` +`Actors` means "the usual cast": `Game.Player` is placed at that +character's `Start`. An empty non-nil slice means an empty scene, which is +how a map or a menu scene opts out. -`Walkboxes` constrain character movement. When non-empty, `Walk(...)` +`Walkboxes` are the same bargain: `nil` falls back to `Game.Walkboxes`, an +empty non-nil slice turns routing off for this scene. They constrain +character movement. When non-empty, `Walk(...)` routes through a BFS over the polygon adjacency graph (polygons that share an edge are neighbours), with the midpoint of each shared edge used as a waypoint. Destinations outside every walkbox are clipped to @@ -680,7 +715,12 @@ Items live in the `ItemManager`. When the player picks one up hotspot with an item selected resolves the action via, in order: 1. `hotspot.OnUseWith[item.Name]` 2. `item.OnUseWith[hotspot.Name]` -3. Otherwise the engine flashes "Nem ehhez." and deselects. +3. Otherwise `Game.UseWithFail(item, hotspot)` is asked for a response and + the item is deselected; with no hook set the engine flashes "Nem ehhez." + +`UseWithFail` is the same shape as `ExitLook` and `ExitTake`: the pair the +author never wrote is still content, and the domain is the only one that +knows what its characters say about it. ### 6.7 Inventory @@ -708,14 +748,26 @@ Not a manager — pure runtime state owned by `Game`. Mutated by actions type Character struct { Name string + Label string // what the UI shows; empty = Name Sprite string // Asset.Name (placeholder if missing) Animations map[string]AnimationClip Speed float64 SpeechColor color.Color - Start Point + Voice Voice // VoiceBubble (default) or VoiceLog + Start Point // where Game.Player stands by default W, H float64 // size hint for placeholder } +type Voice int +const ( + VoiceBubble Voice = iota // speech bubble over the scene, plus the log + VoiceLog // the log alone +) + +func (c Character) DisplayName() string // Label, or Name +func (g *Game) CharacterLabel(name string) string +func (g *Game) VoiceOf(name string) Voice + type AnimationClip struct { Frames []Rectangle FrameTime float64 @@ -723,6 +775,14 @@ type AnimationClip struct { } ``` +`Voice` is where a character reaches the player, and `Say` obeys it: a +`VoiceLog` character — a radio, a tape, a narrator with no body to speak +from — gets the log line without the bubble. A domain never needs a +second spelling of `Say` for that. + +`Label` is what a HUD prints: a code name, a title, a shout. `Start` is +where `Game.Player` is placed in a scene that lists no actors of its own. + When no real sprite is on disk, `drawCharacter` falls back to a stylised placeholder: humanoid if `W < H`, quadruped otherwise. `SpeechColor` (if an `RGBA`) colours both the speech-bubble text and the placeholder body. @@ -1036,6 +1096,12 @@ type Layered interface { func LayerOf(w Widget) Layer // Layered, else LayerHUD +type Gated interface { + VisibleWhen() Condition +} + +func WidgetVisible(ctx *Ctx, w Widget) bool // Gated with a false condition = off + type UICtx struct { Game *Game DT float64 @@ -1077,9 +1143,25 @@ their own `Bounds` (or compute them dynamically, like `RadialVerbs`). - Layers are what let a domain register its widgets **one file at a time** — an `init()` per widget, in whatever order the file names happen to fall — without the HUD coming out shuffled. -- A wrapper widget that forwards to an inner one — a visibility gate, say — - should return `LayerOf(inner)` from its own `Layer`, so that wrapping does - not move the widget. +- A wrapper widget that forwards to an inner one should return + `LayerOf(inner)` from its own `Layer`, so that wrapping does not move the + widget. + +Every built-in widget also carries a `When Condition`. A widget whose +condition evaluates false neither ticks nor draws nor blocks a click, so a +HUD is hidden for a cutscene by saying so on the widgets rather than by +unregistering them or wrapping each one: + +```go +g.WidgetManager.Register(&inkwell.InventoryBar{ + Name: "inventory", + When: inkwell.VarEq("mode", "play"), +}) +``` + +A domain widget joins in by implementing `VisibleWhen() Condition`; one +that does not implement it is always live, which is what a bare widget +always did. After all widgets ticked, the engine offers the (possibly consumed) click to `handleSceneInput`, which is where hotspot interactions live. If a @@ -1266,7 +1348,9 @@ type HotspotDebug struct { A horizontal strip across the top with three sections: -- **Left:** `LeftText` override or `Scene.Title` (falling back to `Name`). +- **Left:** `LeftText` override or `Scene.Title` (falling back to `Name`), + plus `State.Var(NoteVar)` after an em dash when that var holds anything — + `"ALLEY — paused"`. - **Center:** score string built from `State.Var(ScoreVar)`. With `ScoreMax > 0`, formatted as `"Score: X/MAX"`, else `"Score: X"`. - **Right:** time string from `State.Var(TimeVar)`. @@ -1274,17 +1358,37 @@ A horizontal strip across the top with three sections: ```go type TopBar struct { Name string + When Condition Height int // default 12 LeftText string // overrides scene title ScoreVar string // State.Var key; "" = no score ScoreMax int TimeVar string // State.Var key; "" = no time + NoteVar string // State.Var key; appended to the left text } ``` +The title needs no wiring: a domain that wants "the current scene, and +whatever the game is doing to it" sets `NoteVar` and writes that var. + Empty fields are skipped, so the widget gracefully degrades to two- or one-section layouts. +#### `SceneNav` (`ui.scene_nav.go`) + +A development aid: left and right arrow keys step through the registered +scenes in registration order, wrapping at both ends. Draws nothing. + +```go +type SceneNav struct { + Name string + When Condition +} +``` + +Gate it with `When` — or leave it unregistered — when a build should not +let the player walk the catalogue. + #### `CharacterPanel` (`ui.character_panel.go`) A floating info card showing one character's portrait, role badge and @@ -1587,14 +1691,16 @@ func Run(g *Game) error // toplevel — same as g.Run() `State.NoteVisit`, position registered actors, kick off music. 5. Compose `Seq(scene.OnEnter, OnStart script, OnFinale script)` and queue it as the initial action — the first script tick runs them in order. -6. `ebiten.SetWindowSize(Width*4, Height*4)`, - `ebiten.SetWindowTitle(g.Title)`, then `ebiten.RunGame(&engine{g})`. +6. `ebiten.SetWindowSize(Width*WindowScale, Height*WindowScale)` (4 when + unset), `ebiten.SetWindowTitle(g.Title)`, then + `ebiten.RunGame(&engine{g})`. ### 15.1 `engine.Update` ``` poll input update transition +pump the action queue // start the next Do() when nothing is running if transition fading out → return if scriptRunner != nil: @@ -1604,6 +1710,7 @@ if scriptRunner != nil: clear hoverLabel for w in WidgetManager, top layer down: + if not WidgetVisible(w): continue w.Tick(uictx) // widgets consume input top-down handleSceneInput() // hotspot resolution + right-click reset @@ -1618,6 +1725,7 @@ draw scene background image for c in characters sorted by Y: drawCharacter(c) for w in WidgetManager (by layer, then registration order): + if not WidgetVisible(w): continue w.Draw(screen, uictx) draw transition overlay ``` @@ -1631,7 +1739,8 @@ Runs only after every widget had a chance. The flow: `selectedVerb` to `"look"`. 3. On left-click: - If an item is selected, try `hotspot.OnUseWith[item]`, then - `item.OnUseWith[hotspot]`. Fail with a "Nem ehhez." flash. + `item.OnUseWith[hotspot]`, then `Game.UseWithFail`, and only then a + "Nem ehhez." flash. - Otherwise look up `hotspot.handler(selectedVerb)`. Fall back to the verb's `Default` action. Fail with "Semmi említésre méltó." 4. On every successful click, push a `LogAction` line so the `ChatLog` @@ -1872,7 +1981,8 @@ inkwell/ # module git.teletypegames.org/games/inkwell │ ├── input.def.go # Input (consume-on-use) │ -├── ui.widget.go # Widget interface, Layer, UICtx, Size, Align +├── ui.scene_nav.go # SceneNav widget (dev) +├── ui.widget.go # Widget interface, Layer, Gated, UICtx, Size, Align ├── ui.manager.go # WidgetManager alias + layer-ordered iterators ├── ui.theme.go # Theme + ThemeManager ├── ui.theme_presets.go # 4 preset themes diff --git a/action.def.go b/action.def.go index 3203775..e59e405 100644 --- a/action.def.go +++ b/action.def.go @@ -213,6 +213,7 @@ type sayRunner struct { elapsed float64 duration float64 started bool + bubble bool } func Say(speaker, text string) Action { return &sayAction{speaker: speaker, text: text} } @@ -223,8 +224,11 @@ func (r *sayRunner) Tick(ctx *Ctx) Status { if !r.started { r.started = true // duration scales with text length, with a 1.2s floor - r.duration = 1.2 + float64(len(r.spec.text))*0.05 - ctx.Game.SetSpeech(r.spec.speaker, r.spec.text) + r.duration = 1.2 + float64(len([]rune(r.spec.text)))*0.05 + r.bubble = ctx.Game.VoiceOf(r.spec.speaker) == VoiceBubble + if r.bubble { + ctx.Game.SetSpeech(r.spec.speaker, r.spec.text) + } ctx.Game.LogResponse(r.spec.speaker, r.spec.text) } r.elapsed += ctx.DT @@ -233,7 +237,9 @@ func (r *sayRunner) Tick(ctx *Ctx) Status { r.elapsed = r.duration } if r.elapsed >= r.duration { - ctx.Game.ClearSpeech() + if r.bubble { + ctx.Game.ClearSpeech() + } return StatusDone } return StatusRunning diff --git a/actor.def.go b/actor.def.go index fdd3473..057ccf6 100644 --- a/actor.def.go +++ b/actor.def.go @@ -3,15 +3,40 @@ package inkwell import "image/color" type Character struct { - Name string + Name string + // Label is what the UI shows instead of Name — a code name, a title, + // a shout. Empty means the Name is presentable as it stands. + Label string Sprite string // Asset.Name (placeholder if missing) Animations map[string]AnimationClip Speed float64 SpeechColor color.Color - Start Point + // Voice decides where Say puts this character's lines. + Voice Voice + // Start is where the character stands in a scene that lists no actors + // of its own, and Game.Player names the one placed there. + Start Point // Size hints used when the sprite is a placeholder rectangle. W, H float64 } -func (c Character) GetName() string { return c.Name } +// Voice is how a character reaches the player: over the scene as a speech +// bubble, or through the message log alone — a radio, a tape, a narrator +// that has no body to speak from. +type Voice int + +const ( + VoiceBubble Voice = iota // speech bubble over the scene, plus the log + VoiceLog // the log alone +) + +func (c Character) GetName() string { return c.Name } + +// DisplayName is the Label if there is one, the Name otherwise. +func (c Character) DisplayName() string { + if c.Label != "" { + return c.Label + } + return c.Name +} func (c Character) TypeLabel() string { return "character" } diff --git a/asset.text.go b/asset.text.go index 399f4b3..81e6f6e 100644 --- a/asset.text.go +++ b/asset.text.go @@ -1,72 +1,119 @@ package inkwell import ( + "image" "image/color" + "unicode/utf8" "github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2/ebitenutil" ) -// drawText is a minimal text draw using ebiten's built-in debug font. -// The glyphs are 6×16-ish; good enough for a SCUMM-style 320×200 demo. +// The built-in debug font is a fixed 6×16 cell. Every layout measurement +// in the library — and in a domain's own widgets — derives from these. +const ( + GlyphW = 6 + GlyphH = 16 +) + +// textScratch is the offscreen the coloured draw goes through. It grows +// to fit the widest line ever drawn and is reused after that. +var textScratch *ebiten.Image + +// drawText renders s in colour c. ebitenutil.DebugPrintAt only draws +// white, so the glyphs go onto an offscreen first and are blitted back +// tinted with a ColorScale. func drawText(dst *ebiten.Image, s string, x, y int, c color.Color) { if s == "" { return } - // ebitenutil.DebugPrintAt only draws white; for color we render to a - // tiny offscreen, then ColorScale-tint when blitting. Simpler: just - // use the white draw and skip color. (TODO: text/v2 once needed.) - _ = c - ebitenutil.DebugPrintAt(dst, s, x, y) + w, h := TextWidth(s)+GlyphW, GlyphH + if textScratch == nil || textScratch.Bounds().Dx() < w || textScratch.Bounds().Dy() < h { + nw := w + if nw < 320 { + nw = 320 + } + textScratch = ebiten.NewImage(nw, h) + } + textScratch.Clear() + ebitenutil.DebugPrintAt(textScratch, s, 0, 0) + + if c == nil { + c = color.White + } + r, g, b, a := c.RGBA() + op := &ebiten.DrawImageOptions{} + op.GeoM.Translate(float64(x), float64(y)) + op.ColorScale.Scale( + float32(r)/0xffff, float32(g)/0xffff, float32(b)/0xffff, float32(a)/0xffff, + ) + dst.DrawImage(textScratch.SubImage(image.Rect(0, 0, w, h)).(*ebiten.Image), op) } -// textWidth is a coarse pixel-width estimate, used for centering. -func textWidth(s string) int { return len(s) * 6 } +// TextWidth is the pixel width of s in the built-in font. +func TextWidth(s string) int { return utf8.RuneCountInString(s) * GlyphW } -// wrapText splits s at word boundaries into lines no wider than maxPx. -func wrapText(s string, maxPx int) []string { - if maxPx <= 0 || textWidth(s) <= maxPx { +// WrapText splits s at word boundaries into lines no wider than maxPx. +// A newline in s breaks the line where it stands. +func WrapText(s string, maxPx int) []string { + if maxPx <= GlyphW || TextWidth(s) <= maxPx { return []string{s} } - var ( - out []string - line string - ) + var out []string + line, word := "", "" flush := func() { if line != "" { out = append(out, line) line = "" } } - word := "" - for _, r := range s { - if r == ' ' || r == '\n' { - if line == "" { - line = word - } else if textWidth(line+" "+word) <= maxPx { - line += " " + word - } else { - flush() - line = word - } - word = "" - if r == '\n' { - flush() - } - continue + emit := func() { + if word == "" { + return } - word += string(r) - } - if word != "" { - if line == "" { + switch { + case line == "": line = word - } else if textWidth(line+" "+word) <= maxPx { + case TextWidth(line+" "+word) <= maxPx: line += " " + word - } else { + default: flush() line = word } + word = "" } + for _, r := range s { + switch r { + case ' ': + emit() + case '\n': + emit() + flush() + default: + word += string(r) + } + } + emit() flush() + if len(out) == 0 { + return []string{s} + } return out } + +// ClipText cuts s to maxPx pixels, ending in an ellipsis when it had to +// cut. A width with no room at all returns the empty string. +func ClipText(s string, maxPx int) string { + r := []rune(s) + max := maxPx / GlyphW + switch { + case max <= 0: + return "" + case len(r) <= max: + return s + case max == 1: + return string(r[:1]) + default: + return string(r[:max-1]) + "…" + } +} diff --git a/core.dsl.go b/core.dsl.go index 4ad2a8a..268730a 100644 --- a/core.dsl.go +++ b/core.dsl.go @@ -5,7 +5,7 @@ import ( ) // Run validates the game, then enters the ebiten main loop. The window is -// sized to 4× the internal resolution. +// sized to Game.WindowScale times the internal resolution, 4× by default. func Run(g *Game) error { // The domain may have swapped a manager in since NewGame, so the parts // that hold a registry directly are wired here, not at construction. @@ -44,7 +44,11 @@ func Run(g *Game) error { g.queueAction(Seq(seq...), "init") } - ebiten.SetWindowSize(g.Width*4, g.Height*4) + scale := g.WindowScale + if scale <= 0 { + scale = 4 + } + ebiten.SetWindowSize(g.Width*scale, g.Height*scale) ebiten.SetWindowTitle(g.Title) ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled) return ebiten.RunGame(&engine{g: g}) diff --git a/core.engine.go b/core.engine.go index 7a194ec..721f2b9 100644 --- a/core.engine.go +++ b/core.engine.go @@ -25,6 +25,7 @@ func (e *engine) Update() error { g.Input.poll() g.transition.update(dt) + g.pumpQueue() if g.transition.active && g.transition.out { return nil @@ -58,10 +59,14 @@ func (e *engine) Update() error { // race a cutscene that's about to start. g.tickTriggers() - // Top-down input: the widget drawn last (= registered last) gets the - // click first, then the next-to-last, etc. A widget signals "I took it" + // Top-down input: the widget on the highest layer gets the click + // first, then the one below it, etc. A widget signals "I took it" // via g.Input.ConsumeLeft / ConsumeRight. + cctx := g.makeCtx() for _, w := range reversedWidgets(g.WidgetManager) { + if !WidgetVisible(cctx, w) { + continue + } w.Tick(uictx) } @@ -135,7 +140,12 @@ func (g *Game) invokeHotspotVerb(h *Hotspot, verb string) { return } } - g.FlashLine("Nem ehhez.") + if g.UseWithFail != nil { + g.LogAction("> " + verbLabel(g, "use") + " " + sel + " ezen: " + targetLabel) + g.queueAction(g.UseWithFail(sel, h), "useWith fail") + } else { + g.FlashLine("Nem ehhez.") + } g.Inventory.Select("") return } @@ -190,9 +200,13 @@ func (e *engine) Draw(screen *ebiten.Image) { drawCharacter(screen, g, c) } - // widgets in registration order + // widgets from the bottom layer up uictx := &UICtx{Game: g, DT: 1.0 / 60.0} + cctx := g.makeCtx() for _, w := range orderedWidgets(g.WidgetManager) { + if !WidgetVisible(cctx, w) { + continue + } w.Draw(screen, uictx) } diff --git a/core.game.go b/core.game.go index 33c93dd..dadcae4 100644 --- a/core.game.go +++ b/core.game.go @@ -35,6 +35,23 @@ type Game struct { ExitLook func(Exit) Action ExitTake func(Exit) Action + // UseWithFail supplies the response when the player uses an item on a + // hotspot nobody paired it with. Nil falls back to a flash line. + UseWithFail func(item string, h *Hotspot) Action + + // Player names the character a scene gets when it lists no actors of + // its own, placed at that character's Start. Empty leaves such a + // scene unpeopled. + Player string + + // Walkboxes is the floor a scene walks on when it declares none of + // its own. Nil means no routing in those scenes. + Walkboxes []Polygon + + // WindowScale multiplies the internal resolution when Run sizes the + // window. 0 means 4. + WindowScale int + State *State Inventory *Inventory Audio *AudioPlayer @@ -47,6 +64,7 @@ type Game struct { activeTheme string // runtime + queue []Action loaded *loadedAssets currentScene string previousScene string @@ -275,8 +293,7 @@ func (g *Game) CharacterInScene(name string) bool { if g.currentScene == "" { return false } - s := g.SceneManager.MustGet(g.currentScene) - for _, a := range s.Actors { + for _, a := range g.sceneActors(g.SceneManager.MustGet(g.currentScene)) { if a.CharacterName == name { return true } @@ -284,6 +301,24 @@ func (g *Game) CharacterInScene(name string) bool { return false } +// VoiceOf reports how a character reaches the player. An unregistered +// name speaks in a bubble, which is what a bare Say always did. +func (g *Game) VoiceOf(name string) Voice { + if c, ok := g.CharacterManager.Get(name); ok { + return c.Voice + } + return VoiceBubble +} + +// CharacterLabel is what the UI should print for a character: the Label +// if the entity carries one, the name otherwise. +func (g *Game) CharacterLabel(name string) string { + if c, ok := g.CharacterManager.Get(name); ok { + return c.DisplayName() + } + return name +} + // DrawText is a thin wrapper that lets widgets accept a Theme-supplied color // today and switch to text/v2 later without changing call sites. func (g *Game) DrawText(dst *ebiten.Image, s string, x, y int, c color.Color) { @@ -325,7 +360,7 @@ func (g *Game) Validate() error { if s.Music != "" && !g.AssetManager.Has(s.Music) { return fmt.Errorf("%w: scene %q music %q", ErrUnknownAsset, name, s.Music) } - for _, a := range s.Actors { + for _, a := range g.sceneActors(s) { if !g.CharacterManager.Has(a.CharacterName) { return fmt.Errorf("%w: scene %q actor %q", ErrUnknownCharacter, name, a.CharacterName) } @@ -341,6 +376,9 @@ func (g *Game) Validate() error { return fmt.Errorf("%w: %q", ErrUnknownScript, name) } } + if g.Player != "" && !g.CharacterManager.Has(g.Player) { + return fmt.Errorf("%w: player %q", ErrUnknownCharacter, g.Player) + } if g.activeTheme == "" || !g.ThemeManager.Has(g.activeTheme) { return fmt.Errorf("inkwell: no active theme (got %q)", g.activeTheme) } @@ -406,9 +444,25 @@ func (g *Game) changeScene(name string) { }) } +// sceneActors is the cast a scene actually shows: its own list, or the +// player alone when the scene names nobody. +func (g *Game) sceneActors(s Scene) []SceneActor { + if s.Actors != nil || g.Player == "" { + return s.Actors + } + def, ok := g.CharacterManager.Get(g.Player) + if !ok { + return nil + } + return []SceneActor{{ + CharacterName: def.Name, + At: def.Start, + }} +} + func (g *Game) placeActors(sceneName string) { s := g.SceneManager.MustGet(sceneName) - for _, a := range s.Actors { + for _, a := range g.sceneActors(s) { def := g.CharacterManager.MustGet(a.CharacterName) rc, ok := g.chars[def.Name] if !ok { @@ -436,6 +490,9 @@ func (g *Game) walkCharacter(name string, to Point) { if g.currentScene != "" { s := g.SceneManager.MustGet(g.currentScene) boxes = s.Walkboxes + if boxes == nil { + boxes = g.Walkboxes + } } path := pathfind(c.pos, to, boxes) if len(path) == 0 { @@ -492,17 +549,33 @@ func (g *Game) tickCharacters(dt float64) { // ----- script & dialog plumbing ----------------------------------------- +// Do queues an action. Queued actions run one at a time and in order — +// the engine starts the next one on the first frame the previous one is +// done, so a widget or a domain pump can hand work in at any moment. +func (g *Game) Do(a Action) { + if a == nil { + return + } + g.queue = append(g.queue, a) +} + func (g *Game) queueAction(a Action, label string) { if a == nil { return } - if g.scriptRunner != nil { - logf("queueAction: %s ignored, runner busy", label) + logf("queueAction %s", label) + g.Do(a) +} + +// pumpQueue starts the next queued action when nothing else is running. +func (g *Game) pumpQueue() { + if g.scriptRunner != nil || len(g.queue) == 0 { return } + a := g.queue[0] + g.queue = g.queue[1:] g.scriptRunner = a.Start() g.scriptCtx = g.makeCtx() - logf("queueAction %s", label) } func (g *Game) startDialogue(name string) { diff --git a/ui.character_panel.go b/ui.character_panel.go index 0b52b93..30d80c4 100644 --- a/ui.character_panel.go +++ b/ui.character_panel.go @@ -21,15 +21,17 @@ type CharStat struct { // works across scenes. type CharacterPanel struct { Name string + When Condition Bounds Rectangle Character string // Character.Name to display Title string // "PLAYER", "NPC", role badge Stats []CharStat } -func (c *CharacterPanel) GetName() string { return c.Name } -func (c *CharacterPanel) Layer() Layer { return LayerHUD } -func (c *CharacterPanel) Tick(ctx *UICtx) {} +func (c *CharacterPanel) GetName() string { return c.Name } +func (c *CharacterPanel) VisibleWhen() Condition { return c.When } +func (c *CharacterPanel) Layer() Layer { return LayerHUD } +func (c *CharacterPanel) Tick(ctx *UICtx) {} func (c *CharacterPanel) Draw(dst *ebiten.Image, ctx *UICtx) { g := ctx.Game diff --git a/ui.chat_log.go b/ui.chat_log.go index 220d2b4..043ad7c 100644 --- a/ui.chat_log.go +++ b/ui.chat_log.go @@ -12,6 +12,7 @@ import ( // messages scroll up out of view as the buffer fills. type ChatLog struct { Name string + When Condition Bounds Rectangle LineHeight int Padding int @@ -19,9 +20,10 @@ type ChatLog struct { ShowBorder bool } -func (c *ChatLog) GetName() string { return c.Name } -func (c *ChatLog) Layer() Layer { return LayerHUD } -func (c *ChatLog) Tick(ctx *UICtx) {} +func (c *ChatLog) GetName() string { return c.Name } +func (c *ChatLog) VisibleWhen() Condition { return c.When } +func (c *ChatLog) Layer() Layer { return LayerHUD } +func (c *ChatLog) Tick(ctx *UICtx) {} func (c *ChatLog) Draw(dst *ebiten.Image, ctx *UICtx) { g := ctx.Game @@ -71,7 +73,7 @@ func (c *ChatLog) Draw(dst *ebiten.Image, ctx *UICtx) { if m.Kind == LogResponse && m.Speaker != "" { txt = m.Speaker + ": " + m.Text } - for _, w := range wrapText(txt, wrapW) { + for _, w := range WrapText(txt, wrapW) { rendered = append(rendered, line{text: w, col: col}) } } diff --git a/ui.cursor.go b/ui.cursor.go index d9cb763..fdbe518 100644 --- a/ui.cursor.go +++ b/ui.cursor.go @@ -10,11 +10,13 @@ import ( // default crosshair. Registered last so it draws on top. type Cursor struct { Name string + When Condition } -func (c *Cursor) GetName() string { return c.Name } -func (c *Cursor) Layer() Layer { return LayerCursor } -func (c *Cursor) Tick(ctx *UICtx) {} +func (c *Cursor) GetName() string { return c.Name } +func (c *Cursor) VisibleWhen() Condition { return c.When } +func (c *Cursor) Layer() Layer { return LayerCursor } +func (c *Cursor) Tick(ctx *UICtx) {} func (c *Cursor) Draw(dst *ebiten.Image, ctx *UICtx) { g := ctx.Game diff --git a/ui.dialog_box.go b/ui.dialog_box.go index 10256f2..9f24873 100644 --- a/ui.dialog_box.go +++ b/ui.dialog_box.go @@ -11,13 +11,15 @@ import ( // so nothing underneath reacts. type DialogBox struct { Name string + When Condition Bounds Rectangle LineHeight int Padding int } -func (d *DialogBox) GetName() string { return d.Name } -func (d *DialogBox) Layer() Layer { return LayerDialog } +func (d *DialogBox) GetName() string { return d.Name } +func (d *DialogBox) VisibleWhen() Condition { return d.When } +func (d *DialogBox) Layer() Layer { return LayerDialog } func (d *DialogBox) Tick(ctx *UICtx) { g := ctx.Game @@ -112,7 +114,7 @@ func (d *DialogBox) Draw(dst *ebiten.Image, ctx *UICtx) { if dlg.LineIdx >= 0 && dlg.LineIdx < len(dlg.Node.Lines) { ln := dlg.Node.Lines[dlg.LineIdx] g.DrawText(dst, ln.Speaker+":", int(b.X)+pad, int(b.Y)+2, th.DialogSpeaker) - lines := wrapText(ln.Text, int(b.W)-pad*2) + lines := WrapText(ln.Text, int(b.W)-pad*2) for i, l := range lines { g.DrawText(dst, l, int(b.X)+pad, int(b.Y)+18+i*lh, th.DialogText) } diff --git a/ui.end_card.go b/ui.end_card.go index 0cdf57d..59cf60a 100644 --- a/ui.end_card.go +++ b/ui.end_card.go @@ -9,10 +9,12 @@ import ( // While active it consumes every input so nothing underneath reacts. type EndCard struct { Name string + When Condition } -func (e *EndCard) GetName() string { return e.Name } -func (e *EndCard) Layer() Layer { return LayerCurtain } +func (e *EndCard) GetName() string { return e.Name } +func (e *EndCard) VisibleWhen() Condition { return e.When } +func (e *EndCard) Layer() Layer { return LayerCurtain } func (e *EndCard) Tick(ctx *UICtx) { if ctx.Game.endCard == "" { @@ -34,6 +36,6 @@ func (e *EndCard) Draw(dst *ebiten.Image, ctx *UICtx) { } th := g.Theme() vector.DrawFilledRect(dst, 0, 0, float32(g.Width), float32(g.Height), th.EndCardBG, false) - w := textWidth(g.endCard) + w := TextWidth(g.endCard) g.DrawText(dst, g.endCard, (g.Width-w)/2, g.Height/2-8, th.EndCardText) } diff --git a/ui.hotspot_debug.go b/ui.hotspot_debug.go index e2c3e1f..a7561b7 100644 --- a/ui.hotspot_debug.go +++ b/ui.hotspot_debug.go @@ -10,13 +10,15 @@ import ( // scene. F1 toggles it at runtime; the default is off. type HotspotDebug struct { Name string + When Condition Enabled bool // ToggleKey, if non-zero, overrides the default F1 toggle. ToggleKey ebiten.Key } -func (h *HotspotDebug) GetName() string { return h.Name } -func (h *HotspotDebug) Layer() Layer { return LayerScene } +func (h *HotspotDebug) GetName() string { return h.Name } +func (h *HotspotDebug) VisibleWhen() Condition { return h.When } +func (h *HotspotDebug) Layer() Layer { return LayerScene } func (h *HotspotDebug) Tick(ctx *UICtx) { key := h.ToggleKey diff --git a/ui.inventory.go b/ui.inventory.go index 2779534..8fadfe4 100644 --- a/ui.inventory.go +++ b/ui.inventory.go @@ -10,6 +10,7 @@ import ( // other verb, it toggles selection (the cursor "picks up" the item). type InventoryBar struct { Name string + When Condition Origin Point Slots int Cols int @@ -19,8 +20,9 @@ type InventoryBar struct { PanelBG bool } -func (b *InventoryBar) GetName() string { return b.Name } -func (b *InventoryBar) Layer() Layer { return LayerHUD } +func (b *InventoryBar) GetName() string { return b.Name } +func (b *InventoryBar) VisibleWhen() Condition { return b.When } +func (b *InventoryBar) Layer() Layer { return LayerHUD } func (b *InventoryBar) slotRect(idx int) Rectangle { cols := b.Cols diff --git a/ui.panel.go b/ui.panel.go index ccad7e9..a20ff0d 100644 --- a/ui.panel.go +++ b/ui.panel.go @@ -11,6 +11,7 @@ import ( // backdrop for grouping other widgets or as a chat/notebook frame. type Panel struct { Name string + When Condition Bounds Rectangle // BG, if nil, falls back to the active Theme.PanelBG. BG color.Color @@ -20,9 +21,10 @@ type Panel struct { BorderColor color.Color } -func (p *Panel) GetName() string { return p.Name } -func (p *Panel) Layer() Layer { return LayerPanel } -func (p *Panel) Tick(ctx *UICtx) {} +func (p *Panel) GetName() string { return p.Name } +func (p *Panel) VisibleWhen() Condition { return p.When } +func (p *Panel) Layer() Layer { return LayerPanel } +func (p *Panel) Tick(ctx *UICtx) {} func (p *Panel) Draw(dst *ebiten.Image, ctx *UICtx) { bg := p.BG diff --git a/ui.scene_nav.go b/ui.scene_nav.go new file mode 100644 index 0000000..ee2a17e --- /dev/null +++ b/ui.scene_nav.go @@ -0,0 +1,49 @@ +package inkwell + +import ( + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/inpututil" +) + +// SceneNav is a development aid: the left and right arrow keys step +// through the registered scenes in registration order, wrapping at both +// ends. It draws nothing. Gate it with When — or leave it unregistered — +// when a build should not let the player walk the catalogue. +type SceneNav struct { + Name string + When Condition +} + +func (n *SceneNav) GetName() string { return n.Name } +func (n *SceneNav) VisibleWhen() Condition { return n.When } +func (n *SceneNav) Layer() Layer { return LayerScene } + +func (n *SceneNav) Draw(dst *ebiten.Image, ctx *UICtx) {} + +func (n *SceneNav) Tick(ctx *UICtx) { + step := 0 + switch { + case inpututil.IsKeyJustPressed(ebiten.KeyArrowRight): + step = 1 + case inpututil.IsKeyJustPressed(ebiten.KeyArrowLeft): + step = -1 + default: + return + } + if next := n.neighbour(ctx.Game, step); next != "" { + ctx.Game.Do(GoTo(next)) + } +} + +func (n *SceneNav) neighbour(g *Game, step int) string { + deck := g.SceneManager.Names() + if len(deck) < 2 { + return "" + } + for i, name := range deck { + if name == g.CurrentScene() { + return deck[((i+step)%len(deck)+len(deck))%len(deck)] + } + } + return deck[0] +} diff --git a/ui.speech.go b/ui.speech.go index e5454e2..ccdc9ce 100644 --- a/ui.speech.go +++ b/ui.speech.go @@ -11,15 +11,17 @@ import ( // character (or at FallbackY if the speaker has no on-screen position). type SpeechBubble struct { Name string + When Condition MaxWidth int Padding int OffsetY int FallbackY int } -func (s *SpeechBubble) GetName() string { return s.Name } -func (s *SpeechBubble) Layer() Layer { return LayerSpeech } -func (s *SpeechBubble) Tick(ctx *UICtx) {} +func (s *SpeechBubble) GetName() string { return s.Name } +func (s *SpeechBubble) VisibleWhen() Condition { return s.When } +func (s *SpeechBubble) Layer() Layer { return LayerSpeech } +func (s *SpeechBubble) Tick(ctx *UICtx) {} func (s *SpeechBubble) Draw(dst *ebiten.Image, ctx *UICtx) { g := ctx.Game @@ -35,10 +37,10 @@ func (s *SpeechBubble) Draw(dst *ebiten.Image, ctx *UICtx) { if pad <= 0 { pad = 3 } - lines := wrapText(sp.Text, maxW) + lines := WrapText(sp.Text, maxW) w := 0 for _, ln := range lines { - if t := textWidth(ln); t > w { + if t := TextWidth(ln); t > w { w = t } } diff --git a/ui.status.go b/ui.status.go index 9462529..1851dc8 100644 --- a/ui.status.go +++ b/ui.status.go @@ -6,14 +6,16 @@ import "github.com/hajimehoshi/ebiten/v2" // by FlashLine) or the hover-hint (verb + target under the cursor). type StatusLine struct { Name string + When Condition Y int Align Align // ScreenWidth, if 0, uses Game.Width. ScreenWidth int } -func (s *StatusLine) GetName() string { return s.Name } -func (s *StatusLine) Layer() Layer { return LayerHUD } +func (s *StatusLine) GetName() string { return s.Name } +func (s *StatusLine) VisibleWhen() Condition { return s.When } +func (s *StatusLine) Layer() Layer { return LayerHUD } func (s *StatusLine) Tick(ctx *UICtx) { g := ctx.Game @@ -41,7 +43,7 @@ func (s *StatusLine) Draw(dst *ebiten.Image, ctx *UICtx) { if text == "" { return } - tw := textWidth(text) + tw := TextWidth(text) var x int switch s.Align { case AlignLeft: diff --git a/ui.top_bar.go b/ui.top_bar.go index d0ee4a0..a144b6e 100644 --- a/ui.top_bar.go +++ b/ui.top_bar.go @@ -12,6 +12,7 @@ import ( // string on the right. Empty fields are skipped. type TopBar struct { Name string + When Condition Height int // LeftText overrides the auto-discovered scene Title/Name. @@ -24,11 +25,17 @@ type TopBar struct { // TimeVar is a State Var key whose value is printed at the right // edge (e.g. "Day 2 - 14:32"); empty = nothing on the right. TimeVar string + + // NoteVar is a State Var key whose value is appended to the left + // text after an em dash — "ALLEY — paused". Empty or unset = no + // note, and the title stands alone. + NoteVar string } -func (t *TopBar) GetName() string { return t.Name } -func (t *TopBar) Layer() Layer { return LayerHUD } -func (t *TopBar) Tick(ctx *UICtx) {} +func (t *TopBar) GetName() string { return t.Name } +func (t *TopBar) VisibleWhen() Condition { return t.When } +func (t *TopBar) Layer() Layer { return LayerHUD } +func (t *TopBar) Tick(ctx *UICtx) {} func (t *TopBar) Draw(dst *ebiten.Image, ctx *UICtx) { g := ctx.Game @@ -48,6 +55,11 @@ func (t *TopBar) Draw(dst *ebiten.Image, ctx *UICtx) { left = s.Name } } + if t.NoteVar != "" { + if note := fmt.Sprint(g.State.Var(t.NoteVar)); note != "" && note != "" { + left += " — " + note + } + } if left != "" { g.DrawText(dst, left, 4, -1, th.TopBarText) } @@ -60,13 +72,13 @@ func (t *TopBar) Draw(dst *ebiten.Image, ctx *UICtx) { } else { sc = fmt.Sprintf("Score: %v", v) } - x := (g.Width - textWidth(sc)) / 2 + x := (g.Width - TextWidth(sc)) / 2 g.DrawText(dst, sc, x, -1, th.TopBarAccent) } if t.TimeVar != "" { tm := fmt.Sprint(g.State.Var(t.TimeVar)) - x := g.Width - textWidth(tm) - 4 + x := g.Width - TextWidth(tm) - 4 g.DrawText(dst, tm, x, -1, th.TopBarText) } } diff --git a/ui.verb_bar.go b/ui.verb_bar.go index 2c1bac0..f259b9f 100644 --- a/ui.verb_bar.go +++ b/ui.verb_bar.go @@ -9,6 +9,7 @@ import ( // VerbManager every frame so newly-registered verbs show up automatically. type VerbBar struct { Name string + When Condition Origin Point Cols int Button Size @@ -18,8 +19,9 @@ type VerbBar struct { PanelBG bool } -func (v *VerbBar) GetName() string { return v.Name } -func (v *VerbBar) Layer() Layer { return LayerHUD } +func (v *VerbBar) GetName() string { return v.Name } +func (v *VerbBar) VisibleWhen() Condition { return v.When } +func (v *VerbBar) Layer() Layer { return LayerHUD } func (v *VerbBar) buttons(ctx *UICtx) []verbButton { cols := v.Cols diff --git a/ui.verb_radial.go b/ui.verb_radial.go index 9851a21..1819694 100644 --- a/ui.verb_radial.go +++ b/ui.verb_radial.go @@ -16,6 +16,7 @@ import ( // Both can coexist if you really want. type RadialVerbs struct { Name string + When Condition Trigger MouseButton Radius float64 @@ -42,8 +43,9 @@ type RadialVerbs struct { pendingHotspot *Hotspot // remembered hotspot when HotspotOnly opens the coin } -func (r *RadialVerbs) GetName() string { return r.Name } -func (r *RadialVerbs) Layer() Layer { return LayerMenu } +func (r *RadialVerbs) GetName() string { return r.Name } +func (r *RadialVerbs) VisibleWhen() Condition { return r.When } +func (r *RadialVerbs) Layer() Layer { return LayerMenu } func (r *RadialVerbs) Tick(ctx *UICtx) { g := ctx.Game @@ -146,6 +148,9 @@ func (r *RadialVerbs) blockedAt(ctx *UICtx, p Point) bool { continue } w := ctx.Game.WidgetManager.MustGet(name) + if !WidgetVisible(ctx.Game.makeCtx(), w) { + continue + } // DialogBox advertises its bounds via clickBlocker even when no // dialog is open, so the verb-coin would refuse to pop up over // scenery that happens to sit under the dialog rect. Skip the @@ -248,7 +253,7 @@ func (r *RadialVerbs) Draw(dst *ebiten.Image, ctx *UICtx) { ly := r.center.Y + math.Sin(a)*labelDist label := r.labelFor(g, name) - tw := textWidth(label) + tw := TextWidth(label) col := th.VerbButtonText isSelected := name == selected isHover := i == hover diff --git a/ui.widget.go b/ui.widget.go index f962409..c7e53ed 100644 --- a/ui.widget.go +++ b/ui.widget.go @@ -54,6 +54,25 @@ type Layered interface { Layer() Layer } +// Gated is implemented by a widget that can be switched off by game +// state — every built-in is, through its When field. A widget that does +// not implement it, or whose condition is nil, is always live. +type Gated interface { + VisibleWhen() Condition +} + +// WidgetVisible reports whether a widget takes part in this frame at all. +// A widget switched off neither ticks nor draws, and does not block a +// click, so a HUD can be hidden for a cutscene without unregistering it. +func WidgetVisible(ctx *Ctx, w Widget) bool { + g, ok := w.(Gated) + if !ok { + return true + } + c := g.VisibleWhen() + return c == nil || c.Eval(ctx) +} + // LayerOf reports the layer a widget paints on. A wrapper widget that // forwards to an inner one — a visibility gate, say — should return // LayerOf(inner) so that wrapping does not move the widget.