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>
This commit is contained in:
2026-08-30 00:09:20 +02:00
co-authored by Claude Opus 5
parent aa55f1166c
commit f9745e4266
7 changed files with 362 additions and 60 deletions
+121 -21
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)
@@ -237,6 +238,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
@@ -330,11 +336,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 +451,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 +474,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 +593,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 +618,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 +641,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 +660,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 +701,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 +743,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 +758,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 +845,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. |
@@ -1530,6 +1627,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 +1650,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 +1774,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 }
+83 -23
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
@@ -35,21 +46,23 @@ type Game struct {
activeTheme string activeTheme string
// runtime // runtime
loaded *loadedAssets loaded *loadedAssets
currentScene string currentScene string
chars map[string]*runtimeChar previousScene string
scriptRunner Runner exitHotspots map[string][]Hotspot
scriptCtx *Ctx chars map[string]*runtimeChar
transition *transition scriptRunner Runner
selectedVerb string scriptCtx *Ctx
transition *transition
selectedVerb string
// UI runtime state (read by widgets, written by actions / engine) // UI runtime state (read by widgets, written by actions / engine)
hoverLabel string hoverLabel string
flash string flash string
flashTimer float64 flashTimer float64
endCard string endCard string
speech speechState speech speechState
dialog *runtimeDialog dialog *runtimeDialog
activeDialog string activeDialog string
// in-game message log for ChatLog widgets; ring buffer behavior. // in-game message log for ChatLog widgets; ring buffer behavior.
@@ -76,7 +89,7 @@ const (
// LogMessage is a single line in the chat-log buffer. // LogMessage is a single line in the chat-log buffer.
type LogMessage struct { type LogMessage struct {
Speaker string // empty for actions / system Speaker string // empty for actions / system
Text string Text string
Kind LogKind Kind LogKind
} }
@@ -135,16 +148,55 @@ 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
}
func (g *Game) OnStart(a Action) *Game { g.onStart = a; return g }
// ----- theme + UI conveniences ------------------------------------------ // ----- theme + UI conveniences ------------------------------------------
func (g *Game) Theme() Theme { return g.ThemeManager.MustGet(g.activeTheme) } func (g *Game) Theme() Theme { return g.ThemeManager.MustGet(g.activeTheme) }
func (g *Game) UseTheme(name string) { g.activeTheme = name } func (g *Game) UseTheme(name string) { g.activeTheme = name }
func (g *Game) SelectedVerb() string { return g.selectedVerb } func (g *Game) SelectedVerb() string { return g.selectedVerb }
func (g *Game) SetSelectedVerb(s string) { g.selectedVerb = s } func (g *Game) SetSelectedVerb(s string) { g.selectedVerb = s }
func (g *Game) HoverLabel() string { return g.hoverLabel } func (g *Game) HoverLabel() string { return g.hoverLabel }
func (g *Game) SetHoverLabel(s string) { g.hoverLabel = s } func (g *Game) SetHoverLabel(s string) { g.hoverLabel = s }
// SetSpeech / ClearSpeech are called by the Say action; widgets render // SetSpeech / ClearSpeech are called by the Say action; widgets render
// whatever the current state says. // whatever the current state says.
@@ -194,9 +246,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 +318,11 @@ 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)
}
}
} }
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 +366,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 {
+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)
}
}
+17 -14
View File
@@ -15,14 +15,15 @@ const saveVersion = 1
// runtime mutable state lands here — managers, themes and assets are // runtime mutable state lands here — managers, themes and assets are
// reconstructed by the domain's Build() on every launch. // reconstructed by the domain's Build() on every launch.
type saveFile struct { 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"`
SelectedVerb string `json:"selected_verb"` PreviousScene string `json:"previous_scene"`
ActiveTheme string `json:"active_theme"` SelectedVerb string `json:"selected_verb"`
Characters map[string]savedChar `json:"characters"` ActiveTheme string `json:"active_theme"`
Inventory savedInventory `json:"inventory"` Characters map[string]savedChar `json:"characters"`
State savedState `json:"state"` Inventory savedInventory `json:"inventory"`
State savedState `json:"state"`
} }
type savedChar struct { type savedChar struct {
@@ -95,12 +96,13 @@ func (g *Game) Load(slot int) error {
func (g *Game) buildSave() saveFile { func (g *Game) buildSave() saveFile {
sf := saveFile{ sf := saveFile{
Version: saveVersion, Version: saveVersion,
Title: g.Title, Title: g.Title,
CurrentScene: g.currentScene, CurrentScene: g.currentScene,
SelectedVerb: g.selectedVerb, PreviousScene: g.previousScene,
ActiveTheme: g.activeTheme, SelectedVerb: g.selectedVerb,
Characters: make(map[string]savedChar, len(g.chars)), ActiveTheme: g.activeTheme,
Characters: make(map[string]savedChar, len(g.chars)),
Inventory: savedInventory{ Inventory: savedInventory{
Items: g.Inventory.Items(), Items: g.Inventory.Items(),
Selected: g.Inventory.Selected(), Selected: g.Inventory.Selected(),
@@ -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 != "" {