Files
mr.zero c5c0c02c03
ci/woodpecker/push/ebitengine Pipeline was successful
dirs
2026-08-30 23:21:06 +02:00

373 lines
20 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Real World
Point & click adventure — game two of the Norman Arc. Engine:
[inkwell](../../engines/inkwell).
The player is **Paul**, a junk dealer investigating what happened to Norman from
the outside. His companion is **Dex**, a Personal Tape riding in slot one of
Paul's **Armilla**. The world, the terminology and the entity catalogue live in
the wiki (`services/wiki-pages/pages/projects/realworld` and
`.../neumatronic-universe`); this file records the game-specific UI decisions.
## Running
inkwell is consumed as an ordinary Go module from the forge. Prerequisites:
Go 1.26+ and SSH access to `git.teletypegames.org`.
```bash
git clone ssh://git@git.teletypegames.org:2222/games/realworld
cd realworld
export GOPRIVATE=git.teletypegames.org # the Makefile sets this for its own targets
go mod tidy
go run .
```
```bash
go run . # the alley (beat 3)
go run . -finale # the finale — try the Nokia-punk theme switch
go run . -scene selector # the location selector: the map the city hangs off
go run . -scene hospital # any scene in the deck, e.g. to look at the art
```
Scene names are the constants in `names.manager.go` (`selector`, `paul_shop`,
`noodle_house`, `alley`, `norman_apartment`, `police_station`, `hackerspace`,
`ice_cream_shop`, `trinket_shop`, `small_restaurant`, `secret_club`,
`bbs_terminal`, `street`, `hospital`, `server_farm`, `secret_lab`,
`rooftop_hideout`, `public_bbs`, `bvk_branch`, `curator_shop`, `scrap_market`,
`samizdat_press`, `showroom`, `columbarium`).
To work against a local inkwell checkout, add a `replace` line temporarily —
and take it out before committing:
```bash
go mod edit -replace git.teletypegames.org/engines/inkwell=../../engines/inkwell
go mod edit -dropreplace git.teletypegames.org/engines/inkwell
```
## Building
```bash
make build # native binary into bin/
make wasm # dist/game.wasm + wasm_exec.js
make export VERSION=0.1 # zipped HTML/WASM bundle
make binaries VERSION=0.1 # win-x86, win-x64, linux-x64 zips
make clean
```
CI is Woodpecker; `.woodpecker.yaml` only names the platform, and the update
server serves the actual pipeline (`GET /build/config?platform=ebitengine`).
Release metadata lives in `metadata.json`.
| Control | |
|---|---|
| left click | run the selected verb |
| right click | verb coin (Look / Use / Talk / Take) |
| `←` `→` | previous / next scene, in file order, wrapping |
| `F1` | toggle hotspot outlines |
| `SPACE` | during a cutscene: let the tape speak, if it offered |
The scenes are connected to each other (see **The map** below); the arrow keys
are a reviewing tool on top of that, walking every scene in turn. The walk is
inert during a cutscene, a menu, or a stopped world, so it can never cut
across an authored beat.
Screenshots: `EBITEN_SCREENSHOT_KEY=q go run .`, then press `q` in the window.
## Layout
Internal resolution is **640×380**: the size of one painted background.
Integer scaling only; the window opens at 2×.
That size is not a taste call. inkwell scales a scene background over the
*whole* screen (`core.engine.go`), not over the region the HUD leaves free — so
at any screen size other than the art's own, every painting is squashed to fit.
The deck is drawn at 640×380, so the screen is 640×380 and the art lands pixel
for pixel. `TestSceneBackgroundsResolve` keeps the two in step: a background of
any other size fails the build.
It still gives the wiki's "320×200 VGA look" brief what it was after — a low,
wide VGA frame at an exact 2× of a 320-wide canvas — and it still keeps the
engine's fixed **6×16** debug font readable, at 4% of the screen height. (At
320×190 it would be 8%: rows overlap and the top bar cannot hold a line.)
The HUD is therefore an opaque strip laid over the foot of the painting, and it
is measured from the bottom and kept as short as its contents allow — a status
row, then the two rows of inventory slots, which are the tallest thing in it.
Every pixel it gives back is a pixel of art.
```
0 394│396 639
┌────────────────────────────────────────────────────────────────────┐
0 │ ALLEY — paused SRP: 1 tape │ TopBar (20)
20 ├────────────────────────────────────────────────────────────────────┤
│ scene — hotspots, characters, SpeechBubble │ Scene (242)
262├─────────────────────────────────────────┬──────────────────────────┤
│ Use hook on: floor grate │ DEX │
│ ┌───┬───┬───┬───┐ ┌───────┐┌────────┐ │ "The hook is a hand │ HUD (118)
│ └───┴───┴───┴───┘ │ 1 DEX ││ 2 — │ │ shorter than the gap." │
379└─────────────────────────────────────────┴──────────────────────────┘
InventoryBar TapeSlots TapeChannel
```
Every vertical measurement derives from `LineH` (glyph cell + leading) and from
`ScreenH`, both in `widget/widget.manager.go`, not from the wireframe deck's ratios, so
the layout cannot drift out of step with the font — or with the art — again;
`TestLayoutFitsTheFont` guards it. The tape channel comes out at 4 rows of 38
columns, which is about one and a half of Dex's remarks on screen at once.
The vertical divider at x=394 is the one proportion carried over from the
wireframe deck: 61.6% world, 38.4% tape channel. The deck's *vertical*
proportions are not carried over.
The dialogue box deliberately spans only the left region, so a tape can comment
*alongside* a conversation.
## The rule everything follows
Canon's central idea is the dual architecture: every machine has a digital side
and an ACP side, and the "agent-mediated focus" turns that into a UI paradigm —
one main text focus plus a side status panel, with the hand typing at the
digital side and the voice speaking to the ACP.
> **The hand holds the world. The voice speaks to the tape.**
>
> `WORLD` — mouse, the four verbs, inventory → Paul acts.
> `TAPE` — the `talk` verb and tape commentary → nothing happens in the world.
>
> There is always a visible, continuous separator between them, and the tape
> side never carries a button that reaches into the world.
Two consequences worth stating: there is **no text input anywhere** (the tape
channel is voice, opened with `talk`), and **colour carries the rule** — the
world and Paul speak in bone white, tapes in amber. If something is amber, a
tape said it.
There is also no "token" economy. The wireframe deck metered advice, but canon
has no such concept and the wiki describes Dex as commenting continuously. If
scarcity is ever wanted, the canon-native lever is the operator licence —
without one a Personal Tape runs in restricted mode, and Paul certainly has no
licence.
## Structure
One category, one package, one directory. A file still says which entity it
holds in its own name — `[category].[name].go`, the prefix repeated inside the
directory that already carries it, because a file called `alley.go` tells you
nothing in a list of open editor tabs.
```
main.go flags + inkwell.Run
inc/constants.go every name and state key; imports nothing
inc/boot/ New(Opts): the hand-off to the engine
inc/theme/ the Theme alias, RGB, realworld-93, nokia-punk
inc/world/ the run state and the custom actions
inc/tape/ the Tape entity and the lookups over it
inc/widget/ HUD layout, the visibility conditions, 14 widgets
inc/scene/ inc/background/ one file per location, twice
inc/item/ inc/character/ inc/dialog/ inc/script/
```
`inc` itself holds nothing but constants, which is why every package can import
it and it can import none of them. The consequence is that the assembly moved
down rather than up: `New` lives in `inc/boot`, because a package cannot be
imported by what it imports.
The layering is no longer a rule kept by hand — it is the import graph, and the
compiler rejects the arrow that points the wrong way:
```
inc ← theme ← world ← tape ← widget ← boot ← main
↑ ↑ ↑
└── content ───────────────────-┘
```
Adding a scene means adding `inc/scene/scene.<name>.go` and
`inc/background/background.<name>.go`. Nothing else moves.
Every category owns a manager, and they are all the engine's own
`inkwell.Manager[T]` — the same registry the `*Game` hangs its content off, kept
in registration order and addressed by name. There is no second registry type
here: the entity types are aliases of engine structs, so they already satisfy
`inkwell.Named` and need no help reading their own name. Each one lives in its
own package's manager file, next to the alias it holds, and it needs no prefix
because the package already is one:
```go
// inc/character/character.manager.go
type Character = inkwell.Character
var Manager = inkwell.NewManager[Character]()
```
An entity file is a literal that hands itself over in an `init()`:
```go
package background
func init() {
Manager.Register(Background{
Name: inc.BgServerFarm,
Path: "assets/bg/server_farm.png",
Kind: inkwell.AssetImage,
})
}
```
The `init()` only runs if something imports the package, so a package whose
entities nobody names by symbol needs a blank import in `inc/boot`. Exactly one
does — `tape`, everything else is imported for its `Manager` — and the list is
per package, not per entity, so adding a file is still adding a file.
Widgets go the same way, the engine's own included: `widget/widget.cursor.go` registers
an `inkwell.Cursor` exactly as `background/background.street.go` registers an image. What
they cannot take from alphabetical order is their place in the stack, so each
widget names a **layer**`LayerScene`, `LayerPanel`, `LayerHUD`, `LayerSpeech`,
`LayerDialog`, `LayerMenu`, `LayerCurtain`, `LayerCursor` — and inkwell draws
from the bottom layer up, ticks from the top layer down, and lets registration
order decide only ties inside one layer. That is why the HUD frame comes out
under the tape slots and the cursor over everything, whatever the file names
happen to be. The layer was added to inkwell for this; the alternative was a
registration list, which is the thing this package spent its life deleting.
The second thing a widget declares is when it is on screen at all: `When` is an
`inkwell.Condition` over game state, and the HUD's is `whenPlaying`, which reads
`VarMode` out of the engine's `State`. Hiding the HUD for a cutscene is
therefore one word per widget rather than a wrapper around each of them and an
`if` at the top of every `Draw`.
Tapes are the one entity type this game invents. `Tape` names the character
whose voice it is, the cassette that carries it and the dialogue it plays, one
file each — what a tape *sounds* like stays on the character, as
`Character.Label` and `Character.Voice`, because that is a fact about a speaker.
A tape's line is `inkwell.Say` like anybody else's; the engine sends it to the
log instead of a speech bubble because the character says so.
So adding an entity is adding a file, and there is no second list to keep in
step. The price is that registration order is file-name order: the arrow keys
walk the scenes alphabetically rather than in the concept-art deck's order.
That was a deliberate trade — the deck order was a list that had to be kept in
step with the files by hand, and the deck is a thing to look at, not a thing to
play through.
The game's own catalogue is readable without going through the engine:
`scene.Manager.Get("alley")` answers long before there is a `*Game` to ask. And
when the game is built, nothing is copied into it — `New` assigns our managers
onto the `*Game` in place of the empty ones `NewGame` made, so engine and game
share one registry per category instead of keeping two of them in step. Themes
are the exception: `NewGame` seeds its theme manager with four presets, so ours
are added to that set rather than replacing it.
The defaults a scene may leave out are not written into it at all any more:
`g.Player` names the character a scene with no actors of its own receives, at
his `Start`, and `g.Walkboxes` is the floor a scene without one walks on. Both
are fields on the `*Game`, so a scene file that says nothing about either means
"the usual", and the selector opts out by declaring both empty. What is still
written back before the hand-off is the derived half of the map:
`scene.FillSelectorPins` reads the exit graph backwards and `Set`s the selector.
There is one world, and now the package *is* the singleton: `world.Do(…)`,
`world.Slot2()`, `world.SetPending(…)`. Nothing takes a world parameter and no
widget holds a back-reference, which is what lets a script file be a literal —
the tape-insert script closes over the `world` package rather than over a
parameter someone would have had to thread to it. What little it still keeps is
in package-level variables that only `world.Attach` may reset; the mode and the
top bar's note are not among them, because those are `State` vars the engine
itself can see.
**On the word "scene".** The wiki, the concept-art deck and the beat tables all
count *screens*, and this code used to as well: it carried its own `Screen`
struct, because inkwell's `Scene` could not hold the things a screen needs —
its exits above all. Those went into the engine instead (inkwell `Exit`,
`Game.SceneRect`, `CurrentScene`/`PreviousScene`, `Back()`), and with the gap
closed there was nothing left for a second type to carry. The code says scene,
because that is what the thing is. "Screen" survives only where it means the
display: `ScreenW`, `ScreenH`.
Art lives in `assets/bg/` and is embedded into the binary (`main.go`), because
js/wasm has no OS filesystem and `make binaries` packages the executable alone.
## The map
Where a scene leads is recorded **in that scene**, in its own file, and it
comes from the wiki's location graph
(`projects/realworld/story#helyszín-gráf-vázlat`). That graph has two kinds of
edge and so does the code:
- **Physical adjacency** — the alley is behind Noodle's house, the eating place
is next door to the trinket shop, the club is through its back room, the roof
is up Norman's stairs. Declared with `To:` in the scene's `Exits`.
- **The selector** — the wiki centres its map on a *Helyszínválasztó*, "nem
valódi helyszín, hanem a menü-képernyő": a scene every main location connects
to both ways. A main location lists `exitToSelector` among its exits, and
`scene/scene.selector.go` derives the other half of each edge by reading the graph
backwards — every scene with an exit to the selector gets a pin on it. The
list of locations on the map is never written down twice.
The wiki's conditional arrows survive too: `Needs:` holds the flag an exit waits
for, which is how *Bolt →|beengedés| Klub* is expressed — the way into the club
is visible and named from the first visit, and stays shut until the shopkeeper
has been shown the underwater sun.
Until the doors are measured on the paintings, an exit is a strip along one edge
of the picture (`inkwell.ExitLeft`, `ExitRight`, `ExitBack`, `ExitNear`), sized
from `Game.SceneRect` so it lands inside the painting rather than under the HUD
— the convention every 1990s point & click used, and one field to re-aim later.
A scene reached from several rooms has no fixed way out: the BBS terminal is the
same terminal from the club, the flat or the roof, so its exit leaves `To` empty
and the engine binds it to `Back()`.
The graph stays machine-readable: exit hotspots are named `exit:<target>`, so
the connections can be read straight back out of the registered scenes — and
`Validate` rejects an exit that names a scene nobody registered.
## What this game pushed into the engine
Everything below started as a workaround in this package and ended up in
inkwell, because in each case the thing being worked around was a fact an
entity should have carried in the first place. The domain side of each is now a
field in a literal:
| was worked around here | is now |
|---|---|
| a scratch-image text blitter, because `drawText` dropped the colour | `Game.DrawText` renders in colour; `inkwell.TextWidth`, `WrapText`, `ClipText`, `GlyphW/H` are the library's |
| a pump widget driving its own `Runner`, because `queueAction` was unexported | `g.Do(action)` — one queue, in order, engine-side |
| `TapeSay`, a second spelling of `Say` that skipped the speech bubble | `Character.Voice``VoiceLog` sends a character's lines to the log |
| a `Tapes` map of display names | `Character.Label` |
| a `gate` wrapper and `if !World.HUDVisible()` at the top of every `Draw` | `Widget.When`, an `inkwell.Condition` the engine evaluates |
| a `titleBar` widget that pushed the scene title into the top bar | `TopBar` already discovers the title; `NoteVar` adds the "— paused" |
| a `UseWithGuard` widget stealing clicks to answer unauthored use-with pairs | `Game.UseWithFail`, beside `ExitLook` and `ExitTake` |
| a `windowSizer` widget resizing the window on its first tick | `Game.WindowScale` |
| a `sceneNav` widget walking the scene catalogue | `inkwell.SceneNav`, a built-in dev widget |
| `sceneDefaults` rewriting every scene with Paul and a floor | `Game.Player` and `Game.Walkboxes` |
Also: the inkwell README gives the module path as
`git.teletypegames.org/games/inkwell`; the real path per its `go.mod` is
`git.teletypegames.org/engines/inkwell`.
## Known gaps
- **The HUD still covers the foot of every painting.** The art is no longer
stretched, but the strip is opaque and 118px of the 380 sit on top of the
picture. Either the deck gets recut so nothing that matters lives in its
bottom third, or the HUD gets a translucent panel treatment.
- **The scenes are empty.** 22 backgrounds are in and they are wired to each
other, but only the alley has hotspots, NPCs and dialogue; everywhere else
there is nothing to do but leave again.
- **The selector has no art.** The wiki's Helyszínválasztó is the centre of the
map and the deck does not include it, so it renders as a placeholder with one
labelled pin per location on a plain grid.
- **Two scenes are missing from the deck.** The alley (the one authored beat)
and deck 05, Norman's workplace — which is in the wiki's catalogue but has no
scene file yet, so the workplace thread of the story has nowhere to happen.
- **Spent dialogue choices are hidden, not struck through.** `DialogueChoice.Once`
hides them; the deck wanted them struck through but visible, so the list
becomes a memory of what you already tried. Needs a thin override over
`DialogBox`.
- **Only beat 3 exists.** The alley is a vertical slice; the opening script sets
the police-tip flag that beat 2 will eventually set.
- **The render has only been inspected once, by the author of this repo.**
Geometry is derived from one font cell and one screen size, but this
environment cannot take a screenshot (both synthetic keystrokes and screen
capture are blocked by macOS privacy permissions), so every visual judgement
has to come from you.