diff --git a/README.md b/README.md index 967088a..138b1c9 100644 --- a/README.md +++ b/README.md @@ -30,14 +30,15 @@ import "git.teletypegames.org/games/inkwell" 6. [Entity reference](#6-entity-reference) - 6.1 [Asset](#61-asset) - 6.2 [Scene](#62-scene) - - 6.3 [Hotspot](#63-hotspot) - - 6.4 [Trigger](#64-trigger) - - 6.5 [Item](#65-item) - - 6.6 [Inventory](#66-inventory) - - 6.7 [Character](#67-character) - - 6.8 [Dialogue](#68-dialogue) - - 6.9 [Script](#69-script) - - 6.10 [Verb](#610-verb) + - 6.3 [Exit](#63-exit) + - 6.4 [Hotspot](#64-hotspot) + - 6.5 [Trigger](#65-trigger) + - 6.6 [Item](#66-item) + - 6.7 [Inventory](#67-inventory) + - 6.8 [Character](#68-character) + - 6.9 [Dialogue](#69-dialogue) + - 6.10 [Script](#610-script) + - 6.11 [Verb](#611-verb) 7. [The action system](#7-the-action-system) - 7.1 [Action and Runner](#71-action-and-runner) - 7.2 [Status and Ctx](#72-status-and-ctx) @@ -237,6 +238,11 @@ type Game struct { UIManager *UIManager 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 State *State Inventory *Inventory @@ -330,11 +336,28 @@ func (g *Game) Messages() []LogMessage ### 4.6 Scene helpers ```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 ``` -Used by `CharacterPanel` to auto-hide when its character isn't an actor in -the current scene. +`CurrentScene` is where the player is, empty before the first scene is +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 @@ -428,6 +451,7 @@ type Scene struct { Background string // Asset.Name Music string // Asset.Name (optional) Hotspots []Hotspot + Exits []Exit // connections to other scenes Walkboxes []Polygon Triggers []Trigger 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 used as a waypoint. Destinations outside every walkbox are clipped to 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 -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 // 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]` for custom verbs. -### 6.4 Trigger +### 6.5 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 re-entry. -### 6.5 Item +### 6.6 Item ```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]` 3. Otherwise the engine flashes "Nem ehhez." and deselects. -### 6.6 Inventory +### 6.7 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 (`Give`, `TakeAway`) and by the `InventoryBar` widget on click. -### 6.7 Character +### 6.8 Character ```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 computed by [walkbox routing](#62-scene). -### 6.8 Dialogue +### 6.9 Dialogue ```go // inkwell/dialog.def.go @@ -647,7 +743,7 @@ The dialog flow: 5. `EndDialogue()` closes the conversation; `GotoNode("other")` jumps to another node in the same dialogue. -### 6.9 Script +### 6.10 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. Fire one with `RunScript("name")`. -### 6.10 Verb +### 6.11 Verb ```go // inkwell/ui.verb.go @@ -749,6 +845,7 @@ type Ctx struct { | `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. | | `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. | | `Give(item string) Action` | Add `item` to inventory. | | `TakeAway(item string) Action` | Remove `item` from inventory. | @@ -1530,6 +1627,8 @@ It cross-checks: `Asset`. - Optional `Scene.Music` (if non-empty) references a registered `Asset`. - 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. 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 `slot.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. - The full `State`: flags, vars, visited and talked counters. - 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.manager.go # SceneManager alias ├── scene.hotspot.go # Hotspot, CursorKind +├── scene.exit.go # Exit, ExitSide + edge-strip geometry ├── scene.trigger.go # Trigger + rising-edge engine sweep ├── scene.path.go # walkbox routing (BFS over polygon adjacency) ├── scene.transition.go # fade-to-black overlay (internal) diff --git a/action.def.go b/action.def.go index 2f9923c..3203775 100644 --- a/action.def.go +++ b/action.def.go @@ -250,6 +250,24 @@ func (a *gotoAction) Tick(ctx *Ctx) Status { 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 -------------------------------------------------------- type giveAction struct{ item string } diff --git a/core.game.go b/core.game.go index d799f5d..d23277d 100644 --- a/core.game.go +++ b/core.game.go @@ -24,6 +24,17 @@ type Game struct { UIManager *UIManager 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 Inventory *Inventory Audio *AudioPlayer @@ -35,21 +46,23 @@ type Game struct { activeTheme string // runtime - loaded *loadedAssets - currentScene string - chars map[string]*runtimeChar - scriptRunner Runner - scriptCtx *Ctx - transition *transition - selectedVerb string + loaded *loadedAssets + currentScene string + previousScene string + exitHotspots map[string][]Hotspot + chars map[string]*runtimeChar + scriptRunner Runner + scriptCtx *Ctx + transition *transition + selectedVerb string // UI runtime state (read by widgets, written by actions / engine) - hoverLabel string - flash string - flashTimer float64 - endCard string - speech speechState - dialog *runtimeDialog + hoverLabel string + flash string + flashTimer float64 + endCard string + speech speechState + dialog *runtimeDialog activeDialog string // 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. type LogMessage struct { - Speaker string // empty for actions / system + Speaker string // empty for actions / system Text string 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) 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 ------------------------------------------ -func (g *Game) Theme() Theme { return g.ThemeManager.MustGet(g.activeTheme) } -func (g *Game) UseTheme(name string) { g.activeTheme = name } -func (g *Game) SelectedVerb() string { return g.selectedVerb } +func (g *Game) Theme() Theme { return g.ThemeManager.MustGet(g.activeTheme) } +func (g *Game) UseTheme(name string) { g.activeTheme = name } +func (g *Game) SelectedVerb() string { return g.selectedVerb } func (g *Game) SetSelectedVerb(s string) { g.selectedVerb = s } -func (g *Game) HoverLabel() string { return g.hoverLabel } -func (g *Game) SetHoverLabel(s string) { g.hoverLabel = s } +func (g *Game) HoverLabel() string { return g.hoverLabel } +func (g *Game) SetHoverLabel(s string) { g.hoverLabel = s } // SetSpeech / ClearSpeech are called by the Say action; widgets render // whatever the current state says. @@ -194,9 +246,9 @@ func (g *Game) HotspotAt(p Point) *Hotspot { if g.currentScene == "" { return nil } - s := g.SceneManager.MustGet(g.currentScene) - for i := range s.Hotspots { - h := &s.Hotspots[i] + hs := g.SceneHotspots(g.currentScene) + for i := range hs { + h := &hs[i] if h.Area != nil && h.Area.Contains(p) { return h } @@ -266,6 +318,11 @@ func (g *Game) Validate() error { 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) { return fmt.Errorf("inkwell: no active theme (got %q)", g.activeTheme) @@ -309,6 +366,9 @@ func (g *Game) changeScene(name string) { } prev := g.currentScene g.transition.start(func() { + if prev != "" && prev != name { + g.previousScene = prev + } if prev != "" { old := g.SceneManager.MustGet(prev) if old.OnLeave != nil { diff --git a/scene.def.go b/scene.def.go index 7b25084..87fe7b4 100644 --- a/scene.def.go +++ b/scene.def.go @@ -6,6 +6,7 @@ type Scene struct { Background string // Asset.Name Music string // Asset.Name (optional) Hotspots []Hotspot + Exits []Exit // connections to other scenes; see scene.exit.go Walkboxes []Polygon Triggers []Trigger Actors []SceneActor diff --git a/scene.exit.go b/scene.exit.go new file mode 100644 index 0000000..6c4e790 --- /dev/null +++ b/scene.exit.go @@ -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) + } +} diff --git a/state.save.go b/state.save.go index 414854b..11e5faa 100644 --- a/state.save.go +++ b/state.save.go @@ -15,14 +15,15 @@ const saveVersion = 1 // runtime mutable state lands here — managers, themes and assets are // reconstructed by the domain's Build() on every launch. type saveFile struct { - Version int `json:"version"` - Title string `json:"title"` - CurrentScene string `json:"current_scene"` - SelectedVerb string `json:"selected_verb"` - ActiveTheme string `json:"active_theme"` - Characters map[string]savedChar `json:"characters"` - Inventory savedInventory `json:"inventory"` - State savedState `json:"state"` + Version int `json:"version"` + Title string `json:"title"` + CurrentScene string `json:"current_scene"` + PreviousScene string `json:"previous_scene"` + SelectedVerb string `json:"selected_verb"` + ActiveTheme string `json:"active_theme"` + Characters map[string]savedChar `json:"characters"` + Inventory savedInventory `json:"inventory"` + State savedState `json:"state"` } type savedChar struct { @@ -95,12 +96,13 @@ func (g *Game) Load(slot int) error { func (g *Game) buildSave() saveFile { sf := saveFile{ - Version: saveVersion, - Title: g.Title, - CurrentScene: g.currentScene, - SelectedVerb: g.selectedVerb, - ActiveTheme: g.activeTheme, - Characters: make(map[string]savedChar, len(g.chars)), + Version: saveVersion, + Title: g.Title, + CurrentScene: g.currentScene, + PreviousScene: g.previousScene, + SelectedVerb: g.selectedVerb, + ActiveTheme: g.activeTheme, + Characters: make(map[string]savedChar, len(g.chars)), Inventory: savedInventory{ Items: g.Inventory.Items(), Selected: g.Inventory.Selected(), @@ -142,6 +144,7 @@ func (g *Game) applySave(sf *saveFile) error { g.flashTimer = 0 g.currentScene = sf.CurrentScene + g.previousScene = sf.PreviousScene if sf.SelectedVerb != "" { g.selectedVerb = sf.SelectedVerb } diff --git a/ui.hotspot_debug.go b/ui.hotspot_debug.go index 83bbf43..00af5da 100644 --- a/ui.hotspot_debug.go +++ b/ui.hotspot_debug.go @@ -36,8 +36,7 @@ func (h *HotspotDebug) Draw(dst *ebiten.Image, ctx *UICtx) { return } col := g.Theme().HotspotOutline - s := g.SceneManager.MustGet(g.currentScene) - for _, hs := range s.Hotspots { + for _, hs := range g.SceneHotspots(g.currentScene) { b := hs.Area.Bounds() vector.StrokeRect(dst, float32(b.X), float32(b.Y), float32(b.W), float32(b.H), 1, col, false) if hs.Label != "" {