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

403 lines
16 KiB
Markdown
Raw 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
A point & click adventure built on the inkwell engine (Ebitengine).
## Conventions
### No comments
Go source files carry no comments. Not doc comments, not inline comments, not
section headers. If a piece of code needs explaining, rename it or restructure
it until it does not, and put the reasoning in `README.md` instead.
The single exception is compiler directives (`//go:embed`, `//go:build`), which
are instructions to the toolchain rather than prose.
### English only
Everything written in the repository is in English: identifiers, string
literals, commit messages, `README.md`, this file. The design wiki is in
Hungarian; when a concept crosses over, translate it and keep the mapping
one-to-one.
### No tests
Do not write tests and do not add test files. Correctness is checked by running
the game.
### Struct literals are always multi-line
Every field of a struct instance goes on its own line, with a trailing comma —
never on the same line as the brace, never two fields on one line.
```go
return inkwell.Asset{
Name: BgStreet,
Path: "assets/bg/street.png",
Kind: inkwell.AssetImage,
}
```
Not `inkwell.Asset{Name: BgStreet, Path: "...", Kind: inkwell.AssetImage}`.
This holds for nested literals too, including small ones such as
`inkwell.Point`. Slice and map literals are not covered by the rule.
## Layout
One category, one package, one directory. A file keeps the category in its own
name anyway — `[category].[name].go` inside `inc/[category]/` — because the
prefix is what makes a file findable in a list of editor tabs, a grep, or a
diff, where the directory has already fallen off.
- The category is always **singular**: `item`, `scene`, `background`,
`character`, `tape`, `dialog`, `script`, `world`, `widget`, `theme`.
- `[category].manager.go` is the file that ties a package together — its entity
type, its `Manager`, and whatever else the category shares.
- Every other file in a package holds exactly one entity: one scene, one
background, one item. The file registers it itself, in an `init()`.
- Two files have no category. `inc/constants.go` is the vocabulary every
package imports, and `inc/boot/boot.go` builds the game.
```
main.go flags + inkwell.Run
inc/constants.go entity names and world-state keys
inc/boot/boot.go New(Opts) builds the game
inc/theme/theme.manager.go the Theme alias, its Manager, RGB/RGBA
inc/theme/theme.realworld.go realworld-93
inc/theme/theme.nokia_punk.go nokia-punk
inc/world/world.manager.go unsaved runtime state
inc/world/world.action.go custom actions
inc/tape/tape.manager.go the Tape entity and the lookups over it
inc/tape/tape.*.go one tape per file
inc/widget/widget.manager.go the Widget alias, HUD layout, the conditions
inc/widget/widget.*.go one widget per file, registering itself
inc/background/background.*.go one image asset per scene
inc/character/character.*.go the cast, tapes included
inc/item/item.*.go inventory
inc/dialog/dialog.*.go dialogue trees
inc/script/script.*.go named action sequences
inc/scene/scene.manager.go the Scene alias, its Manager, the floor
inc/scene/scene.selector.go the map screen: pins derived from the graph
inc/scene/scene.*.go one file per scene
```
`inc` is constants and nothing else, so every package can import it and it can
import none of them. That is also why `New` sits in `inc/boot` rather than in
`inc`: a package cannot be imported by what it imports, and the assembly is the
one thing that has to name every package at once.
## Managers
Every category that owns a collection of entities has a manager, and they are
all the engine's own `inkwell.Manager[T]` — the same registry type the `*Game`
hangs its content off. The game defines no registry of its own.
Each one is declared in its own package's manager file, beside the alias it
holds, and it needs no prefix because the package already is one:
```go
// inc/character/character.manager.go
package character
type Character = inkwell.Character
var Manager = inkwell.NewManager[Character]()
```
Read from `inc/boot` the nine of them still line up as a list —
`background.Manager`, `character.Manager`, `dialog.Manager`, `item.Manager`,
`scene.Manager`, `script.Manager`, `tape.Manager`, `theme.Manager`,
`widget.Manager` — only now the list is a consequence of the imports rather
than a block someone has to keep in step.
Aliases of engine structs already carry `GetName()` and satisfy
`inkwell.Named`, which is the whole of what a manager asks of them.
The methods the game uses are `Register`, `Set`, `Get`, `All` and `Each`.
`Register` panics on a duplicate name — a second registration is a
construction-time bug, not an update — so rewriting an entity that is already
in the registry goes through `Set`, which keeps its position in the order.
`All` and `Each` both hand back the entities in registration order.
Nearly every entity type is an alias, `Scene` included. It was once a struct of
our own, because inkwell's `Scene` could not carry exits; that gap was closed in
the engine, so there is nothing left for a second type to hold.
`Tape` is the exception, and it is a real one: a tape is a cassette that speaks,
a thing inkwell has no notion of. It names the character whose voice it is, the
item that carries it and the dialogue it plays, and `tape.manager.go` holds the
four lookups over the registry — `tape.Is`, `tape.Of`, `tape.IsItem`,
`tape.Dialogue`. What a tape *sounds* like is not in it: the display name and the
log-only voice are `Character.Label` and `Character.Voice`, because those are
facts about a speaker, not about a cassette.
### Entities register themselves
An entity file is a literal and nothing else. No constructor function, no list
somewhere else to keep in step — the file hands itself to its manager in an
`init()`:
```go
package background
func init() {
Manager.Register(Background{
Name: inc.BgServerFarm,
Path: "assets/bg/server_farm.png",
Kind: inkwell.AssetImage,
})
}
```
Adding an entity is adding a file. Deleting one is deleting a file. Package-level
variables are initialised before any `init()` runs, whatever file each sits in,
so a package's `Manager` exists by the time its first entity file registers into
one.
`init()` order is file-name order, so **registration order is alphabetical**.
Nothing may depend on it — including the order the arrow keys walk the scenes,
which is simply the order the files sit in.
An `init()` only runs if something imports the package. `inc/boot` names eight
of the nine for their `Manager`, which is import enough; `tape` is the one
nobody names, so it is there as a blank import. That list is **per package, not
per entity** — a new scene file still touches nothing but itself.
### Widgets name their layer instead of their order
A widget is a file like any other entity — `widget.<name>.go`, one widget,
registering itself in an `init()` — and that includes the engine's own widgets,
which the game registers as literals the same way it registers a background:
```go
func init() {
Manager.Register(&inkwell.Cursor{
Name: "cursor",
})
}
```
What a widget cannot take from alphabetical registration is its place in the
stack: the frame has to be drawn under the slots that sit on it, the cursor over
everything, and the use-with guard has to see a click after the HUD has had it.
So a widget declares a layer rather than inheriting one from the order it was
registered in — `inkwell.LayerScene`, `LayerPanel`, `LayerHUD`, `LayerSpeech`,
`LayerDialog`, `LayerMenu`, `LayerCurtain`, `LayerCursor`. The engine draws from
the bottom layer up and ticks from the top layer down, and registration order
only decides ties inside one layer:
```go
func (h *hudFrame) Layer() inkwell.Layer { return inkwell.LayerPanel }
```
A widget that says nothing sits on `LayerHUD`; ours all say it, because the
layer is the one thing about a widget the file cannot show.
The other thing a widget declares is **when it is there at all**. The HUD is
hidden for a cutscene, and that is a condition over game state, not a wrapper
and not an `if` at the top of every `Draw`:
```go
Manager.Register(&inkwell.StatusLine{
Name: "status",
When: whenPlaying,
Y: statusY,
})
```
`whenPlaying`, `whenCutscene` and `whenUnpaused` are in `widget.manager.go`, and
they read `inc.VarMode` and `inc.VarNote` out of the engine's own `State`. A widget of
ours says the same thing with a method — `VisibleWhen() inkwell.Condition`
because it has no literal to put a field in. A widget that is switched off
neither ticks nor draws nor blocks a click, so nothing else has to ask.
### Handing a category to the engine
Nothing is copied. `inkwell.NewGame` builds its own empty managers, and `New`
hands ours over in their place — the types are identical, so the engine and the
game end up sharing one registry per category rather than two in step:
```go
g.AssetManager = background.Manager
g.CharacterManager = character.Manager
g.DialogueManager = dialog.Manager
g.ItemManager = item.Manager
g.SceneManager = scene.Manager
g.ScriptManager = script.Manager
g.WidgetManager = widget.Manager
theme.Manager.Each(g.ThemeManager.Register)
```
Themes are the odd one out and stay a copy: `NewGame` puts four preset themes
into its `ThemeManager`, and replacing it would throw them away. Ours are added
to that set instead.
One consequence of not copying: a manager swapped in this way must be in place
before `inkwell.Run`, which is where the engine wires up the parts that hold a
registry directly. `fillSelectorPins` runs before the hand-off for the same
reason — it writes the derived pins back into `SceneManager` with `Set`.
The defaults a scene leaves out are no longer written into it at all. `g.Player`
names the character a scene with no actors of its own gets, placed at his
`Start`; `g.Walkboxes` is the floor a scene walks on when it declares none. Both
are fields on the `*Game`, so a scene file that says nothing about either is
saying "the usual", and the selector opts out by declaring both empty.
### What runs at boot
`New` names two scripts and nothing else — the opening a player sees is content
like every other script, in `script/script.opening.go`, not a slice built in the
wiring:
```go
g.StartAt(start)
g.OnStart(inc.ScriptOpening)
if o.Finale {
g.OnFinale(inc.ScriptFinale)
}
```
`OnFinale` is the engine's hook for a closing script, queued straight after the
opening. Here it is what `-finale` uses to drop into the ending, which is why it
is set from `Opts` rather than always.
The rest of `New` is fields on the `*Game`, and every one of them replaces
something this package used to carry itself:
```go
g.WindowScale = 2
g.Player = inc.Paul
g.Walkboxes = scene.Floor
g.UseWithFail = script.UseWithFail
```
`UseWithFail` is the response to a use-with pair nobody authored — the engine
asks for it the same way it asks for `ExitLook` and `ExitTake`, and the lines
live with the rest of the writing, in `script/script.usewith_fail.go`.
## The world
There is exactly one game, so there is exactly one world — and since there is
exactly one, the **package is the singleton**. There is no `World` value to pass
around and no back-reference for a widget to hold: `world.Do(…)`,
`world.Slot2()`, `world.SetPending(…)` are reachable from anywhere that imports
`world`. `New` calls `world.Attach(g)`, which binds the engine and resets the
run state, so building the game twice is clean.
`world` holds as little as it can get away with. The mode and the top bar's note
are `State` vars (`inc.VarMode`, `inc.VarNote`), because state the engine can
see is state a `Condition` can read — that is what makes a widget's `When`
possible and what puts "— paused" in the top bar without anyone pushing it
there. `world.Do` hands its action to `g.Do`, the engine's queue, so the game
runs one action at a time without a pump of its own. What is left in package
variables is the two things the engine has no notion of: the tape waiting to
speak, and the cassette on its way into slot 2. Only `world.Attach` resets them.
This is what lets an entity file be a literal: the tape-insert script closes
over the `world` package, not over a parameter it would have had to be handed.
## Naming
The package is the namespace now, so an identifier never repeats what the
package already says. It is `scene.Floor`, not `scene.SceneFloor`;
`tape.Dialogue`, not `tape.TapeDialogue`; `widget.Manager`, not
`widget.WidgetManager`; `world.Do`, not `world.WorldDo`. Read the call site, not
the declaration, when choosing a name:
```go
scene.FillSelectorPins() tape.Is(speaker) world.Do(a) widget.ScreenW
```
Entities themselves need no name at all — they are anonymous literals inside
their file's `init()`, and the file name says which one it is.
Exported names are the authoring vocabulary — what a content file spells out:
`boot.New` and `boot.Opts`, the constants in `inc/constants.go`, each package's
`Manager` and entity type, the colour tokens, the tape lookups (`tape.Is`,
`tape.Of`, `tape.IsItem`, `tape.Dialogue`), the world (`world.Do`,
`world.Slot2`, …) and the action constructors (`world.Paused`, `world.SetMode`,
`world.UseTheme`, `world.TapeOffer`, `world.Fn`). A tape's line is `inkwell.Say`
like anyone else's — the tape voice is on the character, not on a second
spelling of Say.
Everything else stays unexported, and now the compiler holds the line: the HUD
widgets (`tapeSlots`, `letterbox`, `hudFrame`, …), the conditions
(`whenPlaying`, `whenCutscene`, `whenUnpaused`), the runners, `setMode`,
`setNote`, `setVarIfEmpty`.
The word is **scene**, the engine's own. The wiki and the concept-art deck count
*screens*, and this code used to as well, but everything a screen had that a
scene did not now lives in inkwell. "Screen" survives only where it means the
display: `ScreenW`, `ScreenH`.
## Layering
The layering is the import graph, so the compiler keeps it:
```
inc ← theme ← world ← tape ← widget ← boot ← main
↑ ↑ ↑
└── content ───────────────────-┘
```
`inc` imports nothing and everything imports it. `world` knows nothing about the
HUD or the content; both build on it, never the other way round — and an arrow
pointing back is now an import cycle, not a review comment. The one edge worth
knowing is `widget → tape`: the tape slots ask which cassette a tape is in, so
the tape concept sits below the HUD rather than beside it.
## Adding a scene
1. Constants in `inc/constants.go`: `Scene<Name>`, `Bg<Name>`
2. `inc/scene/scene.<name>.go` — an `init()` registering a `Scene`
3. `inc/background/background.<name>.go` — an `init()` registering a `Background`
4. A 640×380 PNG in `assets/bg/`
Nothing else moves. There is no list to update.
## The engine next door
inkwell is checked out beside this repository, and so is the wiki that
documents it. Paths are relative to the game's root:
```
../../engines/inkwell the engine source
../../services/wiki-pages/pages/engines/inkwell/default.en.md the manual, English
../../services/wiki-pages/pages/engines/inkwell/default.hu.md the manual, Hungarian
```
The engine's module path is `git.teletypegames.org/engines/inkwell` and `go.mod`
pins a pseudo-version of it, so a local edit is invisible here until it is
pushed:
```
git -C ../../engines/inkwell commit … && git -C ../../engines/inkwell push
go get git.teletypegames.org/engines/inkwell@master
```
A `replace` directive is fine while trying something out, but it never survives
into a commit — drop it and bump the pin instead.
Three things move together when the engine changes: the code, `README.md` in
the inkwell checkout, and **both** wiki pages. The two pages are a translation
pair, section for section — an edit to one is an edit to the other. The
no-comment rule stops at the module boundary: inkwell's own source is commented,
and edits there follow its style, not this one's.
## Commands
```
make build native binary into bin/
make wasm js/wasm build into dist/
make watch rebuild on change
go run . -scene <name> start on a given scene
go run . -finale start with the finale
```
Art is embedded into the binary (`//go:embed assets` in `main.go`), because
js/wasm has no OS filesystem and `make binaries` ships the executable alone.
The screen is 640×380 because the engine stretches a background over the whole
window: any other size squashes the paintings.