diff --git a/README.md b/README.md index 1bff2c8..1ea1787 100644 --- a/README.md +++ b/README.md @@ -23,11 +23,19 @@ go run . ``` ```bash -go run . # the alley scene (beat 3) -go run . -finale # the finale — try the Nokia-punk theme switch -go run . -scene alley # pick a starting scene +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 `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/.go` and one line in `scene/scene.go`. -Nothing else moves. +Adding a screen means adding `screen/.go` and `background/.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 diff --git a/assets/bg/bbs_terminal.png b/assets/bg/bbs_terminal.png new file mode 100644 index 0000000..419c27e Binary files /dev/null and b/assets/bg/bbs_terminal.png differ diff --git a/assets/bg/bvk_branch.png b/assets/bg/bvk_branch.png new file mode 100644 index 0000000..728a722 Binary files /dev/null and b/assets/bg/bvk_branch.png differ diff --git a/assets/bg/columbarium.png b/assets/bg/columbarium.png new file mode 100644 index 0000000..d16bce9 Binary files /dev/null and b/assets/bg/columbarium.png differ diff --git a/assets/bg/curator_shop.png b/assets/bg/curator_shop.png new file mode 100644 index 0000000..bd2c89c Binary files /dev/null and b/assets/bg/curator_shop.png differ diff --git a/assets/bg/hackerspace.png b/assets/bg/hackerspace.png new file mode 100644 index 0000000..0bf95dc Binary files /dev/null and b/assets/bg/hackerspace.png differ diff --git a/assets/bg/hospital.png b/assets/bg/hospital.png new file mode 100644 index 0000000..30b7348 Binary files /dev/null and b/assets/bg/hospital.png differ diff --git a/assets/bg/ice_cream_shop.png b/assets/bg/ice_cream_shop.png new file mode 100644 index 0000000..b9ff989 Binary files /dev/null and b/assets/bg/ice_cream_shop.png differ diff --git a/assets/bg/noodle_house.png b/assets/bg/noodle_house.png new file mode 100644 index 0000000..2f39b50 Binary files /dev/null and b/assets/bg/noodle_house.png differ diff --git a/assets/bg/norman_apartment.png b/assets/bg/norman_apartment.png new file mode 100644 index 0000000..9cfed6e Binary files /dev/null and b/assets/bg/norman_apartment.png differ diff --git a/assets/bg/paul_shop.png b/assets/bg/paul_shop.png new file mode 100644 index 0000000..a989d35 Binary files /dev/null and b/assets/bg/paul_shop.png differ diff --git a/assets/bg/police_station.png b/assets/bg/police_station.png new file mode 100644 index 0000000..e3f5b4f Binary files /dev/null and b/assets/bg/police_station.png differ diff --git a/assets/bg/public_bbs.png b/assets/bg/public_bbs.png new file mode 100644 index 0000000..be314da Binary files /dev/null and b/assets/bg/public_bbs.png differ diff --git a/assets/bg/rooftop_hideout.png b/assets/bg/rooftop_hideout.png new file mode 100644 index 0000000..8a1abf2 Binary files /dev/null and b/assets/bg/rooftop_hideout.png differ diff --git a/assets/bg/samizdat_press.png b/assets/bg/samizdat_press.png new file mode 100644 index 0000000..53bbbd8 Binary files /dev/null and b/assets/bg/samizdat_press.png differ diff --git a/assets/bg/scrap_market.png b/assets/bg/scrap_market.png new file mode 100644 index 0000000..7e6d980 Binary files /dev/null and b/assets/bg/scrap_market.png differ diff --git a/assets/bg/secret_club.png b/assets/bg/secret_club.png new file mode 100644 index 0000000..092f86e Binary files /dev/null and b/assets/bg/secret_club.png differ diff --git a/assets/bg/secret_lab.png b/assets/bg/secret_lab.png new file mode 100644 index 0000000..0ded35c Binary files /dev/null and b/assets/bg/secret_lab.png differ diff --git a/assets/bg/server_farm.png b/assets/bg/server_farm.png new file mode 100644 index 0000000..0190a37 Binary files /dev/null and b/assets/bg/server_farm.png differ diff --git a/assets/bg/showroom.png b/assets/bg/showroom.png new file mode 100644 index 0000000..7ae6cfb Binary files /dev/null and b/assets/bg/showroom.png differ diff --git a/assets/bg/small_restaurant.png b/assets/bg/small_restaurant.png new file mode 100644 index 0000000..2a1fe6e Binary files /dev/null and b/assets/bg/small_restaurant.png differ diff --git a/assets/bg/street.png b/assets/bg/street.png new file mode 100644 index 0000000..a250363 Binary files /dev/null and b/assets/bg/street.png differ diff --git a/assets/bg/trinket_shop.png b/assets/bg/trinket_shop.png new file mode 100644 index 0000000..2fe23d6 Binary files /dev/null and b/assets/bg/trinket_shop.png differ diff --git a/internal/boot/boot.go b/internal/boot/boot.go index cb4c90b..d7f2d99 100644 --- a/internal/boot/boot.go +++ b/internal/boot/boot.go @@ -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."), diff --git a/internal/boot/boot_test.go b/internal/boot/boot_test.go index 1377ba6..ac0b814 100644 --- a/internal/boot/boot_test.go +++ b/internal/boot/boot_test.go @@ -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:", 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) + } + } +} diff --git a/internal/content/asset/alley_background.go b/internal/content/asset/alley_background.go deleted file mode 100644 index 88624ba..0000000 --- a/internal/content/asset/alley_background.go +++ /dev/null @@ -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, - } -} diff --git a/internal/content/asset/asset.go b/internal/content/asset/asset.go deleted file mode 100644 index 64481f4..0000000 --- a/internal/content/asset/asset.go +++ /dev/null @@ -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) - } -} diff --git a/internal/content/background/alley.go b/internal/content/background/alley.go new file mode 100644 index 0000000..b537387 --- /dev/null +++ b/internal/content/background/alley.go @@ -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, + } +} diff --git a/internal/content/background/background.go b/internal/content/background/background.go new file mode 100644 index 0000000..82e20fe --- /dev/null +++ b/internal/content/background/background.go @@ -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) + } +} diff --git a/internal/content/background/bbs_terminal.go b/internal/content/background/bbs_terminal.go new file mode 100644 index 0000000..af43011 --- /dev/null +++ b/internal/content/background/bbs_terminal.go @@ -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, + } +} diff --git a/internal/content/background/bvk_branch.go b/internal/content/background/bvk_branch.go new file mode 100644 index 0000000..4b85542 --- /dev/null +++ b/internal/content/background/bvk_branch.go @@ -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, + } +} diff --git a/internal/content/background/columbarium.go b/internal/content/background/columbarium.go new file mode 100644 index 0000000..26b92fe --- /dev/null +++ b/internal/content/background/columbarium.go @@ -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, + } +} diff --git a/internal/content/background/curator_shop.go b/internal/content/background/curator_shop.go new file mode 100644 index 0000000..df9945e --- /dev/null +++ b/internal/content/background/curator_shop.go @@ -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, + } +} diff --git a/internal/content/background/hackerspace.go b/internal/content/background/hackerspace.go new file mode 100644 index 0000000..4330887 --- /dev/null +++ b/internal/content/background/hackerspace.go @@ -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, + } +} diff --git a/internal/content/background/hospital.go b/internal/content/background/hospital.go new file mode 100644 index 0000000..33d4090 --- /dev/null +++ b/internal/content/background/hospital.go @@ -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, + } +} diff --git a/internal/content/background/ice_cream_shop.go b/internal/content/background/ice_cream_shop.go new file mode 100644 index 0000000..552ccf3 --- /dev/null +++ b/internal/content/background/ice_cream_shop.go @@ -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, + } +} diff --git a/internal/content/background/noodle_house.go b/internal/content/background/noodle_house.go new file mode 100644 index 0000000..3bedf11 --- /dev/null +++ b/internal/content/background/noodle_house.go @@ -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, + } +} diff --git a/internal/content/background/norman_apartment.go b/internal/content/background/norman_apartment.go new file mode 100644 index 0000000..f920259 --- /dev/null +++ b/internal/content/background/norman_apartment.go @@ -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, + } +} diff --git a/internal/content/background/paul_shop.go b/internal/content/background/paul_shop.go new file mode 100644 index 0000000..4a89fe6 --- /dev/null +++ b/internal/content/background/paul_shop.go @@ -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, + } +} diff --git a/internal/content/background/police_station.go b/internal/content/background/police_station.go new file mode 100644 index 0000000..7254ddd --- /dev/null +++ b/internal/content/background/police_station.go @@ -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, + } +} diff --git a/internal/content/background/public_bbs.go b/internal/content/background/public_bbs.go new file mode 100644 index 0000000..fa9ae7e --- /dev/null +++ b/internal/content/background/public_bbs.go @@ -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, + } +} diff --git a/internal/content/background/rooftop_hideout.go b/internal/content/background/rooftop_hideout.go new file mode 100644 index 0000000..33655da --- /dev/null +++ b/internal/content/background/rooftop_hideout.go @@ -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, + } +} diff --git a/internal/content/background/samizdat_press.go b/internal/content/background/samizdat_press.go new file mode 100644 index 0000000..7760886 --- /dev/null +++ b/internal/content/background/samizdat_press.go @@ -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, + } +} diff --git a/internal/content/background/scrap_market.go b/internal/content/background/scrap_market.go new file mode 100644 index 0000000..8614c73 --- /dev/null +++ b/internal/content/background/scrap_market.go @@ -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, + } +} diff --git a/internal/content/background/secret_club.go b/internal/content/background/secret_club.go new file mode 100644 index 0000000..865f6ef --- /dev/null +++ b/internal/content/background/secret_club.go @@ -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, + } +} diff --git a/internal/content/background/secret_lab.go b/internal/content/background/secret_lab.go new file mode 100644 index 0000000..f4dc041 --- /dev/null +++ b/internal/content/background/secret_lab.go @@ -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, + } +} diff --git a/internal/content/background/selector.go b/internal/content/background/selector.go new file mode 100644 index 0000000..46b1d11 --- /dev/null +++ b/internal/content/background/selector.go @@ -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, + } +} diff --git a/internal/content/background/server_farm.go b/internal/content/background/server_farm.go new file mode 100644 index 0000000..113e3b3 --- /dev/null +++ b/internal/content/background/server_farm.go @@ -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, + } +} diff --git a/internal/content/background/showroom.go b/internal/content/background/showroom.go new file mode 100644 index 0000000..bf997b0 --- /dev/null +++ b/internal/content/background/showroom.go @@ -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, + } +} diff --git a/internal/content/background/small_restaurant.go b/internal/content/background/small_restaurant.go new file mode 100644 index 0000000..2f7969e --- /dev/null +++ b/internal/content/background/small_restaurant.go @@ -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, + } +} diff --git a/internal/content/background/street.go b/internal/content/background/street.go new file mode 100644 index 0000000..eb4c45f --- /dev/null +++ b/internal/content/background/street.go @@ -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, + } +} diff --git a/internal/content/background/trinket_shop.go b/internal/content/background/trinket_shop.go new file mode 100644 index 0000000..6a05786 --- /dev/null +++ b/internal/content/background/trinket_shop.go @@ -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, + } +} diff --git a/internal/content/content.go b/internal/content/content.go index 52917f4..a4a9d20 100644 --- a/internal/content/content.go +++ b/internal/content/content.go @@ -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/.go and one line in -// scene/scene.go — nothing else moves. +// its kind. Adding a screen means adding screen/.go, background/.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) } diff --git a/internal/content/scene/scene.go b/internal/content/scene/scene.go deleted file mode 100644 index 553c9f1..0000000 --- a/internal/content/scene/scene.go +++ /dev/null @@ -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) - } - }) -} diff --git a/internal/content/scene/alley.go b/internal/content/screen/alley.go similarity index 78% rename from internal/content/scene/alley.go rename to internal/content/screen/alley.go index bd16acb..f8c0e0d 100644 --- a/internal/content/scene/alley.go +++ b/internal/content/screen/alley.go @@ -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()}, } } diff --git a/internal/content/screen/bbs_terminal.go b/internal/content/screen/bbs_terminal.go new file mode 100644 index 0000000..a21272c --- /dev/null +++ b/internal/content/screen/bbs_terminal.go @@ -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}, + }, + } +} diff --git a/internal/content/screen/bvk_branch.go b/internal/content/screen/bvk_branch.go new file mode 100644 index 0000000..b13b84f --- /dev/null +++ b/internal/content/screen/bvk_branch.go @@ -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, + } +} diff --git a/internal/content/screen/columbarium.go b/internal/content/screen/columbarium.go new file mode 100644 index 0000000..66d6639 --- /dev/null +++ b/internal/content/screen/columbarium.go @@ -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, + } +} diff --git a/internal/content/screen/curator_shop.go b/internal/content/screen/curator_shop.go new file mode 100644 index 0000000..ed089ab --- /dev/null +++ b/internal/content/screen/curator_shop.go @@ -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, + } +} diff --git a/internal/content/screen/exit.go b/internal/content/screen/exit.go new file mode 100644 index 0000000..6810648 --- /dev/null +++ b/internal/content/screen/exit.go @@ -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."), + } +} diff --git a/internal/content/screen/hackerspace.go b/internal/content/screen/hackerspace.go new file mode 100644 index 0000000..9541d0a --- /dev/null +++ b/internal/content/screen/hackerspace.go @@ -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, + } +} diff --git a/internal/content/screen/hospital.go b/internal/content/screen/hospital.go new file mode 100644 index 0000000..45de302 --- /dev/null +++ b/internal/content/screen/hospital.go @@ -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, + } +} diff --git a/internal/content/screen/ice_cream_shop.go b/internal/content/screen/ice_cream_shop.go new file mode 100644 index 0000000..4169517 --- /dev/null +++ b/internal/content/screen/ice_cream_shop.go @@ -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, + } +} diff --git a/internal/content/screen/noodle_house.go b/internal/content/screen/noodle_house.go new file mode 100644 index 0000000..b8257cc --- /dev/null +++ b/internal/content/screen/noodle_house.go @@ -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}, + }, + } +} diff --git a/internal/content/screen/norman_apartment.go b/internal/content/screen/norman_apartment.go new file mode 100644 index 0000000..0810096 --- /dev/null +++ b/internal/content/screen/norman_apartment.go @@ -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}, + }, + } +} diff --git a/internal/content/screen/paul_shop.go b/internal/content/screen/paul_shop.go new file mode 100644 index 0000000..dbfe5ae --- /dev/null +++ b/internal/content/screen/paul_shop.go @@ -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 1–2). +// +// 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}, + }, + } +} diff --git a/internal/content/screen/police_station.go b/internal/content/screen/police_station.go new file mode 100644 index 0000000..e4eee64 --- /dev/null +++ b/internal/content/screen/police_station.go @@ -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, + } +} diff --git a/internal/content/screen/public_bbs.go b/internal/content/screen/public_bbs.go new file mode 100644 index 0000000..6c69196 --- /dev/null +++ b/internal/content/screen/public_bbs.go @@ -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, + } +} diff --git a/internal/content/screen/rooftop_hideout.go b/internal/content/screen/rooftop_hideout.go new file mode 100644 index 0000000..8c4f529 --- /dev/null +++ b/internal/content/screen/rooftop_hideout.go @@ -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}, + }, + } +} diff --git a/internal/content/screen/samizdat_press.go b/internal/content/screen/samizdat_press.go new file mode 100644 index 0000000..0e3dddd --- /dev/null +++ b/internal/content/screen/samizdat_press.go @@ -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, + } +} diff --git a/internal/content/screen/scrap_market.go b/internal/content/screen/scrap_market.go new file mode 100644 index 0000000..bc4a7c1 --- /dev/null +++ b/internal/content/screen/scrap_market.go @@ -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, + } +} diff --git a/internal/content/screen/screen.go b/internal/content/screen/screen.go new file mode 100644 index 0000000..edbbbb8 --- /dev/null +++ b/internal/content/screen/screen.go @@ -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) + } + }) +} diff --git a/internal/content/screen/secret_club.go b/internal/content/screen/secret_club.go new file mode 100644 index 0000000..b2ae010 --- /dev/null +++ b/internal/content/screen/secret_club.go @@ -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}, + }, + } +} diff --git a/internal/content/screen/secret_lab.go b/internal/content/screen/secret_lab.go new file mode 100644 index 0000000..4250ec4 --- /dev/null +++ b/internal/content/screen/secret_lab.go @@ -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, + } +} diff --git a/internal/content/screen/selector.go b/internal/content/screen/selector.go new file mode 100644 index 0000000..a4c5111 --- /dev/null +++ b/internal/content/screen/selector.go @@ -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 +} diff --git a/internal/content/screen/server_farm.go b/internal/content/screen/server_farm.go new file mode 100644 index 0000000..ba148e3 --- /dev/null +++ b/internal/content/screen/server_farm.go @@ -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, + } +} diff --git a/internal/content/screen/showroom.go b/internal/content/screen/showroom.go new file mode 100644 index 0000000..38db216 --- /dev/null +++ b/internal/content/screen/showroom.go @@ -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, + } +} diff --git a/internal/content/screen/small_restaurant.go b/internal/content/screen/small_restaurant.go new file mode 100644 index 0000000..48fcb6f --- /dev/null +++ b/internal/content/screen/small_restaurant.go @@ -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}, + }, + } +} diff --git a/internal/content/screen/street.go b/internal/content/screen/street.go new file mode 100644 index 0000000..ec963ad --- /dev/null +++ b/internal/content/screen/street.go @@ -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, + } +} diff --git a/internal/content/screen/trinket_shop.go b/internal/content/screen/trinket_shop.go new file mode 100644 index 0000000..3e1f79f --- /dev/null +++ b/internal/content/screen/trinket_shop.go @@ -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.", + }, + }, + } +} diff --git a/internal/names/names.go b/internal/names/names.go index 1f73dde..736b849 100644 --- a/internal/names/names.go +++ b/internal/names/names.go @@ -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. diff --git a/internal/ui/screen_nav.go b/internal/ui/screen_nav.go new file mode 100644 index 0000000..81db856 --- /dev/null +++ b/internal/ui/screen_nav.go @@ -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] +} diff --git a/internal/ui/screen_nav_test.go b/internal/ui/screen_nav_test.go new file mode 100644 index 0000000..8d6bb59 --- /dev/null +++ b/internal/ui/screen_nav_test.go @@ -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) + } + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go index 7a728c8..7f9b173 100644 --- a/internal/ui/ui.go +++ b/internal/ui/ui.go @@ -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 diff --git a/internal/world/action.go b/internal/world/action.go index 906e31a..3e6ae30 100644 --- a/internal/world/action.go +++ b/internal/world/action.go @@ -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 ---------------------------------------------------------- diff --git a/internal/world/world.go b/internal/world/world.go index 249eea9..df8b719 100644 --- a/internal/world/world.go +++ b/internal/world/world.go @@ -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 } diff --git a/main.go b/main.go index bb22ecb..a694533 100644 --- a/main.go +++ b/main.go @@ -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()