5 Commits
Author SHA1 Message Date
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
11 changed files with 479 additions and 84 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 ./...
+172 -29
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)
@@ -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
@@ -237,6 +245,11 @@ type Game struct {
UIManager *UIManager UIManager *UIManager
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
@@ -262,16 +275,50 @@ 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 `UIManager` 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. |
@@ -1413,13 +1551,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 `UIManager` 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`
@@ -1530,6 +1670,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 +1693,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 +1817,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)
+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 }
+9 -2
View File
@@ -7,6 +7,10 @@ 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
} }
@@ -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")
+83 -6
View File
@@ -24,6 +24,17 @@ type Game struct {
UIManager *UIManager UIManager *UIManager
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,
@@ -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 -2
View File
@@ -36,8 +36,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 != "" {
+5 -12
View File
@@ -7,20 +7,13 @@ type UIManager = Manager[Widget]
// order — used by the engine for top-down input dispatch (the widget // order — used by the engine for top-down input dispatch (the widget
// drawn on top gets the click first). // drawn on top gets the click first).
func reversedWidgets(m *UIManager) []Widget { func reversedWidgets(m *UIManager) []Widget {
names := m.Names() all := m.All()
out := make([]Widget, len(names)) out := make([]Widget, len(all))
for i, n := range names { for i, w := range all {
out[len(names)-1-i] = m.MustGet(n) out[len(all)-1-i] = w
} }
return out return out
} }
// orderedWidgets iterates in registration order — bottom-up draw. // orderedWidgets iterates in registration order — bottom-up draw.
func orderedWidgets(m *UIManager) []Widget { func orderedWidgets(m *UIManager) []Widget { return m.All() }
names := m.Names()
out := make([]Widget, len(names))
for i, n := range names {
out[i] = m.MustGet(n)
}
return out
}