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>
120 lines
2.6 KiB
Go
120 lines
2.6 KiB
Go
package inkwell
|
||
|
||
import (
|
||
"image"
|
||
"image/color"
|
||
"unicode/utf8"
|
||
|
||
"github.com/hajimehoshi/ebiten/v2"
|
||
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
|
||
)
|
||
|
||
// 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
|
||
}
|
||
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 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.
|
||
// 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, word := "", ""
|
||
flush := func() {
|
||
if line != "" {
|
||
out = append(out, line)
|
||
line = ""
|
||
}
|
||
}
|
||
emit := func() {
|
||
if word == "" {
|
||
return
|
||
}
|
||
switch {
|
||
case line == "":
|
||
line = word
|
||
case TextWidth(line+" "+word) <= maxPx:
|
||
line += " " + word
|
||
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]) + "…"
|
||
}
|
||
}
|