306 lines
15 KiB
Markdown
306 lines
15 KiB
Markdown
# 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 . -screen selector # the location selector: the map the city hangs off
|
||
go run . -screen hospital # any screen in the deck, e.g. to look at the art
|
||
```
|
||
|
||
Screen 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 test # headless: validates content and HUD layout
|
||
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 screen, in concept-art order, wrapping |
|
||
| `F1` | toggle hotspot outlines |
|
||
| `SPACE` | during a cutscene: let the tape speak, if it offered |
|
||
|
||
The screens are connected to each other (see **The map** below); the arrow keys
|
||
are a reviewing tool on top of that, walking the deck in concept-art order. 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 `ui.LineH` (glyph cell + leading) and
|
||
from `ui.ScreenH`, 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
|
||
|
||
The game is one flat package, `inc`. There are no subdirectories: a file's
|
||
name says where it belongs, in the form `[category].[name].go`. The category is
|
||
always singular, and `[category].manager.go` is the file that ties that category
|
||
together — its `register…`, its shared types, its list.
|
||
|
||
```
|
||
main.go flags + inkwell.Run
|
||
inc/names.manager.go entity names and world-state keys
|
||
inc/theme.manager.go realworld-93 + nokia-punk
|
||
inc/world.*.go unsaved runtime state, custom actions, action pump
|
||
inc/ui.*.go HUD: layout, custom widgets, coloured text
|
||
inc/<kind>.<name>.go one file per registered entity, by kind
|
||
inc/boot.manager.go wiring
|
||
```
|
||
|
||
Nothing is enforced by the compiler any more, so the layering is a rule the
|
||
code keeps by hand: the world knows nothing about the HUD or the content, and
|
||
both build on it, never the other way round.
|
||
|
||
```
|
||
names ← theme ← world ← ui
|
||
↑ ↑
|
||
content ──┴── boot ← main
|
||
```
|
||
|
||
Content is one registered entity per file, and the category prefix groups them
|
||
the way directories used to:
|
||
|
||
```
|
||
background.*.go one file per screen: background.paul_shop.go, …
|
||
character.*.go character.paul.go character.dex.go character.mystery_tape.go
|
||
item.*.go item.noodle_letter.go item.black_market_armilla.go …
|
||
dialog.*.go dialog.dex_talk.go dialog.mystery_tape_silent.go
|
||
script.*.go script.tape_insert.go script.awakening_finale.go
|
||
screen.*.go one file per screen: screen.alley.go, … + screen.exit.go
|
||
```
|
||
|
||
Adding a screen means adding `screen.<name>.go` and `background.<name>.go`, and
|
||
one line in each category's manager list. Nothing else moves.
|
||
|
||
Since everything shares one namespace, an entity's constructor carries its
|
||
category: `screenAlley()` is the screen, `backgroundAlley()` the painting behind
|
||
it, `itemMysteryTape()` the prop and `characterMysteryTape()` the voice on it.
|
||
|
||
**On the word "screen".** inkwell's entity is called a `Scene`, and that is the
|
||
type every `screen.*.go` file returns — but the wiki, the concept-art deck and
|
||
the beat tables all count *screens*, so the category, the files and the flag say
|
||
screen. "Scene" survives only where the code is talking to the engine
|
||
(`EnterScene`, `SceneManager`).
|
||
|
||
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 screen leads is recorded **in that screen**, 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 screen'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 screen every main location connects
|
||
to both ways. A screen marks itself `onSelector: true`; `screen/selector.go`
|
||
builds the other half of each of those edges, so 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 (`sideLeft`, `sideRight`, `sideBack`, `sideNear`) — the
|
||
convention every 1990s point & click used, and one field to re-aim later. A
|
||
screen 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 is `back`.
|
||
|
||
Three tests keep the map honest: every exit names a registered screen, every
|
||
screen has a way out, and every screen can be walked to from Paul's shop through
|
||
the exits alone — the arrow keys do not count.
|
||
|
||
## Engine workarounds
|
||
|
||
Five inkwell limits turned up during implementation that the engine README does
|
||
not mention. All five are worked around on the domain side; each is a candidate
|
||
for a small engine change.
|
||
|
||
1. **`drawText` discards colour.** In `asset.text.go` the colour argument is
|
||
`_ = c` and rendering goes through `ebitenutil.DebugPrintAt`, which only
|
||
draws white. Every text colour in `Theme` is therefore inert. *Workaround:*
|
||
`ui.text.go` renders onto a scratch image and blits it tinted with
|
||
`ColorScale`. The custom widgets colour correctly; the built-ins
|
||
(`StatusLine`, `DialogBox`, `TopBar`, `InventoryBar`) are still white.
|
||
*Fix:* move `drawText` to `text/v2` — no call site would change.
|
||
|
||
2. **`queueAction` is unexported**, so a domain widget cannot start an action.
|
||
*Workaround:* the pump in `world.action.go` drives its own `Runner` through
|
||
the exported `inkwell.Ctx`. *Caveat:* it runs alongside the engine's script
|
||
runner, not instead of it.
|
||
|
||
3. **The `"Nem ehhez."` flash is hardcoded** in `core.engine.go` for an
|
||
item/hotspot pair with no `OnUseWith`, and cannot be replaced from the
|
||
domain. *Workaround:* `UseWithGuard` — widgets tick before the engine's
|
||
`handleSceneInput` and can consume the click, so unauthored pairs fail in
|
||
character instead, escalating on repeats.
|
||
|
||
4. **No exported `CurrentScene()`**, but the pump needs `Ctx.Scene`.
|
||
*Workaround:* `EnterScene` is the first step of every scene's
|
||
`OnEnter`; the domain tracks it.
|
||
|
||
5. **Widgets have no `Visible` field and the `Manager` cannot unregister**, so
|
||
the built-in HUD cannot be hidden during a cutscene. *Workaround:* the
|
||
`gate` wrapper in `ui/ui.go` forwards `Tick`/`Draw`/`BlocksClickAt` only
|
||
while the HUD is visible.
|
||
|
||
6. **`Run` hardcodes a 4× window** (`core.dsl.go`), which at 640×400 would be
|
||
2560×1600 — bigger than most laptop screens. *Workaround:* the `windowSizer`
|
||
widget resizes once on the first tick, since Run sets the size before
|
||
entering the loop.
|
||
|
||
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 screens 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 screens 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
|
||
screen 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; `boot.opening` 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 covered by tests and a layout dump, 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.
|