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
+7 -1
View File
@@ -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
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 {
if r.bubble {
ctx.Game.ClearSpeech()
}
return StatusDone
}
return StatusRunning
+25
View File
@@ -4,14 +4,39 @@ import "image/color"
type Character struct {
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
// 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
}
// 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" }
+82 -35
View File
@@ -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 == "" {
emit := func() {
if word == "" {
return
}
switch {
case line == "":
line = word
} else if textWidth(line+" "+word) <= maxPx {
case TextWidth(line+" "+word) <= maxPx:
line += " " + word
} else {
default:
flush()
line = word
}
word = ""
if r == '\n' {
}
for _, r := range s {
switch r {
case ' ':
emit()
case '\n':
emit()
flush()
}
continue
}
default:
word += string(r)
}
if word != "" {
if line == "" {
line = word
} else if textWidth(line+" "+word) <= maxPx {
line += " " + word
} else {
flush()
line = word
}
}
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]) + "…"
}
}
+6 -2
View File
@@ -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})
+17 -3
View File
@@ -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
}
}
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)
}
+80 -7
View File
@@ -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) {
+2
View File
@@ -21,6 +21,7 @@ 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
@@ -28,6 +29,7 @@ type CharacterPanel struct {
}
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) {}
+3 -1
View File
@@ -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
@@ -20,6 +21,7 @@ type ChatLog struct {
}
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) {}
@@ -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})
}
}
+2
View File
@@ -10,9 +10,11 @@ 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) VisibleWhen() Condition { return c.When }
func (c *Cursor) Layer() Layer { return LayerCursor }
func (c *Cursor) Tick(ctx *UICtx) {}
+3 -1
View File
@@ -11,12 +11,14 @@ 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) VisibleWhen() Condition { return d.When }
func (d *DialogBox) Layer() Layer { return LayerDialog }
func (d *DialogBox) Tick(ctx *UICtx) {
@@ -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)
}
+3 -1
View File
@@ -9,9 +9,11 @@ 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) VisibleWhen() Condition { return e.When }
func (e *EndCard) Layer() Layer { return LayerCurtain }
func (e *EndCard) Tick(ctx *UICtx) {
@@ -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)
}
+2
View File
@@ -10,12 +10,14 @@ 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) VisibleWhen() Condition { return h.When }
func (h *HotspotDebug) Layer() Layer { return LayerScene }
func (h *HotspotDebug) Tick(ctx *UICtx) {
+2
View File
@@ -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
@@ -20,6 +21,7 @@ type InventoryBar struct {
}
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 {
+2
View File
@@ -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
@@ -21,6 +22,7 @@ type Panel struct {
}
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) {}
+49
View File
@@ -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]
}
+4 -2
View File
@@ -11,6 +11,7 @@ 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
@@ -18,6 +19,7 @@ type SpeechBubble struct {
}
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) {}
@@ -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
}
}
+3 -1
View File
@@ -6,6 +6,7 @@ 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.
@@ -13,6 +14,7 @@ type StatusLine struct {
}
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) {
@@ -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:
+14 -2
View File
@@ -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,9 +25,15 @@ 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) VisibleWhen() Condition { return t.When }
func (t *TopBar) Layer() Layer { return LayerHUD }
func (t *TopBar) Tick(ctx *UICtx) {}
@@ -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 != "<nil>" {
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)
}
}
+2
View File
@@ -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
@@ -19,6 +20,7 @@ type VerbBar struct {
}
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 {
+6 -1
View File
@@ -16,6 +16,7 @@ import (
// Both can coexist if you really want.
type RadialVerbs struct {
Name string
When Condition
Trigger MouseButton
Radius float64
@@ -43,6 +44,7 @@ type RadialVerbs struct {
}
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) {
@@ -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
+19
View File
@@ -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.