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
+85 -38
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 == "" {
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]) + "…"
}
}