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 ExitLook func(Exit) Action // default look response for generated exits
ExitTake func(Exit) Action // default take 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 // runtime services
State *State State *State
Inventory *Inventory 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 `CharacterInScene` is used by `CharacterPanel` to auto-hide when its character
isn't an actor in the current scene. isn't an actor in the current scene.
### 4.7 Text drawing hook ### 4.7 Text drawing and measuring
```go ```go
func (g *Game) DrawText(dst *ebiten.Image, s string, x, y int, c color.Color) 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 `DrawText` renders in the colour it is given: `ebitenutil.DebugPrintAt`
library can swap to `text/v2` later without changing call sites. 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); `OnEnter` is queued the moment the scene becomes current (after a fade-in);
`OnLeave` runs on the transition out. `Actors` lists which registered `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 routes through a BFS over the polygon adjacency graph (polygons that
share an edge are neighbours), with the midpoint of each shared edge share an edge are neighbours), with the midpoint of each shared edge
used as a waypoint. Destinations outside every walkbox are clipped to 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: hotspot with an item selected resolves the action via, in order:
1. `hotspot.OnUseWith[item.Name]` 1. `hotspot.OnUseWith[item.Name]`
2. `item.OnUseWith[hotspot.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 ### 6.7 Inventory
@@ -708,14 +748,26 @@ Not a manager — pure runtime state owned by `Game`. Mutated by actions
type Character struct { type Character struct {
Name string Name string
Label string // what the UI shows; empty = Name
Sprite string // Asset.Name (placeholder if missing) Sprite string // Asset.Name (placeholder if missing)
Animations map[string]AnimationClip Animations map[string]AnimationClip
Speed float64 Speed float64
SpeechColor color.Color 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 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 { type AnimationClip struct {
Frames []Rectangle Frames []Rectangle
FrameTime float64 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 When no real sprite is on disk, `drawCharacter` falls back to a stylised
placeholder: humanoid if `W < H`, quadruped otherwise. `SpeechColor` (if placeholder: humanoid if `W < H`, quadruped otherwise. `SpeechColor` (if
an `RGBA`) colours both the speech-bubble text and the placeholder body. 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 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 { type UICtx struct {
Game *Game Game *Game
DT float64 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** - 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 — an `init()` per widget, in whatever order the file names happen to fall —
without the HUD coming out shuffled. without the HUD coming out shuffled.
- A wrapper widget that forwards to an inner one — a visibility gate, say — - A wrapper widget that forwards to an inner one should return
should return `LayerOf(inner)` from its own `Layer`, so that wrapping does `LayerOf(inner)` from its own `Layer`, so that wrapping does not move the
not move the widget. 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 After all widgets ticked, the engine offers the (possibly consumed) click
to `handleSceneInput`, which is where hotspot interactions live. If a 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: 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 - **Center:** score string built from `State.Var(ScoreVar)`. With
`ScoreMax > 0`, formatted as `"Score: X/MAX"`, else `"Score: X"`. `ScoreMax > 0`, formatted as `"Score: X/MAX"`, else `"Score: X"`.
- **Right:** time string from `State.Var(TimeVar)`. - **Right:** time string from `State.Var(TimeVar)`.
@@ -1274,17 +1358,37 @@ A horizontal strip across the top with three sections:
```go ```go
type TopBar struct { type TopBar struct {
Name string Name string
When Condition
Height int // default 12 Height int // default 12
LeftText string // overrides scene title LeftText string // overrides scene title
ScoreVar string // State.Var key; "" = no score ScoreVar string // State.Var key; "" = no score
ScoreMax int ScoreMax int
TimeVar string // State.Var key; "" = no time 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 Empty fields are skipped, so the widget gracefully degrades to two- or
one-section layouts. 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`) #### `CharacterPanel` (`ui.character_panel.go`)
A floating info card showing one character's portrait, role badge and 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. `State.NoteVisit`, position registered actors, kick off music.
5. Compose `Seq(scene.OnEnter, OnStart script, OnFinale script)` and queue 5. Compose `Seq(scene.OnEnter, OnStart script, OnFinale script)` and queue
it as the initial action — the first script tick runs them in order. it as the initial action — the first script tick runs them in order.
6. `ebiten.SetWindowSize(Width*4, Height*4)`, 6. `ebiten.SetWindowSize(Width*WindowScale, Height*WindowScale)` (4 when
`ebiten.SetWindowTitle(g.Title)`, then `ebiten.RunGame(&engine{g})`. unset), `ebiten.SetWindowTitle(g.Title)`, then
`ebiten.RunGame(&engine{g})`.
### 15.1 `engine.Update` ### 15.1 `engine.Update`
``` ```
poll input poll input
update transition update transition
pump the action queue // start the next Do() when nothing is running
if transition fading out → return if transition fading out → return
if scriptRunner != nil: if scriptRunner != nil:
@@ -1604,6 +1710,7 @@ if scriptRunner != nil:
clear hoverLabel clear hoverLabel
for w in WidgetManager, top layer down: for w in WidgetManager, top layer down:
if not WidgetVisible(w): continue
w.Tick(uictx) // widgets consume input top-down w.Tick(uictx) // widgets consume input top-down
handleSceneInput() // hotspot resolution + right-click reset handleSceneInput() // hotspot resolution + right-click reset
@@ -1618,6 +1725,7 @@ draw scene background image
for c in characters sorted by Y: for c in characters sorted by Y:
drawCharacter(c) drawCharacter(c)
for w in WidgetManager (by layer, then registration order): for w in WidgetManager (by layer, then registration order):
if not WidgetVisible(w): continue
w.Draw(screen, uictx) w.Draw(screen, uictx)
draw transition overlay draw transition overlay
``` ```
@@ -1631,7 +1739,8 @@ Runs only after every widget had a chance. The flow:
`selectedVerb` to `"look"`. `selectedVerb` to `"look"`.
3. On left-click: 3. On left-click:
- If an item is selected, try `hotspot.OnUseWith[item]`, then - 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 - Otherwise look up `hotspot.handler(selectedVerb)`. Fall back to the
verb's `Default` action. Fail with "Semmi említésre méltó." verb's `Default` action. Fail with "Semmi említésre méltó."
4. On every successful click, push a `LogAction` line so the `ChatLog` 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) ├── 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.manager.go # WidgetManager alias + layer-ordered iterators
├── ui.theme.go # Theme + ThemeManager ├── ui.theme.go # Theme + ThemeManager
├── ui.theme_presets.go # 4 preset themes ├── ui.theme_presets.go # 4 preset themes
+7 -1
View File
@@ -213,6 +213,7 @@ type sayRunner struct {
elapsed float64 elapsed float64
duration float64 duration float64
started bool started bool
bubble bool
} }
func Say(speaker, text string) Action { return &sayAction{speaker: speaker, text: text} } 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 { if !r.started {
r.started = true r.started = true
// duration scales with text length, with a 1.2s floor // 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.SetSpeech(r.spec.speaker, r.spec.text)
}
ctx.Game.LogResponse(r.spec.speaker, r.spec.text) ctx.Game.LogResponse(r.spec.speaker, r.spec.text)
} }
r.elapsed += ctx.DT r.elapsed += ctx.DT
@@ -233,7 +237,9 @@ func (r *sayRunner) Tick(ctx *Ctx) Status {
r.elapsed = r.duration r.elapsed = r.duration
} }
if r.elapsed >= r.duration { if r.elapsed >= r.duration {
if r.bubble {
ctx.Game.ClearSpeech() ctx.Game.ClearSpeech()
}
return StatusDone return StatusDone
} }
return StatusRunning return StatusRunning
+25
View File
@@ -4,14 +4,39 @@ import "image/color"
type Character struct { 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) Sprite string // Asset.Name (placeholder if missing)
Animations map[string]AnimationClip Animations map[string]AnimationClip
Speed float64 Speed float64
SpeechColor color.Color 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 Start Point
// Size hints used when the sprite is a placeholder rectangle. // Size hints used when the sprite is a placeholder rectangle.
W, H float64 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 } 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" } func (c Character) TypeLabel() string { return "character" }
+82 -35
View File
@@ -1,72 +1,119 @@
package inkwell package inkwell
import ( import (
"image"
"image/color" "image/color"
"unicode/utf8"
"github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil" "github.com/hajimehoshi/ebiten/v2/ebitenutil"
) )
// drawText is a minimal text draw using ebiten's built-in debug font. // The built-in debug font is a fixed 6×16 cell. Every layout measurement
// The glyphs are 6×16-ish; good enough for a SCUMM-style 320×200 demo. // 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) { func drawText(dst *ebiten.Image, s string, x, y int, c color.Color) {
if s == "" { if s == "" {
return return
} }
// ebitenutil.DebugPrintAt only draws white; for color we render to a w, h := TextWidth(s)+GlyphW, GlyphH
// tiny offscreen, then ColorScale-tint when blitting. Simpler: just if textScratch == nil || textScratch.Bounds().Dx() < w || textScratch.Bounds().Dy() < h {
// use the white draw and skip color. (TODO: text/v2 once needed.) nw := w
_ = c if nw < 320 {
ebitenutil.DebugPrintAt(dst, s, x, y) 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. // TextWidth is the pixel width of s in the built-in font.
func textWidth(s string) int { return len(s) * 6 } func TextWidth(s string) int { return utf8.RuneCountInString(s) * GlyphW }
// wrapText splits s at word boundaries into lines no wider than maxPx. // WrapText splits s at word boundaries into lines no wider than maxPx.
func wrapText(s string, maxPx int) []string { // A newline in s breaks the line where it stands.
if maxPx <= 0 || textWidth(s) <= maxPx { func WrapText(s string, maxPx int) []string {
if maxPx <= GlyphW || TextWidth(s) <= maxPx {
return []string{s} return []string{s}
} }
var ( var out []string
out []string line, word := "", ""
line string
)
flush := func() { flush := func() {
if line != "" { if line != "" {
out = append(out, line) out = append(out, line)
line = "" line = ""
} }
} }
word := "" emit := func() {
for _, r := range s { if word == "" {
if r == ' ' || r == '\n' { return
if line == "" { }
switch {
case line == "":
line = word line = word
} else if textWidth(line+" "+word) <= maxPx { case TextWidth(line+" "+word) <= maxPx:
line += " " + word line += " " + word
} else { default:
flush() flush()
line = word line = word
} }
word = "" word = ""
if r == '\n' { }
for _, r := range s {
switch r {
case ' ':
emit()
case '\n':
emit()
flush() flush()
} default:
continue
}
word += string(r) word += string(r)
} }
if word != "" {
if line == "" {
line = word
} else if textWidth(line+" "+word) <= maxPx {
line += " " + word
} else {
flush()
line = word
}
} }
emit()
flush() flush()
if len(out) == 0 {
return []string{s}
}
return out 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 // 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 { func Run(g *Game) error {
// The domain may have swapped a manager in since NewGame, so the parts // The domain may have swapped a manager in since NewGame, so the parts
// that hold a registry directly are wired here, not at construction. // 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") 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.SetWindowTitle(g.Title)
ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled) ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
return ebiten.RunGame(&engine{g: g}) return ebiten.RunGame(&engine{g: g})
+17 -3
View File
@@ -25,6 +25,7 @@ func (e *engine) Update() error {
g.Input.poll() g.Input.poll()
g.transition.update(dt) g.transition.update(dt)
g.pumpQueue()
if g.transition.active && g.transition.out { if g.transition.active && g.transition.out {
return nil return nil
@@ -58,10 +59,14 @@ func (e *engine) Update() error {
// race a cutscene that's about to start. // race a cutscene that's about to start.
g.tickTriggers() g.tickTriggers()
// Top-down input: the widget drawn last (= registered last) gets the // Top-down input: the widget on the highest layer gets the click
// click first, then the next-to-last, etc. A widget signals "I took it" // first, then the one below it, etc. A widget signals "I took it"
// via g.Input.ConsumeLeft / ConsumeRight. // via g.Input.ConsumeLeft / ConsumeRight.
cctx := g.makeCtx()
for _, w := range reversedWidgets(g.WidgetManager) { for _, w := range reversedWidgets(g.WidgetManager) {
if !WidgetVisible(cctx, w) {
continue
}
w.Tick(uictx) w.Tick(uictx)
} }
@@ -135,7 +140,12 @@ func (g *Game) invokeHotspotVerb(h *Hotspot, verb string) {
return 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.FlashLine("Nem ehhez.")
}
g.Inventory.Select("") g.Inventory.Select("")
return return
} }
@@ -190,9 +200,13 @@ func (e *engine) Draw(screen *ebiten.Image) {
drawCharacter(screen, g, c) drawCharacter(screen, g, c)
} }
// widgets in registration order // widgets from the bottom layer up
uictx := &UICtx{Game: g, DT: 1.0 / 60.0} uictx := &UICtx{Game: g, DT: 1.0 / 60.0}
cctx := g.makeCtx()
for _, w := range orderedWidgets(g.WidgetManager) { for _, w := range orderedWidgets(g.WidgetManager) {
if !WidgetVisible(cctx, w) {
continue
}
w.Draw(screen, uictx) w.Draw(screen, uictx)
} }
+80 -7
View File
@@ -35,6 +35,23 @@ type Game struct {
ExitLook func(Exit) Action ExitLook func(Exit) Action
ExitTake 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 State *State
Inventory *Inventory Inventory *Inventory
Audio *AudioPlayer Audio *AudioPlayer
@@ -47,6 +64,7 @@ type Game struct {
activeTheme string activeTheme string
// runtime // runtime
queue []Action
loaded *loadedAssets loaded *loadedAssets
currentScene string currentScene string
previousScene string previousScene string
@@ -275,8 +293,7 @@ func (g *Game) CharacterInScene(name string) bool {
if g.currentScene == "" { if g.currentScene == "" {
return false return false
} }
s := g.SceneManager.MustGet(g.currentScene) for _, a := range g.sceneActors(g.SceneManager.MustGet(g.currentScene)) {
for _, a := range s.Actors {
if a.CharacterName == name { if a.CharacterName == name {
return true return true
} }
@@ -284,6 +301,24 @@ func (g *Game) CharacterInScene(name string) bool {
return false 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 // DrawText is a thin wrapper that lets widgets accept a Theme-supplied color
// today and switch to text/v2 later without changing call sites. // 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) { 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) { if s.Music != "" && !g.AssetManager.Has(s.Music) {
return fmt.Errorf("%w: scene %q music %q", ErrUnknownAsset, name, 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) { if !g.CharacterManager.Has(a.CharacterName) {
return fmt.Errorf("%w: scene %q actor %q", ErrUnknownCharacter, name, 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) 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) { if g.activeTheme == "" || !g.ThemeManager.Has(g.activeTheme) {
return fmt.Errorf("inkwell: no active theme (got %q)", 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) { func (g *Game) placeActors(sceneName string) {
s := g.SceneManager.MustGet(sceneName) s := g.SceneManager.MustGet(sceneName)
for _, a := range s.Actors { for _, a := range g.sceneActors(s) {
def := g.CharacterManager.MustGet(a.CharacterName) def := g.CharacterManager.MustGet(a.CharacterName)
rc, ok := g.chars[def.Name] rc, ok := g.chars[def.Name]
if !ok { if !ok {
@@ -436,6 +490,9 @@ func (g *Game) walkCharacter(name string, to Point) {
if g.currentScene != "" { if g.currentScene != "" {
s := g.SceneManager.MustGet(g.currentScene) s := g.SceneManager.MustGet(g.currentScene)
boxes = s.Walkboxes boxes = s.Walkboxes
if boxes == nil {
boxes = g.Walkboxes
}
} }
path := pathfind(c.pos, to, boxes) path := pathfind(c.pos, to, boxes)
if len(path) == 0 { if len(path) == 0 {
@@ -492,17 +549,33 @@ func (g *Game) tickCharacters(dt float64) {
// ----- script & dialog plumbing ----------------------------------------- // ----- 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) { func (g *Game) queueAction(a Action, label string) {
if a == nil { if a == nil {
return return
} }
if g.scriptRunner != nil { logf("queueAction %s", label)
logf("queueAction: %s ignored, runner busy", 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 return
} }
a := g.queue[0]
g.queue = g.queue[1:]
g.scriptRunner = a.Start() g.scriptRunner = a.Start()
g.scriptCtx = g.makeCtx() g.scriptCtx = g.makeCtx()
logf("queueAction %s", label)
} }
func (g *Game) startDialogue(name string) { func (g *Game) startDialogue(name string) {
+2
View File
@@ -21,6 +21,7 @@ type CharStat struct {
// works across scenes. // works across scenes.
type CharacterPanel struct { type CharacterPanel struct {
Name string Name string
When Condition
Bounds Rectangle Bounds Rectangle
Character string // Character.Name to display Character string // Character.Name to display
Title string // "PLAYER", "NPC", role badge Title string // "PLAYER", "NPC", role badge
@@ -28,6 +29,7 @@ type CharacterPanel struct {
} }
func (c *CharacterPanel) GetName() string { return c.Name } 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) Layer() Layer { return LayerHUD }
func (c *CharacterPanel) Tick(ctx *UICtx) {} 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. // messages scroll up out of view as the buffer fills.
type ChatLog struct { type ChatLog struct {
Name string Name string
When Condition
Bounds Rectangle Bounds Rectangle
LineHeight int LineHeight int
Padding int Padding int
@@ -20,6 +21,7 @@ type ChatLog struct {
} }
func (c *ChatLog) GetName() string { return c.Name } 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) Layer() Layer { return LayerHUD }
func (c *ChatLog) Tick(ctx *UICtx) {} 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 != "" { if m.Kind == LogResponse && m.Speaker != "" {
txt = m.Speaker + ": " + m.Text 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}) 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. // default crosshair. Registered last so it draws on top.
type Cursor struct { type Cursor struct {
Name string Name string
When Condition
} }
func (c *Cursor) GetName() string { return c.Name } 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) Layer() Layer { return LayerCursor }
func (c *Cursor) Tick(ctx *UICtx) {} func (c *Cursor) Tick(ctx *UICtx) {}
+3 -1
View File
@@ -11,12 +11,14 @@ import (
// so nothing underneath reacts. // so nothing underneath reacts.
type DialogBox struct { type DialogBox struct {
Name string Name string
When Condition
Bounds Rectangle Bounds Rectangle
LineHeight int LineHeight int
Padding int Padding int
} }
func (d *DialogBox) GetName() string { return d.Name } 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) Layer() Layer { return LayerDialog }
func (d *DialogBox) Tick(ctx *UICtx) { 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) { if dlg.LineIdx >= 0 && dlg.LineIdx < len(dlg.Node.Lines) {
ln := dlg.Node.Lines[dlg.LineIdx] ln := dlg.Node.Lines[dlg.LineIdx]
g.DrawText(dst, ln.Speaker+":", int(b.X)+pad, int(b.Y)+2, th.DialogSpeaker) 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 { for i, l := range lines {
g.DrawText(dst, l, int(b.X)+pad, int(b.Y)+18+i*lh, th.DialogText) 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. // While active it consumes every input so nothing underneath reacts.
type EndCard struct { type EndCard struct {
Name string Name string
When Condition
} }
func (e *EndCard) GetName() string { return e.Name } 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) Layer() Layer { return LayerCurtain }
func (e *EndCard) Tick(ctx *UICtx) { func (e *EndCard) Tick(ctx *UICtx) {
@@ -34,6 +36,6 @@ func (e *EndCard) Draw(dst *ebiten.Image, ctx *UICtx) {
} }
th := g.Theme() th := g.Theme()
vector.DrawFilledRect(dst, 0, 0, float32(g.Width), float32(g.Height), th.EndCardBG, false) 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) 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. // scene. F1 toggles it at runtime; the default is off.
type HotspotDebug struct { type HotspotDebug struct {
Name string Name string
When Condition
Enabled bool Enabled bool
// ToggleKey, if non-zero, overrides the default F1 toggle. // ToggleKey, if non-zero, overrides the default F1 toggle.
ToggleKey ebiten.Key ToggleKey ebiten.Key
} }
func (h *HotspotDebug) GetName() string { return h.Name } 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) Layer() Layer { return LayerScene }
func (h *HotspotDebug) Tick(ctx *UICtx) { 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). // other verb, it toggles selection (the cursor "picks up" the item).
type InventoryBar struct { type InventoryBar struct {
Name string Name string
When Condition
Origin Point Origin Point
Slots int Slots int
Cols int Cols int
@@ -20,6 +21,7 @@ type InventoryBar struct {
} }
func (b *InventoryBar) GetName() string { return b.Name } 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) Layer() Layer { return LayerHUD }
func (b *InventoryBar) slotRect(idx int) Rectangle { 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. // backdrop for grouping other widgets or as a chat/notebook frame.
type Panel struct { type Panel struct {
Name string Name string
When Condition
Bounds Rectangle Bounds Rectangle
// BG, if nil, falls back to the active Theme.PanelBG. // BG, if nil, falls back to the active Theme.PanelBG.
BG color.Color BG color.Color
@@ -21,6 +22,7 @@ type Panel struct {
} }
func (p *Panel) GetName() string { return p.Name } 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) Layer() Layer { return LayerPanel }
func (p *Panel) Tick(ctx *UICtx) {} 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). // character (or at FallbackY if the speaker has no on-screen position).
type SpeechBubble struct { type SpeechBubble struct {
Name string Name string
When Condition
MaxWidth int MaxWidth int
Padding int Padding int
OffsetY int OffsetY int
@@ -18,6 +19,7 @@ type SpeechBubble struct {
} }
func (s *SpeechBubble) GetName() string { return s.Name } 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) Layer() Layer { return LayerSpeech }
func (s *SpeechBubble) Tick(ctx *UICtx) {} func (s *SpeechBubble) Tick(ctx *UICtx) {}
@@ -35,10 +37,10 @@ func (s *SpeechBubble) Draw(dst *ebiten.Image, ctx *UICtx) {
if pad <= 0 { if pad <= 0 {
pad = 3 pad = 3
} }
lines := wrapText(sp.Text, maxW) lines := WrapText(sp.Text, maxW)
w := 0 w := 0
for _, ln := range lines { for _, ln := range lines {
if t := textWidth(ln); t > w { if t := TextWidth(ln); t > w {
w = t 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). // by FlashLine) or the hover-hint (verb + target under the cursor).
type StatusLine struct { type StatusLine struct {
Name string Name string
When Condition
Y int Y int
Align Align Align Align
// ScreenWidth, if 0, uses Game.Width. // ScreenWidth, if 0, uses Game.Width.
@@ -13,6 +14,7 @@ type StatusLine struct {
} }
func (s *StatusLine) GetName() string { return s.Name } 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) Layer() Layer { return LayerHUD }
func (s *StatusLine) Tick(ctx *UICtx) { func (s *StatusLine) Tick(ctx *UICtx) {
@@ -41,7 +43,7 @@ func (s *StatusLine) Draw(dst *ebiten.Image, ctx *UICtx) {
if text == "" { if text == "" {
return return
} }
tw := textWidth(text) tw := TextWidth(text)
var x int var x int
switch s.Align { switch s.Align {
case AlignLeft: case AlignLeft:
+14 -2
View File
@@ -12,6 +12,7 @@ import (
// string on the right. Empty fields are skipped. // string on the right. Empty fields are skipped.
type TopBar struct { type TopBar struct {
Name string Name string
When Condition
Height int Height int
// LeftText overrides the auto-discovered scene Title/Name. // 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 // 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. // edge (e.g. "Day 2 - 14:32"); empty = nothing on the right.
TimeVar string 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) GetName() string { return t.Name }
func (t *TopBar) VisibleWhen() Condition { return t.When }
func (t *TopBar) Layer() Layer { return LayerHUD } func (t *TopBar) Layer() Layer { return LayerHUD }
func (t *TopBar) Tick(ctx *UICtx) {} func (t *TopBar) Tick(ctx *UICtx) {}
@@ -48,6 +55,11 @@ func (t *TopBar) Draw(dst *ebiten.Image, ctx *UICtx) {
left = s.Name left = s.Name
} }
} }
if t.NoteVar != "" {
if note := fmt.Sprint(g.State.Var(t.NoteVar)); note != "" && note != "<nil>" {
left += " — " + note
}
}
if left != "" { if left != "" {
g.DrawText(dst, left, 4, -1, th.TopBarText) g.DrawText(dst, left, 4, -1, th.TopBarText)
} }
@@ -60,13 +72,13 @@ func (t *TopBar) Draw(dst *ebiten.Image, ctx *UICtx) {
} else { } else {
sc = fmt.Sprintf("Score: %v", v) 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) g.DrawText(dst, sc, x, -1, th.TopBarAccent)
} }
if t.TimeVar != "" { if t.TimeVar != "" {
tm := fmt.Sprint(g.State.Var(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) 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. // VerbManager every frame so newly-registered verbs show up automatically.
type VerbBar struct { type VerbBar struct {
Name string Name string
When Condition
Origin Point Origin Point
Cols int Cols int
Button Size Button Size
@@ -19,6 +20,7 @@ type VerbBar struct {
} }
func (v *VerbBar) GetName() string { return v.Name } 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) Layer() Layer { return LayerHUD }
func (v *VerbBar) buttons(ctx *UICtx) []verbButton { func (v *VerbBar) buttons(ctx *UICtx) []verbButton {
+6 -1
View File
@@ -16,6 +16,7 @@ import (
// Both can coexist if you really want. // Both can coexist if you really want.
type RadialVerbs struct { type RadialVerbs struct {
Name string Name string
When Condition
Trigger MouseButton Trigger MouseButton
Radius float64 Radius float64
@@ -43,6 +44,7 @@ type RadialVerbs struct {
} }
func (r *RadialVerbs) GetName() string { return r.Name } 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) Layer() Layer { return LayerMenu }
func (r *RadialVerbs) Tick(ctx *UICtx) { func (r *RadialVerbs) Tick(ctx *UICtx) {
@@ -146,6 +148,9 @@ func (r *RadialVerbs) blockedAt(ctx *UICtx, p Point) bool {
continue continue
} }
w := ctx.Game.WidgetManager.MustGet(name) w := ctx.Game.WidgetManager.MustGet(name)
if !WidgetVisible(ctx.Game.makeCtx(), w) {
continue
}
// DialogBox advertises its bounds via clickBlocker even when no // DialogBox advertises its bounds via clickBlocker even when no
// dialog is open, so the verb-coin would refuse to pop up over // dialog is open, so the verb-coin would refuse to pop up over
// scenery that happens to sit under the dialog rect. Skip the // 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 ly := r.center.Y + math.Sin(a)*labelDist
label := r.labelFor(g, name) label := r.labelFor(g, name)
tw := textWidth(label) tw := TextWidth(label)
col := th.VerbButtonText col := th.VerbButtonText
isSelected := name == selected isSelected := name == selected
isHover := i == hover isHover := i == hover
+19
View File
@@ -54,6 +54,25 @@ type Layered interface {
Layer() Layer 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 // LayerOf reports the layer a widget paints on. A wrapper widget that
// forwards to an inner one — a visibility gate, say — should return // forwards to an inner one — a visibility gate, say — should return
// LayerOf(inner) so that wrapping does not move the widget. // LayerOf(inner) so that wrapping does not move the widget.