7 Commits
Author SHA1 Message Date
mr.zeroandClaude Opus 5 2429ada090 A widget declares its layer
ci/woodpecker/push/woodpecker Pipeline was successful
Registration order was the only thing deciding what a widget was drawn
over, which forced a domain to keep one ordered list of every widget it
owns — the one shape that cannot be split into a file per widget.

A widget now says where it belongs: Layer, the eight LayerScene..LayerCursor
constants, the optional Layered interface and LayerOf for wrappers. Draw
runs from the bottom layer up, Tick from the top down, and registration
order only breaks ties inside a layer. Every built-in declares its own;
anything that stays quiet sits on LayerHUD, so existing HUDs come out where
they were.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 22:19:17 +02:00
mr.zeroandClaude Opus 5 53f4df0466 The manager that holds widgets is the WidgetManager
ci/woodpecker/push/woodpecker Pipeline was successful
UIManager named the category, not the contents. Every other manager is
named after what it registers — ItemManager holds Items, SceneManager
holds Scenes — so the one that registers Widgets is the WidgetManager.

The alias, the Game field and both manual pages move together.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 22:05:55 +02:00
mr.zeroandClaude Opus 5 e26f7345c1 OnStart takes a script name, and OnFinale joins it
ci/woodpecker/push/woodpecker Pipeline was successful
The opening a game plays was the one piece of content that had to be
assembled in the wiring, because OnStart wanted an Action. It takes the
name of a registered Script now, so an opening is a Script like any
other, and Validate rejects a name that is not there.

OnFinale names the closing script and queues it straight after the start
one — what a "boot into the ending" debug flag wants.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 11:25:49 +02:00
mr.zeroandClaude Opus 5 39af65f3c9 Wire the audio player in Run, not in NewGame
ci/woodpecker/push/woodpecker Pipeline was successful
AudioPlayer keeps the *AssetManager it is handed, so attaching it during
construction quietly pinned it to the manager NewGame happened to make. A
domain that assigns its own registry onto the Game — legal, the fields are
plain *Manager values — got a working image path and a silent audio one.

Run attaches instead, once the Game is final.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 10:36:13 +02:00
mr.zeroandClaude Opus 5 08f15a7e3d Manager.Set and Manager.All
ci/woodpecker/push/woodpecker Pipeline was successful
Set is the deliberate overwrite: it replaces a registered entry in place,
keeping its position in the insertion order, and falls back to Register
when the name is new. Register keeps panicking on duplicates.

All returns every entry in insertion order; orderedWidgets is now just
that call, and reversedWidgets builds on it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 09:50:28 +02:00
mr.zeroandClaude Opus 5 f9745e4266 Scene exits, Back(), and the current/previous scene accessors
ci/woodpecker/push/woodpecker Pipeline was successful
A connection between two scenes had to be hand-built as a hotspot: an area,
a cursor, a GoTo, and a flag check written out again for every door. Scene
now carries Exits, and the engine expands each one into a hotspot.

- Exit{To, Label, Side, Area, Needs, Blocked, OnLook, OnTake} in scene.exit.go.
  With no Area an exit is an edge strip, sized as a fraction of Game.SceneArea()
  so a game whose HUD covers the foot of the window gets strips inside the
  painting. Game.ExitLook / Game.ExitTake supply the look and take responses
  once for the whole game, so the phrasing is not repeated per exit.
- Game.SceneHotspots(name) is the scene's own hotspots followed by its exits,
  cached per scene. Authored hotspots come first, so a painted thing beats the
  edge strip wherever the two overlap. HotspotAt and HotspotDebug read it.
- Game.CurrentScene() / PreviousScene(), both persisted in the save file. The
  engine tracked the current scene but exposed no accessor, so every game had
  to shadow it to know where it was.
- Back() returns to the previous scene — what an exit with an empty To binds
  to, for a scene reachable from several rooms whose way out is a direction
  rather than a place.
- Validate rejects an exit naming a scene nobody registered.

Generated hotspots are named ExitName(To) ("exit:street", "exit:back"), so the
location graph can be read straight back out of the registered scenes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 00:09:20 +02:00
mr.zeroandClaude Opus 5 aa55f1166c Add a CI check
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/manual/woodpecker Pipeline was successful
The repo was registered in Woodpecker but had no pipeline, so nothing
ever ran on it. There is no test suite here, so the check is what can
actually catch a regression in a library: that it still compiles and
loads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 07:07:31 +02:00
26 changed files with 634 additions and 145 deletions
+12
View File
@@ -0,0 +1,12 @@
# A könyvtárnak nincs teszt-suite-ja, ezért a regressziót a fordítás és a
# `go vet` fogja meg. Az ebitengine builder image kell hozzá: az Ebitengine
# Linuxon cgo-t használ, tehát a fordításhoz X11/GL/ALSA fejlécek kellenek.
steps:
- name: test
image: git.teletypegames.org/build/ebitengine-builder:latest
commands:
- go version
- echo "==> go build"
- go build ./...
- echo "==> go vet"
- go vet ./...
+216 -45
View File
@@ -30,14 +30,15 @@ import "git.teletypegames.org/games/inkwell"
6. [Entity reference](#6-entity-reference) 6. [Entity reference](#6-entity-reference)
- 6.1 [Asset](#61-asset) - 6.1 [Asset](#61-asset)
- 6.2 [Scene](#62-scene) - 6.2 [Scene](#62-scene)
- 6.3 [Hotspot](#63-hotspot) - 6.3 [Exit](#63-exit)
- 6.4 [Trigger](#64-trigger) - 6.4 [Hotspot](#64-hotspot)
- 6.5 [Item](#65-item) - 6.5 [Trigger](#65-trigger)
- 6.6 [Inventory](#66-inventory) - 6.6 [Item](#66-item)
- 6.7 [Character](#67-character) - 6.7 [Inventory](#67-inventory)
- 6.8 [Dialogue](#68-dialogue) - 6.8 [Character](#68-character)
- 6.9 [Script](#69-script) - 6.9 [Dialogue](#69-dialogue)
- 6.10 [Verb](#610-verb) - 6.10 [Script](#610-script)
- 6.11 [Verb](#611-verb)
7. [The action system](#7-the-action-system) 7. [The action system](#7-the-action-system)
- 7.1 [Action and Runner](#71-action-and-runner) - 7.1 [Action and Runner](#71-action-and-runner)
- 7.2 [Status and Ctx](#72-status-and-ctx) - 7.2 [Status and Ctx](#72-status-and-ctx)
@@ -179,7 +180,7 @@ type DialogueManager = Manager[Dialogue]
type ScriptManager = Manager[Script] type ScriptManager = Manager[Script]
type AssetManager = Manager[Asset] type AssetManager = Manager[Asset]
type VerbManager = Manager[Verb] type VerbManager = Manager[Verb]
type UIManager = Manager[Widget] type WidgetManager = Manager[Widget]
type ThemeManager = Manager[Theme] type ThemeManager = Manager[Theme]
``` ```
@@ -188,11 +189,13 @@ type ThemeManager = Manager[Theme]
| Method | Behaviour | | Method | Behaviour |
|------------------------|------------------------------------------------------------| |------------------------|------------------------------------------------------------|
| `Register(v T)` | Adds `v` to the registry. Panics on empty or duplicate `Name`. | | `Register(v T)` | Adds `v` to the registry. Panics on empty or duplicate `Name`. |
| `Set(v T)` | Replaces an entry in place, keeping its position; registers it when the name is new. |
| `Get(name) (T, bool)` | Looks up by name. The boolean is `false` if absent. | | `Get(name) (T, bool)` | Looks up by name. The boolean is `false` if absent. |
| `MustGet(name) T` | Same as `Get`, but panics on missing names. | | `MustGet(name) T` | Same as `Get`, but panics on missing names. |
| `Has(name) bool` | True if the name is registered. | | `Has(name) bool` | True if the name is registered. |
| `Len() int` | Number of registered entries. | | `Len() int` | Number of registered entries. |
| `Names() []string` | Returns names in **insertion order** (used for widget Z-order). | | `Names() []string` | Returns names in **insertion order** (used for widget Z-order). |
| `All() []T` | Returns every entry in **insertion order**. |
| `SortedNames() []string` | Returns names alphabetically. | | `SortedNames() []string` | Returns names alphabetically. |
| `Each(fn func(T))` | Iterates in insertion order. | | `Each(fn func(T))` | Iterates in insertion order. |
| `Remove(name string)` | Drops a registration; silent no-op if unknown. | | `Remove(name string)` | Drops a registration; silent no-op if unknown. |
@@ -215,6 +218,11 @@ on startup. Crashing loudly during `Build()` surfaces the problem in
development; quietly accepting the second registration would silently mask development; quietly accepting the second registration would silently mask
shadowed entities at runtime. shadowed entities at runtime.
When a registration genuinely has to be rewritten — a derived field filled in
after the fact, a hot-reloaded entity — `Set` is the deliberate overwrite. It
keeps the entry's place in the insertion order, so widget Z-order and any other
order-sensitive iteration survive the rewrite.
--- ---
## 4. The Game aggregate ## 4. The Game aggregate
@@ -234,9 +242,14 @@ type Game struct {
ScriptManager *ScriptManager ScriptManager *ScriptManager
AssetManager *AssetManager AssetManager *AssetManager
VerbManager *VerbManager VerbManager *VerbManager
UIManager *UIManager WidgetManager *WidgetManager
ThemeManager *ThemeManager ThemeManager *ThemeManager
// exits
SceneRect Rectangle // the part of the window the picture occupies
ExitLook func(Exit) Action // default look response for generated exits
ExitTake func(Exit) Action // default take response for generated exits
// runtime services // runtime services
State *State State *State
Inventory *Inventory Inventory *Inventory
@@ -260,18 +273,52 @@ Initialises every manager (empty), registers the default SCUMM verb set
(`look`, `use`, `talk`, `take`), installs all four preset themes, and (`look`, `use`, `talk`, `take`), installs all four preset themes, and
selects `classic-scumm` as the active theme. Widgets are **not** selects `classic-scumm` as the active theme. Widgets are **not**
auto-registered — call `RegisterDefaultUI(g)` (or one of its siblings) auto-registered — call `RegisterDefaultUI(g)` (or one of its siblings)
explicitly, or let `Run` install the default set if `UIManager` is empty. explicitly, or let `Run` install the default set if `WidgetManager` is empty.
The manager fields are ordinary `*Manager` values, so a domain that keeps its
own registries can **assign them onto the Game** instead of copying every entry
across:
```go
g := inkwell.NewGame("Real World", 640, 380)
g.SceneManager = mySceneManager
g.AssetManager = myAssetManager
```
Do it before `Run` — that is where the engine wires up the parts that hold a
registry directly, so whichever managers the `*Game` carries by then are the
ones that get used. Two of them are worth a second thought before replacing:
`ThemeManager` arrives holding the four presets and `classic-scumm` selected,
and `VerbManager` the four SCUMM verbs. Replacing either throws that away —
register into them instead unless that is what you want.
### 4.2 Lifecycle hooks ### 4.2 Lifecycle hooks
```go ```go
func (g *Game) StartAt(name string) *Game // entry scene func (g *Game) StartAt(name string) *Game // entry scene
func (g *Game) OnStart(a Action) *Game // action run after the scene's OnEnter func (g *Game) OnStart(script string) *Game // script run after the scene's OnEnter
func (g *Game) OnFinale(script string) *Game // closing script, queued after OnStart
func (g *Game) Validate() error // cross-check name references func (g *Game) Validate() error // cross-check name references
func (g *Game) Run() error // same as inkwell.Run(g) func (g *Game) Run() error // same as inkwell.Run(g)
``` ```
`StartAt` and `OnStart` return `*Game` so they chain at the end of `Build`. `OnStart` and `OnFinale` name registered scripts rather than taking an action,
so the opening a game plays is content like any other — a `Script` in the
`ScriptManager`, reachable by name and editable without touching the wiring.
`Validate` rejects a name that is not registered.
`OnFinale` is queued directly after the start script, which is what a "boot
straight into the ending" debug flag wants:
```go
g.StartAt(start)
g.OnStart(ScriptOpening)
if finale {
g.OnFinale(ScriptFinale)
}
```
All three return `*Game` so they chain at the end of `Build`.
### 4.3 Theme accessors ### 4.3 Theme accessors
@@ -330,11 +377,28 @@ func (g *Game) Messages() []LogMessage
### 4.6 Scene helpers ### 4.6 Scene helpers
```go ```go
func (g *Game) CurrentScene() string
func (g *Game) PreviousScene() string
func (g *Game) SceneArea() Rectangle
func (g *Game) SceneHotspots(name string) []Hotspot
func (g *Game) HotspotAt(p Point) *Hotspot
func (g *Game) CharacterInScene(name string) bool func (g *Game) CharacterInScene(name string) bool
``` ```
Used by `CharacterPanel` to auto-hide when its character isn't an actor in `CurrentScene` is where the player is, empty before the first scene is
the current scene. entered; `PreviousScene` is the one before it, which is where
[`Back()`](#73-built-in-actions) leads. Both survive a save.
`SceneArea` is `SceneRect`, or the whole window when it was never set. It is
what [exit strips](#63-exit) are measured against.
`SceneHotspots` is everything clickable in a scene: its own `Hotspots` first,
then one per `Exit`. Authored hotspots come first because the engine takes the
first area that contains the click, so a painted thing beats the edge strip an
exit sits on wherever the two overlap. The expansion is cached per scene.
`CharacterInScene` is used by `CharacterPanel` to auto-hide when its character
isn't an actor in the current scene.
### 4.7 Text drawing hook ### 4.7 Text drawing hook
@@ -428,6 +492,7 @@ type Scene struct {
Background string // Asset.Name Background string // Asset.Name
Music string // Asset.Name (optional) Music string // Asset.Name (optional)
Hotspots []Hotspot Hotspots []Hotspot
Exits []Exit // connections to other scenes
Walkboxes []Polygon Walkboxes []Polygon
Triggers []Trigger Triggers []Trigger
Actors []SceneActor Actors []SceneActor
@@ -450,12 +515,84 @@ 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
the nearest boundary. With no walkboxes the character walks in a the nearest boundary. With no walkboxes the character walks in a
straight line — see [§6.7](#67-character) and [§15.4](#154-character-movement). straight line — see [§6.8](#68-character) and [§15.4](#154-character-movement).
`Triggers` fire on the rising edge of their `When` condition. The `Triggers` fire on the rising edge of their `When` condition. The
engine samples each trigger once per idle frame; see [§6.4](#64-trigger). engine samples each trigger once per idle frame; see [§6.5](#65-trigger).
### 6.3 Hotspot `Exits` are the scene's connections to other scenes, declared as data. The
engine turns each one into a hotspot — see [§6.3](#63-exit).
### 6.3 Exit
```go
// inkwell/scene.exit.go
type Exit struct {
To string // target scene; empty means "back the way you came"
Label string // what the status line calls it
Side ExitSide // where it sits when Area is nil
Area Shape // overrides the edge strip Side would give it
Needs string // flag that has to be set before it opens
Blocked Action // what happens while it is not
OnLook Action // overrides Game.ExitLook for this exit
OnTake Action // overrides Game.ExitTake for this exit
}
type ExitSide int
const (
ExitLeft ExitSide = iota // off the left edge
ExitRight // off the right edge
ExitBack // into the depth of the picture
ExitNear // out towards the camera
)
func ExitName(to string) string
```
A connection belongs to the scene it leads out of, so it is written in that
scene's own literal rather than in a map kept somewhere else:
```go
Scene{
Name: "alley",
Exits: []Exit{
{To: "noodle_house", Label: "back out to the street", Side: ExitLeft},
{To: "", Label: "the fire escape", Side: ExitBack,
Needs: "has_ladder", Blocked: Say("paul", "Can't reach it.")},
},
}
```
The engine expands each exit into a `Hotspot` with `Cursor: CursorExit`, the
exit's `Label`, `OnUse` bound to `GoTo(To)` — or [`Back()`](#73-built-in-actions)
when `To` is empty — and, when `Needs` is set, the whole travel wrapped in
`If(Flag(Needs), travel, Blocked)`. The exit is visible either way: a locked
door still says it is a door.
`OnLook` and `OnTake` are usually not written per exit. `Game.ExitLook` and
`Game.ExitTake` supply them for every exit in the game, so the phrasing lives
in one place:
```go
g.ExitLook = func(e Exit) Action { return Say("paul", "That way: "+e.Label+".") }
g.ExitTake = func(e Exit) Action { return Say("paul", "It's a way out, not a thing.") }
```
**Placement.** With no `Area`, an exit is a strip along one edge of the
picture, sized as a fraction of [`Game.SceneArea()`](#46-scene-helpers) — the
convention every 1990s point & click used, and one field to re-aim at a real
door once the artwork is measured. A game whose HUD covers the foot of the
window sets `Game.SceneRect`, so the strips land inside the painting instead of
under the HUD.
**The graph stays readable.** The generated hotspot is named
`ExitName(To)``"exit:noodle_house"`, or `"exit:back"` — so the location
graph can be read straight back out of the registered scenes, and
[`Validate`](#17-validation) rejects an exit that names a scene nobody
registered.
### 6.4 Hotspot
```go ```go
// inkwell/scene.hotspot.go // inkwell/scene.hotspot.go
@@ -497,7 +634,7 @@ resolves a click via `hotspot.handler(verbName)` which maps the four
built-in verbs to their `On*` fields and falls back to `OnVerb[verbName]` built-in verbs to their `On*` fields and falls back to `OnVerb[verbName]`
for custom verbs. for custom verbs.
### 6.4 Trigger ### 6.5 Trigger
```go ```go
// inkwell/scene.trigger.go // inkwell/scene.trigger.go
@@ -522,7 +659,7 @@ frame — the edge is preserved across the busy window. Save/Load resets
trigger state to "freshly armed", matching the behaviour of scene trigger state to "freshly armed", matching the behaviour of scene
re-entry. re-entry.
### 6.5 Item ### 6.6 Item
```go ```go
// inkwell/item.def.go // inkwell/item.def.go
@@ -545,7 +682,7 @@ hotspot with an item selected resolves the action via, in order:
2. `item.OnUseWith[hotspot.Name]` 2. `item.OnUseWith[hotspot.Name]`
3. Otherwise the engine flashes "Nem ehhez." and deselects. 3. Otherwise the engine flashes "Nem ehhez." and deselects.
### 6.6 Inventory ### 6.7 Inventory
```go ```go
// inkwell/item.inventory.go // inkwell/item.inventory.go
@@ -564,7 +701,7 @@ func (i *Inventory) Items() []string // copy
Not a manager — pure runtime state owned by `Game`. Mutated by actions Not a manager — pure runtime state owned by `Game`. Mutated by actions
(`Give`, `TakeAway`) and by the `InventoryBar` widget on click. (`Give`, `TakeAway`) and by the `InventoryBar` widget on click.
### 6.7 Character ### 6.8 Character
```go ```go
// inkwell/actor.def.go // inkwell/actor.def.go
@@ -605,7 +742,7 @@ Movement is driven by the `Walk` action and `Game.tickCharacters` —
stepping at `Speed` pixels/sec (default 60) along the waypoint list stepping at `Speed` pixels/sec (default 60) along the waypoint list
computed by [walkbox routing](#62-scene). computed by [walkbox routing](#62-scene).
### 6.8 Dialogue ### 6.9 Dialogue
```go ```go
// inkwell/dialog.def.go // inkwell/dialog.def.go
@@ -647,7 +784,7 @@ The dialog flow:
5. `EndDialogue()` closes the conversation; `GotoNode("other")` jumps to 5. `EndDialogue()` closes the conversation; `GotoNode("other")` jumps to
another node in the same dialogue. another node in the same dialogue.
### 6.9 Script ### 6.10 Script
```go ```go
// inkwell/action.script.go // inkwell/action.script.go
@@ -662,7 +799,7 @@ A `Script` is just a named composite action — useful when you want to
reuse a cutscene (intro, victory, transition) from multiple call sites. reuse a cutscene (intro, victory, transition) from multiple call sites.
Fire one with `RunScript("name")`. Fire one with `RunScript("name")`.
### 6.10 Verb ### 6.11 Verb
```go ```go
// inkwell/ui.verb.go // inkwell/ui.verb.go
@@ -749,6 +886,7 @@ type Ctx struct {
| `Wait(seconds float64) Action` | Block the runner for `seconds`. | | `Wait(seconds float64) Action` | Block the runner for `seconds`. |
| `Say(speaker, text string) Action` | Show the line above the speaker (`SpeechBubble`), append to chat-log. Click-to-skip. Duration scales with text length, 1.2s floor. | | `Say(speaker, text string) Action` | Show the line above the speaker (`SpeechBubble`), append to chat-log. Click-to-skip. Duration scales with text length, 1.2s floor. |
| `GoTo(scene string) Action` | Switch the current scene via a fade transition. | | `GoTo(scene string) Action` | Switch the current scene via a fade transition. |
| `Back() Action` | Return to `PreviousScene()`. No-op when there is nowhere to go back to. |
| `Walk(character string, to Point) Action`| Move a character to `to` at `Character.Speed`. Returns when arrived. | | `Walk(character string, to Point) Action`| Move a character to `to` at `Character.Speed`. Returns when arrived. |
| `Give(item string) Action` | Add `item` to inventory. | | `Give(item string) Action` | Add `item` to inventory. |
| `TakeAway(item string) Action` | Remove `item` from inventory. | | `TakeAway(item string) Action` | Remove `item` from inventory. |
@@ -864,7 +1002,7 @@ could be used by triggers once those land.
## 10. The widget system ## 10. The widget system
The HUD is a tree of `Widget`s, each registered into `g.UIManager`. The The HUD is a tree of `Widget`s, each registered into `g.WidgetManager`. The
library ships built-in widgets for every classic adventure UI piece; library ships built-in widgets for every classic adventure UI piece;
domain code can register arbitrary new ones — chat panels, minimaps, domain code can register arbitrary new ones — chat panels, minimaps,
hotbars — without touching the library. hotbars — without touching the library.
@@ -880,6 +1018,24 @@ type Widget interface {
Draw(dst *ebiten.Image, ctx *UICtx) Draw(dst *ebiten.Image, ctx *UICtx)
} }
type Layer int
const (
LayerScene Layer = 0 // over the picture, under the HUD
LayerPanel Layer = 100 // the plate the HUD sits on
LayerHUD Layer = 200 // verbs, inventory, status line, top bar
LayerSpeech Layer = 300 // speech bubbles
LayerDialog Layer = 400 // the dialogue box
LayerMenu Layer = 500 // radial verbs, menus
LayerCurtain Layer = 600 // end cards, fades
LayerCursor Layer = 700 // the pointer
)
type Layered interface {
Layer() Layer
}
func LayerOf(w Widget) Layer // Layered, else LayerHUD
type UICtx struct { type UICtx struct {
Game *Game Game *Game
DT float64 DT float64
@@ -907,13 +1063,23 @@ their own `Bounds` (or compute them dynamically, like `RadialVerbs`).
### 10.2 Z-order and input consumption ### 10.2 Z-order and input consumption
- **`Tick` runs in reverse registration order.** Widgets registered later - **`Draw` runs from the bottom layer up** — a widget on a higher layer is
(drawn on top) get the click first. Each widget calls painted on top. Inside one layer, registration order decides.
`ctx.Game.Input.ConsumeLeft()` / `ConsumeRight()` to claim the event; - **`Tick` runs from the top layer down.** The widget drawn on top gets the
later widgets see `LeftClicked() == false`. click first. Each widget calls `ctx.Game.Input.ConsumeLeft()` /
- **`Draw` runs in registration order** — registered last → painted on top. `ConsumeRight()` to claim the event; widgets below see
- The `Cursor` widget is registered last by convention so it always wins `LeftClicked() == false`.
on visual layer (and effectively never claims clicks). - **A widget declares its layer**, it does not inherit one from the order it
was registered in. Every built-in implements `Layered`; a widget that does
not sits on `LayerHUD`. `Cursor` is on `LayerCursor`, so it always wins on
visual layer (and effectively never claims clicks) no matter when it was
registered.
- Layers are what let a domain register its widgets **one file at a time**
an `init()` per widget, in whatever order the file names happen to fall —
without the HUD coming out shuffled.
- A wrapper widget that forwards to an inner one — a visibility gate, say —
should return `LayerOf(inner)` from its own `Layer`, so that wrapping does
not move the widget.
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
@@ -1214,7 +1380,7 @@ func (m *Minimap) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
// ... render scene thumbnail, mark NPCs, etc. // ... render scene thumbnail, mark NPCs, etc.
} }
g.UIManager.Register(&Minimap{ g.WidgetManager.Register(&Minimap{
Name: "minimap", Name: "minimap",
Bounds: inkwell.Rect(220, 4, 96, 56), Bounds: inkwell.Rect(220, 4, 96, 56),
}) })
@@ -1413,13 +1579,15 @@ func Run(g *Game) error // toplevel — same as g.Run()
`Run` does, in order: `Run` does, in order:
1. `g.Validate()` — cross-check name references between managers. 1. `g.Audio.attach(g.AssetManager)` — wire the audio player to whichever
2. If `UIManager` is empty, call `RegisterDefaultUI(g)`. asset registry the game is carrying by now.
3. Place the start scene directly (no transition), bump 2. `g.Validate()` — cross-check name references between managers.
3. If `WidgetManager` is empty, call `RegisterDefaultUI(g)`.
4. Place the start scene directly (no transition), bump
`State.NoteVisit`, position registered actors, kick off music. `State.NoteVisit`, position registered actors, kick off music.
4. Compose `Seq(scene.OnEnter, game.OnStart)` and queue it as the initial 5. Compose `Seq(scene.OnEnter, OnStart script, OnFinale script)` and queue
action — the first script tick runs both in order. it as the initial action — the first script tick runs them in order.
5. `ebiten.SetWindowSize(Width*4, Height*4)`, 6. `ebiten.SetWindowSize(Width*4, Height*4)`,
`ebiten.SetWindowTitle(g.Title)`, then `ebiten.RunGame(&engine{g})`. `ebiten.SetWindowTitle(g.Title)`, then `ebiten.RunGame(&engine{g})`.
### 15.1 `engine.Update` ### 15.1 `engine.Update`
@@ -1435,7 +1603,7 @@ if scriptRunner != nil:
return return
clear hoverLabel clear hoverLabel
for w in reversed(UIManager): for w in WidgetManager, top layer down:
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
@@ -1449,7 +1617,7 @@ fill Theme.SceneBackdrop (if any)
draw scene background image 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 UIManager (registration order): for w in WidgetManager (by layer, then registration order):
w.Draw(screen, uictx) w.Draw(screen, uictx)
draw transition overlay draw transition overlay
``` ```
@@ -1530,6 +1698,8 @@ It cross-checks:
`Asset`. `Asset`.
- Optional `Scene.Music` (if non-empty) references a registered `Asset`. - Optional `Scene.Music` (if non-empty) references a registered `Asset`.
- Every `SceneActor.CharacterName` is a registered character. - Every `SceneActor.CharacterName` is a registered character.
- Every `Scene.Exit.To` names a registered scene (empty is allowed — it
means "back the way you came").
- An active theme is selected and registered. - An active theme is selected and registered.
Returns the first error wrapping one of the `Err...` sentinels (so callers Returns the first error wrapping one of the `Err...` sentinels (so callers
@@ -1551,7 +1721,7 @@ Slots are written as JSON files under `g.SaveDir` (default `saves/`,
relative to the working directory), one file per slot named relative to the working directory), one file per slot named
`slot<N>.json`. The save captures the **mutable runtime state**: `slot<N>.json`. The save captures the **mutable runtime state**:
- `currentScene`, the active verb, and the active theme. - `currentScene` and `previousScene`, the active verb, and the active theme.
- Every character's position, target, and moving flag. - Every character's position, target, and moving flag.
- The full `State`: flags, vars, visited and talked counters. - The full `State`: flags, vars, visited and talked counters.
- The inventory item list plus the currently selected slot. - The inventory item list plus the currently selected slot.
@@ -1675,6 +1845,7 @@ inkwell/ # module git.teletypegames.org/games/inkwell
├── scene.def.go # Scene, SceneActor ├── scene.def.go # Scene, SceneActor
├── scene.manager.go # SceneManager alias ├── scene.manager.go # SceneManager alias
├── scene.hotspot.go # Hotspot, CursorKind ├── scene.hotspot.go # Hotspot, CursorKind
├── scene.exit.go # Exit, ExitSide + edge-strip geometry
├── scene.trigger.go # Trigger + rising-edge engine sweep ├── scene.trigger.go # Trigger + rising-edge engine sweep
├── scene.path.go # walkbox routing (BFS over polygon adjacency) ├── scene.path.go # walkbox routing (BFS over polygon adjacency)
├── scene.transition.go # fade-to-black overlay (internal) ├── scene.transition.go # fade-to-black overlay (internal)
@@ -1701,8 +1872,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, UICtx, Size, Align ├── ui.widget.go # Widget interface, Layer, UICtx, Size, Align
├── ui.manager.go # UIManager alias + reversed/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
├── ui.defaults.go # RegisterDefaultUI/RadialVerbUI/RichUI ├── ui.defaults.go # RegisterDefaultUI/RadialVerbUI/RichUI
+18
View File
@@ -250,6 +250,24 @@ func (a *gotoAction) Tick(ctx *Ctx) Status {
return StatusDone return StatusDone
} }
// ----- Back -------------------------------------------------------------
type backAction struct{}
// Back returns to the scene the player came from. Most exits name their
// destination, because a door leads where it leads; a scene reached from
// several different rooms cannot, so its way out is a direction, not a place.
// A no-op when there is nowhere to go back to.
func Back() Action { return backAction{} }
func (a backAction) Start() Runner { return a }
func (a backAction) Tick(ctx *Ctx) Status {
if prev := ctx.Game.PreviousScene(); prev != "" {
ctx.Game.changeScene(prev)
}
return StatusDone
}
// ----- inventory -------------------------------------------------------- // ----- inventory --------------------------------------------------------
type giveAction struct{ item string } type giveAction struct{ item string }
+10 -3
View File
@@ -7,12 +7,16 @@ 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 4× the internal resolution.
func Run(g *Game) error { func Run(g *Game) error {
// The domain may have swapped a manager in since NewGame, so the parts
// that hold a registry directly are wired here, not at construction.
g.Audio.attach(g.AssetManager)
if err := g.Validate(); err != nil { if err := g.Validate(); err != nil {
return err return err
} }
// If the domain didn't register any widgets, fall back to the SCUMM // If the domain didn't register any widgets, fall back to the SCUMM
// preset so the game is still playable. // preset so the game is still playable.
if g.UIManager.Len() == 0 { if g.WidgetManager.Len() == 0 {
RegisterDefaultUI(g) RegisterDefaultUI(g)
} }
@@ -30,8 +34,11 @@ func Run(g *Game) error {
if s.OnEnter != nil { if s.OnEnter != nil {
seq = append(seq, s.OnEnter) seq = append(seq, s.OnEnter)
} }
if g.onStart != nil { if g.onStart != "" {
seq = append(seq, g.onStart) seq = append(seq, RunScript(g.onStart))
}
if g.onFinale != "" {
seq = append(seq, RunScript(g.onFinale))
} }
if len(seq) > 0 { if len(seq) > 0 {
g.queueAction(Seq(seq...), "init") g.queueAction(Seq(seq...), "init")
+2 -2
View File
@@ -61,7 +61,7 @@ func (e *engine) Update() error {
// Top-down input: the widget drawn last (= registered last) gets the // Top-down input: the widget drawn last (= registered last) gets the
// click first, then the next-to-last, etc. A widget signals "I took it" // click first, then the next-to-last, etc. A widget signals "I took it"
// via g.Input.ConsumeLeft / ConsumeRight. // via g.Input.ConsumeLeft / ConsumeRight.
for _, w := range reversedWidgets(g.UIManager) { for _, w := range reversedWidgets(g.WidgetManager) {
w.Tick(uictx) w.Tick(uictx)
} }
@@ -192,7 +192,7 @@ func (e *engine) Draw(screen *ebiten.Image) {
// widgets in registration order // widgets in registration order
uictx := &UICtx{Game: g, DT: 1.0 / 60.0} uictx := &UICtx{Game: g, DT: 1.0 / 60.0}
for _, w := range orderedWidgets(g.UIManager) { for _, w := range orderedWidgets(g.WidgetManager) {
w.Draw(screen, uictx) w.Draw(screen, uictx)
} }
+85 -8
View File
@@ -21,9 +21,20 @@ type Game struct {
ScriptManager *ScriptManager ScriptManager *ScriptManager
AssetManager *AssetManager AssetManager *AssetManager
VerbManager *VerbManager VerbManager *VerbManager
UIManager *UIManager WidgetManager *WidgetManager
ThemeManager *ThemeManager ThemeManager *ThemeManager
// SceneRect is the part of the window the picture occupies. Zero means
// the whole window. A game with a HUD along the foot sets it, so that
// generated exit strips (see scene.exit.go) land inside the painting.
SceneRect Rectangle
// ExitLook and ExitTake supply the default look and take responses for
// every hotspot generated from Scene.Exits. Nil means no response.
// An Exit can override either one.
ExitLook func(Exit) Action
ExitTake func(Exit) Action
State *State State *State
Inventory *Inventory Inventory *Inventory
Audio *AudioPlayer Audio *AudioPlayer
@@ -31,12 +42,15 @@ type Game struct {
Input *Input Input *Input
startID string startID string
onStart Action onStart string
onFinale string
activeTheme string activeTheme string
// runtime // runtime
loaded *loadedAssets loaded *loadedAssets
currentScene string currentScene string
previousScene string
exitHotspots map[string][]Hotspot
chars map[string]*runtimeChar chars map[string]*runtimeChar
scriptRunner Runner scriptRunner Runner
scriptCtx *Ctx scriptCtx *Ctx
@@ -101,6 +115,10 @@ type runtimeDialog struct {
// NewGame initializes a game with empty entity managers, the SCUMM-style // NewGame initializes a game with empty entity managers, the SCUMM-style
// verb set, all preset themes, and "classic-scumm" selected. Widgets are // verb set, all preset themes, and "classic-scumm" selected. Widgets are
// NOT auto-registered — call RegisterDefaultUI(g) explicitly. // NOT auto-registered — call RegisterDefaultUI(g) explicitly.
//
// A domain is free to replace any of the entity managers with one of its
// own before Run — the registries are ordinary *Manager values, and Run
// wires the engine to whichever ones the Game holds by then.
func NewGame(title string, w, h int) *Game { func NewGame(title string, w, h int) *Game {
g := &Game{ g := &Game{
Title: title, Title: title,
@@ -113,7 +131,7 @@ func NewGame(title string, w, h int) *Game {
ScriptManager: NewManager[Script](), ScriptManager: NewManager[Script](),
AssetManager: NewManager[Asset](), AssetManager: NewManager[Asset](),
VerbManager: NewManager[Verb](), VerbManager: NewManager[Verb](),
UIManager: NewManager[Widget](), WidgetManager: NewManager[Widget](),
ThemeManager: NewManager[Theme](), ThemeManager: NewManager[Theme](),
State: NewState(), State: NewState(),
Inventory: NewInventory(), Inventory: NewInventory(),
@@ -125,7 +143,6 @@ func NewGame(title string, w, h int) *Game {
transition: &transition{}, transition: &transition{},
selectedVerb: "look", selectedVerb: "look",
} }
g.Audio.attach(g.AssetManager)
for _, v := range defaultVerbs() { for _, v := range defaultVerbs() {
g.VerbManager.Register(v) g.VerbManager.Register(v)
} }
@@ -135,7 +152,54 @@ func NewGame(title string, w, h int) *Game {
} }
func (g *Game) StartAt(name string) *Game { g.startID = name; return g } func (g *Game) StartAt(name string) *Game { g.startID = name; return g }
func (g *Game) OnStart(a Action) *Game { g.onStart = a; return g }
// CurrentScene is the scene the player is in, empty before the first one is
// entered. PreviousScene is the one before it, which is where Back() leads.
func (g *Game) CurrentScene() string { return g.currentScene }
func (g *Game) PreviousScene() string { return g.previousScene }
// SceneArea is SceneRect, or the whole window when it was never set.
func (g *Game) SceneArea() Rectangle {
if g.SceneRect.W > 0 && g.SceneRect.H > 0 {
return g.SceneRect
}
return Rect(0, 0, float64(g.Width), float64(g.Height))
}
// SceneHotspots is everything clickable in a scene: its own hotspots first,
// then one per Exit. Authored hotspots come first because the engine takes the
// first area that contains the click, so a painted thing beats the edge strip
// an exit sits on wherever the two overlap.
//
// The expansion is cached, so the pointers HotspotAt hands out stay valid.
func (g *Game) SceneHotspots(name string) []Hotspot {
if hs, ok := g.exitHotspots[name]; ok {
return hs
}
s, ok := g.SceneManager.Get(name)
if !ok {
return nil
}
hs := make([]Hotspot, 0, len(s.Hotspots)+len(s.Exits))
hs = append(hs, s.Hotspots...)
for _, e := range s.Exits {
hs = append(hs, e.hotspot(g))
}
if g.exitHotspots == nil {
g.exitHotspots = make(map[string][]Hotspot)
}
g.exitHotspots[name] = hs
return hs
}
// OnStart names the script queued after the start scene's OnEnter, once,
// when the game boots.
func (g *Game) OnStart(script string) *Game { g.onStart = script; return g }
// OnFinale names the game's closing script, queued directly after the start
// script — which is what a "boot straight into the ending" debug flag wants.
// Leave it unset for an ordinary run.
func (g *Game) OnFinale(script string) *Game { g.onFinale = script; return g }
// ----- theme + UI conveniences ------------------------------------------ // ----- theme + UI conveniences ------------------------------------------
@@ -194,9 +258,9 @@ func (g *Game) HotspotAt(p Point) *Hotspot {
if g.currentScene == "" { if g.currentScene == "" {
return nil return nil
} }
s := g.SceneManager.MustGet(g.currentScene) hs := g.SceneHotspots(g.currentScene)
for i := range s.Hotspots { for i := range hs {
h := &s.Hotspots[i] h := &hs[i]
if h.Area != nil && h.Area.Contains(p) { if h.Area != nil && h.Area.Contains(p) {
return h return h
} }
@@ -266,6 +330,16 @@ func (g *Game) Validate() error {
return fmt.Errorf("%w: scene %q actor %q", ErrUnknownCharacter, name, a.CharacterName) return fmt.Errorf("%w: scene %q actor %q", ErrUnknownCharacter, name, a.CharacterName)
} }
} }
for _, e := range s.Exits {
if e.To != "" && !g.SceneManager.Has(e.To) {
return fmt.Errorf("%w: scene %q exit to %q", ErrUnknownScene, name, e.To)
}
}
}
for _, name := range []string{g.onStart, g.onFinale} {
if name != "" && !g.ScriptManager.Has(name) {
return fmt.Errorf("%w: %q", ErrUnknownScript, name)
}
} }
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)
@@ -309,6 +383,9 @@ func (g *Game) changeScene(name string) {
} }
prev := g.currentScene prev := g.currentScene
g.transition.start(func() { g.transition.start(func() {
if prev != "" && prev != name {
g.previousScene = prev
}
if prev != "" { if prev != "" {
old := g.SceneManager.MustGet(prev) old := g.SceneManager.MustGet(prev)
if old.OnLeave != nil { if old.OnLeave != nil {
+21
View File
@@ -33,6 +33,18 @@ func (m *Manager[T]) Register(v T) {
m.order = append(m.order, name) m.order = append(m.order, name)
} }
// Set replaces a registered entry in place, keeping its position in the
// registration order, and registers the entry when the name is new. Register
// panics on a duplicate; Set is the deliberate overwrite.
func (m *Manager[T]) Set(v T) {
name := v.GetName()
if _, ok := m.items[name]; !ok {
m.Register(v)
return
}
m.items[name] = v
}
func (m *Manager[T]) Get(name string) (T, bool) { func (m *Manager[T]) Get(name string) (T, bool) {
v, ok := m.items[name] v, ok := m.items[name]
return v, ok return v, ok
@@ -59,6 +71,15 @@ func (m *Manager[T]) Names() []string {
return append([]string(nil), m.order...) return append([]string(nil), m.order...)
} }
// All returns every registered entry in insertion order.
func (m *Manager[T]) All() []T {
out := make([]T, len(m.order))
for i, n := range m.order {
out[i] = m.items[n]
}
return out
}
func (m *Manager[T]) SortedNames() []string { func (m *Manager[T]) SortedNames() []string {
names := append([]string(nil), m.order...) names := append([]string(nil), m.order...)
sort.Strings(names) sort.Strings(names)
+1
View File
@@ -6,6 +6,7 @@ type Scene struct {
Background string // Asset.Name Background string // Asset.Name
Music string // Asset.Name (optional) Music string // Asset.Name (optional)
Hotspots []Hotspot Hotspots []Hotspot
Exits []Exit // connections to other scenes; see scene.exit.go
Walkboxes []Polygon Walkboxes []Polygon
Triggers []Trigger Triggers []Trigger
Actors []SceneActor Actors []SceneActor
+121
View File
@@ -0,0 +1,121 @@
package inkwell
// ExitSide is where on the picture an exit sits when it has no Area of its
// own: off one edge, into the depth of the picture, or out towards the
// camera. Until the doors are measured on the artwork, an edge strip is the
// convention every 1990s point & click used, and re-aiming an exit at a real
// door later is a one-field change.
type ExitSide int
const (
ExitLeft ExitSide = iota // off the left edge
ExitRight // off the right edge
ExitBack // into the depth of the picture
ExitNear // out towards the camera
)
// Exit is one way out of a scene. The engine turns it into a Hotspot, so a
// connection can be declared as data — where it leads, what the status line
// calls it, and what has to be true before it opens — instead of being spelled
// out as another hand-built hotspot.
//
// A connection stays machine-readable: the generated hotspot is named
// ExitName(To), so the location graph can be read straight back out of the
// registered scenes.
type Exit struct {
// To is the scene the exit leads to. Empty means "back the way you came" —
// for a scene reachable from several others, where the way out is a
// direction rather than a place.
To string
// Label is what the status line calls it.
Label string
// Side places the exit when Area is nil.
Side ExitSide
// Area overrides the edge strip Side would give it.
Area Shape
// Needs is a flag that has to be set before the exit opens; Blocked is
// what happens while it is not. The exit is visible either way — a locked
// door still says it is a door.
Needs string
Blocked Action
// OnLook and OnTake override Game.ExitLook and Game.ExitTake for this one
// exit.
OnLook Action
OnTake Action
}
// ExitName is the hotspot name generated for an exit to the named scene.
func ExitName(to string) string {
if to == "" {
return "exit:back"
}
return "exit:" + to
}
func (e Exit) hotspot(g *Game) Hotspot {
var travel Action = GoTo(e.To)
if e.To == "" {
travel = Back()
}
if e.Needs != "" {
if e.Blocked != nil {
travel = If(Flag(e.Needs), travel, e.Blocked)
} else {
travel = If(Flag(e.Needs), travel)
}
}
area := e.Area
if area == nil {
area = e.Side.area(g.SceneArea())
}
look, take := e.OnLook, e.OnTake
if look == nil && g.ExitLook != nil {
look = g.ExitLook(e)
}
if take == nil && g.ExitTake != nil {
take = g.ExitTake(e)
}
return Hotspot{
Name: ExitName(e.To),
Label: e.Label,
Area: area,
Cursor: CursorExit,
OnLook: look,
OnUse: travel,
OnTake: take,
}
}
// The edge strips, as fractions of the scene area, so a game that hands over
// only part of the window to the picture (a HUD along the foot, letterbox
// bars) gets strips that sit inside the picture rather than under the HUD.
const (
exitEdgeWidth = 0.0875
exitEdgeInset = 0.080
exitEdgeHeight = 0.876
exitDoorWidth = 0.275
exitBackInset = 0.033
exitBackHeight = 0.347
exitNearHeight = 0.198
)
func (s ExitSide) area(r Rectangle) Shape {
edge := r.W * exitEdgeWidth
door := r.W * exitDoorWidth
doorX := r.X + (r.W-door)/2
switch s {
case ExitLeft:
return Rect(r.X, r.Y+r.H*exitEdgeInset, edge, r.H*exitEdgeHeight)
case ExitRight:
return Rect(r.X+r.W-edge, r.Y+r.H*exitEdgeInset, edge, r.H*exitEdgeHeight)
case ExitBack:
return Rect(doorX, r.Y+r.H*exitBackInset, door, r.H*exitBackHeight)
default:
return Rect(doorX, r.Y+r.H*(1-exitNearHeight), door, r.H*exitNearHeight)
}
}
+3
View File
@@ -18,6 +18,7 @@ type saveFile struct {
Version int `json:"version"` Version int `json:"version"`
Title string `json:"title"` Title string `json:"title"`
CurrentScene string `json:"current_scene"` CurrentScene string `json:"current_scene"`
PreviousScene string `json:"previous_scene"`
SelectedVerb string `json:"selected_verb"` SelectedVerb string `json:"selected_verb"`
ActiveTheme string `json:"active_theme"` ActiveTheme string `json:"active_theme"`
Characters map[string]savedChar `json:"characters"` Characters map[string]savedChar `json:"characters"`
@@ -98,6 +99,7 @@ func (g *Game) buildSave() saveFile {
Version: saveVersion, Version: saveVersion,
Title: g.Title, Title: g.Title,
CurrentScene: g.currentScene, CurrentScene: g.currentScene,
PreviousScene: g.previousScene,
SelectedVerb: g.selectedVerb, SelectedVerb: g.selectedVerb,
ActiveTheme: g.activeTheme, ActiveTheme: g.activeTheme,
Characters: make(map[string]savedChar, len(g.chars)), Characters: make(map[string]savedChar, len(g.chars)),
@@ -142,6 +144,7 @@ func (g *Game) applySave(sf *saveFile) error {
g.flashTimer = 0 g.flashTimer = 0
g.currentScene = sf.CurrentScene g.currentScene = sf.CurrentScene
g.previousScene = sf.PreviousScene
if sf.SelectedVerb != "" { if sf.SelectedVerb != "" {
g.selectedVerb = sf.SelectedVerb g.selectedVerb = sf.SelectedVerb
} }
+1
View File
@@ -28,6 +28,7 @@ type CharacterPanel struct {
} }
func (c *CharacterPanel) GetName() string { return c.Name } func (c *CharacterPanel) GetName() string { return c.Name }
func (c *CharacterPanel) Layer() Layer { return LayerHUD }
func (c *CharacterPanel) Tick(ctx *UICtx) {} func (c *CharacterPanel) Tick(ctx *UICtx) {}
func (c *CharacterPanel) Draw(dst *ebiten.Image, ctx *UICtx) { func (c *CharacterPanel) Draw(dst *ebiten.Image, ctx *UICtx) {
+1
View File
@@ -20,6 +20,7 @@ type ChatLog struct {
} }
func (c *ChatLog) GetName() string { return c.Name } func (c *ChatLog) GetName() string { return c.Name }
func (c *ChatLog) Layer() Layer { return LayerHUD }
func (c *ChatLog) Tick(ctx *UICtx) {} func (c *ChatLog) Tick(ctx *UICtx) {}
func (c *ChatLog) Draw(dst *ebiten.Image, ctx *UICtx) { func (c *ChatLog) Draw(dst *ebiten.Image, ctx *UICtx) {
+1
View File
@@ -13,6 +13,7 @@ type Cursor struct {
} }
func (c *Cursor) GetName() string { return c.Name } func (c *Cursor) GetName() string { return c.Name }
func (c *Cursor) Layer() Layer { return LayerCursor }
func (c *Cursor) Tick(ctx *UICtx) {} func (c *Cursor) Tick(ctx *UICtx) {}
func (c *Cursor) Draw(dst *ebiten.Image, ctx *UICtx) { func (c *Cursor) Draw(dst *ebiten.Image, ctx *UICtx) {
+28 -28
View File
@@ -1,20 +1,20 @@
package inkwell package inkwell
// RegisterDefaultUI installs the SCUMM-style HUD widgets into g.UIManager // RegisterDefaultUI installs the SCUMM-style HUD widgets into g.WidgetManager
// in the conventional Z-order (HotspotDebug at the back, Cursor on top). // in the conventional Z-order (HotspotDebug at the back, Cursor on top).
// Domains that want a different layout call this and then either tweak // Domains that want a different layout call this and then either tweak
// the registered widgets in place or replace them entirely. // the registered widgets in place or replace them entirely.
// //
// Layout assumes the default 320×200 internal resolution. // Layout assumes the default 320×200 internal resolution.
func RegisterDefaultUI(g *Game) { func RegisterDefaultUI(g *Game) {
g.UIManager.Register(&HotspotDebug{Name: "hotspot_debug"}) g.WidgetManager.Register(&HotspotDebug{Name: "hotspot_debug"})
g.UIManager.Register(&VerbBar{ g.WidgetManager.Register(&VerbBar{
Name: "verbs", Name: "verbs",
Origin: Point{X: 4, Y: 152}, Origin: Point{X: 4, Y: 152},
Cols: 2, Cols: 2,
Button: Size{W: 60, H: 14}, Button: Size{W: 60, H: 14},
}) })
g.UIManager.Register(&InventoryBar{ g.WidgetManager.Register(&InventoryBar{
Name: "inventory", Name: "inventory",
Origin: Point{X: 132, Y: 152}, Origin: Point{X: 132, Y: 152},
Slots: 8, Slots: 8,
@@ -22,11 +22,11 @@ func RegisterDefaultUI(g *Game) {
SlotSize: 22, SlotSize: 22,
Gap: 2, Gap: 2,
}) })
g.UIManager.Register(&StatusLine{Name: "status", Y: 142, Align: AlignCenter}) g.WidgetManager.Register(&StatusLine{Name: "status", Y: 142, Align: AlignCenter})
g.UIManager.Register(&SpeechBubble{Name: "speech", MaxWidth: 200, Padding: 3, OffsetY: 4, FallbackY: 14}) g.WidgetManager.Register(&SpeechBubble{Name: "speech", MaxWidth: 200, Padding: 3, OffsetY: 4, FallbackY: 14})
g.UIManager.Register(&DialogBox{Name: "dialog", Bounds: Rect(0, 140, float64(g.Width), 60), LineHeight: 14, Padding: 6}) g.WidgetManager.Register(&DialogBox{Name: "dialog", Bounds: Rect(0, 140, float64(g.Width), 60), LineHeight: 14, Padding: 6})
g.UIManager.Register(&EndCard{Name: "endcard"}) g.WidgetManager.Register(&EndCard{Name: "endcard"})
g.UIManager.Register(&Cursor{Name: "cursor"}) g.WidgetManager.Register(&Cursor{Name: "cursor"})
} }
// RegisterRichUI installs a "story-rich" HUD that matches the layout of // RegisterRichUI installs a "story-rich" HUD that matches the layout of
@@ -39,15 +39,15 @@ func RegisterDefaultUI(g *Game) {
// PlayerName / NPCName correspond to registered characters. Pass "" for // PlayerName / NPCName correspond to registered characters. Pass "" for
// NPCName to skip the NPC panel. // NPCName to skip the NPC panel.
func RegisterRichUI(g *Game, playerName, npcName string) { func RegisterRichUI(g *Game, playerName, npcName string) {
g.UIManager.Register(&TopBar{ g.WidgetManager.Register(&TopBar{
Name: "topbar", Name: "topbar",
Height: 12, Height: 12,
ScoreVar: "score", ScoreMax: 100, ScoreVar: "score", ScoreMax: 100,
TimeVar: "time", TimeVar: "time",
}) })
g.UIManager.Register(&HotspotDebug{Name: "hotspot_debug"}) g.WidgetManager.Register(&HotspotDebug{Name: "hotspot_debug"})
if playerName != "" { if playerName != "" {
g.UIManager.Register(&CharacterPanel{ g.WidgetManager.Register(&CharacterPanel{
Name: "panel_player", Name: "panel_player",
Bounds: Rect(2, 14, 92, 36), Bounds: Rect(2, 14, 92, 36),
Character: playerName, Character: playerName,
@@ -59,7 +59,7 @@ func RegisterRichUI(g *Game, playerName, npcName string) {
}) })
} }
if npcName != "" { if npcName != "" {
g.UIManager.Register(&CharacterPanel{ g.WidgetManager.Register(&CharacterPanel{
Name: "panel_npc", Name: "panel_npc",
Bounds: Rect(180, 14, 92, 36), Bounds: Rect(180, 14, 92, 36),
Character: npcName, Character: npcName,
@@ -70,7 +70,7 @@ func RegisterRichUI(g *Game, playerName, npcName string) {
}, },
}) })
} }
g.UIManager.Register(&InventoryBar{ g.WidgetManager.Register(&InventoryBar{
Name: "inventory", Name: "inventory",
Origin: Point{X: 4, Y: 188}, Origin: Point{X: 4, Y: 188},
Slots: 8, Slots: 8,
@@ -78,14 +78,14 @@ func RegisterRichUI(g *Game, playerName, npcName string) {
SlotSize: 10, SlotSize: 10,
Gap: 1, Gap: 1,
}) })
g.UIManager.Register(&ChatLog{ g.WidgetManager.Register(&ChatLog{
Name: "chat", Name: "chat",
Bounds: Rect(2, 148, 280, 38), Bounds: Rect(2, 148, 280, 38),
LineHeight: 9, LineHeight: 9,
Padding: 3, Padding: 3,
ShowBorder: true, ShowBorder: true,
}) })
g.UIManager.Register(&RadialVerbs{ g.WidgetManager.Register(&RadialVerbs{
Name: "verbs", Name: "verbs",
AlwaysVisible: true, AlwaysVisible: true,
Center: Point{X: 282, Y: 90}, Center: Point{X: 282, Y: 90},
@@ -97,18 +97,18 @@ func RegisterRichUI(g *Game, playerName, npcName string) {
"take": "Vedd", "take": "Vedd",
}, },
}) })
g.UIManager.Register(&SpeechBubble{Name: "speech", MaxWidth: 200, Padding: 3, OffsetY: 4, FallbackY: 18}) g.WidgetManager.Register(&SpeechBubble{Name: "speech", MaxWidth: 200, Padding: 3, OffsetY: 4, FallbackY: 18})
g.UIManager.Register(&DialogBox{Name: "dialog", Bounds: Rect(0, 90, float64(g.Width), 56), LineHeight: 12, Padding: 6}) g.WidgetManager.Register(&DialogBox{Name: "dialog", Bounds: Rect(0, 90, float64(g.Width), 56), LineHeight: 12, Padding: 6})
g.UIManager.Register(&EndCard{Name: "endcard"}) g.WidgetManager.Register(&EndCard{Name: "endcard"})
g.UIManager.Register(&Cursor{Name: "cursor"}) g.WidgetManager.Register(&Cursor{Name: "cursor"})
} }
// RegisterRadialVerbUI installs an alternative HUD that swaps the // RegisterRadialVerbUI installs an alternative HUD that swaps the
// permanent verb-bar for a verb-coin (right-click radial menu). Inventory, // permanent verb-bar for a verb-coin (right-click radial menu). Inventory,
// dialog, speech and cursor stay the same. // dialog, speech and cursor stay the same.
func RegisterRadialVerbUI(g *Game) { func RegisterRadialVerbUI(g *Game) {
g.UIManager.Register(&HotspotDebug{Name: "hotspot_debug"}) g.WidgetManager.Register(&HotspotDebug{Name: "hotspot_debug"})
g.UIManager.Register(&InventoryBar{ g.WidgetManager.Register(&InventoryBar{
Name: "inventory", Name: "inventory",
Origin: Point{X: 4, Y: 178}, Origin: Point{X: 4, Y: 178},
Slots: 14, Slots: 14,
@@ -116,10 +116,10 @@ func RegisterRadialVerbUI(g *Game) {
SlotSize: 22, SlotSize: 22,
Gap: 0, Gap: 0,
}) })
g.UIManager.Register(&StatusLine{Name: "status", Y: 168, Align: AlignCenter}) g.WidgetManager.Register(&StatusLine{Name: "status", Y: 168, Align: AlignCenter})
g.UIManager.Register(&SpeechBubble{Name: "speech", MaxWidth: 200, Padding: 3, OffsetY: 4, FallbackY: 14}) g.WidgetManager.Register(&SpeechBubble{Name: "speech", MaxWidth: 200, Padding: 3, OffsetY: 4, FallbackY: 14})
g.UIManager.Register(&DialogBox{Name: "dialog", Bounds: Rect(0, 140, float64(g.Width), 60), LineHeight: 14, Padding: 6}) g.WidgetManager.Register(&DialogBox{Name: "dialog", Bounds: Rect(0, 140, float64(g.Width), 60), LineHeight: 14, Padding: 6})
g.UIManager.Register(&RadialVerbs{Name: "verbs", Trigger: MouseButtonRight, Radius: 40}) g.WidgetManager.Register(&RadialVerbs{Name: "verbs", Trigger: MouseButtonRight, Radius: 40})
g.UIManager.Register(&EndCard{Name: "endcard"}) g.WidgetManager.Register(&EndCard{Name: "endcard"})
g.UIManager.Register(&Cursor{Name: "cursor"}) g.WidgetManager.Register(&Cursor{Name: "cursor"})
} }
+1
View File
@@ -17,6 +17,7 @@ type DialogBox struct {
} }
func (d *DialogBox) GetName() string { return d.Name } func (d *DialogBox) GetName() string { return d.Name }
func (d *DialogBox) Layer() Layer { return LayerDialog }
func (d *DialogBox) Tick(ctx *UICtx) { func (d *DialogBox) Tick(ctx *UICtx) {
g := ctx.Game g := ctx.Game
+1
View File
@@ -12,6 +12,7 @@ type EndCard struct {
} }
func (e *EndCard) GetName() string { return e.Name } func (e *EndCard) GetName() string { return e.Name }
func (e *EndCard) Layer() Layer { return LayerCurtain }
func (e *EndCard) Tick(ctx *UICtx) { func (e *EndCard) Tick(ctx *UICtx) {
if ctx.Game.endCard == "" { if ctx.Game.endCard == "" {
+2 -2
View File
@@ -16,6 +16,7 @@ type HotspotDebug struct {
} }
func (h *HotspotDebug) GetName() string { return h.Name } func (h *HotspotDebug) GetName() string { return h.Name }
func (h *HotspotDebug) Layer() Layer { return LayerScene }
func (h *HotspotDebug) Tick(ctx *UICtx) { func (h *HotspotDebug) Tick(ctx *UICtx) {
key := h.ToggleKey key := h.ToggleKey
@@ -36,8 +37,7 @@ func (h *HotspotDebug) Draw(dst *ebiten.Image, ctx *UICtx) {
return return
} }
col := g.Theme().HotspotOutline col := g.Theme().HotspotOutline
s := g.SceneManager.MustGet(g.currentScene) for _, hs := range g.SceneHotspots(g.currentScene) {
for _, hs := range s.Hotspots {
b := hs.Area.Bounds() b := hs.Area.Bounds()
vector.StrokeRect(dst, float32(b.X), float32(b.Y), float32(b.W), float32(b.H), 1, col, false) vector.StrokeRect(dst, float32(b.X), float32(b.Y), float32(b.W), float32(b.H), 1, col, false)
if hs.Label != "" { if hs.Label != "" {
+1
View File
@@ -20,6 +20,7 @@ type InventoryBar struct {
} }
func (b *InventoryBar) GetName() string { return b.Name } func (b *InventoryBar) GetName() string { return b.Name }
func (b *InventoryBar) Layer() Layer { return LayerHUD }
func (b *InventoryBar) slotRect(idx int) Rectangle { func (b *InventoryBar) slotRect(idx int) Rectangle {
cols := b.Cols cols := b.Cols
+20 -18
View File
@@ -1,26 +1,28 @@
package inkwell package inkwell
// UIManager registers Widget instances. Same shape as every other manager. import "sort"
type UIManager = Manager[Widget]
// reversedWidgets iterates a manager's contents in reverse registration // WidgetManager registers Widget instances. Same shape as every other manager.
// order — used by the engine for top-down input dispatch (the widget type WidgetManager = Manager[Widget]
// drawn on top gets the click first).
func reversedWidgets(m *UIManager) []Widget { // reversedWidgets iterates from the top layer down — used by the engine
names := m.Names() // for top-down input dispatch (the widget drawn on top gets the click
out := make([]Widget, len(names)) // first).
for i, n := range names { func reversedWidgets(m *WidgetManager) []Widget {
out[len(names)-1-i] = m.MustGet(n) ordered := orderedWidgets(m)
out := make([]Widget, len(ordered))
for i, w := range ordered {
out[len(ordered)-1-i] = w
} }
return out return out
} }
// orderedWidgets iterates in registration order — bottom-up draw. // orderedWidgets iterates bottom-up draw order: by layer, and by
func orderedWidgets(m *UIManager) []Widget { // registration order inside a layer.
names := m.Names() func orderedWidgets(m *WidgetManager) []Widget {
out := make([]Widget, len(names)) all := m.All()
for i, n := range names { sort.SliceStable(all, func(i, j int) bool {
out[i] = m.MustGet(n) return LayerOf(all[i]) < LayerOf(all[j])
} })
return out return all
} }
+1
View File
@@ -21,6 +21,7 @@ type Panel struct {
} }
func (p *Panel) GetName() string { return p.Name } func (p *Panel) GetName() string { return p.Name }
func (p *Panel) Layer() Layer { return LayerPanel }
func (p *Panel) Tick(ctx *UICtx) {} func (p *Panel) Tick(ctx *UICtx) {}
func (p *Panel) Draw(dst *ebiten.Image, ctx *UICtx) { func (p *Panel) Draw(dst *ebiten.Image, ctx *UICtx) {
+1
View File
@@ -18,6 +18,7 @@ type SpeechBubble struct {
} }
func (s *SpeechBubble) GetName() string { return s.Name } func (s *SpeechBubble) GetName() string { return s.Name }
func (s *SpeechBubble) Layer() Layer { return LayerSpeech }
func (s *SpeechBubble) Tick(ctx *UICtx) {} func (s *SpeechBubble) Tick(ctx *UICtx) {}
func (s *SpeechBubble) Draw(dst *ebiten.Image, ctx *UICtx) { func (s *SpeechBubble) Draw(dst *ebiten.Image, ctx *UICtx) {
+1
View File
@@ -13,6 +13,7 @@ type StatusLine struct {
} }
func (s *StatusLine) GetName() string { return s.Name } func (s *StatusLine) GetName() string { return s.Name }
func (s *StatusLine) Layer() Layer { return LayerHUD }
func (s *StatusLine) Tick(ctx *UICtx) { func (s *StatusLine) Tick(ctx *UICtx) {
g := ctx.Game g := ctx.Game
+1
View File
@@ -27,6 +27,7 @@ type TopBar struct {
} }
func (t *TopBar) GetName() string { return t.Name } func (t *TopBar) GetName() string { return t.Name }
func (t *TopBar) Layer() Layer { return LayerHUD }
func (t *TopBar) Tick(ctx *UICtx) {} func (t *TopBar) Tick(ctx *UICtx) {}
func (t *TopBar) Draw(dst *ebiten.Image, ctx *UICtx) { func (t *TopBar) Draw(dst *ebiten.Image, ctx *UICtx) {
+1
View File
@@ -19,6 +19,7 @@ type VerbBar struct {
} }
func (v *VerbBar) GetName() string { return v.Name } func (v *VerbBar) GetName() string { return v.Name }
func (v *VerbBar) Layer() Layer { return LayerHUD }
func (v *VerbBar) buttons(ctx *UICtx) []verbButton { func (v *VerbBar) buttons(ctx *UICtx) []verbButton {
cols := v.Cols cols := v.Cols
+3 -2
View File
@@ -43,6 +43,7 @@ type RadialVerbs struct {
} }
func (r *RadialVerbs) GetName() string { return r.Name } func (r *RadialVerbs) GetName() string { return r.Name }
func (r *RadialVerbs) Layer() Layer { return LayerMenu }
func (r *RadialVerbs) Tick(ctx *UICtx) { func (r *RadialVerbs) Tick(ctx *UICtx) {
g := ctx.Game g := ctx.Game
@@ -140,11 +141,11 @@ func (r *RadialVerbs) consumeTrigger(g *Game) {
// whether its area covers the cursor — so the radial menu doesn't open // whether its area covers the cursor — so the radial menu doesn't open
// over an InventoryBar that would also want this click. // over an InventoryBar that would also want this click.
func (r *RadialVerbs) blockedAt(ctx *UICtx, p Point) bool { func (r *RadialVerbs) blockedAt(ctx *UICtx, p Point) bool {
for _, name := range ctx.Game.UIManager.Names() { for _, name := range ctx.Game.WidgetManager.Names() {
if name == r.Name { if name == r.Name {
continue continue
} }
w := ctx.Game.UIManager.MustGet(name) w := ctx.Game.WidgetManager.MustGet(name)
// 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
+48 -4
View File
@@ -9,17 +9,61 @@ import "github.com/hajimehoshi/ebiten/v2"
// satisfy this interface. // satisfy this interface.
// //
// Lifecycle: // Lifecycle:
// - Tick runs once per frame in REVERSE registration order so the // - Tick runs once per frame from the TOP layer down so the top-most
// top-most widget gets a chance to consume input first via // widget gets a chance to consume input first via
// ctx.Game.Input.ConsumeLeft / ConsumeRight. // ctx.Game.Input.ConsumeLeft / ConsumeRight.
// - Draw runs once per frame in REGISTRATION order, so widgets // - Draw runs once per frame from the BOTTOM layer up, so a widget on a
// registered later are painted on top. // higher layer is painted on top.
//
// Registration order only decides ties inside one layer — see Layer.
type Widget interface { type Widget interface {
Named Named
Tick(ctx *UICtx) Tick(ctx *UICtx)
Draw(dst *ebiten.Image, ctx *UICtx) Draw(dst *ebiten.Image, ctx *UICtx)
} }
// Layer is a widget's place in the paint order. A widget says where it
// belongs rather than depending on the order it happened to be registered
// in, which frees a domain to register its widgets one file at a time.
type Layer int
const (
// LayerScene is over the picture and under the HUD: hotspot outlines,
// cutscene bars, and the invisible widgets that only tick.
LayerScene Layer = 0
// LayerPanel is the plate the HUD is drawn on.
LayerPanel Layer = 100
// LayerHUD is everything mounted on that plate: verbs, inventory,
// status line, top bar.
LayerHUD Layer = 200
// LayerSpeech is speech bubbles, which float over the scene.
LayerSpeech Layer = 300
// LayerDialog is the dialogue box.
LayerDialog Layer = 400
// LayerMenu is what opens on top of the game: radial verbs, menus.
LayerMenu Layer = 500
// LayerCurtain is what covers the game: end cards, fades.
LayerCurtain Layer = 600
// LayerCursor is the pointer, and nothing else belongs above it.
LayerCursor Layer = 700
)
// Layered is implemented by a widget that declares its own layer. Every
// built-in widget does. A widget that does not sits on LayerHUD.
type Layered interface {
Layer() Layer
}
// LayerOf reports the layer a widget paints on. A wrapper widget that
// forwards to an inner one — a visibility gate, say — should return
// LayerOf(inner) so that wrapping does not move the widget.
func LayerOf(w Widget) Layer {
if l, ok := w.(Layered); ok {
return l.Layer()
}
return LayerHUD
}
// UICtx is the per-tick context handed to widgets. Game is the root // UICtx is the per-tick context handed to widgets. Game is the root
// aggregate; DT is seconds since the previous frame. // aggregate; DT is seconds since the previous frame.
type UICtx struct { type UICtx struct {