tmp backgrounds
ci/woodpecker/push/ebitengine Pipeline was successful

This commit is contained in:
2026-08-29 22:30:22 +02:00
parent 45dac473fd
commit bbc3faf3d7
87 changed files with 1672 additions and 158 deletions
+99 -22
View File
@@ -23,11 +23,19 @@ go run .
```
```bash
go run . # the alley scene (beat 3)
go run . # the alley (beat 3)
go run . -finale # the finale — try the Nokia-punk theme switch
go run . -scene alley # pick a starting scene
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 `internal/names` (`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:
@@ -55,41 +63,58 @@ Release metadata lives in `metadata.json`.
|---|---|
| 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×400** the same 16:10 as the wiki's "320×200 VGA
look" brief and exactly 2× it, so art drawn at 320×200 upscales cleanly.
Internal resolution is **640×380**: the size of one painted background.
Integer scaling only; the window opens at 2×.
The doubling is forced by type, not taste. inkwell renders text through a fixed
**6×16** debug font that the domain cannot replace. At 320×200 a 16px glyph is
8% of the screen height: rows overlap, and a top bar sized from the wireframe
deck's proportions cannot hold a single line. At 640×400 the same glyph lands
at 4%, which is the proportion the design was drawn for.
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 (244)
264├─────────────────────────────────────────┬──────────────────────────┤
│ scene — hotspots, characters, SpeechBubble │ Scene (242)
262├─────────────────────────────────────────┬──────────────────────────┤
│ Use hook on: floor grate │ DEX │
│ ┌───┬───┬───┬───┐ ┌───────┐┌────────┐ │ "The hook is a hand │ HUD (136)
│ ┌───┬───┬───┬───┐ ┌───────┐┌────────┐ │ "The hook is a hand │ HUD (118)
│ └───┴───┴───┴───┘ │ 1 DEX ││ 2 — │ │ shorter than the gap." │
399└─────────────────────────────────────────┴──────────────────────────┘
379└─────────────────────────────────────────┴──────────────────────────┘
InventoryBar TapeSlots TapeChannel
```
Every vertical measurement derives from `ui.LineH` (glyph cell + leading), not
from the deck's ratios, so the layout cannot drift out of step with the font
again — `TestLayoutFitsTheFont` guards it. The tape channel comes out at 5 rows
of 38 columns, which is about two of Dex's remarks on screen at once.
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*
@@ -149,16 +174,56 @@ Content is one registered entity per file, grouped by kind:
```
internal/content/
asset/ alley_background.go
background/ one file per screen: paul_shop.go, noodle_house.go, …
character/ paul.go dex.go mystery_tape.go support_tape.go
item/ noodle_letter.go black_market_armilla.go mystery_tape.go
dialog/ dex_talk.go mystery_tape_silent.go
script/ tape_insert.go awakening_finale.go
scene/ alley.go
screen/ one file per screen: alley.go, trinket_shop.go, … + exit.go
```
Adding a scene means adding `scene/<name>.go` and one line in `scene/scene.go`.
Nothing else moves.
Adding a screen means adding `screen/<name>.go` and `background/<name>.go`, and
one line in each package's list. Nothing else moves.
**On the word "screen".** inkwell's entity is called a `Scene`, and that is the
type every file in `screen/` returns — but the wiki, the concept-art deck and
the beat tables all count *screens*, so the package, the files and the flag say
screen. "Scene" survives only where the code is talking to the engine
(`world.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
@@ -205,7 +270,19 @@ Also: the inkwell README gives the module path as
## Known gaps
- **No art.** Everything is an inkwell placeholder.
- **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
Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 399 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 423 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 445 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 391 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 293 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 416 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 309 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 329 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 341 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 313 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 366 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 318 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 295 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 349 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 408 KiB

+10 -10
View File
@@ -14,15 +14,15 @@ import (
// Opts are the run parameters.
type Opts struct {
Scene string // starting scene; empty means the alley
Screen string // starting screen; empty means the alley
Finale bool // start with the finale, to try the Nokia-punk theme switch
}
// New builds a ready-to-run game.
func New(o Opts) *inkwell.Game {
scene := o.Scene
if scene == "" {
scene = names.SceneAlley
screen := o.Screen
if screen == "" {
screen = names.ScreenAlley
}
g := inkwell.NewGame("Real World", ui.ScreenW, ui.ScreenH)
@@ -35,16 +35,16 @@ func New(o Opts) *inkwell.Game {
g.UseTheme(theme.RealWorld)
ui.Register(w)
g.StartAt(scene)
g.OnStart(inkwell.Seq(opening(w, scene, o.Finale)...))
g.StartAt(screen)
g.OnStart(inkwell.Seq(opening(w, screen, o.Finale)...))
return g
}
func opening(w *world.World, scene string, finale bool) []inkwell.Action {
func opening(w *world.World, screen string, finale bool) []inkwell.Action {
acts := []inkwell.Action{
world.EnterScene(w, scene),
// Beat 2 (the police station) will set the tip flag. Until that scene
// exists we set it here so the alley slice is playable end to end —
world.EnterScene(w, screen),
// Beat 2 (the police station) will set the tip flag. Until that beat
// is written we set it here so the alley slice is playable end to end —
// the conditional branch itself is real code, not bypassed.
inkwell.SetFlag(names.FlagPoliceTip),
inkwell.Say(names.Paul, "Back alley. Just like the clerk said."),
+122
View File
@@ -1,6 +1,10 @@
package boot_test
import (
"image/png"
"os"
"path/filepath"
"strings"
"testing"
inkwell "git.teletypegames.org/engines/inkwell"
@@ -136,3 +140,121 @@ func TestWidgetOrder(t *testing.T) {
t.Errorf("cursor is not registered last: %v", n)
}
}
// Every screen needs a background, the drawn ones need the file to be where the
// asset says it is, and the file has to be exactly screen-sized.
//
// The path check: a renamed or mistyped path shows up in the running game only
// as an inkwell placeholder, which is exactly what a still-greyboxed screen
// looks like — invisible as a bug.
//
// The size check: the engine scales a background over the WHOLE screen, so a
// painting that is not ScreenW×ScreenH is silently squashed to fit. That is why
// the screen is 640×380 in the first place; this keeps the two in step.
func TestSceneBackgroundsResolve(t *testing.T) {
g := build(t)
// Not drawn yet: these render as placeholders on purpose. The alley is
// missing from the concept-art deck; the selector is the wiki's menu
// screen, which the deck does not cover at all.
undrawn := map[string]bool{names.BgAlley: true, names.BgSelector: true}
for _, n := range g.SceneManager.Names() {
s := g.SceneManager.MustGet(n)
a, ok := g.AssetManager.Get(s.Background)
if !ok {
t.Errorf("scene %q: unknown background %q", n, s.Background)
continue
}
if undrawn[a.Name] {
continue
}
f, err := os.Open(filepath.Join("..", "..", a.Path))
if err != nil {
t.Errorf("scene %q: background file missing: %v", n, err)
continue
}
cfg, err := png.DecodeConfig(f)
f.Close()
if err != nil {
t.Errorf("scene %q: unreadable background: %v", n, err)
continue
}
if cfg.Width != ui.ScreenW || cfg.Height != ui.ScreenH {
t.Errorf("scene %q: background is %d×%d, want %d×%d — it will be stretched",
n, cfg.Width, cfg.Height, ui.ScreenW, ui.ScreenH)
}
}
}
// exits reads the connection graph back out of the registered screens. The
// exit hotspots are named "exit:<target>", which is what keeps the graph
// machine-readable — see content/screen/exit.go.
func exits(g *inkwell.Game, screen string) []string {
var out []string
for _, h := range g.SceneManager.MustGet(screen).Hotspots {
if to, ok := strings.CutPrefix(h.Name, "exit:"); ok && to != "back" {
out = append(out, to)
}
}
return out
}
// An exit that names a screen nobody registered is a dead end the player walks
// into: the engine logs "changeScene: unknown" and nothing happens on screen.
func TestExitsResolve(t *testing.T) {
g := build(t)
for _, n := range g.SceneManager.Names() {
for _, to := range exits(g, n) {
if !g.SceneManager.Has(to) {
t.Errorf("screen %q: exit to unknown screen %q", n, to)
}
}
}
}
// Every screen has to be walkable to from the start of the game, through the
// exits alone — the arrow keys are a browsing tool, not the map. A screen that
// only the arrow keys can reach is content no player would ever see.
//
// The walk starts at Paul's shop, where the letter arrives, and mostly runs
// through the selector: that is what the wiki's location graph is centred on.
func TestEveryScreenIsReachable(t *testing.T) {
g := build(t)
seen := map[string]bool{names.ScreenPaulShop: true}
queue := []string{names.ScreenPaulShop}
for len(queue) > 0 {
at := queue[0]
queue = queue[1:]
for _, to := range exits(g, at) {
if !seen[to] && g.SceneManager.Has(to) {
seen[to] = true
queue = append(queue, to)
}
}
}
for _, n := range g.SceneManager.Names() {
if !seen[n] {
t.Errorf("screen %q cannot be reached from %q by its exits", n, names.ScreenPaulShop)
}
}
}
// A screen with no way out at all is a trap: the player walks in and the only
// way on is the debug walk.
func TestEveryScreenHasAWayOut(t *testing.T) {
g := build(t)
for _, n := range g.SceneManager.Names() {
var ways int
for _, h := range g.SceneManager.MustGet(n).Hotspots {
if strings.HasPrefix(h.Name, "exit:") {
ways++
}
}
if ways == 0 {
t.Errorf("screen %q has no exit", n)
}
}
}
@@ -1,16 +0,0 @@
package asset
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// alleyBackground is the alley behind Noodle's house (beat 3).
func alleyBackground() inkwell.Asset {
return inkwell.Asset{
Name: names.AssetAlleyBG,
Path: "assets/bg/alley.png",
Kind: inkwell.AssetImage,
}
}
-22
View File
@@ -1,22 +0,0 @@
// Package asset registers the game's assets.
//
// During the greybox phase no asset file exists on disk: inkwell draws a
// placeholder when a sprite is missing, which is exactly the wireframe deck's
// dashed-outline convention, for free. Only the registration has to exist —
// Game.Validate requires every scene background to name a registered asset.
package asset
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/world"
)
// Register adds every asset to the game.
func Register(w *world.World) {
for _, a := range []inkwell.Asset{
alleyBackground(),
} {
w.G.AssetManager.Register(a)
}
}
+19
View File
@@ -0,0 +1,19 @@
package background
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// alley — part of the catalogue's "Noodle háza & a sikátor" row — not in the deck.
//
// Not painted yet: the file is missing on purpose, and inkwell draws its
// placeholder instead — which is exactly the greybox convention.
func alley() inkwell.Asset {
return inkwell.Asset{
Name: names.BgAlley,
Path: "assets/bg/alley.png",
Kind: inkwell.AssetImage,
}
}
+48
View File
@@ -0,0 +1,48 @@
// Package background registers one image asset per screen — one file each.
//
// A background is the whole of a screen's art: inkwell scales it over the
// entire window, so every file here points at a 640×380 PNG under assets/bg/,
// the size the concept-art deck is drawn at. Two of them are not painted yet
// and fall back to inkwell's placeholder.
//
// The order below is the deck's, which is the wiki's location-catalogue order
// (projects/realworld/entities#helyszinek).
package background
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/world"
)
// Register adds every background to the game.
func Register(w *world.World) {
for _, a := range []inkwell.Asset{
selector(),
paulShop(),
noodleHouse(),
normanApartment(),
policeStation(),
alley(),
hackerspace(),
iceCreamShop(),
trinketShop(),
smallRestaurant(),
secretClub(),
bbsTerminal(),
street(),
hospital(),
serverFarm(),
secretLab(),
rooftopHideout(),
publicBbs(),
bvkBranch(),
curatorShop(),
scrapMarket(),
samizdatPress(),
showroom(),
columbarium(),
} {
w.G.AssetManager.Register(a)
}
}
@@ -0,0 +1,16 @@
package background
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// bbsTerminal — deck 11.
func bbsTerminal() inkwell.Asset {
return inkwell.Asset{
Name: names.BgBBSTerminal,
Path: "assets/bg/bbs_terminal.png",
Kind: inkwell.AssetImage,
}
}
+16
View File
@@ -0,0 +1,16 @@
package background
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// bvkBranch — deck 18.
func bvkBranch() inkwell.Asset {
return inkwell.Asset{
Name: names.BgBVKBranch,
Path: "assets/bg/bvk_branch.png",
Kind: inkwell.AssetImage,
}
}
@@ -0,0 +1,16 @@
package background
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// columbarium — deck 23.
func columbarium() inkwell.Asset {
return inkwell.Asset{
Name: names.BgColumbarium,
Path: "assets/bg/columbarium.png",
Kind: inkwell.AssetImage,
}
}
@@ -0,0 +1,16 @@
package background
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// curatorShop — deck 19.
func curatorShop() inkwell.Asset {
return inkwell.Asset{
Name: names.BgCuratorShop,
Path: "assets/bg/curator_shop.png",
Kind: inkwell.AssetImage,
}
}
@@ -0,0 +1,16 @@
package background
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// hackerspace — deck 06.
func hackerspace() inkwell.Asset {
return inkwell.Asset{
Name: names.BgHackerspace,
Path: "assets/bg/hackerspace.png",
Kind: inkwell.AssetImage,
}
}
+16
View File
@@ -0,0 +1,16 @@
package background
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// hospital — deck 13.
func hospital() inkwell.Asset {
return inkwell.Asset{
Name: names.BgHospital,
Path: "assets/bg/hospital.png",
Kind: inkwell.AssetImage,
}
}
@@ -0,0 +1,16 @@
package background
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// iceCreamShop — deck 07.
func iceCreamShop() inkwell.Asset {
return inkwell.Asset{
Name: names.BgIceCreamShop,
Path: "assets/bg/ice_cream_shop.png",
Kind: inkwell.AssetImage,
}
}
@@ -0,0 +1,16 @@
package background
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// noodleHouse — deck 02.
func noodleHouse() inkwell.Asset {
return inkwell.Asset{
Name: names.BgNoodleHouse,
Path: "assets/bg/noodle_house.png",
Kind: inkwell.AssetImage,
}
}
@@ -0,0 +1,16 @@
package background
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// normanApartment — deck 03.
func normanApartment() inkwell.Asset {
return inkwell.Asset{
Name: names.BgNormanApartment,
Path: "assets/bg/norman_apartment.png",
Kind: inkwell.AssetImage,
}
}
+16
View File
@@ -0,0 +1,16 @@
package background
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// paulShop — deck 01.
func paulShop() inkwell.Asset {
return inkwell.Asset{
Name: names.BgPaulShop,
Path: "assets/bg/paul_shop.png",
Kind: inkwell.AssetImage,
}
}
@@ -0,0 +1,16 @@
package background
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// policeStation — deck 04.
func policeStation() inkwell.Asset {
return inkwell.Asset{
Name: names.BgPoliceStation,
Path: "assets/bg/police_station.png",
Kind: inkwell.AssetImage,
}
}
+16
View File
@@ -0,0 +1,16 @@
package background
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// publicBbs — deck 17.
func publicBbs() inkwell.Asset {
return inkwell.Asset{
Name: names.BgPublicBBS,
Path: "assets/bg/public_bbs.png",
Kind: inkwell.AssetImage,
}
}
@@ -0,0 +1,16 @@
package background
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// rooftopHideout — deck 16.
func rooftopHideout() inkwell.Asset {
return inkwell.Asset{
Name: names.BgRooftopHideout,
Path: "assets/bg/rooftop_hideout.png",
Kind: inkwell.AssetImage,
}
}
@@ -0,0 +1,16 @@
package background
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// samizdatPress — deck 21.
func samizdatPress() inkwell.Asset {
return inkwell.Asset{
Name: names.BgSamizdatPress,
Path: "assets/bg/samizdat_press.png",
Kind: inkwell.AssetImage,
}
}
@@ -0,0 +1,16 @@
package background
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// scrapMarket — deck 20.
func scrapMarket() inkwell.Asset {
return inkwell.Asset{
Name: names.BgScrapMarket,
Path: "assets/bg/scrap_market.png",
Kind: inkwell.AssetImage,
}
}
@@ -0,0 +1,16 @@
package background
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// secretClub — deck 10.
func secretClub() inkwell.Asset {
return inkwell.Asset{
Name: names.BgSecretClub,
Path: "assets/bg/secret_club.png",
Kind: inkwell.AssetImage,
}
}
+16
View File
@@ -0,0 +1,16 @@
package background
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// secretLab — deck 15.
func secretLab() inkwell.Asset {
return inkwell.Asset{
Name: names.BgSecretLab,
Path: "assets/bg/secret_lab.png",
Kind: inkwell.AssetImage,
}
}
+19
View File
@@ -0,0 +1,19 @@
package background
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// selector — the wiki's Helyszínválasztó — no deck number, and not drawn.
//
// Not painted yet: the file is missing on purpose, and inkwell draws its
// placeholder instead — which is exactly the greybox convention.
func selector() inkwell.Asset {
return inkwell.Asset{
Name: names.BgSelector,
Path: "assets/bg/selector.png",
Kind: inkwell.AssetImage,
}
}
@@ -0,0 +1,16 @@
package background
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// serverFarm — deck 14.
func serverFarm() inkwell.Asset {
return inkwell.Asset{
Name: names.BgServerFarm,
Path: "assets/bg/server_farm.png",
Kind: inkwell.AssetImage,
}
}
+16
View File
@@ -0,0 +1,16 @@
package background
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// showroom — deck 22.
func showroom() inkwell.Asset {
return inkwell.Asset{
Name: names.BgShowroom,
Path: "assets/bg/showroom.png",
Kind: inkwell.AssetImage,
}
}
@@ -0,0 +1,16 @@
package background
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// smallRestaurant — deck 09.
func smallRestaurant() inkwell.Asset {
return inkwell.Asset{
Name: names.BgSmallRestaurant,
Path: "assets/bg/small_restaurant.png",
Kind: inkwell.AssetImage,
}
}
+16
View File
@@ -0,0 +1,16 @@
package background
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// street — deck 12.
func street() inkwell.Asset {
return inkwell.Asset{
Name: names.BgStreet,
Path: "assets/bg/street.png",
Kind: inkwell.AssetImage,
}
}
@@ -0,0 +1,16 @@
package background
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// trinketShop — deck 08.
func trinketShop() inkwell.Asset {
return inkwell.Asset{
Name: names.BgTrinketShop,
Path: "assets/bg/trinket_shop.png",
Kind: inkwell.AssetImage,
}
}
+8 -8
View File
@@ -2,27 +2,27 @@
// assets, characters, items, dialogues, scripts and scenes.
//
// Each registered entity lives in its own file, under a subpackage named after
// its kind. Adding a scene means adding scene/<name>.go and one line in
// scene/scene.go — nothing else moves.
// its kind. Adding a screen means adding screen/<name>.go, background/<name>.go
// and one line in each package's list — nothing else moves.
package content
import (
"git.teletypegames.org/games/realworld/internal/content/asset"
"git.teletypegames.org/games/realworld/internal/content/background"
"git.teletypegames.org/games/realworld/internal/content/character"
"git.teletypegames.org/games/realworld/internal/content/dialog"
"git.teletypegames.org/games/realworld/internal/content/item"
"git.teletypegames.org/games/realworld/internal/content/scene"
"git.teletypegames.org/games/realworld/internal/content/screen"
"git.teletypegames.org/games/realworld/internal/content/script"
"git.teletypegames.org/games/realworld/internal/world"
)
// Register adds every entity to the game. Order matters only in that scenes
// reference assets and characters, which Game.Validate cross-checks.
// Register adds every entity to the game. Order matters only in that screens
// reference backgrounds and characters, which Game.Validate cross-checks.
func Register(w *world.World) {
asset.Register(w)
background.Register(w)
character.Register(w)
item.Register(w)
dialog.Register(w)
script.Register(w)
scene.Register(w)
screen.Register(w)
}
-27
View File
@@ -1,27 +0,0 @@
// Package scene registers the game's scenes.
package scene
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/world"
)
// Register adds every scene to the game.
func Register(w *world.World) {
for _, s := range []inkwell.Scene{
alley(w),
} {
w.G.SceneManager.Register(s)
}
}
// setVarIfEmpty only writes when the variable is still unset, so re-entering a
// scene does not clobber what the game has set in the meantime.
func setVarIfEmpty(name string, v any) inkwell.Action {
return world.Fn(func(ctx *inkwell.Ctx) {
if ctx.Game.State.Var(name) == nil {
ctx.Game.State.SetVar(name, v)
}
})
}
@@ -1,4 +1,4 @@
package scene
package screen
import (
inkwell "git.teletypegames.org/engines/inkwell"
@@ -7,33 +7,28 @@ import (
"git.teletypegames.org/games/realworld/internal/world"
)
// alley is the alley behind Noodle's house — beat 3, and the vertical slice
// the whole HUD is measured against: hotspots, a gated pickup, the two-slot
// alley is the alley behind Noodle's house — beat 3, and the vertical slice the
// whole HUD is measured against: hotspots, a gated pickup, the two-slot
// Armilla, tape dialogue and an authored failure.
func alley(w *world.World) inkwell.Scene {
return inkwell.Scene{
Name: names.SceneAlley,
Title: "ALLEY — BEHIND NOODLE'S HOUSE",
Background: names.AssetAlleyBG,
Actors: []inkwell.SceneActor{
//
// The wiki keeps it in the same catalogue row as the house ("Noodle háza & a
// sikátor") and its graph has one arrow in, NoodleUtcafront → Sikátor, so the
// only way out is back to the street. The alley's own locked back door is the
// New Game+ hook and leads to a room the deck has not painted.
func alley() screen {
return screen{
name: names.ScreenAlley,
title: "ALLEY — BEHIND NOODLE'S HOUSE",
background: names.BgAlley,
exits: []exit{
// On the left: the back door hotspot below owns the other end.
{to: names.ScreenNoodleHouse, label: "back out to the street", side: sideLeft},
},
actors: []inkwell.SceneActor{
{CharacterName: names.Paul, At: inkwell.Point{X: 120, Y: 232}},
},
Walkboxes: []inkwell.Polygon{
inkwell.Poly(
inkwell.Point{X: 16, Y: 196}, inkwell.Point{X: 624, Y: 196},
inkwell.Point{X: 624, Y: 258}, inkwell.Point{X: 16, Y: 258},
),
},
OnEnter: inkwell.Seq(
world.EnterScene(w, names.SceneAlley),
setVarIfEmpty(names.VarArmillaStrip, "SRP: 1 tape"),
),
Hotspots: []inkwell.Hotspot{
hidingPlace(),
backDoor(),
bin(),
fireEscape(),
},
onEnter: setVarIfEmpty(names.VarArmillaStrip, "SRP: 1 tape"),
hotspots: []inkwell.Hotspot{hidingPlace(), backDoor(), bin(), fireEscape()},
}
}
+21
View File
@@ -0,0 +1,21 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
// bbsTerminal is the screen you get when Paul sits down at a terminal: the
// catalogue calls it a "Tárgy/portál", a physical object in the world that
// opens the SPOKE — BBS surface (wiki: entities#helyszinek).
//
// It is the same screen wherever the terminal stands — the club's ECHO box,
// Norman's machine at the flat, the old one on the roof — which is why its way
// out is "back", and not a place.
func bbsTerminal() screen {
return screen{
name: names.ScreenBBSTerminal,
title: "TERMINAL — BBS ACCESS POINT",
background: names.BgBBSTerminal,
exits: []exit{
{label: "step back from the terminal", side: sideNear},
},
}
}
+16
View File
@@ -0,0 +1,16 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
// bvkBranch is a 💡 suggestion: the local certification office, imprimatur
// stamps and licence checks and numbered waiting rooms — everything a man with
// a jailbroken two-slot Armilla should stay away from
// (wiki: entities#potenciális-további-helyszínek).
func bvkBranch() screen {
return screen{
name: names.ScreenBVKBranch,
title: "BVK — SAN FRANCISCO BRANCH",
background: names.BgBVKBranch,
onSelector: true,
}
}
+16
View File
@@ -0,0 +1,16 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
// columbarium is a 💡 suggestion: where Dex is buried. A quiet place, away
// from the noise of the terminals — the loss-and-letting-go thread, which is
// the same thread as Norman and The AI
// (wiki: entities#potenciális-további-helyszínek).
func columbarium() screen {
return screen{
name: names.ScreenColumbarium,
title: "COLUMBARIUM — DEX'S MEMORIAL",
background: names.BgColumbarium,
onSelector: true,
}
}
+15
View File
@@ -0,0 +1,15 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
// curatorShop is a 💡 suggestion: the last workshop of the declining curators'
// guild — quartz tuning, tape repair, craft handed down for generations. The
// man who built Paul's second slot (wiki: entities#potenciális-további-helyszínek).
func curatorShop() screen {
return screen{
name: names.ScreenCuratorShop,
title: "CURATOR SERVICE — THE MASTER'S WORKSHOP",
background: names.BgCuratorShop,
onSelector: true,
}
}
+109
View File
@@ -0,0 +1,109 @@
package screen
// Exits — the connections between screens.
//
// Where a screen leads is recorded in that screen's 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 this file:
//
// - Physical adjacency. The alley is behind Noodle's house; the club is
// through the back of the trinket shop; the noodle place is next door to
// it. These are the arrows the wiki draws between locations, and they are
// declared with `to`.
// - The selector. The wiki centres its map on a "Helyszínválasztó" — not a
// real location but the menu screen every main location connects to both
// ways. A screen marks itself with onSelector; selector.go builds the other
// half of each of those edges.
//
// Some of the wiki's arrows are one-way with a condition on them ("beengedés",
// "NG+ kód", "Sumphor liftkódja"). `needs` carries that: the exit is there, and
// says so, but will not open until the flag is set.
//
// Until the doors are measured on the paintings, an exit is a strip along one
// edge of the picture — the convention every 1990s point & click used, and a
// one-field change to re-aim at a real door later.
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
"git.teletypegames.org/games/realworld/internal/world"
)
type side int
const (
sideLeft side = iota // off the left edge
sideRight // off the right edge
sideBack // into the depth of the picture
sideNear // out towards the camera
)
// area is the strip of picture the exit occupies, inside the scene region
// (below the top bar at y=20, above the HUD divider at y=262).
func (s side) area() inkwell.Shape {
switch s {
case sideLeft:
return inkwell.Rect(0, 40, 56, 212)
case sideRight:
return inkwell.Rect(584, 40, 56, 212)
case sideBack:
return inkwell.Rect(232, 28, 176, 84)
default: // sideNear
return inkwell.Rect(232, 214, 176, 48)
}
}
// exit is one way out of a screen.
type exit struct {
to string // the screen it leads to; empty means "back the way you came"
label string // what the status line calls it
side side
// area overrides the edge strip. The selector's map pins use it; nothing
// else should need to until the art arrives.
area inkwell.Shape
// needs is a flag that has to be set before the exit opens, and blocked is
// what Paul says while it is not.
needs string
blocked string
}
// toSelector is the way back out to the rest of the city, on every screen the
// wiki's Helyszínválasztó lists.
func toSelector() exit {
return exit{to: names.ScreenSelector, label: "the rest of the city", side: sideNear}
}
// exitName is how a connection stays machine-readable: the graph can be read
// straight back out of the registered scenes, so a test can walk it.
func exitName(to string) string {
if to == "" {
return "exit:back"
}
return "exit:" + to
}
func (e exit) hotspot(w *world.World) inkwell.Hotspot {
var travel inkwell.Action = inkwell.GoTo(e.to)
if e.to == "" {
travel = world.Back(w)
}
if e.needs != "" {
travel = inkwell.If(inkwell.Flag(e.needs), travel, inkwell.Say(names.Paul, e.blocked))
}
area := e.area
if area == nil {
area = e.side.area()
}
return inkwell.Hotspot{
Name: exitName(e.to),
Label: e.label,
Area: area,
Cursor: inkwell.CursorExit,
OnLook: inkwell.Say(names.Paul, "That way: "+e.label+"."),
OnUse: travel,
OnTake: inkwell.Say(names.Paul, "It's a way out, not a thing."),
}
}
+18
View File
@@ -0,0 +1,18 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
// hackerspace: the community workshop at the edge of the Mission, printers and
// stripped servers and paranoid amateur cryptographers. Where the server-farm
// access tape can be had (wiki: entities#helyszinek, timeline 24).
//
// The wiki's graph gives it no physical neighbour, so it hangs off the
// selector like every other main location.
func hackerspace() screen {
return screen{
name: names.ScreenHackerspace,
title: "HACKERSPACE",
background: names.BgHackerspace,
onSelector: true,
}
}
+14
View File
@@ -0,0 +1,14 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
// hospital is the clinical dead end: Dr. Maren looked for a medical reason for
// Norman's state and did not find one (wiki: entities, timeline 21).
func hospital() screen {
return screen{
name: names.ScreenHospital,
title: "HOSPITAL — PSYCHIATRIC WING",
background: names.BgHospital,
onSelector: true,
}
}
+19
View File
@@ -0,0 +1,19 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
// iceCreamShop stands where Paul's old bar used to be, and they insist there
// never was a bar. The BVK-registered terminal in the corner is the one the
// candy-crush guy will not give up (wiki: story, the Fagyizó beat).
//
// The search he eventually allows is what names the club — a lead, not a door:
// the way there is the trinket shop's address, so this screen has no exit of
// its own.
func iceCreamShop() screen {
return screen{
name: names.ScreenIceCreamShop,
title: "ICE CREAM SHOP — WHERE THE BAR WAS",
background: names.BgIceCreamShop,
onSelector: true,
}
}
+21
View File
@@ -0,0 +1,21 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
// noodleHouse is the street front outside Noodle's place. By the time Paul
// gets there the police have sealed the house and taken Noodle in, and a
// uniform stands in front of the tape (wiki: story#noodle-szál).
//
// The wiki's graph runs NoodleUtcafront → Sikátor: the alley behind the house
// is reached from here, and from nowhere else.
func noodleHouse() screen {
return screen{
name: names.ScreenNoodleHouse,
title: "NOODLE'S HOUSE — SEALED",
background: names.BgNoodleHouse,
onSelector: true,
exits: []exit{
{to: names.ScreenAlley, label: "the alley behind the house", side: sideRight},
},
}
}
@@ -0,0 +1,23 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
// normanApartment is the flat Norman was taken from: his Armilla left behind
// on the side, his Personal Tape missing from it, his terminal full of the
// correspondence and the diary (wiki: story#norman-lakása).
//
// Two ways on from here. His terminal is one of the BBS access points the
// catalogue lists ("a titkos klub ECHO-terminálja; később Norman lakása /
// tetőterasz"), and the roof hideout is up his own stairs.
func normanApartment() screen {
return screen{
name: names.ScreenNormanApartment,
title: "NORMAN'S APARTMENT",
background: names.BgNormanApartment,
onSelector: true,
exits: []exit{
{to: names.ScreenRooftopHideout, label: "the stairs up to the roof", side: sideBack},
{to: names.ScreenBBSTerminal, label: "Norman's terminal", side: sideRight},
},
}
}
+22
View File
@@ -0,0 +1,22 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
// paulShop is where the game starts: the junk shop and garage Paul lives
// behind (wiki: entities#helyszinek). It is the only screen outside San
// Francisco — Noodle's letter arrives here and puts him on a bus
// (wiki: entities, Paul's timeline 12).
//
// The bus is a screen the deck does not have, so the exit is the journey
// itself: one way, with no coming back to the shop mid-story. That is also why
// the shop is not on the selector — it is not one of the city's locations.
func paulShop() screen {
return screen{
name: names.ScreenPaulShop,
title: "PAUL'S SHOP — JUNK AND GARAGE",
background: names.BgPaulShop,
exits: []exit{
{to: names.ScreenNoodleHouse, label: "the bus to San Francisco", side: sideNear},
},
}
}
+20
View File
@@ -0,0 +1,20 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
// policeStation is where Noodle is held for dealing in cracked Armillas, and
// where a clerk quietly tips Paul off about the hiding place
// (wiki: entities, Paul's timeline 4 and 15).
//
// The wiki's graph splits it three ways — utcafront → recepció → kihallgató —
// but the deck paints it as one screen, so the reception desk, the waiting
// room and the interview window are hotspots inside this one, not screens of
// their own.
func policeStation() screen {
return screen{
name: names.ScreenPoliceStation,
title: "SFPD — STATION AND HOLDING",
background: names.BgPoliceStation,
onSelector: true,
}
}
+16
View File
@@ -0,0 +1,16 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
// publicBBS is one of the wiki's 💡 suggestions: the canon has BBS terminals
// standing in cafés, post offices and bus stops — live infrastructure, not
// relics. An alternative way to read the boards, in a place where somebody can
// read over your shoulder (wiki: entities#potenciális-további-helyszínek).
func publicBBS() screen {
return screen{
name: names.ScreenPublicBBS,
title: "PUBLIC BBS TERMINAL",
background: names.BgPublicBBS,
onSelector: true,
}
}
@@ -0,0 +1,21 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
// rooftopHideout is Norman's hiding place: an abandoned roof terrace with his
// personal things, his notes and an old BBS terminal — the emotional high point
// of the investigation (wiki: entities#helyszinek, timeline 23).
//
// It is up the stairs from his flat, and it is one of the places the catalogue
// puts a terminal.
func rooftopHideout() screen {
return screen{
name: names.ScreenRooftopHideout,
title: "NORMAN'S ROOFTOP HIDEOUT",
background: names.BgRooftopHideout,
exits: []exit{
{to: names.ScreenNormanApartment, label: "back down into the flat", side: sideNear},
{to: names.ScreenBBSTerminal, label: "the old terminal", side: sideRight},
},
}
}
+16
View File
@@ -0,0 +1,16 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
// samizdatPress is a 💡 suggestion: the Resistance's physical node — parody
// BVK databases, underground publications, a P2P node humming in the cellar.
// The samizdat intellectuals are the first to write down that the world is
// slipping (wiki: entities#potenciális-további-helyszínek).
func samizdatPress() screen {
return screen{
name: names.ScreenSamizdatPress,
title: "SAMIZDAT PRINTING HOUSE",
background: names.BgSamizdatPress,
onSelector: true,
}
}
+15
View File
@@ -0,0 +1,15 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
// scrapMarket is a 💡 suggestion: the black market in condemned ACPs and bare,
// unstamped tape. Canon says the leak of scrap is not an accident — "something
// wants to live on" (wiki: entities#potenciális-további-helyszínek).
func scrapMarket() screen {
return screen{
name: names.ScreenScrapMarket,
title: "SCRAP MARKET",
background: names.BgScrapMarket,
onSelector: true,
}
}
+149
View File
@@ -0,0 +1,149 @@
// Package screen registers the game's screens — one file per screen.
//
// "Screen" is this project's word: the wiki's catalogue, the concept-art deck
// and the beat tables all count screens. inkwell's word for the same thing is
// Scene, which is the type every file here returns — a screen IS the engine's
// scene, and the rest of the code says "scene" only where it is talking to the
// engine.
//
// A file here holds everything that is true of one screen: its picture, the
// name on the top bar, where you can get to from it (exit.go), and — once its
// beat is written — its hotspots, NPCs and dialogue. Only the alley has that
// last part so far; the rest are greybox.
package screen
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
"git.teletypegames.org/games/realworld/internal/world"
)
// screen is what each file in this package fills in.
type screen struct {
name string
title string
background string
// exits: where you can get to from here. See exit.go.
exits []exit
// onSelector marks a main location — one of those the wiki's
// Helyszínválasztó lists (story#helyszín-gráf-vázlat). It gets a way back
// to the selector for free, and the selector gets a way here.
onSelector bool
// What an authored beat adds on top. Zero values are fine: Paul lands in
// the middle of the default floor strip and nothing else happens.
hotspots []inkwell.Hotspot
actors []inkwell.SceneActor
walkboxes []inkwell.Polygon
onEnter inkwell.Action
}
// deck is every screen in the game, in the order of the wiki's location
// catalogue (entities#helyszinek) — which the concept-art deck follows number
// for number. Registration order is this order, and it is also the order the
// arrow keys walk (ui/screen_nav.go).
//
// Deck 05, Norman's workplace, is in the catalogue but has not been drawn, so
// it has no file yet. The alley has no deck number: the catalogue keeps it in
// the same row as Noodle's house, and that is where it sits here too.
var deck = []func() screen{
paulShop, // 01
noodleHouse, // 02
alley, // 02, same catalogue row
normanApartment, // 03
policeStation, // 04
hackerspace, // 06
iceCreamShop, // 07
trinketShop, // 08
smallRestaurant, // 09
secretClub, // 10
bbsTerminal, // 11
street, // 12
hospital, // 13
serverFarm, // 14
secretLab, // 15
rooftopHideout, // 16
publicBBS, // 17
bvkBranch, // 18
curatorShop, // 19
scrapMarket, // 20
samizdatPress, // 21
showroom, // 22
columbarium, // 23
}
// Register adds every screen to the game, the selector first: it is the centre
// of the wiki's location graph, and it is built from the deck rather than
// listed in it.
func Register(w *world.World) {
screens := make([]screen, 0, len(deck)+1)
for _, f := range deck {
screens = append(screens, f())
}
for _, s := range append([]screen{selector(screens)}, screens...) {
w.G.SceneManager.Register(build(w, s))
}
}
// The default floor: the bottom of the scene region, above the HUD divider.
// A screen with a painted floor of its own overrides it.
var floor = []inkwell.Polygon{
inkwell.Poly(
inkwell.Point{X: 16, Y: 196}, inkwell.Point{X: 624, Y: 196},
inkwell.Point{X: 624, Y: 258}, inkwell.Point{X: 16, Y: 258},
),
}
// build turns a screen into the engine's scene.
//
// The authored hotspots go in front of the exits, because the engine takes the
// first one whose area contains the click (HotspotAt): a painted thing beats
// the edge strip an exit sits on, wherever the two overlap.
func build(w *world.World, s screen) inkwell.Scene {
exits := s.exits
if s.onSelector {
exits = append(exits, toSelector())
}
hotspots := make([]inkwell.Hotspot, 0, len(exits)+len(s.hotspots))
hotspots = append(hotspots, s.hotspots...)
for _, e := range exits {
hotspots = append(hotspots, e.hotspot(w))
}
actors := s.actors
if actors == nil {
actors = []inkwell.SceneActor{
{CharacterName: names.Paul, At: inkwell.Point{X: 320, Y: 232}},
}
}
walkboxes := s.walkboxes
if walkboxes == nil {
walkboxes = floor
}
onEnter := inkwell.Action(world.EnterScene(w, s.name))
if s.onEnter != nil {
onEnter = inkwell.Seq(onEnter, s.onEnter)
}
return inkwell.Scene{
Name: s.name,
Title: s.title,
Background: s.background,
Actors: actors,
Walkboxes: walkboxes,
Hotspots: hotspots,
OnEnter: onEnter,
}
}
// setVarIfEmpty only writes when the variable is still unset, so re-entering a
// screen does not clobber what the game has set in the meantime.
func setVarIfEmpty(name string, v any) inkwell.Action {
return world.Fn(func(ctx *inkwell.Ctx) {
if ctx.Game.State.Var(name) == nil {
ctx.Game.State.SetVar(name, v)
}
})
}
+23
View File
@@ -0,0 +1,23 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
// secretClub is the empty room behind the shop where the "underwater sun"
// company gave their talk, with a secretary who knows about the BVK's plans for
// people's minds and nothing about the key or the tape
// (wiki: story, the club beat).
//
// The terminal in here is the one that matters: not under BVK control, ECHO
// running under the standard LECTOR surface. Reading it is where the mystery
// tape finally speaks.
func secretClub() screen {
return screen{
name: names.ScreenSecretClub,
title: "SECRET CLUB — THE UNDERWATER SUN",
background: names.BgSecretClub,
exits: []exit{
{to: names.ScreenBBSTerminal, label: "the terminal in the corner", side: sideBack},
{to: names.ScreenTrinketShop, label: "back out through the shop", side: sideNear},
},
}
}
+20
View File
@@ -0,0 +1,20 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
// secretLab is the lab the BVK's secret section runs, where Norman lies wired
// into the simulator: the two-thirds point where Paul finds him, and the
// screen the finale plays on (wiki: entities#helyszinek, timeline 22 and 25).
//
// The wiki's graph runs Híd → security szoba → Norman szimulációs szobája, and
// the story puts the whole thing inside the Golden Gate Bridge. The deck paints
// it as one screen, so the door at the foot of the bridge, the observation room
// and the glass wall are hotspots in here.
func secretLab() screen {
return screen{
name: names.ScreenSecretLab,
title: "SECRET RESEARCH LABORATORY",
background: names.BgSecretLab,
onSelector: true,
}
}
+66
View File
@@ -0,0 +1,66 @@
package screen
import (
"strings"
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// selector is the wiki's Helyszínválasztó: "nem valódi helyszín, hanem a
// menü-képernyő, ahonnan az összes fő helyszín elérhető"
// (story#helyszínválasztó). The location graph is centred on it — every main
// location connects to it both ways, and physical adjacency takes over from
// there inwards — so without it the city is a handful of disconnected rooms.
//
// It is not in the concept-art deck and has no painting, so it renders as an
// inkwell placeholder with one labelled pin per destination, laid out on a
// grid. Whatever the map ends up looking like, the connections it carries are
// the ones the screens declare, not a list kept here: selector() is built from
// whichever screens marked themselves onSelector.
func selector(rest []screen) screen {
var exits []exit
for _, s := range rest {
if !s.onSelector {
continue
}
exits = append(exits, exit{
to: s.name,
label: pinLabel(s.title),
area: pin(len(exits)),
})
}
return screen{
name: names.ScreenSelector,
title: "SAN FRANCISCO",
background: names.BgSelector,
exits: exits,
// Nobody stands on a map.
actors: []inkwell.SceneActor{},
walkboxes: []inkwell.Polygon{},
}
}
// The pin grid: three columns down the scene region, filled top to bottom.
const (
pinCols = 3
pinW = 192.0
pinH = 32.0
pinX = 16.0
pinY = 28.0
pinGapX = 204.0
pinGapY = 38.0
)
func pin(i int) inkwell.Shape {
col, row := i%pinCols, i/pinCols
return inkwell.Rect(pinX+float64(col)*pinGapX, pinY+float64(row)*pinGapY, pinW, pinH)
}
// pinLabel is the screen's title without the em-dash subtitle: on a map a
// location wants its name, not its description.
func pinLabel(title string) string {
name, _, _ := strings.Cut(title, " — ")
return name
}
+15
View File
@@ -0,0 +1,15 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
// serverFarm is the cooled, humming corridors where The AI's physical
// infrastructure can be seen for what it is. Getting in needs the access tape
// from the hackerspace or from Noodle (wiki: entities, timeline 24).
func serverFarm() screen {
return screen{
name: names.ScreenServerFarm,
title: "SERVER FARM",
background: names.BgServerFarm,
onSelector: true,
}
}
+16
View File
@@ -0,0 +1,16 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
// showroom is a 💡 suggestion: the legitimate, glittering side — Armilla
// cabinets, licensed operators, matte-black Nokia-punk under showroom lights.
// The contrast with Paul's edge of the world
// (wiki: entities#potenciális-további-helyszínek).
func showroom() screen {
return screen{
name: names.ScreenShowroom,
title: "NEUMATRONIC SHOWROOM",
background: names.BgShowroom,
onSelector: true,
}
}
@@ -0,0 +1,21 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
// smallRestaurant is the little Chinese place beside the trinket shop, and the
// lit mirror in its toilet is the puzzle: it flickers, and something shows
// behind it (wiki: story, the Kajálda beat).
//
// The wiki's graph runs Kajálda → étterem rész → WC; the deck paints all three
// as one screen, so the mirror is a hotspot here. The only way out is back to
// the shop — this is an inner room, not a location on the selector.
func smallRestaurant() screen {
return screen{
name: names.ScreenSmallRestaurant,
title: "SMALL RESTAURANT — NEXT DOOR",
background: names.BgSmallRestaurant,
exits: []exit{
{to: names.ScreenTrinketShop, label: "back to the trinket shop", side: sideLeft},
},
}
}
+19
View File
@@ -0,0 +1,19 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
// street is the city itself: pavements, a square, a metro entrance, and the
// environmental decay that tracks The AI's spread — glitching systems, odd
// news, infrastructure that slips (wiki: entities#helyszinek).
//
// The wiki's own street front is the one with the hotdog stand and Sumphor on
// it (story#norman-lakása); that is a beat for this screen, not a screen of its
// own.
func street() screen {
return screen{
name: names.ScreenStreet,
title: "STREET",
background: names.BgStreet,
onSelector: true,
}
}
+27
View File
@@ -0,0 +1,27 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
// trinketShop is the Chinatown shop at the club's address, whose owner sells
// Paul junk he does not need and is, in fact, the club's doorman
// (wiki: story#csecsebecse-bolt--kajálda-szál).
//
// Both of the wiki's arrows out of it are here: Bolt → Kajálda, the eating
// place next door, and Bolt →|beengedés| Klub, which stays shut until the
// shopkeeper has been shown the underwater sun.
func trinketShop() screen {
return screen{
name: names.ScreenTrinketShop,
title: "CHINATOWN — TRINKET SHOP",
background: names.BgTrinketShop,
onSelector: true,
exits: []exit{
{to: names.ScreenSmallRestaurant, label: "the eating place next door", side: sideRight},
{
to: names.ScreenSecretClub, label: "the way in at the back", side: sideBack,
needs: names.FlagClubEntry,
blocked: "Just a shop, as far as the man behind the counter is concerned.",
},
},
}
}
+62 -4
View File
@@ -46,20 +46,78 @@ const (
FlagPoliceTip = "police_tip" // wiki: hatosagi_tipp
FlagHasTape = "has_mystery_tape" // wiki: titokzatos_tape_megvan
FlagTapeSpoke = "tape_spoke" // wiki: tape_megszolalt
FlagClubEntry = "club_entry" // wiki: klub_bejutas — the shopkeeper let Paul through
VarSlot2 = "armilla.slot2"
VarArmillaStrip = "armilla.strip"
VarDecay = "decay_level" // wiki: romlas_szint
)
// Scenes.
// Screens.
//
// One constant per screen in the wiki's location catalogue
// (entities#helyszinek), whose order the concept-art deck follows number for
// number (games/realworld_screen_assets); the trailing comment is the wiki's
// Hungarian name. The last seven are the catalogue's "javaslat" locations —
// drawn, so they are wired up, but no beat lands on them yet.
//
// Two screens have no deck number. The alley shares the catalogue's "Noodle
// háza & a sikátor" row with the house; the selector is the wiki's
// Helyszínválasztó (story#helyszínválasztó), the menu screen the location
// graph is centred on.
const (
SceneAlley = "alley" // wiki: sikator
ScreenSelector = "selector" // wiki: Helyszínválasztó
ScreenPaulShop = "paul_shop" // wiki: Paul boltja / garázsa
ScreenNoodleHouse = "noodle_house" // wiki: Noodle háza
ScreenNormanApartment = "norman_apartment" // wiki: Norman lakása / műhelye
ScreenPoliceStation = "police_station" // wiki: rendőrség / fogda
ScreenAlley = "alley" // wiki: sikátor (Noodle háza mögött)
ScreenHackerspace = "hackerspace" // wiki: hackerspace
ScreenIceCreamShop = "ice_cream_shop" // wiki: fagyizó
ScreenTrinketShop = "trinket_shop" // wiki: chinatowni csecsebecse-bolt
ScreenSmallRestaurant = "small_restaurant" // wiki: kajálda
ScreenSecretClub = "secret_club" // wiki: titkos klub („vízalatti nap")
ScreenBBSTerminal = "bbs_terminal" // wiki: a terminál / BBS-hozzáférési pont
ScreenStreet = "street" // wiki: köztér / utca
ScreenHospital = "hospital" // wiki: kórház / pszichiátria
ScreenServerFarm = "server_farm" // wiki: szerverfarm / adatközpont
ScreenSecretLab = "secret_lab" // wiki: titkos kutatólabor
ScreenRooftopHideout = "rooftop_hideout" // wiki: Norman tetőterasz-rejteke
ScreenPublicBBS = "public_bbs" // wiki 💡: nyilvános BBS-terminál
ScreenBVKBranch = "bvk_branch" // wiki 💡: BVK-kirendeltség (San Francisco)
ScreenCuratorShop = "curator_shop" // wiki 💡: kurátor-szerviz („a Mester")
ScreenScrapMarket = "scrap_market" // wiki 💡: selejt-piac
ScreenSamizdatPress = "samizdat_press" // wiki 💡: samizdat-nyomda / P2P-csomópont
ScreenShowroom = "showroom" // wiki 💡: Neumatronic márkakereskedés
ScreenColumbarium = "columbarium" // wiki 💡: kolumbárium / Dex emlékhelye
)
// Assets.
// Backgrounds — one per screen, same order.
const (
AssetAlleyBG = "bg_alley"
BgSelector = "bg_selector"
BgPaulShop = "bg_paul_shop"
BgNoodleHouse = "bg_noodle_house"
BgNormanApartment = "bg_norman_apartment"
BgPoliceStation = "bg_police_station"
BgAlley = "bg_alley"
BgHackerspace = "bg_hackerspace"
BgIceCreamShop = "bg_ice_cream_shop"
BgTrinketShop = "bg_trinket_shop"
BgSmallRestaurant = "bg_small_restaurant"
BgSecretClub = "bg_secret_club"
BgBBSTerminal = "bg_bbs_terminal"
BgStreet = "bg_street"
BgHospital = "bg_hospital"
BgServerFarm = "bg_server_farm"
BgSecretLab = "bg_secret_lab"
BgRooftopHideout = "bg_rooftop_hideout"
BgPublicBBS = "bg_public_bbs"
BgBVKBranch = "bg_bvk_branch"
BgCuratorShop = "bg_curator_shop"
BgScrapMarket = "bg_scrap_market"
BgSamizdatPress = "bg_samizdat_press"
BgShowroom = "bg_showroom"
BgColumbarium = "bg_columbarium"
)
// Tapes maps a character name to the label shown in the tape channel header.
+64
View File
@@ -0,0 +1,64 @@
package ui
// screenNav — walking the deck with the arrow keys.
//
// The screens are connected — each one records where you can get to from it
// (content/screen/exit.go) — but walking the map is the player's job, and a
// screen whose beat is not written yet has nothing in it to walk towards. LEFT
// and RIGHT step to the previous and next screen so the deck can be reviewed as
// a deck: in concept-art order, wrapping at both ends. It is a tool for looking
// at the game, not a way through it.
//
// It is deliberately inert whenever the game is actually doing something: no
// stepping out of a cutscene, a menu, or a stopped world during dialogue. So
// once a beat owns a screen, this cannot cut across it.
import (
inkwell "git.teletypegames.org/engines/inkwell"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
"git.teletypegames.org/games/realworld/internal/world"
)
type screenNav struct {
Name string
W *world.World
}
func (n *screenNav) GetName() string { return n.Name }
func (n *screenNav) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {}
func (n *screenNav) Tick(ctx *inkwell.UICtx) {
if !n.W.HUDVisible() || n.W.Paused() {
return
}
step := 0
// inkwell's Input only covers the mouse; read the keys directly.
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 != "" {
n.W.Do(inkwell.GoTo(next))
}
}
// neighbour returns the screen step places from the current one, wrapping.
// Registration order is the deck order — see content/screen/screen.go.
func (n *screenNav) neighbour(g *inkwell.Game, step int) string {
deck := g.SceneManager.Names()
if len(deck) < 2 {
return ""
}
for i, name := range deck {
if name == n.W.Scene() {
return deck[((i+step)%len(deck)+len(deck))%len(deck)]
}
}
return deck[0]
}
+36
View File
@@ -0,0 +1,36 @@
package ui
import (
"testing"
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/world"
)
// The arrow keys walk the deck in registration order and wrap at both ends —
// until the beats grow doors, that walk is the only way through most screens,
// so a dead end at either end would strand the player on a greybox screen.
func TestScreenNavWrapsBothWays(t *testing.T) {
g := inkwell.NewGame("t", ScreenW, ScreenH)
for _, n := range []string{"a", "b", "c"} {
g.SceneManager.Register(inkwell.Scene{Name: n, Background: "bg"})
}
w := world.New(g)
nav := &screenNav{Name: "screen_nav", W: w}
at := func(name string) { world.EnterScene(w, name).Start().Tick(&inkwell.Ctx{Game: g}) }
for _, tc := range []struct {
from string
step int
want string
}{
{"a", 1, "b"}, {"b", 1, "c"}, {"c", 1, "a"},
{"c", -1, "b"}, {"b", -1, "a"}, {"a", -1, "c"},
} {
at(tc.from)
if got := nav.neighbour(g, tc.step); got != tc.want {
t.Errorf("from %q step %+d = %q, want %q", tc.from, tc.step, got, tc.want)
}
}
}
+37 -21
View File
@@ -7,26 +7,31 @@ package ui
// 0 │ ALLEY — paused SRP: 1 tape │ TopBar (20)
// 20 ├────────────────────────────────────────────────────────────────────┤
// │ │
// │ scene — hotspots, characters, SpeechBubble │ Scene (244)
// │ scene — hotspots, characters, SpeechBubble │ Scene (242)
// │ │
// 264├─────────────────────────────────────────┬──────────────────────────┤
// 262├─────────────────────────────────────────┬──────────────────────────┤
// │ Use hook on: floor grate │ DEX │
// │ ┌───┬───┬───┬───┐ ┌───────┐┌────────┐ │ "The hook is a hand │ HUD (135)
// │ ┌───┬───┬───┬───┐ ┌───────┐┌────────┐ │ "The hook is a hand │ HUD (118)
// │ ├───┼───┼───┼───┤ │ 1 DEX ││ 2 — │ │ shorter than the gap." │
// │ └───┴───┴───┴───┘ └───────┘└────────┘ │ │
// 399└─────────────────────────────────────────┴──────────────────────────┘
// 379└─────────────────────────────────────────┴──────────────────────────
// InventoryBar TapeSlots TapeChannel
//
// On the resolution: the wiki's art brief asks for a "320×200 VGA look", and
// inkwell's widget defaults are authored for exactly that. We render at 640×400
// the same 16:10, exactly 2× the brief, so art drawn at 320×200 upscales
// cleanly — because the engine's text is a fixed 6×16 debug font. At 320×200 a
// 16px glyph is 8% of the screen height and nothing fits; at 640×400 it lands
// at 4%, which is the proportion the design was drawn for.
// On the resolution: the screen is exactly one painted background. inkwell
// stretches a scene background over the WHOLE screen (core.engine.go), not over
// the region left free by the HUD, so any screen size other than the art's own
// squashes every painting — and the deck is drawn at 640×380
// (games/realworld_screen_assets). Hence 640×380: the art lands pixel for
// pixel, and the HUD is an opaque strip laid over its foot.
//
// Everything vertical is derived from LineH below rather than from the
// wireframe deck's ratios, so the layout cannot drift out of step with the font
// again.
// That still satisfies what the wiki's "320×200 VGA look" brief 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: a 16px glyph is 4% of 380, the
// proportion the design was drawn for. (At 320×190 it would be 8%, rows would
// overlap and the top bar could not hold a line.)
//
// Everything vertical is derived from LineH and from ScreenH below, never from
// the wireframe deck's ratios, so the layout cannot drift out of step with the
// font — or with the art — again.
import (
inkwell "git.teletypegames.org/engines/inkwell"
@@ -39,8 +44,11 @@ import (
// Screen and layout, in internal (unscaled) pixels.
const (
// The screen is the size of one painted background, because the engine
// scales a background to the full screen: at any other size the art is
// squashed. See the note above.
ScreenW = 640
ScreenH = 400
ScreenH = 380
// LineH is one text row: the glyph cell plus leading. Every text-bearing
// box is sized as a multiple of this, so rows can never overlap.
@@ -48,14 +56,21 @@ const (
Pad = 6 // inner padding for every panel
TopBarH = LineH + 2 // 20 — one row plus a hairline of breathing room
HUDTop = 264 // the scene | HUD divider
DividerX = 394 // world | tape channel (61.6% of the width)
// The HUD strip is as short as its own contents allow, and is measured
// from the bottom of the screen — every pixel it takes is a pixel of the
// painting it covers. It has to hold the status row and, under it, the two
// rows of inventory slots (the tallest thing in the strip), padded above
// and below.
HUDH = Pad + LineH + 4 + invSlot*2 + invGap + Pad // 118
HUDTop = ScreenH - HUDH // 262 — the scene | HUD divider
// Derived HUD rows.
statusY = HUDTop + Pad // 270
slotsY = HUDTop + Pad + LineH // 288
statusY = HUDTop + Pad // 268
slotsY = HUDTop + Pad + LineH // 286
slotsH = LineH*2 + Pad*2 // 48 — two rows plus padding
invY = slotsY + 4 // 292
invY = slotsY + 4 // 290
invSlot = 40
invGap = 4
)
@@ -70,6 +85,7 @@ func Register(w *world.World) {
// it does, and it only ever catches clicks that land on the scene.
g.UIManager.Register(&UseWithGuard{Name: "usewith_guard", W: w})
g.UIManager.Register(&world.ActionPump{Name: "pump", W: w})
g.UIManager.Register(&screenNav{Name: "screen_nav", W: w})
g.UIManager.Register(&windowSizer{Name: "window", Scale: 2})
g.UIManager.Register(&inkwell.HotspotDebug{Name: "hotspot_debug"})
@@ -129,8 +145,8 @@ func Register(w *world.World) {
// windowSizer resizes the window once, on the first frame.
//
// inkwell.Run hardcodes a 4× window (core.dsl.go), which at 640×400 would be
// 2560×1600 — larger than most laptop screens. Run sets the size before
// inkwell.Run hardcodes a 4× window (core.dsl.go), which at 640×380 would be
// 2560×1520 — larger than most laptop screens. Run sets the size before
// entering the loop, so the only place to override it is the first tick.
type windowSizer struct {
Name string
+21 -1
View File
@@ -104,7 +104,27 @@ func SetMode(w *World, m Mode) inkwell.Action {
// EnterScene tells the domain which scene we are in. inkwell exposes no
// CurrentScene accessor, and the pump needs Ctx.Scene.
func EnterScene(w *World, name string) inkwell.Action {
return Fn(func(*inkwell.Ctx) { w.scene = name })
return Fn(func(*inkwell.Ctx) {
if name != w.scene {
w.prev = w.scene
}
w.scene = name
})
}
// Back returns to the screen we came from.
//
// Most exits name their destination, because a door leads where it leads. A
// screen that is reached from several different rooms cannot: the BBS terminal
// is the same terminal whether you walked to it from the club, from Norman's
// flat or from the roof, so its way out is "back", not a place.
func Back(w *World) inkwell.Action {
return Fn(func(ctx *inkwell.Ctx) {
if w.prev == "" {
return
}
inkwell.GoTo(w.prev).Start().Tick(ctx)
})
}
// ----- TapeSay ----------------------------------------------------------
+4
View File
@@ -34,6 +34,7 @@ type World struct {
mode Mode
scene string // current scene — inkwell exposes no accessor for it
prev string // the one before it, for screens with no fixed way out
paused bool // is the world stopped (during dialogue), for the TopBar suffix
pending string // the tape currently being loaded into slot 2
queue []inkwell.Action // HUD-originated actions waiting to run
@@ -63,6 +64,9 @@ func (w *World) HUDVisible() bool { return w.mode == ModePlay }
// Scene returns the name of the current scene.
func (w *World) Scene() string { return w.scene }
// Previous returns the screen we came from, empty at the start of the game.
func (w *World) Previous() string { return w.prev }
// Paused reports whether the world is stopped (during dialogue).
func (w *World) Paused() bool { return w.paused }
+12 -1
View File
@@ -1,6 +1,7 @@
package main
import (
"embed"
"flag"
"log"
@@ -9,9 +10,19 @@ import (
"git.teletypegames.org/games/realworld/internal/boot"
)
// The art ships inside the binary. js/wasm has no OS filesystem, so without
// this every background in the web build falls back to a placeholder; and
// `make binaries` packages the executable alone, with no assets directory
// beside it.
//
//go:embed assets
var assets embed.FS
func init() { inkwell.AssetFS = assets }
func main() {
var o boot.Opts
flag.StringVar(&o.Scene, "scene", "", "starting scene (empty: the alley)")
flag.StringVar(&o.Screen, "screen", "", "starting screen (empty: the alley)")
flag.BoolVar(&o.Finale, "finale", false,
"start with the finale, to try the Nokia-punk theme switch")
flag.Parse()