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:
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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) {}
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user