8 Commits
Author SHA1 Message Date
mr.zeroandClaude Opus 5 92fc36bdf5 Nine things a domain kept having to invent
ci/woodpecker/push/woodpecker Pipeline was successful
Every change here started life as a workaround in a game and is really
the same finding: a fact about an entity had nowhere to live, so the
domain built machinery around the gap.

  Character.Label / Character.Voice — a tape is a character that speaks
  into the log, not a second spelling of Say. VoiceOf and CharacterLabel
  read them; Say obeys them.

  Game.Do — a real action queue, in order, so a widget can start an
  action. queueAction no longer drops what arrives while the runner is
  busy; it queues it.

  Widget.When + Gated + WidgetVisible — a widget declares when it is on
  screen. Nothing ticks, draws or blocks a click while its condition is
  false, so a HUD is hidden for a cutscene without a wrapper per widget.

  TopBar.NoteVar — the title was already discovered; the note beside it
  no longer needs a domain widget to push it in.

  Game.UseWithFail — the pair nobody authored is content, and belongs to
  the domain, exactly like ExitLook and ExitTake.

  Game.Player / Game.Walkboxes — a scene that names no cast and no floor
  means "the usual", instead of a defaults pass rewriting every scene.

  Game.WindowScale — Run's 4× is now a default, not a decision.

  SceneNav — the dev widget every game was writing.

  Colour text: DrawText paints in the colour it is handed, and GlyphW/H,
  TextWidth, WrapText and ClipText are the library's, not each game's.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-30 22:57:16 +02:00
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
29 changed files with 1115 additions and 240 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 ./...
+336 -55
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,20 @@ 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
// domain defaults
UseWithFail func(item string, h *Hotspot) Action // unauthored use-with pair
Player string // actor a scene with none gets
Walkboxes []Polygon // floor a scene with none walks
WindowScale int // window = resolution × this (0 = 4)
// runtime services // runtime services
State *State State *State
Inventory *Inventory Inventory *Inventory
@@ -260,18 +279,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,20 +383,61 @@ 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.
### 4.7 Text drawing hook `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 and measuring
```go ```go
func (g *Game) DrawText(dst *ebiten.Image, s string, x, y int, c color.Color) func (g *Game) DrawText(dst *ebiten.Image, s string, x, y int, c color.Color)
const GlyphW, GlyphH = 6, 16 // the built-in font's cell
func TextWidth(s string) int // pixel width, rune-counted
func WrapText(s string, maxPx int) []string // word wrap; \n breaks where it stands
func ClipText(s string, maxPx int) string // cut to width, ellipsis when cut
``` ```
Single indirection so widgets can hand a `color.Color` today and the `DrawText` renders in the colour it is given: `ebitenutil.DebugPrintAt`
library can swap to `text/v2` later without changing call sites. only paints white, so the glyphs go onto a reused offscreen and are
blitted back tinted with a `ColorScale`. Every `Theme` text colour is
therefore live, in the built-in widgets and in a domain's own.
The three measuring helpers are what the built-in widgets lay out with,
and every vertical measurement in a HUD should derive from `GlyphH`
rather than a number that happens to look right.
### 4.8 The action queue
```go
func (g *Game) Do(a Action)
```
Queues an action. Queued actions run **one at a time and in order** — the
engine starts the next one on the first frame the previous is done, so a
widget, a trigger and a hotspot click can all hand work in without any of
them being dropped or racing the others. The engine's own hotspot and
`OnEnter` actions go through the same queue.
--- ---
@@ -428,6 +522,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
@@ -443,19 +538,96 @@ type SceneActor struct {
`OnEnter` is queued the moment the scene becomes current (after a fade-in); `OnEnter` is queued the moment the scene becomes current (after a fade-in);
`OnLeave` runs on the transition out. `Actors` lists which registered `OnLeave` runs on the transition out. `Actors` lists which registered
characters are placed in the scene and where their feet start. characters are placed in the scene and where their feet start. A `nil`
`Actors` means "the usual cast": `Game.Player` is placed at that
character's `Start`. An empty non-nil slice means an empty scene, which is
how a map or a menu scene opts out.
`Walkboxes` constrain character movement. When non-empty, `Walk(...)` `Walkboxes` are the same bargain: `nil` falls back to `Game.Walkboxes`, an
empty non-nil slice turns routing off for this scene. They constrain
character movement. When non-empty, `Walk(...)`
routes through a BFS over the polygon adjacency graph (polygons that routes through a BFS over the polygon adjacency graph (polygons that
share an edge are neighbours), with the midpoint of each shared edge share an edge are neighbours), with the midpoint of each shared edge
used as a waypoint. Destinations outside every walkbox are clipped to used as a waypoint. Destinations outside every walkbox are clipped to
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 +669,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 +694,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
@@ -543,9 +715,14 @@ Items live in the `ItemManager`. When the player picks one up
hotspot with an item selected resolves the action via, in order: hotspot with an item selected resolves the action via, in order:
1. `hotspot.OnUseWith[item.Name]` 1. `hotspot.OnUseWith[item.Name]`
2. `item.OnUseWith[hotspot.Name]` 2. `item.OnUseWith[hotspot.Name]`
3. Otherwise the engine flashes "Nem ehhez." and deselects. 3. Otherwise `Game.UseWithFail(item, hotspot)` is asked for a response and
the item is deselected; with no hook set the engine flashes "Nem ehhez."
### 6.6 Inventory `UseWithFail` is the same shape as `ExitLook` and `ExitTake`: the pair the
author never wrote is still content, and the domain is the only one that
knows what its characters say about it.
### 6.7 Inventory
```go ```go
// inkwell/item.inventory.go // inkwell/item.inventory.go
@@ -564,21 +741,33 @@ 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
type Character struct { type Character struct {
Name string Name string
Label string // what the UI shows; empty = Name
Sprite string // Asset.Name (placeholder if missing) Sprite string // Asset.Name (placeholder if missing)
Animations map[string]AnimationClip Animations map[string]AnimationClip
Speed float64 Speed float64
SpeechColor color.Color SpeechColor color.Color
Start Point Voice Voice // VoiceBubble (default) or VoiceLog
Start Point // where Game.Player stands by default
W, H float64 // size hint for placeholder W, H float64 // size hint for placeholder
} }
type Voice int
const (
VoiceBubble Voice = iota // speech bubble over the scene, plus the log
VoiceLog // the log alone
)
func (c Character) DisplayName() string // Label, or Name
func (g *Game) CharacterLabel(name string) string
func (g *Game) VoiceOf(name string) Voice
type AnimationClip struct { type AnimationClip struct {
Frames []Rectangle Frames []Rectangle
FrameTime float64 FrameTime float64
@@ -586,6 +775,14 @@ type AnimationClip struct {
} }
``` ```
`Voice` is where a character reaches the player, and `Say` obeys it: a
`VoiceLog` character — a radio, a tape, a narrator with no body to speak
from — gets the log line without the bubble. A domain never needs a
second spelling of `Say` for that.
`Label` is what a HUD prints: a code name, a title, a shout. `Start` is
where `Game.Player` is placed in a scene that lists no actors of its own.
When no real sprite is on disk, `drawCharacter` falls back to a stylised When no real sprite is on disk, `drawCharacter` falls back to a stylised
placeholder: humanoid if `W < H`, quadruped otherwise. `SpeechColor` (if placeholder: humanoid if `W < H`, quadruped otherwise. `SpeechColor` (if
an `RGBA`) colours both the speech-bubble text and the placeholder body. an `RGBA`) colours both the speech-bubble text and the placeholder body.
@@ -605,7 +802,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 +844,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 +859,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 +946,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 +1062,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 +1078,30 @@ 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 Gated interface {
VisibleWhen() Condition
}
func WidgetVisible(ctx *Ctx, w Widget) bool // Gated with a false condition = off
type UICtx struct { type UICtx struct {
Game *Game Game *Game
DT float64 DT float64
@@ -907,13 +1129,39 @@ 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 should return
`LayerOf(inner)` from its own `Layer`, so that wrapping does not move the
widget.
Every built-in widget also carries a `When Condition`. A widget whose
condition evaluates false neither ticks nor draws nor blocks a click, so a
HUD is hidden for a cutscene by saying so on the widgets rather than by
unregistering them or wrapping each one:
```go
g.WidgetManager.Register(&inkwell.InventoryBar{
Name: "inventory",
When: inkwell.VarEq("mode", "play"),
})
```
A domain widget joins in by implementing `VisibleWhen() Condition`; one
that does not implement it is always live, which is what a bare widget
always did.
After all widgets ticked, the engine offers the (possibly consumed) click After all widgets ticked, the engine offers the (possibly consumed) click
to `handleSceneInput`, which is where hotspot interactions live. If a to `handleSceneInput`, which is where hotspot interactions live. If a
@@ -1100,7 +1348,9 @@ type HotspotDebug struct {
A horizontal strip across the top with three sections: A horizontal strip across the top with three sections:
- **Left:** `LeftText` override or `Scene.Title` (falling back to `Name`). - **Left:** `LeftText` override or `Scene.Title` (falling back to `Name`),
plus `State.Var(NoteVar)` after an em dash when that var holds anything —
`"ALLEY — paused"`.
- **Center:** score string built from `State.Var(ScoreVar)`. With - **Center:** score string built from `State.Var(ScoreVar)`. With
`ScoreMax > 0`, formatted as `"Score: X/MAX"`, else `"Score: X"`. `ScoreMax > 0`, formatted as `"Score: X/MAX"`, else `"Score: X"`.
- **Right:** time string from `State.Var(TimeVar)`. - **Right:** time string from `State.Var(TimeVar)`.
@@ -1108,17 +1358,37 @@ A horizontal strip across the top with three sections:
```go ```go
type TopBar struct { type TopBar struct {
Name string Name string
When Condition
Height int // default 12 Height int // default 12
LeftText string // overrides scene title LeftText string // overrides scene title
ScoreVar string // State.Var key; "" = no score ScoreVar string // State.Var key; "" = no score
ScoreMax int ScoreMax int
TimeVar string // State.Var key; "" = no time TimeVar string // State.Var key; "" = no time
NoteVar string // State.Var key; appended to the left text
} }
``` ```
The title needs no wiring: a domain that wants "the current scene, and
whatever the game is doing to it" sets `NoteVar` and writes that var.
Empty fields are skipped, so the widget gracefully degrades to two- or Empty fields are skipped, so the widget gracefully degrades to two- or
one-section layouts. one-section layouts.
#### `SceneNav` (`ui.scene_nav.go`)
A development aid: left and right arrow keys step through the registered
scenes in registration order, wrapping at both ends. Draws nothing.
```go
type SceneNav struct {
Name string
When Condition
}
```
Gate it with `When` — or leave it unregistered — when a build should not
let the player walk the catalogue.
#### `CharacterPanel` (`ui.character_panel.go`) #### `CharacterPanel` (`ui.character_panel.go`)
A floating info card showing one character's portrait, role badge and A floating info card showing one character's portrait, role badge and
@@ -1214,7 +1484,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,20 +1683,24 @@ 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*WindowScale, Height*WindowScale)` (4 when
`ebiten.SetWindowTitle(g.Title)`, then `ebiten.RunGame(&engine{g})`. unset), `ebiten.SetWindowTitle(g.Title)`, then
`ebiten.RunGame(&engine{g})`.
### 15.1 `engine.Update` ### 15.1 `engine.Update`
``` ```
poll input poll input
update transition update transition
pump the action queue // start the next Do() when nothing is running
if transition fading out → return if transition fading out → return
if scriptRunner != nil: if scriptRunner != nil:
@@ -1435,7 +1709,8 @@ if scriptRunner != nil:
return return
clear hoverLabel clear hoverLabel
for w in reversed(UIManager): for w in WidgetManager, top layer down:
if not WidgetVisible(w): continue
w.Tick(uictx) // widgets consume input top-down w.Tick(uictx) // widgets consume input top-down
handleSceneInput() // hotspot resolution + right-click reset handleSceneInput() // hotspot resolution + right-click reset
@@ -1449,7 +1724,8 @@ 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):
if not WidgetVisible(w): continue
w.Draw(screen, uictx) w.Draw(screen, uictx)
draw transition overlay draw transition overlay
``` ```
@@ -1463,7 +1739,8 @@ Runs only after every widget had a chance. The flow:
`selectedVerb` to `"look"`. `selectedVerb` to `"look"`.
3. On left-click: 3. On left-click:
- If an item is selected, try `hotspot.OnUseWith[item]`, then - If an item is selected, try `hotspot.OnUseWith[item]`, then
`item.OnUseWith[hotspot]`. Fail with a "Nem ehhez." flash. `item.OnUseWith[hotspot]`, then `Game.UseWithFail`, and only then a
"Nem ehhez." flash.
- Otherwise look up `hotspot.handler(selectedVerb)`. Fall back to the - Otherwise look up `hotspot.handler(selectedVerb)`. Fall back to the
verb's `Default` action. Fail with "Semmi említésre méltó." verb's `Default` action. Fail with "Semmi említésre méltó."
4. On every successful click, push a `LogAction` line so the `ChatLog` 4. On every successful click, push a `LogAction` line so the `ChatLog`
@@ -1530,6 +1807,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 +1830,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 +1954,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 +1981,9 @@ 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.scene_nav.go # SceneNav widget (dev)
├── ui.manager.go # UIManager alias + reversed/ordered iterators ├── ui.widget.go # Widget interface, Layer, Gated, UICtx, Size, Align
├── 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
+27 -3
View File
@@ -213,6 +213,7 @@ type sayRunner struct {
elapsed float64 elapsed float64
duration float64 duration float64
started bool started bool
bubble bool
} }
func Say(speaker, text string) Action { return &sayAction{speaker: speaker, text: text} } func Say(speaker, text string) Action { return &sayAction{speaker: speaker, text: text} }
@@ -223,8 +224,11 @@ func (r *sayRunner) Tick(ctx *Ctx) Status {
if !r.started { if !r.started {
r.started = true r.started = true
// duration scales with text length, with a 1.2s floor // duration scales with text length, with a 1.2s floor
r.duration = 1.2 + float64(len(r.spec.text))*0.05 r.duration = 1.2 + float64(len([]rune(r.spec.text)))*0.05
ctx.Game.SetSpeech(r.spec.speaker, r.spec.text) r.bubble = ctx.Game.VoiceOf(r.spec.speaker) == VoiceBubble
if r.bubble {
ctx.Game.SetSpeech(r.spec.speaker, r.spec.text)
}
ctx.Game.LogResponse(r.spec.speaker, r.spec.text) ctx.Game.LogResponse(r.spec.speaker, r.spec.text)
} }
r.elapsed += ctx.DT r.elapsed += ctx.DT
@@ -233,7 +237,9 @@ func (r *sayRunner) Tick(ctx *Ctx) Status {
r.elapsed = r.duration r.elapsed = r.duration
} }
if r.elapsed >= r.duration { if r.elapsed >= r.duration {
ctx.Game.ClearSpeech() if r.bubble {
ctx.Game.ClearSpeech()
}
return StatusDone return StatusDone
} }
return StatusRunning return StatusRunning
@@ -250,6 +256,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 }
+28 -3
View File
@@ -3,15 +3,40 @@ package inkwell
import "image/color" import "image/color"
type Character struct { type Character struct {
Name string Name string
// Label is what the UI shows instead of Name — a code name, a title,
// a shout. Empty means the Name is presentable as it stands.
Label string
Sprite string // Asset.Name (placeholder if missing) Sprite string // Asset.Name (placeholder if missing)
Animations map[string]AnimationClip Animations map[string]AnimationClip
Speed float64 Speed float64
SpeechColor color.Color SpeechColor color.Color
Start Point // Voice decides where Say puts this character's lines.
Voice Voice
// Start is where the character stands in a scene that lists no actors
// of its own, and Game.Player names the one placed there.
Start Point
// Size hints used when the sprite is a placeholder rectangle. // Size hints used when the sprite is a placeholder rectangle.
W, H float64 W, H float64
} }
func (c Character) GetName() string { return c.Name } // Voice is how a character reaches the player: over the scene as a speech
// bubble, or through the message log alone — a radio, a tape, a narrator
// that has no body to speak from.
type Voice int
const (
VoiceBubble Voice = iota // speech bubble over the scene, plus the log
VoiceLog // the log alone
)
func (c Character) GetName() string { return c.Name }
// DisplayName is the Label if there is one, the Name otherwise.
func (c Character) DisplayName() string {
if c.Label != "" {
return c.Label
}
return c.Name
}
func (c Character) TypeLabel() string { return "character" } func (c Character) TypeLabel() string { return "character" }
+85 -38
View File
@@ -1,72 +1,119 @@
package inkwell package inkwell
import ( import (
"image"
"image/color" "image/color"
"unicode/utf8"
"github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil" "github.com/hajimehoshi/ebiten/v2/ebitenutil"
) )
// drawText is a minimal text draw using ebiten's built-in debug font. // The built-in debug font is a fixed 6×16 cell. Every layout measurement
// The glyphs are 6×16-ish; good enough for a SCUMM-style 320×200 demo. // in the library — and in a domain's own widgets — derives from these.
const (
GlyphW = 6
GlyphH = 16
)
// textScratch is the offscreen the coloured draw goes through. It grows
// to fit the widest line ever drawn and is reused after that.
var textScratch *ebiten.Image
// drawText renders s in colour c. ebitenutil.DebugPrintAt only draws
// white, so the glyphs go onto an offscreen first and are blitted back
// tinted with a ColorScale.
func drawText(dst *ebiten.Image, s string, x, y int, c color.Color) { func drawText(dst *ebiten.Image, s string, x, y int, c color.Color) {
if s == "" { if s == "" {
return return
} }
// ebitenutil.DebugPrintAt only draws white; for color we render to a w, h := TextWidth(s)+GlyphW, GlyphH
// tiny offscreen, then ColorScale-tint when blitting. Simpler: just if textScratch == nil || textScratch.Bounds().Dx() < w || textScratch.Bounds().Dy() < h {
// use the white draw and skip color. (TODO: text/v2 once needed.) nw := w
_ = c if nw < 320 {
ebitenutil.DebugPrintAt(dst, s, x, y) nw = 320
}
textScratch = ebiten.NewImage(nw, h)
}
textScratch.Clear()
ebitenutil.DebugPrintAt(textScratch, s, 0, 0)
if c == nil {
c = color.White
}
r, g, b, a := c.RGBA()
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(float64(x), float64(y))
op.ColorScale.Scale(
float32(r)/0xffff, float32(g)/0xffff, float32(b)/0xffff, float32(a)/0xffff,
)
dst.DrawImage(textScratch.SubImage(image.Rect(0, 0, w, h)).(*ebiten.Image), op)
} }
// textWidth is a coarse pixel-width estimate, used for centering. // TextWidth is the pixel width of s in the built-in font.
func textWidth(s string) int { return len(s) * 6 } func TextWidth(s string) int { return utf8.RuneCountInString(s) * GlyphW }
// wrapText splits s at word boundaries into lines no wider than maxPx. // WrapText splits s at word boundaries into lines no wider than maxPx.
func wrapText(s string, maxPx int) []string { // A newline in s breaks the line where it stands.
if maxPx <= 0 || textWidth(s) <= maxPx { func WrapText(s string, maxPx int) []string {
if maxPx <= GlyphW || TextWidth(s) <= maxPx {
return []string{s} return []string{s}
} }
var ( var out []string
out []string line, word := "", ""
line string
)
flush := func() { flush := func() {
if line != "" { if line != "" {
out = append(out, line) out = append(out, line)
line = "" line = ""
} }
} }
word := "" emit := func() {
for _, r := range s { if word == "" {
if r == ' ' || r == '\n' { return
if line == "" {
line = word
} else if textWidth(line+" "+word) <= maxPx {
line += " " + word
} else {
flush()
line = word
}
word = ""
if r == '\n' {
flush()
}
continue
} }
word += string(r) switch {
} case line == "":
if word != "" {
if line == "" {
line = word line = word
} else if textWidth(line+" "+word) <= maxPx { case TextWidth(line+" "+word) <= maxPx:
line += " " + word line += " " + word
} else { default:
flush() flush()
line = word line = word
} }
word = ""
} }
for _, r := range s {
switch r {
case ' ':
emit()
case '\n':
emit()
flush()
default:
word += string(r)
}
}
emit()
flush() flush()
if len(out) == 0 {
return []string{s}
}
return out return out
} }
// ClipText cuts s to maxPx pixels, ending in an ellipsis when it had to
// cut. A width with no room at all returns the empty string.
func ClipText(s string, maxPx int) string {
r := []rune(s)
max := maxPx / GlyphW
switch {
case max <= 0:
return ""
case len(r) <= max:
return s
case max == 1:
return string(r[:1])
default:
return string(r[:max-1]) + "…"
}
}
+16 -5
View File
@@ -5,14 +5,18 @@ import (
) )
// Run validates the game, then enters the ebiten main loop. The window is // Run validates the game, then enters the ebiten main loop. The window is
// sized to 4× the internal resolution. // sized to Game.WindowScale times the internal resolution, 4× by default.
func Run(g *Game) error { func Run(g *Game) error {
// The domain may have swapped a manager in since NewGame, so the parts
// 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,14 +34,21 @@ 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")
} }
ebiten.SetWindowSize(g.Width*4, g.Height*4) scale := g.WindowScale
if scale <= 0 {
scale = 4
}
ebiten.SetWindowSize(g.Width*scale, g.Height*scale)
ebiten.SetWindowTitle(g.Title) ebiten.SetWindowTitle(g.Title)
ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled) ebiten.SetWindowResizingMode(ebiten.WindowResizingModeEnabled)
return ebiten.RunGame(&engine{g: g}) return ebiten.RunGame(&engine{g: g})
+20 -6
View File
@@ -25,6 +25,7 @@ func (e *engine) Update() error {
g.Input.poll() g.Input.poll()
g.transition.update(dt) g.transition.update(dt)
g.pumpQueue()
if g.transition.active && g.transition.out { if g.transition.active && g.transition.out {
return nil return nil
@@ -58,10 +59,14 @@ func (e *engine) Update() error {
// race a cutscene that's about to start. // race a cutscene that's about to start.
g.tickTriggers() g.tickTriggers()
// Top-down input: the widget drawn last (= registered last) gets the // Top-down input: the widget on the highest layer gets the click
// click first, then the next-to-last, etc. A widget signals "I took it" // first, then the one below it, etc. A widget signals "I took it"
// via g.Input.ConsumeLeft / ConsumeRight. // via g.Input.ConsumeLeft / ConsumeRight.
for _, w := range reversedWidgets(g.UIManager) { cctx := g.makeCtx()
for _, w := range reversedWidgets(g.WidgetManager) {
if !WidgetVisible(cctx, w) {
continue
}
w.Tick(uictx) w.Tick(uictx)
} }
@@ -135,7 +140,12 @@ func (g *Game) invokeHotspotVerb(h *Hotspot, verb string) {
return return
} }
} }
g.FlashLine("Nem ehhez.") if g.UseWithFail != nil {
g.LogAction("> " + verbLabel(g, "use") + " " + sel + " ezen: " + targetLabel)
g.queueAction(g.UseWithFail(sel, h), "useWith fail")
} else {
g.FlashLine("Nem ehhez.")
}
g.Inventory.Select("") g.Inventory.Select("")
return return
} }
@@ -190,9 +200,13 @@ func (e *engine) Draw(screen *ebiten.Image) {
drawCharacter(screen, g, c) drawCharacter(screen, g, c)
} }
// widgets in registration order // widgets from the bottom layer up
uictx := &UICtx{Game: g, DT: 1.0 / 60.0} uictx := &UICtx{Game: g, DT: 1.0 / 60.0}
for _, w := range orderedWidgets(g.UIManager) { cctx := g.makeCtx()
for _, w := range orderedWidgets(g.WidgetManager) {
if !WidgetVisible(cctx, w) {
continue
}
w.Draw(screen, uictx) w.Draw(screen, uictx)
} }
+184 -34
View File
@@ -21,9 +21,37 @@ 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
// UseWithFail supplies the response when the player uses an item on a
// hotspot nobody paired it with. Nil falls back to a flash line.
UseWithFail func(item string, h *Hotspot) Action
// Player names the character a scene gets when it lists no actors of
// its own, placed at that character's Start. Empty leaves such a
// scene unpeopled.
Player string
// Walkboxes is the floor a scene walks on when it declares none of
// its own. Nil means no routing in those scenes.
Walkboxes []Polygon
// WindowScale multiplies the internal resolution when Run sizes the
// window. 0 means 4.
WindowScale int
State *State State *State
Inventory *Inventory Inventory *Inventory
Audio *AudioPlayer Audio *AudioPlayer
@@ -31,25 +59,29 @@ 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 queue []Action
currentScene string loaded *loadedAssets
chars map[string]*runtimeChar currentScene string
scriptRunner Runner previousScene string
scriptCtx *Ctx exitHotspots map[string][]Hotspot
transition *transition chars map[string]*runtimeChar
selectedVerb string scriptRunner Runner
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 +108,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
} }
@@ -101,6 +133,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 +149,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 +161,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,16 +170,63 @@ 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 ------------------------------------------
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 +276,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
} }
@@ -211,8 +293,7 @@ func (g *Game) CharacterInScene(name string) bool {
if g.currentScene == "" { if g.currentScene == "" {
return false return false
} }
s := g.SceneManager.MustGet(g.currentScene) for _, a := range g.sceneActors(g.SceneManager.MustGet(g.currentScene)) {
for _, a := range s.Actors {
if a.CharacterName == name { if a.CharacterName == name {
return true return true
} }
@@ -220,6 +301,24 @@ func (g *Game) CharacterInScene(name string) bool {
return false return false
} }
// VoiceOf reports how a character reaches the player. An unregistered
// name speaks in a bubble, which is what a bare Say always did.
func (g *Game) VoiceOf(name string) Voice {
if c, ok := g.CharacterManager.Get(name); ok {
return c.Voice
}
return VoiceBubble
}
// CharacterLabel is what the UI should print for a character: the Label
// if the entity carries one, the name otherwise.
func (g *Game) CharacterLabel(name string) string {
if c, ok := g.CharacterManager.Get(name); ok {
return c.DisplayName()
}
return name
}
// DrawText is a thin wrapper that lets widgets accept a Theme-supplied color // DrawText is a thin wrapper that lets widgets accept a Theme-supplied color
// today and switch to text/v2 later without changing call sites. // today and switch to text/v2 later without changing call sites.
func (g *Game) DrawText(dst *ebiten.Image, s string, x, y int, c color.Color) { func (g *Game) DrawText(dst *ebiten.Image, s string, x, y int, c color.Color) {
@@ -261,11 +360,24 @@ func (g *Game) Validate() error {
if s.Music != "" && !g.AssetManager.Has(s.Music) { if s.Music != "" && !g.AssetManager.Has(s.Music) {
return fmt.Errorf("%w: scene %q music %q", ErrUnknownAsset, name, s.Music) return fmt.Errorf("%w: scene %q music %q", ErrUnknownAsset, name, s.Music)
} }
for _, a := range s.Actors { for _, a := range g.sceneActors(s) {
if !g.CharacterManager.Has(a.CharacterName) { if !g.CharacterManager.Has(a.CharacterName) {
return fmt.Errorf("%w: scene %q actor %q", ErrUnknownCharacter, name, a.CharacterName) return fmt.Errorf("%w: scene %q actor %q", ErrUnknownCharacter, name, a.CharacterName)
} }
} }
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.Player != "" && !g.CharacterManager.Has(g.Player) {
return fmt.Errorf("%w: player %q", ErrUnknownCharacter, g.Player)
} }
if g.activeTheme == "" || !g.ThemeManager.Has(g.activeTheme) { if g.activeTheme == "" || !g.ThemeManager.Has(g.activeTheme) {
return fmt.Errorf("inkwell: no active theme (got %q)", g.activeTheme) return fmt.Errorf("inkwell: no active theme (got %q)", g.activeTheme)
@@ -309,6 +421,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 {
@@ -329,9 +444,25 @@ func (g *Game) changeScene(name string) {
}) })
} }
// sceneActors is the cast a scene actually shows: its own list, or the
// player alone when the scene names nobody.
func (g *Game) sceneActors(s Scene) []SceneActor {
if s.Actors != nil || g.Player == "" {
return s.Actors
}
def, ok := g.CharacterManager.Get(g.Player)
if !ok {
return nil
}
return []SceneActor{{
CharacterName: def.Name,
At: def.Start,
}}
}
func (g *Game) placeActors(sceneName string) { func (g *Game) placeActors(sceneName string) {
s := g.SceneManager.MustGet(sceneName) s := g.SceneManager.MustGet(sceneName)
for _, a := range s.Actors { for _, a := range g.sceneActors(s) {
def := g.CharacterManager.MustGet(a.CharacterName) def := g.CharacterManager.MustGet(a.CharacterName)
rc, ok := g.chars[def.Name] rc, ok := g.chars[def.Name]
if !ok { if !ok {
@@ -359,6 +490,9 @@ func (g *Game) walkCharacter(name string, to Point) {
if g.currentScene != "" { if g.currentScene != "" {
s := g.SceneManager.MustGet(g.currentScene) s := g.SceneManager.MustGet(g.currentScene)
boxes = s.Walkboxes boxes = s.Walkboxes
if boxes == nil {
boxes = g.Walkboxes
}
} }
path := pathfind(c.pos, to, boxes) path := pathfind(c.pos, to, boxes)
if len(path) == 0 { if len(path) == 0 {
@@ -415,17 +549,33 @@ func (g *Game) tickCharacters(dt float64) {
// ----- script & dialog plumbing ----------------------------------------- // ----- script & dialog plumbing -----------------------------------------
// Do queues an action. Queued actions run one at a time and in order —
// the engine starts the next one on the first frame the previous one is
// done, so a widget or a domain pump can hand work in at any moment.
func (g *Game) Do(a Action) {
if a == nil {
return
}
g.queue = append(g.queue, a)
}
func (g *Game) queueAction(a Action, label string) { func (g *Game) queueAction(a Action, label string) {
if a == nil { if a == nil {
return return
} }
if g.scriptRunner != nil { logf("queueAction %s", label)
logf("queueAction: %s ignored, runner busy", label) g.Do(a)
}
// pumpQueue starts the next queued action when nothing else is running.
func (g *Game) pumpQueue() {
if g.scriptRunner != nil || len(g.queue) == 0 {
return return
} }
a := g.queue[0]
g.queue = g.queue[1:]
g.scriptRunner = a.Start() g.scriptRunner = a.Start()
g.scriptCtx = g.makeCtx() g.scriptCtx = g.makeCtx()
logf("queueAction %s", label)
} }
func (g *Game) startDialogue(name string) { func (g *Game) startDialogue(name string) {
+21
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)
}
}
+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
} }
+5 -2
View File
@@ -21,14 +21,17 @@ type CharStat struct {
// works across scenes. // works across scenes.
type CharacterPanel struct { type CharacterPanel struct {
Name string Name string
When Condition
Bounds Rectangle Bounds Rectangle
Character string // Character.Name to display Character string // Character.Name to display
Title string // "PLAYER", "NPC", role badge Title string // "PLAYER", "NPC", role badge
Stats []CharStat Stats []CharStat
} }
func (c *CharacterPanel) GetName() string { return c.Name } func (c *CharacterPanel) GetName() string { return c.Name }
func (c *CharacterPanel) Tick(ctx *UICtx) {} func (c *CharacterPanel) VisibleWhen() Condition { return c.When }
func (c *CharacterPanel) Layer() Layer { return LayerHUD }
func (c *CharacterPanel) Tick(ctx *UICtx) {}
func (c *CharacterPanel) Draw(dst *ebiten.Image, ctx *UICtx) { func (c *CharacterPanel) Draw(dst *ebiten.Image, ctx *UICtx) {
g := ctx.Game g := ctx.Game
+6 -3
View File
@@ -12,6 +12,7 @@ import (
// messages scroll up out of view as the buffer fills. // messages scroll up out of view as the buffer fills.
type ChatLog struct { type ChatLog struct {
Name string Name string
When Condition
Bounds Rectangle Bounds Rectangle
LineHeight int LineHeight int
Padding int Padding int
@@ -19,8 +20,10 @@ type ChatLog struct {
ShowBorder bool ShowBorder bool
} }
func (c *ChatLog) GetName() string { return c.Name } func (c *ChatLog) GetName() string { return c.Name }
func (c *ChatLog) Tick(ctx *UICtx) {} func (c *ChatLog) VisibleWhen() Condition { return c.When }
func (c *ChatLog) Layer() Layer { return LayerHUD }
func (c *ChatLog) Tick(ctx *UICtx) {}
func (c *ChatLog) Draw(dst *ebiten.Image, ctx *UICtx) { func (c *ChatLog) Draw(dst *ebiten.Image, ctx *UICtx) {
g := ctx.Game g := ctx.Game
@@ -70,7 +73,7 @@ func (c *ChatLog) Draw(dst *ebiten.Image, ctx *UICtx) {
if m.Kind == LogResponse && m.Speaker != "" { if m.Kind == LogResponse && m.Speaker != "" {
txt = m.Speaker + ": " + m.Text txt = m.Speaker + ": " + m.Text
} }
for _, w := range wrapText(txt, wrapW) { for _, w := range WrapText(txt, wrapW) {
rendered = append(rendered, line{text: w, col: col}) rendered = append(rendered, line{text: w, col: col})
} }
} }
+5 -2
View File
@@ -10,10 +10,13 @@ import (
// default crosshair. Registered last so it draws on top. // default crosshair. Registered last so it draws on top.
type Cursor struct { type Cursor struct {
Name string Name string
When Condition
} }
func (c *Cursor) GetName() string { return c.Name } func (c *Cursor) GetName() string { return c.Name }
func (c *Cursor) Tick(ctx *UICtx) {} func (c *Cursor) VisibleWhen() Condition { return c.When }
func (c *Cursor) Layer() Layer { return LayerCursor }
func (c *Cursor) Tick(ctx *UICtx) {}
func (c *Cursor) Draw(dst *ebiten.Image, ctx *UICtx) { func (c *Cursor) Draw(dst *ebiten.Image, ctx *UICtx) {
g := ctx.Game g := ctx.Game
+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"})
} }
+5 -2
View File
@@ -11,12 +11,15 @@ import (
// so nothing underneath reacts. // so nothing underneath reacts.
type DialogBox struct { type DialogBox struct {
Name string Name string
When Condition
Bounds Rectangle Bounds Rectangle
LineHeight int LineHeight int
Padding int Padding int
} }
func (d *DialogBox) GetName() string { return d.Name } func (d *DialogBox) GetName() string { return d.Name }
func (d *DialogBox) VisibleWhen() Condition { return d.When }
func (d *DialogBox) Layer() Layer { return LayerDialog }
func (d *DialogBox) Tick(ctx *UICtx) { func (d *DialogBox) Tick(ctx *UICtx) {
g := ctx.Game g := ctx.Game
@@ -111,7 +114,7 @@ func (d *DialogBox) Draw(dst *ebiten.Image, ctx *UICtx) {
if dlg.LineIdx >= 0 && dlg.LineIdx < len(dlg.Node.Lines) { if dlg.LineIdx >= 0 && dlg.LineIdx < len(dlg.Node.Lines) {
ln := dlg.Node.Lines[dlg.LineIdx] ln := dlg.Node.Lines[dlg.LineIdx]
g.DrawText(dst, ln.Speaker+":", int(b.X)+pad, int(b.Y)+2, th.DialogSpeaker) g.DrawText(dst, ln.Speaker+":", int(b.X)+pad, int(b.Y)+2, th.DialogSpeaker)
lines := wrapText(ln.Text, int(b.W)-pad*2) lines := WrapText(ln.Text, int(b.W)-pad*2)
for i, l := range lines { for i, l := range lines {
g.DrawText(dst, l, int(b.X)+pad, int(b.Y)+18+i*lh, th.DialogText) g.DrawText(dst, l, int(b.X)+pad, int(b.Y)+18+i*lh, th.DialogText)
} }
+5 -2
View File
@@ -9,9 +9,12 @@ import (
// While active it consumes every input so nothing underneath reacts. // While active it consumes every input so nothing underneath reacts.
type EndCard struct { type EndCard struct {
Name string Name string
When Condition
} }
func (e *EndCard) GetName() string { return e.Name } func (e *EndCard) GetName() string { return e.Name }
func (e *EndCard) VisibleWhen() Condition { return e.When }
func (e *EndCard) Layer() Layer { return LayerCurtain }
func (e *EndCard) Tick(ctx *UICtx) { func (e *EndCard) Tick(ctx *UICtx) {
if ctx.Game.endCard == "" { if ctx.Game.endCard == "" {
@@ -33,6 +36,6 @@ func (e *EndCard) Draw(dst *ebiten.Image, ctx *UICtx) {
} }
th := g.Theme() th := g.Theme()
vector.DrawFilledRect(dst, 0, 0, float32(g.Width), float32(g.Height), th.EndCardBG, false) vector.DrawFilledRect(dst, 0, 0, float32(g.Width), float32(g.Height), th.EndCardBG, false)
w := textWidth(g.endCard) w := TextWidth(g.endCard)
g.DrawText(dst, g.endCard, (g.Width-w)/2, g.Height/2-8, th.EndCardText) g.DrawText(dst, g.endCard, (g.Width-w)/2, g.Height/2-8, th.EndCardText)
} }
+5 -3
View File
@@ -10,12 +10,15 @@ import (
// scene. F1 toggles it at runtime; the default is off. // scene. F1 toggles it at runtime; the default is off.
type HotspotDebug struct { type HotspotDebug struct {
Name string Name string
When Condition
Enabled bool Enabled bool
// ToggleKey, if non-zero, overrides the default F1 toggle. // ToggleKey, if non-zero, overrides the default F1 toggle.
ToggleKey ebiten.Key ToggleKey ebiten.Key
} }
func (h *HotspotDebug) GetName() string { return h.Name } func (h *HotspotDebug) GetName() string { return h.Name }
func (h *HotspotDebug) VisibleWhen() Condition { return h.When }
func (h *HotspotDebug) Layer() Layer { return LayerScene }
func (h *HotspotDebug) Tick(ctx *UICtx) { func (h *HotspotDebug) Tick(ctx *UICtx) {
key := h.ToggleKey key := h.ToggleKey
@@ -36,8 +39,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 != "" {
+4 -1
View File
@@ -10,6 +10,7 @@ import (
// other verb, it toggles selection (the cursor "picks up" the item). // other verb, it toggles selection (the cursor "picks up" the item).
type InventoryBar struct { type InventoryBar struct {
Name string Name string
When Condition
Origin Point Origin Point
Slots int Slots int
Cols int Cols int
@@ -19,7 +20,9 @@ type InventoryBar struct {
PanelBG bool PanelBG bool
} }
func (b *InventoryBar) GetName() string { return b.Name } func (b *InventoryBar) GetName() string { return b.Name }
func (b *InventoryBar) VisibleWhen() Condition { return b.When }
func (b *InventoryBar) Layer() Layer { return LayerHUD }
func (b *InventoryBar) 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
} }
+5 -2
View File
@@ -11,6 +11,7 @@ import (
// backdrop for grouping other widgets or as a chat/notebook frame. // backdrop for grouping other widgets or as a chat/notebook frame.
type Panel struct { type Panel struct {
Name string Name string
When Condition
Bounds Rectangle Bounds Rectangle
// BG, if nil, falls back to the active Theme.PanelBG. // BG, if nil, falls back to the active Theme.PanelBG.
BG color.Color BG color.Color
@@ -20,8 +21,10 @@ type Panel struct {
BorderColor color.Color BorderColor color.Color
} }
func (p *Panel) GetName() string { return p.Name } func (p *Panel) GetName() string { return p.Name }
func (p *Panel) Tick(ctx *UICtx) {} func (p *Panel) VisibleWhen() Condition { return p.When }
func (p *Panel) Layer() Layer { return LayerPanel }
func (p *Panel) Tick(ctx *UICtx) {}
func (p *Panel) Draw(dst *ebiten.Image, ctx *UICtx) { func (p *Panel) Draw(dst *ebiten.Image, ctx *UICtx) {
bg := p.BG bg := p.BG
+49
View File
@@ -0,0 +1,49 @@
package inkwell
import (
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
)
// SceneNav is a development aid: the left and right arrow keys step
// through the registered scenes in registration order, wrapping at both
// ends. It draws nothing. Gate it with When — or leave it unregistered —
// when a build should not let the player walk the catalogue.
type SceneNav struct {
Name string
When Condition
}
func (n *SceneNav) GetName() string { return n.Name }
func (n *SceneNav) VisibleWhen() Condition { return n.When }
func (n *SceneNav) Layer() Layer { return LayerScene }
func (n *SceneNav) Draw(dst *ebiten.Image, ctx *UICtx) {}
func (n *SceneNav) Tick(ctx *UICtx) {
step := 0
switch {
case inpututil.IsKeyJustPressed(ebiten.KeyArrowRight):
step = 1
case inpututil.IsKeyJustPressed(ebiten.KeyArrowLeft):
step = -1
default:
return
}
if next := n.neighbour(ctx.Game, step); next != "" {
ctx.Game.Do(GoTo(next))
}
}
func (n *SceneNav) neighbour(g *Game, step int) string {
deck := g.SceneManager.Names()
if len(deck) < 2 {
return ""
}
for i, name := range deck {
if name == g.CurrentScene() {
return deck[((i+step)%len(deck)+len(deck))%len(deck)]
}
}
return deck[0]
}
+7 -4
View File
@@ -11,14 +11,17 @@ import (
// character (or at FallbackY if the speaker has no on-screen position). // character (or at FallbackY if the speaker has no on-screen position).
type SpeechBubble struct { type SpeechBubble struct {
Name string Name string
When Condition
MaxWidth int MaxWidth int
Padding int Padding int
OffsetY int OffsetY int
FallbackY int FallbackY int
} }
func (s *SpeechBubble) GetName() string { return s.Name } func (s *SpeechBubble) GetName() string { return s.Name }
func (s *SpeechBubble) Tick(ctx *UICtx) {} func (s *SpeechBubble) VisibleWhen() Condition { return s.When }
func (s *SpeechBubble) Layer() Layer { return LayerSpeech }
func (s *SpeechBubble) Tick(ctx *UICtx) {}
func (s *SpeechBubble) Draw(dst *ebiten.Image, ctx *UICtx) { func (s *SpeechBubble) Draw(dst *ebiten.Image, ctx *UICtx) {
g := ctx.Game g := ctx.Game
@@ -34,10 +37,10 @@ func (s *SpeechBubble) Draw(dst *ebiten.Image, ctx *UICtx) {
if pad <= 0 { if pad <= 0 {
pad = 3 pad = 3
} }
lines := wrapText(sp.Text, maxW) lines := WrapText(sp.Text, maxW)
w := 0 w := 0
for _, ln := range lines { for _, ln := range lines {
if t := textWidth(ln); t > w { if t := TextWidth(ln); t > w {
w = t w = t
} }
} }
+5 -2
View File
@@ -6,13 +6,16 @@ import "github.com/hajimehoshi/ebiten/v2"
// by FlashLine) or the hover-hint (verb + target under the cursor). // by FlashLine) or the hover-hint (verb + target under the cursor).
type StatusLine struct { type StatusLine struct {
Name string Name string
When Condition
Y int Y int
Align Align Align Align
// ScreenWidth, if 0, uses Game.Width. // ScreenWidth, if 0, uses Game.Width.
ScreenWidth int ScreenWidth int
} }
func (s *StatusLine) GetName() string { return s.Name } func (s *StatusLine) GetName() string { return s.Name }
func (s *StatusLine) VisibleWhen() Condition { return s.When }
func (s *StatusLine) Layer() Layer { return LayerHUD }
func (s *StatusLine) Tick(ctx *UICtx) { func (s *StatusLine) Tick(ctx *UICtx) {
g := ctx.Game g := ctx.Game
@@ -40,7 +43,7 @@ func (s *StatusLine) Draw(dst *ebiten.Image, ctx *UICtx) {
if text == "" { if text == "" {
return return
} }
tw := textWidth(text) tw := TextWidth(text)
var x int var x int
switch s.Align { switch s.Align {
case AlignLeft: case AlignLeft:
+17 -4
View File
@@ -12,6 +12,7 @@ import (
// string on the right. Empty fields are skipped. // string on the right. Empty fields are skipped.
type TopBar struct { type TopBar struct {
Name string Name string
When Condition
Height int Height int
// LeftText overrides the auto-discovered scene Title/Name. // LeftText overrides the auto-discovered scene Title/Name.
@@ -24,10 +25,17 @@ type TopBar struct {
// TimeVar is a State Var key whose value is printed at the right // TimeVar is a State Var key whose value is printed at the right
// edge (e.g. "Day 2 - 14:32"); empty = nothing on the right. // edge (e.g. "Day 2 - 14:32"); empty = nothing on the right.
TimeVar string TimeVar string
// NoteVar is a State Var key whose value is appended to the left
// text after an em dash — "ALLEY — paused". Empty or unset = no
// note, and the title stands alone.
NoteVar string
} }
func (t *TopBar) GetName() string { return t.Name } func (t *TopBar) GetName() string { return t.Name }
func (t *TopBar) Tick(ctx *UICtx) {} func (t *TopBar) VisibleWhen() Condition { return t.When }
func (t *TopBar) Layer() Layer { return LayerHUD }
func (t *TopBar) Tick(ctx *UICtx) {}
func (t *TopBar) Draw(dst *ebiten.Image, ctx *UICtx) { func (t *TopBar) Draw(dst *ebiten.Image, ctx *UICtx) {
g := ctx.Game g := ctx.Game
@@ -47,6 +55,11 @@ func (t *TopBar) Draw(dst *ebiten.Image, ctx *UICtx) {
left = s.Name left = s.Name
} }
} }
if t.NoteVar != "" {
if note := fmt.Sprint(g.State.Var(t.NoteVar)); note != "" && note != "<nil>" {
left += " — " + note
}
}
if left != "" { if left != "" {
g.DrawText(dst, left, 4, -1, th.TopBarText) g.DrawText(dst, left, 4, -1, th.TopBarText)
} }
@@ -59,13 +72,13 @@ func (t *TopBar) Draw(dst *ebiten.Image, ctx *UICtx) {
} else { } else {
sc = fmt.Sprintf("Score: %v", v) sc = fmt.Sprintf("Score: %v", v)
} }
x := (g.Width - textWidth(sc)) / 2 x := (g.Width - TextWidth(sc)) / 2
g.DrawText(dst, sc, x, -1, th.TopBarAccent) g.DrawText(dst, sc, x, -1, th.TopBarAccent)
} }
if t.TimeVar != "" { if t.TimeVar != "" {
tm := fmt.Sprint(g.State.Var(t.TimeVar)) tm := fmt.Sprint(g.State.Var(t.TimeVar))
x := g.Width - textWidth(tm) - 4 x := g.Width - TextWidth(tm) - 4
g.DrawText(dst, tm, x, -1, th.TopBarText) g.DrawText(dst, tm, x, -1, th.TopBarText)
} }
} }
+4 -1
View File
@@ -9,6 +9,7 @@ import (
// VerbManager every frame so newly-registered verbs show up automatically. // VerbManager every frame so newly-registered verbs show up automatically.
type VerbBar struct { type VerbBar struct {
Name string Name string
When Condition
Origin Point Origin Point
Cols int Cols int
Button Size Button Size
@@ -18,7 +19,9 @@ type VerbBar struct {
PanelBG bool PanelBG bool
} }
func (v *VerbBar) GetName() string { return v.Name } func (v *VerbBar) GetName() string { return v.Name }
func (v *VerbBar) VisibleWhen() Condition { return v.When }
func (v *VerbBar) Layer() Layer { return LayerHUD }
func (v *VerbBar) buttons(ctx *UICtx) []verbButton { func (v *VerbBar) buttons(ctx *UICtx) []verbButton {
cols := v.Cols cols := v.Cols
+10 -4
View File
@@ -16,6 +16,7 @@ import (
// Both can coexist if you really want. // Both can coexist if you really want.
type RadialVerbs struct { type RadialVerbs struct {
Name string Name string
When Condition
Trigger MouseButton Trigger MouseButton
Radius float64 Radius float64
@@ -42,7 +43,9 @@ type RadialVerbs struct {
pendingHotspot *Hotspot // remembered hotspot when HotspotOnly opens the coin pendingHotspot *Hotspot // remembered hotspot when HotspotOnly opens the coin
} }
func (r *RadialVerbs) GetName() string { return r.Name } func (r *RadialVerbs) GetName() string { return r.Name }
func (r *RadialVerbs) VisibleWhen() Condition { return r.When }
func (r *RadialVerbs) Layer() Layer { return LayerMenu }
func (r *RadialVerbs) Tick(ctx *UICtx) { func (r *RadialVerbs) Tick(ctx *UICtx) {
g := ctx.Game g := ctx.Game
@@ -140,11 +143,14 @@ 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)
if !WidgetVisible(ctx.Game.makeCtx(), w) {
continue
}
// DialogBox advertises its bounds via clickBlocker even when no // DialogBox advertises its bounds via clickBlocker even when no
// dialog is open, so the verb-coin would refuse to pop up over // dialog is open, so the verb-coin would refuse to pop up over
// scenery that happens to sit under the dialog rect. Skip the // scenery that happens to sit under the dialog rect. Skip the
@@ -247,7 +253,7 @@ func (r *RadialVerbs) Draw(dst *ebiten.Image, ctx *UICtx) {
ly := r.center.Y + math.Sin(a)*labelDist ly := r.center.Y + math.Sin(a)*labelDist
label := r.labelFor(g, name) label := r.labelFor(g, name)
tw := textWidth(label) tw := TextWidth(label)
col := th.VerbButtonText col := th.VerbButtonText
isSelected := name == selected isSelected := name == selected
isHover := i == hover isHover := i == hover
+67 -4
View File
@@ -9,17 +9,80 @@ 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
}
// Gated is implemented by a widget that can be switched off by game
// state — every built-in is, through its When field. A widget that does
// not implement it, or whose condition is nil, is always live.
type Gated interface {
VisibleWhen() Condition
}
// WidgetVisible reports whether a widget takes part in this frame at all.
// A widget switched off neither ticks nor draws, and does not block a
// click, so a HUD can be hidden for a cutscene without unregistering it.
func WidgetVisible(ctx *Ctx, w Widget) bool {
g, ok := w.(Gated)
if !ok {
return true
}
c := g.VisibleWhen()
return c == nil || c.Eval(ctx)
}
// LayerOf reports the layer a widget paints on. A wrapper widget that
// 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 {