Nine things a domain kept having to invent
ci/woodpecker/push/woodpecker Pipeline was successful

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-30 22:57:16 +02:00
co-authored by Claude Opus 5
parent 2429ada090
commit 92fc36bdf5
22 changed files with 499 additions and 113 deletions
+125 -15
View File
@@ -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