From 0d8918e6f5c67309154892b805221a36617a1c21 Mon Sep 17 00:00:00 2001 From: Zsolt Tasnadi Date: Sat, 29 Aug 2026 18:59:03 +0200 Subject: [PATCH] game skeleton --- .gitignore | 4 + README.md | 188 +++++++++++++++ go.mod | 22 ++ go.sum | 26 +++ internal/boot/boot.go | 57 +++++ internal/boot/boot_test.go | 138 +++++++++++ internal/content/asset/alley_background.go | 16 ++ internal/content/asset/asset.go | 22 ++ internal/content/character/character.go | 24 ++ internal/content/character/dex.go | 17 ++ internal/content/character/mystery_tape.go | 20 ++ internal/content/character/paul.go | 23 ++ internal/content/character/support_tape.go | 17 ++ internal/content/content.go | 28 +++ internal/content/dialog/dex_talk.go | 57 +++++ internal/content/dialog/dialog.go | 26 +++ .../content/dialog/mystery_tape_silent.go | 34 +++ internal/content/item/black_market_armilla.go | 29 +++ internal/content/item/item.go | 22 ++ internal/content/item/mystery_tape.go | 21 ++ internal/content/item/noodle_letter.go | 20 ++ internal/content/scene/alley.go | 112 +++++++++ internal/content/scene/scene.go | 27 +++ internal/content/script/awakening_finale.go | 33 +++ internal/content/script/script.go | 19 ++ internal/content/script/tape_insert.go | 31 +++ internal/names/names.go | 101 ++++++++ internal/theme/theme.go | 154 ++++++++++++ internal/ui/letterbox.go | 58 +++++ internal/ui/tape_channel.go | 123 ++++++++++ internal/ui/tape_slots.go | 155 +++++++++++++ internal/ui/text.go | 127 ++++++++++ internal/ui/text_test.go | 73 ++++++ internal/ui/ui.go | 219 ++++++++++++++++++ internal/ui/usewith_guard.go | 128 ++++++++++ internal/world/action.go | 204 ++++++++++++++++ internal/world/world.go | 98 ++++++++ internal/world/world_test.go | 45 ++++ main.go | 22 ++ 39 files changed, 2540 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 go.sum create mode 100644 internal/boot/boot.go create mode 100644 internal/boot/boot_test.go create mode 100644 internal/content/asset/alley_background.go create mode 100644 internal/content/asset/asset.go create mode 100644 internal/content/character/character.go create mode 100644 internal/content/character/dex.go create mode 100644 internal/content/character/mystery_tape.go create mode 100644 internal/content/character/paul.go create mode 100644 internal/content/character/support_tape.go create mode 100644 internal/content/content.go create mode 100644 internal/content/dialog/dex_talk.go create mode 100644 internal/content/dialog/dialog.go create mode 100644 internal/content/dialog/mystery_tape_silent.go create mode 100644 internal/content/item/black_market_armilla.go create mode 100644 internal/content/item/item.go create mode 100644 internal/content/item/mystery_tape.go create mode 100644 internal/content/item/noodle_letter.go create mode 100644 internal/content/scene/alley.go create mode 100644 internal/content/scene/scene.go create mode 100644 internal/content/script/awakening_finale.go create mode 100644 internal/content/script/script.go create mode 100644 internal/content/script/tape_insert.go create mode 100644 internal/names/names.go create mode 100644 internal/theme/theme.go create mode 100644 internal/ui/letterbox.go create mode 100644 internal/ui/tape_channel.go create mode 100644 internal/ui/tape_slots.go create mode 100644 internal/ui/text.go create mode 100644 internal/ui/text_test.go create mode 100644 internal/ui/ui.go create mode 100644 internal/ui/usewith_guard.go create mode 100644 internal/world/action.go create mode 100644 internal/world/world.go create mode 100644 internal/world/world_test.go create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d42b550 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/realworld +/saves/ +*.png.tmp +screenshot*.png diff --git a/README.md b/README.md new file mode 100644 index 0000000..87ea219 --- /dev/null +++ b/README.md @@ -0,0 +1,188 @@ +# Real World + +Point & click adventure — game two of the Norman Arc. Engine: +[inkwell](../../engines/inkwell). + +The player is **Paul**, a junk dealer investigating what happened to Norman from +the outside. His companion is **Dex**, a Personal Tape riding in slot one of +Paul's **Armilla**. The world, the terminology and the entity catalogue live in +the wiki (`services/wiki-pages/pages/projects/realworld` and +`.../neumatronic-universe`); this file records the game-specific UI decisions. + +## Running + +```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 test ./... # content and layout checks, no window needed +``` + +`go.mod` carries a `replace` pointing at the neighbouring inkwell checkout so +the engine and the game move together. Drop that line for a release build. + +| Control | | +|---|---| +| left click | run the selected verb | +| right click | verb coin (Look / Use / Talk / Take) | +| `F1` | toggle hotspot outlines | +| `SPACE` | during a cutscene: let the tape speak, if it offered | + +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. +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. + +``` + 0 394│396 639 + ┌────────────────────────────────────────────────────────────────────┐ + 0 │ ALLEY — paused SRP: 1 tape │ TopBar (20) +20 ├────────────────────────────────────────────────────────────────────┤ + │ scene — hotspots, characters, SpeechBubble │ Scene (244) +264├─────────────────────────────────────────┬──────────────────────────┤ + │ Use hook on: floor grate │ DEX │ + │ ┌───┬───┬───┬───┐ ┌───────┐┌────────┐ │ "The hook is a hand │ HUD (136) + │ └───┴───┴───┴───┘ │ 1 DEX ││ 2 — │ │ shorter than the gap." │ +399└─────────────────────────────────────────┴──────────────────────────┘ + 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. + +The vertical divider at x=394 is the one proportion carried over from the +wireframe deck: 61.6% world, 38.4% tape channel. The deck's *vertical* +proportions are not carried over. + +The dialogue box deliberately spans only the left region, so a tape can comment +*alongside* a conversation. + +## The rule everything follows + +Canon's central idea is the dual architecture: every machine has a digital side +and an ACP side, and the "agent-mediated focus" turns that into a UI paradigm — +one main text focus plus a side status panel, with the hand typing at the +digital side and the voice speaking to the ACP. + +> **The hand holds the world. The voice speaks to the tape.** +> +> `WORLD` — mouse, the four verbs, inventory → Paul acts. +> `TAPE` — the `talk` verb and tape commentary → nothing happens in the world. +> +> There is always a visible, continuous separator between them, and the tape +> side never carries a button that reaches into the world. + +Two consequences worth stating: there is **no text input anywhere** (the tape +channel is voice, opened with `talk`), and **colour carries the rule** — the +world and Paul speak in bone white, tapes in amber. If something is amber, a +tape said it. + +There is also no "token" economy. The wireframe deck metered advice, but canon +has no such concept and the wiki describes Dex as commenting continuously. If +scarcity is ever wanted, the canon-native lever is the operator licence — +without one a Personal Tape runs in restricted mode, and Paul certainly has no +licence. + +## Structure + +``` +main.go flags + inkwell.Run +internal/names/ entity names and world-state keys +internal/theme/ realworld-93 + nokia-punk +internal/world/ unsaved runtime state, custom actions, action pump +internal/ui/ HUD: layout, custom widgets, coloured text +internal/content/ one file per registered entity, by kind +internal/boot/ wiring +``` + +`world` deliberately knows nothing about the HUD or the content — both build on +it, so the reverse would be a cycle: + +``` +names ← theme ← world ← ui + ↑ ↑ + content ──┴── boot ← main +``` + +Content is one registered entity per file, grouped by kind: + +``` +internal/content/ + asset/ alley_background.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 +``` + +Adding a scene means adding `scene/.go` and one line in `scene/scene.go`. +Nothing else moves. + +## Engine workarounds + +Five inkwell limits turned up during implementation that the engine README does +not mention. All five are worked around on the domain side; each is a candidate +for a small engine change. + +1. **`drawText` discards colour.** In `asset.text.go` the colour argument is + `_ = c` and rendering goes through `ebitenutil.DebugPrintAt`, which only + draws white. Every text colour in `Theme` is therefore inert. *Workaround:* + `ui/text.go` renders onto a scratch image and blits it tinted with + `ColorScale`. The custom widgets colour correctly; the built-ins + (`StatusLine`, `DialogBox`, `TopBar`, `InventoryBar`) are still white. + *Fix:* move `drawText` to `text/v2` — no call site would change. + +2. **`queueAction` is unexported**, so a domain widget cannot start an action. + *Workaround:* the pump in `world/action.go` drives its own `Runner` through + the exported `inkwell.Ctx`. *Caveat:* it runs alongside the engine's script + runner, not instead of it. + +3. **The `"Nem ehhez."` flash is hardcoded** in `core.engine.go` for an + item/hotspot pair with no `OnUseWith`, and cannot be replaced from the + domain. *Workaround:* `UseWithGuard` — widgets tick before the engine's + `handleSceneInput` and can consume the click, so unauthored pairs fail in + character instead, escalating on repeats. + +4. **No exported `CurrentScene()`**, but the pump needs `Ctx.Scene`. + *Workaround:* `world.EnterScene` is the first step of every scene's + `OnEnter`; the domain tracks it. + +5. **Widgets have no `Visible` field and the `Manager` cannot unregister**, so + the built-in HUD cannot be hidden during a cutscene. *Workaround:* the + `gate` wrapper in `ui/ui.go` forwards `Tick`/`Draw`/`BlocksClickAt` only + while the HUD is visible. + +6. **`Run` hardcodes a 4× window** (`core.dsl.go`), which at 640×400 would be + 2560×1600 — bigger than most laptop screens. *Workaround:* the `windowSizer` + widget resizes once on the first tick, since Run sets the size before + entering the loop. + +Also: the inkwell README gives the module path as +`git.teletypegames.org/games/inkwell`; the real path per its `go.mod` is +`git.teletypegames.org/engines/inkwell`. + +## Known gaps + +- **No art.** Everything is an inkwell placeholder. +- **Spent dialogue choices are hidden, not struck through.** `DialogueChoice.Once` + hides them; the deck wanted them struck through but visible, so the list + becomes a memory of what you already tried. Needs a thin override over + `DialogBox`. +- **Only beat 3 exists.** The alley is a vertical slice; `boot.opening` sets the + police-tip flag that beat 2 will eventually set. +- **The render has only been inspected once, by the author of this repo.** + Geometry is covered by tests and a layout dump, but this environment cannot + take a screenshot (both synthetic keystrokes and screen capture are blocked by + macOS privacy permissions), so every visual judgement has to come from you. diff --git a/go.mod b/go.mod index 9c60162..c1a105e 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,25 @@ module realworld go 1.26.5 + +// Fejlesztés közben a szomszédos checkout-ra mutatunk, hogy a motor és a +// játék együtt mozogjon. Kiadáskor ezt a sort kell törölni. +replace git.teletypegames.org/engines/inkwell => ../../engines/inkwell + +require ( + git.teletypegames.org/engines/inkwell v0.0.0-00010101000000-000000000000 + github.com/hajimehoshi/ebiten/v2 v2.9.10 +) + +require ( + github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 // indirect + github.com/ebitengine/hideconsole v1.0.0 // indirect + github.com/ebitengine/oto/v3 v3.4.1 // indirect + github.com/ebitengine/purego v0.9.0 // indirect + github.com/hajimehoshi/go-mp3 v0.3.4 // indirect + github.com/jezek/xgb v1.1.1 // indirect + github.com/jfreymuth/oggvorbis v1.0.5 // indirect + github.com/jfreymuth/vorbis v1.0.2 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.44.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..84f13ee --- /dev/null +++ b/go.sum @@ -0,0 +1,26 @@ +github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1 h1:+kz5iTT3L7uU+VhlMfTb8hHcxLO3TlaELlX8wa4XjA0= +github.com/ebitengine/gomobile v0.0.0-20250923094054-ea854a63cce1/go.mod h1:lKJoeixeJwnFmYsBny4vvCJGVFc3aYDalhuDsfZzWHI= +github.com/ebitengine/hideconsole v1.0.0 h1:5J4U0kXF+pv/DhiXt5/lTz0eO5ogJ1iXb8Yj1yReDqE= +github.com/ebitengine/hideconsole v1.0.0/go.mod h1:hTTBTvVYWKBuxPr7peweneWdkUwEuHuB3C1R/ielR1A= +github.com/ebitengine/oto/v3 v3.4.1 h1:uX7B03/P2P8oWiSI5HXjyjSP4besYn3V9nDk3cR+eIY= +github.com/ebitengine/oto/v3 v3.4.1/go.mod h1:IOleLVD0m+CMak3mRVwsYY8vTctQgOM0iiL6S7Ar7eI= +github.com/ebitengine/purego v0.9.0 h1:mh0zpKBIXDceC63hpvPuGLiJ8ZAa3DfrFTudmfi8A4k= +github.com/ebitengine/purego v0.9.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/hajimehoshi/ebiten/v2 v2.9.10 h1:Z2z8hq8/RVS4tfUcjKGdwzhBhNTbxSkxv867MPiGCLM= +github.com/hajimehoshi/ebiten/v2 v2.9.10/go.mod h1:UqZjna6ppO9dTZtO97LySdB5ustokqOcQKrAfWPrVro= +github.com/hajimehoshi/go-mp3 v0.3.4 h1:NUP7pBYH8OguP4diaTZ9wJbUbk3tC0KlfzsEpWmYj68= +github.com/hajimehoshi/go-mp3 v0.3.4/go.mod h1:fRtZraRFcWb0pu7ok0LqyFhCUrPeMsGRSVop0eemFmo= +github.com/hajimehoshi/oto/v2 v2.3.1/go.mod h1:seWLbgHH7AyUMYKfKYT9pg7PhUu9/SisyJvNTT+ASQo= +github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4= +github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= +github.com/jfreymuth/oggvorbis v1.0.5 h1:u+Ck+R0eLSRhgq8WTmffYnrVtSztJcYrl588DM4e3kQ= +github.com/jfreymuth/oggvorbis v1.0.5/go.mod h1:1U4pqWmghcoVsCJJ4fRBKv9peUJMBHixthRlBeD6uII= +github.com/jfreymuth/vorbis v1.0.2 h1:m1xH6+ZI4thH927pgKD8JOH4eaGRm18rEE9/0WKjvNE= +github.com/jfreymuth/vorbis v1.0.2/go.mod h1:DoftRo4AznKnShRl1GxiTFCseHr4zR9BN3TWXyuzrqQ= +golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= +golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20220712014510-0a85c31ab51e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= diff --git a/internal/boot/boot.go b/internal/boot/boot.go new file mode 100644 index 0000000..68e042e --- /dev/null +++ b/internal/boot/boot.go @@ -0,0 +1,57 @@ +// Package boot wires the layers together: this is the only place where the +// world, the content, the theme and the HUD meet. main.go knows nothing else. +package boot + +import ( + inkwell "git.teletypegames.org/engines/inkwell" + + "realworld/internal/content" + "realworld/internal/names" + "realworld/internal/theme" + "realworld/internal/ui" + "realworld/internal/world" +) + +// Opts are the run parameters. +type Opts struct { + Scene string // starting scene; 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 + } + + g := inkwell.NewGame("Real World", ui.ScreenW, ui.ScreenH) + g.MaxLogLines = 64 + + w := world.New(g) + theme.Register(g) + content.Register(w) + + g.UseTheme(theme.RealWorld) + ui.Register(w) + + g.StartAt(scene) + g.OnStart(inkwell.Seq(opening(w, scene, o.Finale)...)) + return g +} + +func opening(w *world.World, scene 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 — + // the conditional branch itself is real code, not bypassed. + inkwell.SetFlag(names.FlagPoliceTip), + inkwell.Say(names.Paul, "Back alley. Just like the clerk said."), + world.TapeSay(names.Dex, "A government office tipped you off to remove something from a scene. That doesn't strike you as odd?"), + } + if finale { + acts = append(acts, inkwell.RunScript(names.ScriptFinale)) + } + return acts +} diff --git a/internal/boot/boot_test.go b/internal/boot/boot_test.go new file mode 100644 index 0000000..8bc4569 --- /dev/null +++ b/internal/boot/boot_test.go @@ -0,0 +1,138 @@ +package boot_test + +import ( + "testing" + + inkwell "git.teletypegames.org/engines/inkwell" + + "realworld/internal/boot" + "realworld/internal/names" + "realworld/internal/ui" +) + +func build(t *testing.T) *inkwell.Game { + t.Helper() + g := boot.New(boot.Opts{}) + if err := g.Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } + return g +} + +// The engine's Validate only checks scene→asset/character references. A typo in +// a dialogue, script or item name would surface at runtime as a silent no-op +// (RunDialogue and RunScript fail quietly), so check them here. +func TestReferencesResolve(t *testing.T) { + g := build(t) + + for _, n := range []string{names.DlgDex, names.DlgMysteryTape} { + if !g.DialogueManager.Has(n) { + t.Errorf("missing dialogue: %q", n) + } + } + for _, n := range []string{names.ScriptTapeInsert, names.ScriptFinale} { + if !g.ScriptManager.Has(n) { + t.Errorf("missing script: %q", n) + } + } + for _, n := range []string{names.ItemNoodleLetter, names.ItemMysteryTape, names.ItemBlackArmilla} { + if !g.ItemManager.Has(n) { + t.Errorf("missing item: %q", n) + } + } + for n := range names.Tapes { + if !g.CharacterManager.Has(n) { + t.Errorf("tape character not registered: %q", n) + } + } + // Every loadable tape needs an item, a character and a dialogue. + for item, char := range names.TapeItems { + if !g.ItemManager.Has(item) { + t.Errorf("tape item not registered: %q", item) + } + if !g.CharacterManager.Has(char) { + t.Errorf("tape character not registered: %q", char) + } + if !g.DialogueManager.Has(names.TapeDialogue(item)) { + t.Errorf("tape %q has no dialogue: %q", item, names.TapeDialogue(item)) + } + } +} + +// The two-channel rule: every tape needs its own voice colour, distinct from +// the world's. Without that, "if something is amber, a tape said it" is not +// readable. +func TestTapeVoicesAreDistinct(t *testing.T) { + g := build(t) + paul, _ := g.CharacterManager.Get(names.Paul) + seen := map[[4]uint32]string{} + for n := range names.Tapes { + c, _ := g.CharacterManager.Get(n) + if c.SpeechColor == nil { + t.Errorf("%s: no SpeechColor", n) + continue + } + if c.SpeechColor == paul.SpeechColor { + t.Errorf("%s speaks in the same colour as Paul", n) + } + r, gr, b, a := c.SpeechColor.RGBA() + key := [4]uint32{r, gr, b, a} + if other, dup := seen[key]; dup { + t.Errorf("%s and %s share a voice colour", n, other) + } + seen[key] = n + } +} + +// HUD geometry: the two channels must not overlap, and both must stay inside +// the HUD strip. +func TestHUDGeometry(t *testing.T) { + g := build(t) + + ch, ok := g.UIManager.Get("channel") + if !ok { + t.Fatal("no 'channel' widget") + } + tc := ch.(*ui.TapeChannel) + if tc.Bounds.X < ui.DividerX { + t.Errorf("tape channel crosses into the world side: X=%v < %v", tc.Bounds.X, ui.DividerX) + } + if tc.Bounds.X+tc.Bounds.W > ui.ScreenW { + t.Errorf("tape channel runs off screen: %v", tc.Bounds.X+tc.Bounds.W) + } + if tc.Bounds.Y < ui.HUDTop { + t.Errorf("tape channel reaches into the scene: Y=%v < %v", tc.Bounds.Y, ui.HUDTop) + } + + sl, ok := g.UIManager.Get("tapes") + if !ok { + t.Fatal("no 'tapes' widget") + } + if ts := sl.(*ui.TapeSlots); ts.Bounds.X+ts.Bounds.W > ui.DividerX { + t.Errorf("tape slots cross the divider: %v > %v", ts.Bounds.X+ts.Bounds.W, ui.DividerX) + } + + // The dialogue box deliberately covers only the left region, so a tape can + // comment alongside the conversation. + db, ok := g.UIManager.Get("dialog") + if !ok { + t.Fatal("no 'dialog' widget") + } + if box := db.(*inkwell.DialogBox); box.Bounds.X+box.Bounds.W > ui.DividerX { + t.Errorf("dialogue box would cover the tape channel: %v > %v", + box.Bounds.X+box.Bounds.W, ui.DividerX) + } +} + +// The guard must be registered FIRST so it ticks LAST among the widgets — +// otherwise it would swallow HUD clicks. +func TestWidgetOrder(t *testing.T) { + g := build(t) + n := g.UIManager.Names() + if len(n) == 0 || n[0] != "usewith_guard" { + t.Errorf("usewith_guard is not registered first: %v", n) + } + if n[len(n)-1] != "cursor" { + t.Errorf("cursor is not registered last: %v", n) + } +} diff --git a/internal/content/asset/alley_background.go b/internal/content/asset/alley_background.go new file mode 100644 index 0000000..7cc3202 --- /dev/null +++ b/internal/content/asset/alley_background.go @@ -0,0 +1,16 @@ +package asset + +import ( + inkwell "git.teletypegames.org/engines/inkwell" + + "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 new file mode 100644 index 0000000..9631794 --- /dev/null +++ b/internal/content/asset/asset.go @@ -0,0 +1,22 @@ +// 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" + + "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/character/character.go b/internal/content/character/character.go new file mode 100644 index 0000000..45e6177 --- /dev/null +++ b/internal/content/character/character.go @@ -0,0 +1,24 @@ +// Package character registers the game's characters. +// +// Tapes are characters too, not props — the wiki is explicit about it. They +// have no body in the scene, so they carry no W/H and never appear in +// Scene.Actors; their voice lives in the tape channel (see world.TapeSay). +package character + +import ( + inkwell "git.teletypegames.org/engines/inkwell" + + "realworld/internal/world" +) + +// Register adds every character to the game. +func Register(w *world.World) { + for _, c := range []inkwell.Character{ + paul(), + dex(), + mysteryTape(), + supportTape(), + } { + w.G.CharacterManager.Register(c) + } +} diff --git a/internal/content/character/dex.go b/internal/content/character/dex.go new file mode 100644 index 0000000..4906fd0 --- /dev/null +++ b/internal/content/character/dex.go @@ -0,0 +1,17 @@ +package character + +import ( + inkwell "git.teletypegames.org/engines/inkwell" + + "realworld/internal/names" + "realworld/internal/theme" +) + +// dex is the personality imprint of a dead friend, permanently in slot 1 of +// Paul's Armilla. Constant commentary, hint system, dialogue partner. +func dex() inkwell.Character { + return inkwell.Character{ + Name: names.Dex, + SpeechColor: theme.Amber, + } +} diff --git a/internal/content/character/mystery_tape.go b/internal/content/character/mystery_tape.go new file mode 100644 index 0000000..5f91c4c --- /dev/null +++ b/internal/content/character/mystery_tape.go @@ -0,0 +1,20 @@ +package character + +import ( + inkwell "git.teletypegames.org/engines/inkwell" + + "realworld/internal/names" + "realworld/internal/theme" +) + +// mysteryTape is the tape found in the alley — in truth Norman's Personal +// Tape, but the player only learns that in the finale. +// +// Its voice is a colder, greyer amber than Dex's. That pays off in reverse: +// it never once spoke in the same voice as the friend. +func mysteryTape() inkwell.Character { + return inkwell.Character{ + Name: names.TapeMystery, + SpeechColor: theme.RGB(0xB9A98A), + } +} diff --git a/internal/content/character/paul.go b/internal/content/character/paul.go new file mode 100644 index 0000000..cc5f23e --- /dev/null +++ b/internal/content/character/paul.go @@ -0,0 +1,23 @@ +package character + +import ( + inkwell "git.teletypegames.org/engines/inkwell" + + "realworld/internal/names" + "realworld/internal/theme" +) + +// paul is the player character: a junk dealer and street survivor, technically +// competent, living on the edge of the city. +func paul() inkwell.Character { + return inkwell.Character{ + Name: names.Paul, + Speed: 96, + W: 28, + H: 68, + Start: inkwell.Point{X: 120, Y: 232}, + // Bone white: the world and Paul. If something is amber, a tape said + // it — see the colour rule in README. + SpeechColor: theme.Ink, + } +} diff --git a/internal/content/character/support_tape.go b/internal/content/character/support_tape.go new file mode 100644 index 0000000..a575eb6 --- /dev/null +++ b/internal/content/character/support_tape.go @@ -0,0 +1,17 @@ +package character + +import ( + inkwell "git.teletypegames.org/engines/inkwell" + + "realworld/internal/names" + "realworld/internal/theme" +) + +// supportTape answers everything with basic information, insufferably. Dex +// hates it. Acquired in Chinatown (beat 5) — not reachable yet. +func supportTape() inkwell.Character { + return inkwell.Character{ + Name: names.TapeSupport, + SpeechColor: theme.RGB(0xE8C86A), + } +} diff --git a/internal/content/content.go b/internal/content/content.go new file mode 100644 index 0000000..21eeab1 --- /dev/null +++ b/internal/content/content.go @@ -0,0 +1,28 @@ +// Package content is the composition root for everything the game declares: +// 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. +package content + +import ( + "realworld/internal/content/asset" + "realworld/internal/content/character" + "realworld/internal/content/dialog" + "realworld/internal/content/item" + "realworld/internal/content/scene" + "realworld/internal/content/script" + "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. +func Register(w *world.World) { + asset.Register(w) + character.Register(w) + item.Register(w) + dialog.Register(w) + script.Register(w) + scene.Register(w) +} diff --git a/internal/content/dialog/dex_talk.go b/internal/content/dialog/dex_talk.go new file mode 100644 index 0000000..c214d9c --- /dev/null +++ b/internal/content/dialog/dex_talk.go @@ -0,0 +1,57 @@ +package dialog + +import ( + inkwell "git.teletypegames.org/engines/inkwell" + + "realworld/internal/names" +) + +// dexTalk is the standing conversation with Dex. Its branches are gated on +// story flags, so the same entry point stays useful as the game progresses. +func dexTalk() inkwell.Dialogue { + return inkwell.Dialogue{ + Name: names.DlgDex, + Start: "root", + Nodes: []inkwell.DialogueNode{{ + Name: "root", + Lines: []inkwell.DialogueLine{{Speaker: names.Dex, Text: "Talk."}}, + Choices: []inkwell.DialogueChoice{ + { + Text: "What am I doing here, Dex?", + Actions: []inkwell.Action{ + inkwell.Say(names.Dex, "You got a letter from a man you hadn't seen in twenty years. And you came. That says more about you than it does about him."), + inkwell.GotoNode("root"), + }, + }, + { + Text: "What do you know about Noodle?", + Show: inkwell.Not(inkwell.Flag(names.FlagHasTape)), + Actions: []inkwell.Action{ + inkwell.Say(names.Dex, "He traded cracked Armillas. That isn't charity, Paul, that's a business. The kind they take you in for."), + inkwell.GotoNode("root"), + }, + }, + { + Text: "What do you make of this tape?", + Show: inkwell.Flag(names.FlagHasTape), + Actions: []inkwell.Action{ + inkwell.Say(names.Dex, "I make of it that you found a password-locked tape in a gap between two bins, on a tip from a government office. Count how many times that has ended well."), + inkwell.GotoNode("root"), + }, + }, + { + Text: "I miss you.", + Once: true, + Actions: []inkwell.Action{ + inkwell.Say(names.Dex, "I'm four kilobytes of a dead man. Don't do this to yourself."), + inkwell.GotoNode("root"), + }, + }, + { + Text: "Nothing. Forget it.", + Actions: []inkwell.Action{inkwell.EndDialogue()}, + }, + }, + }}, + } +} diff --git a/internal/content/dialog/dialog.go b/internal/content/dialog/dialog.go new file mode 100644 index 0000000..5f14779 --- /dev/null +++ b/internal/content/dialog/dialog.go @@ -0,0 +1,26 @@ +// Package dialog registers the game's dialogue trees. +// +// Talking to a tape carries the same weight as talking to a human NPC — the +// wiki states this explicitly. Tapes are dialogue partners, not menus. +// +// A note on DialogueChoice.Once: inkwell HIDES a spent choice. The wireframe +// deck wanted it struck through but still visible, so the list becomes a +// memory of what you already tried. That is still open — see README, "Known +// gaps". +package dialog + +import ( + inkwell "git.teletypegames.org/engines/inkwell" + + "realworld/internal/world" +) + +// Register adds every dialogue to the game. +func Register(w *world.World) { + for _, d := range []inkwell.Dialogue{ + dexTalk(), + mysteryTapeSilent(), + } { + w.G.DialogueManager.Register(d) + } +} diff --git a/internal/content/dialog/mystery_tape_silent.go b/internal/content/dialog/mystery_tape_silent.go new file mode 100644 index 0000000..9714a79 --- /dev/null +++ b/internal/content/dialog/mystery_tape_silent.go @@ -0,0 +1,34 @@ +package dialog + +import ( + inkwell "git.teletypegames.org/engines/inkwell" + + "realworld/internal/names" +) + +// mysteryTapeSilent covers the stretch before the club trigger (beat 6), while +// the tape is still mute. Talking to it is really Dex talking about it — the +// tape itself says nothing. +func mysteryTapeSilent() inkwell.Dialogue { + return inkwell.Dialogue{ + Name: names.DlgMysteryTape, + Start: "root", + Nodes: []inkwell.DialogueNode{{ + Name: "root", + Lines: []inkwell.DialogueLine{{Speaker: names.Dex, Text: "Nothing. Warm, spinning, and silent."}}, + Choices: []inkwell.DialogueChoice{ + { + Text: "Are you sure it works?", + Actions: []inkwell.Action{ + inkwell.Say(names.Dex, "It works. It just isn't talking to you. That is not the same as silence."), + inkwell.GotoNode("root"), + }, + }, + { + Text: "Fine. It'll speak when it speaks.", + Actions: []inkwell.Action{inkwell.EndDialogue()}, + }, + }, + }}, + } +} diff --git a/internal/content/item/black_market_armilla.go b/internal/content/item/black_market_armilla.go new file mode 100644 index 0000000..3155526 --- /dev/null +++ b/internal/content/item/black_market_armilla.go @@ -0,0 +1,29 @@ +package item + +import ( + inkwell "git.teletypegames.org/engines/inkwell" + + "realworld/internal/names" + "realworld/internal/world" +) + +// blackMarketArmilla is flavour, not a quest trigger: the gap between +// legitimised technology and street reality. +func blackMarketArmilla() inkwell.Item { + return inkwell.Item{ + Name: names.ItemBlackArmilla, + Description: "black-market Armilla", + OnUseSelf: inkwell.Seq( + inkwell.Say(names.Paul, "Jailbroken. Pushes ads, but it was cheap."), + world.TapeSay(names.Dex, "Two slots, and both of them lie about the temperature. Don't trade me in for it."), + ), + OnUseWith: map[string]inkwell.Action{ + // An AUTHORED failure. Nothing is consumed, the character reacts, + // the tape remarks. Never a generic error line. + names.ItemNoodleLetter: inkwell.Seq( + inkwell.Say(names.Paul, "A letter and a bracelet. Brilliant."), + world.TapeSay(names.Dex, "Nothing. Which is what I'd charge for it."), + ), + }, + } +} diff --git a/internal/content/item/item.go b/internal/content/item/item.go new file mode 100644 index 0000000..99c54ae --- /dev/null +++ b/internal/content/item/item.go @@ -0,0 +1,22 @@ +// Package item registers the game's inventory items. +// +// Personal Tapes are inventory items and quest triggers at the same time: they +// go into slot 2 of Paul's Armilla, and loading one activates its effect. +package item + +import ( + inkwell "git.teletypegames.org/engines/inkwell" + + "realworld/internal/world" +) + +// Register adds every item to the game. +func Register(w *world.World) { + for _, i := range []inkwell.Item{ + noodleLetter(), + blackMarketArmilla(), + mysteryTape(), + } { + w.G.ItemManager.Register(i) + } +} diff --git a/internal/content/item/mystery_tape.go b/internal/content/item/mystery_tape.go new file mode 100644 index 0000000..18d5791 --- /dev/null +++ b/internal/content/item/mystery_tape.go @@ -0,0 +1,21 @@ +package item + +import ( + inkwell "git.teletypegames.org/engines/inkwell" + + "realworld/internal/names" + "realworld/internal/world" +) + +// mysteryTape is the engine of the investigation. Password-locked; it only +// starts speaking after the club trigger (beat 6). +func mysteryTape() inkwell.Item { + return inkwell.Item{ + Name: names.ItemMysteryTape, + Description: "unmarked Personal Tape", + OnUseSelf: inkwell.Seq( + inkwell.Say(names.Paul, "Password-locked. Of course."), + world.TapeSay(names.Dex, "Put it in the second slot if you care that much. I did warn you."), + ), + } +} diff --git a/internal/content/item/noodle_letter.go b/internal/content/item/noodle_letter.go new file mode 100644 index 0000000..d6865c5 --- /dev/null +++ b/internal/content/item/noodle_letter.go @@ -0,0 +1,20 @@ +package item + +import ( + inkwell "git.teletypegames.org/engines/inkwell" + + "realworld/internal/names" + "realworld/internal/world" +) + +// noodleLetter starts the story: it is what brings Paul to the city. +func noodleLetter() inkwell.Item { + return inkwell.Item{ + Name: names.ItemNoodleLetter, + Description: "Noodle's letter", + OnUseSelf: inkwell.Seq( + inkwell.Say(names.Paul, "“Come out, Paul. Not a phone conversation.” That's all it says."), + world.TapeSay(names.Dex, "And now you're here. And he isn't."), + ), + } +} diff --git a/internal/content/scene/alley.go b/internal/content/scene/alley.go new file mode 100644 index 0000000..a1aa143 --- /dev/null +++ b/internal/content/scene/alley.go @@ -0,0 +1,112 @@ +package scene + +import ( + inkwell "git.teletypegames.org/engines/inkwell" + + "realworld/internal/names" + "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 +// 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{ + {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(), + }, + } +} + +// hidingPlace holds the mystery tape. It only opens once Paul has the tip from +// the police station (beat 2) — without it he does not know what to look for. +func hidingPlace() inkwell.Hotspot { + return inkwell.Hotspot{ + Name: "hiding_place", + Label: "gap at the foot of the wall", + Area: inkwell.Rect(416, 150, 68, 52), + OnLook: inkwell.If(inkwell.Flag(names.FlagHasTape), + inkwell.Say(names.Paul, "Empty. Whatever was in there is on me now."), + inkwell.Say(names.Paul, "Loose brick. There's a gap behind it."), + ), + OnUse: inkwell.If(inkwell.Flag(names.FlagPoliceTip), + inkwell.If(inkwell.Flag(names.FlagHasTape), + inkwell.Say(names.Paul, "There's nothing else in there."), + inkwell.Seq( + inkwell.Say(names.Paul, "“Behind the brick.” All right then."), + inkwell.Give(names.ItemMysteryTape), + inkwell.SetFlag(names.FlagHasTape), + inkwell.Say(names.Paul, "A Personal Tape. No label, no seal."), + world.TapeSay(names.Dex, "Password-locked. And somebody very much did not want it found."), + ), + ), + inkwell.Say(names.Paul, "One loose brick. I'm not taking the alley apart over it."), + ), + OnTake: inkwell.Say(names.Paul, "I'm not carrying a wall."), + } +} + +// backDoor is the New Game+ hook: the code can be entered on the first visit +// too, if the player already knows it. +func backDoor() inkwell.Hotspot { + return inkwell.Hotspot{ + Name: "back_door", + Label: "locked back door", + Area: inkwell.Rect(72, 106, 80, 124), + OnLook: inkwell.Say(names.Paul, "Keypad. Four digits, worn keys."), + OnUse: inkwell.Seq( + inkwell.Say(names.Paul, "Locked. And I'm not guessing four digits."), + world.TapeSay(names.Dex, "The wear is heaviest on the two and the seven. That isn't a code. It's fewer options."), + ), + OnTalk: inkwell.Say(names.Paul, "To the door? I'm not there yet."), + OnUseWith: map[string]inkwell.Action{ + // An AUTHORED failure — not the engine's hardcoded flash. + names.ItemBlackArmilla: inkwell.Seq( + inkwell.Say(names.Paul, "A black-market bracelet doesn't open a keypad."), + world.TapeSay(names.Dex, "But you do get an advert out of it."), + ), + }, + } +} + +func bin() inkwell.Hotspot { + return inkwell.Hotspot{ + Name: "bin", + Label: "toppled bin", + Area: inkwell.Rect(240, 170, 88, 60), + OnLook: inkwell.Say(names.Paul, "Somebody's been through it. Thoroughly."), + OnUse: inkwell.Seq( + inkwell.Say(names.Paul, "Already turned out. I'm not going to be the second one."), + world.TapeSay(names.Dex, "The police don't go through bins. So who did?"), + ), + } +} + +func fireEscape() inkwell.Hotspot { + return inkwell.Hotspot{ + Name: "fire_escape", + Label: "fire escape", + Area: inkwell.Rect(528, 44, 88, 160), + OnLook: inkwell.Say(names.Paul, "Runs all the way to the roof. Bottom rung is two metres over my head."), + OnUse: inkwell.Say(names.Paul, "Can't reach it. I'd need something to stand on."), + } +} diff --git a/internal/content/scene/scene.go b/internal/content/scene/scene.go new file mode 100644 index 0000000..f287f38 --- /dev/null +++ b/internal/content/scene/scene.go @@ -0,0 +1,27 @@ +// Package scene registers the game's scenes. +package scene + +import ( + inkwell "git.teletypegames.org/engines/inkwell" + + "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/script/awakening_finale.go b/internal/content/script/awakening_finale.go new file mode 100644 index 0000000..1c7cd42 --- /dev/null +++ b/internal/content/script/awakening_finale.go @@ -0,0 +1,33 @@ +package script + +import ( + inkwell "git.teletypegames.org/engines/inkwell" + + "realworld/internal/names" + "realworld/internal/theme" + "realworld/internal/world" +) + +// awakeningFinale is the end of game two. The point of it, mechanically, is +// the theme switch: this is where the Nokia-punk texture breaks in. +// +// Run it early and often during development (`go run . -finale`) — it is the +// single most important visual beat in the game, so you want to be looking at +// it long before the surrounding scene exists. +func awakeningFinale(w *world.World) inkwell.Script { + return inkwell.Script{ + Name: names.ScriptFinale, + Actions: inkwell.Seq( + world.SetMode(w, world.ModeCutscene), + inkwell.Wait(0.6), + inkwell.Say(names.Paul, "I'm putting it in. That's all this is."), + inkwell.Wait(0.4), + world.UseTheme(theme.NokiaPunk), + inkwell.Say("norman", "No — this isn't what you were supposed to do!"), + inkwell.Wait(0.6), + world.TapeSay(names.TapeMystery, "Thank you, Paul. You did exactly what I asked, the whole way through."), + inkwell.Wait(1.0), + inkwell.ShowEnd("REAL WORLD — end of game two"), + ), + } +} diff --git a/internal/content/script/script.go b/internal/content/script/script.go new file mode 100644 index 0000000..5a499dc --- /dev/null +++ b/internal/content/script/script.go @@ -0,0 +1,19 @@ +// Package script registers the game's named, reusable action sequences — +// cutscenes and recurring logic, built from the inkwell action set. +package script + +import ( + inkwell "git.teletypegames.org/engines/inkwell" + + "realworld/internal/world" +) + +// Register adds every script to the game. +func Register(w *world.World) { + for _, s := range []inkwell.Script{ + tapeInsert(w), + awakeningFinale(w), + } { + w.G.ScriptManager.Register(s) + } +} diff --git a/internal/content/script/tape_insert.go b/internal/content/script/tape_insert.go new file mode 100644 index 0000000..2c732bc --- /dev/null +++ b/internal/content/script/tape_insert.go @@ -0,0 +1,31 @@ +package script + +import ( + inkwell "git.teletypegames.org/engines/inkwell" + + "realworld/internal/names" + "realworld/internal/world" +) + +// tapeInsert runs whenever a tape goes into slot 2: it activates the tape and +// makes Dex comment. The wiki calls the comment mandatory — Dex remarks on +// every newly loaded tape, without exception. +func tapeInsert(w *world.World) inkwell.Script { + return inkwell.Script{ + Name: names.ScriptTapeInsert, + Actions: inkwell.Seq( + world.Fn(func(ctx *inkwell.Ctx) { + item := w.TakePending() + if item == "" { + return + } + w.SetSlot2(item) + ctx.Game.Inventory.Remove(item) + ctx.Game.State.SetVar(names.VarArmillaStrip, "SLOT2: READING") + }), + inkwell.Say(names.Paul, "Right. Let's see who you are."), + world.TapeSay(names.Dex, "Clicked in. Spinning. And now: nothing."), + world.TapeSay(names.Dex, "A stranger's tape in your own Armilla, Paul. I hope you know what you're doing."), + ), + } +} diff --git a/internal/names/names.go b/internal/names/names.go new file mode 100644 index 0000000..1f73dde --- /dev/null +++ b/internal/names/names.go @@ -0,0 +1,101 @@ +// Package names is the single source of entity names and world-state keys. +// +// The names follow the wiki entity catalogue +// (services/wiki-pages/pages/projects/realworld/entities) so that content and +// design share one vocabulary. Note that the wiki's world-state table spells +// its keys in Hungarian (hatosagi_tipp, titokzatos_tape_megvan, romlas_szint, +// tape_behelyezes, sikator); the constants below are their English +// equivalents — the mapping is one-to-one, in catalogue order. +// +// This package imports nothing from the project, so every layer can depend on +// it without creating a cycle. +package names + +// Characters. +const ( + Paul = "paul" + Dex = "dex" // Personal Tape, permanently in slot 1 of Paul's Armilla + + // Tapes (Personal Tapes) are characters, not props: the wiki is explicit + // that they have their own personality, motivation and voice. + TapeMystery = "tape_mystery" // = The AI, but that only surfaces in the finale + TapeSupport = "tape_support" // the "Indian tech support" tape +) + +// Inventory items. +const ( + ItemNoodleLetter = "noodle_letter" + ItemMysteryTape = "mystery_tape" + ItemBlackArmilla = "black_market_armilla" +) + +// Dialogues. +const ( + DlgDex = "dex_talk" + DlgMysteryTape = "mystery_tape_silent" +) + +// Scripts. +const ( + ScriptTapeInsert = "tape_insert" + ScriptFinale = "awakening_finale" +) + +// World state — flags and variables. +const ( + FlagPoliceTip = "police_tip" // wiki: hatosagi_tipp + FlagHasTape = "has_mystery_tape" // wiki: titokzatos_tape_megvan + FlagTapeSpoke = "tape_spoke" // wiki: tape_megszolalt + + VarSlot2 = "armilla.slot2" + VarArmillaStrip = "armilla.strip" + VarDecay = "decay_level" // wiki: romlas_szint +) + +// Scenes. +const ( + SceneAlley = "alley" // wiki: sikator +) + +// Assets. +const ( + AssetAlleyBG = "bg_alley" +) + +// Tapes maps a character name to the label shown in the tape channel header. +// TapeChannel uses this to decide whether a line belongs in the tape channel +// or stays in the scene. +var Tapes = map[string]string{ + Dex: "DEX", + TapeMystery: "UNKNOWN TAPE", + TapeSupport: "TECH SUPPORT", +} + +// IsTape reports whether a character is a tape. +func IsTape(name string) bool { _, ok := Tapes[name]; return ok } + +// TapeDisplayName is the label shown in the tape channel header. +func TapeDisplayName(name string) string { + if d, ok := Tapes[name]; ok { + return d + } + return name +} + +// TapeItems maps an inventory item that can go into slot 2 to the character +// living on it. +var TapeItems = map[string]string{ + ItemMysteryTape: TapeMystery, +} + +// IsTapeItem reports whether an item can be loaded into slot 2. +func IsTapeItem(item string) bool { _, ok := TapeItems[item]; return ok } + +// TapeDialogue is the dialogue belonging to a tape in slot 2. +func TapeDialogue(item string) string { + switch item { + case ItemMysteryTape: + return DlgMysteryTape + } + return DlgDex +} diff --git a/internal/theme/theme.go b/internal/theme/theme.go new file mode 100644 index 0000000..e9c0404 --- /dev/null +++ b/internal/theme/theme.go @@ -0,0 +1,154 @@ +// Package theme holds the game's two colour themes and its colour tokens. +// +// In canon the machines of this world are matte black with a green or amber +// character display, and in the Real World finale "the Nokia-punk texture +// breaks in" — with inkwell's runtime theme switching that beat is a single +// line (see world.UseTheme and script.awakeningFinale). +// +// The colour rule: tapes speak in amber (green in Nokia-punk), the world and +// Paul speak in bone white. If something is amber, a tape said it. +package theme + +import ( + "image/color" + + inkwell "git.teletypegames.org/engines/inkwell" +) + +// The two theme names. Runtime switching (inkwell UseTheme) refers to these. +const ( + RealWorld = "realworld-93" + NokiaPunk = "nokia-punk" +) + +// RGB builds an opaque colour from a hex literal. +func RGB(hex uint32) color.Color { + return color.RGBA{R: uint8(hex >> 16), G: uint8(hex >> 8), B: uint8(hex), A: 0xff} +} + +// RGBA builds a colour with the given alpha from a hex literal. +func RGBA(hex uint32, a uint8) color.Color { + return color.RGBA{R: uint8(hex >> 16), G: uint8(hex >> 8), B: uint8(hex), A: a} +} + +// realworld-93 — 95% of the game. Muted earth tones, black HUD, amber tape +// channel. The Neumatronic layer is not visible yet. +// The realworld-93 palette. Ink and Amber are the two sides of the two-channel +// colour rule: the world speaks in bone white, tapes speak in amber. +var ( + Ink = RGB(0xDCD5C4) // bone white — the voice of the world and of Paul + InkDim = RGB(0x8A806B) + Amber = RGB(0xE0A33E) // tape voice + AmberLo = RGB(0xA8701A) + black = RGB(0x14120E) + panel = RGB(0x0D0B08) + sceneBG = RGB(0x241F18) +) + +func realWorldTheme() inkwell.Theme { + return inkwell.Theme{ + Name: RealWorld, + PanelBG: black, + StatusText: Ink, + FlashText: Amber, + + VerbButtonBG: RGB(0x1E1A14), + VerbButtonSelectedBG: AmberLo, + VerbButtonText: Ink, + + InventorySlotBG: RGB(0x1E1A14), + InventorySlotSelectedBG: AmberLo, + + SpeechBubbleBG: RGBA(0x14120E, 0xDC), + SpeechDefaultText: Ink, + + DialogBG: RGBA(0x0D0B08, 0xF0), + DialogBorder: AmberLo, + DialogChoiceBG: RGB(0x1E1A14), + DialogChoiceHover: Amber, + DialogSpeaker: Amber, + DialogText: Ink, + + EndCardBG: RGBA(0x000000, 0xFA), + EndCardText: Ink, + + CursorColor: Amber, + HotspotOutline: RGB(0x7A6A44), + SceneBackdrop: sceneBG, + + TopBarBG: panel, + TopBarText: InkDim, + TopBarAccent: Amber, + + ChatLogBG: panel, + ChatLogPrompt: InkDim, // your own actions — quiet + ChatLogResponse: Amber, // tape voice + ChatLogSystem: RGB(0x6B6353), + + CharacterPanelBG: panel, + CharacterPanelBorder: AmberLo, + CharacterPanelTitle: Amber, + } +} + +// nokia-punk — at peak decay, on BBS/ECHO terminals, and in the finale. +// Matte black and phosphor green: a tuned variant of the engine's +// terminal-green preset. +// The nokia-punk palette — phosphor green on black glass. +var ( + Green = RGB(0x3BE86B) + GreenLo = RGB(0x1F7A39) + npBlack = RGB(0x030603) +) + +func nokiaPunkTheme() inkwell.Theme { + return inkwell.Theme{ + Name: NokiaPunk, + PanelBG: npBlack, + StatusText: Green, + FlashText: RGB(0xD8F06A), + + VerbButtonBG: RGB(0x08140A), + VerbButtonSelectedBG: GreenLo, + VerbButtonText: Green, + + InventorySlotBG: RGB(0x08140A), + InventorySlotSelectedBG: GreenLo, + + SpeechBubbleBG: RGBA(0x030603, 0xDC), + SpeechDefaultText: Green, + + DialogBG: RGBA(0x030603, 0xF0), + DialogBorder: Green, + DialogChoiceBG: RGB(0x08140A), + DialogChoiceHover: RGB(0xB4FFC8), + DialogSpeaker: RGB(0xB4FFC8), + DialogText: Green, + + EndCardBG: RGBA(0x000000, 0xFA), + EndCardText: Green, + + CursorColor: Green, + HotspotOutline: GreenLo, + SceneBackdrop: npBlack, + + TopBarBG: RGB(0x061006), + TopBarText: GreenLo, + TopBarAccent: Green, + + ChatLogBG: RGB(0x040A04), + ChatLogPrompt: GreenLo, + ChatLogResponse: Green, + ChatLogSystem: RGB(0x156030), + + CharacterPanelBG: RGB(0x040A04), + CharacterPanelBorder: Green, + CharacterPanelTitle: RGB(0xB4FFC8), + } +} + +// Register adds both themes to the game. +func Register(g *inkwell.Game) { + g.ThemeManager.Register(realWorldTheme()) + g.ThemeManager.Register(nokiaPunkTheme()) +} diff --git a/internal/ui/letterbox.go b/internal/ui/letterbox.go new file mode 100644 index 0000000..2e5f60d --- /dev/null +++ b/internal/ui/letterbox.go @@ -0,0 +1,58 @@ +package ui + +// Letterbox — the cutscene frame. +// +// The one exception to the fixed layout. In cutscene mode the HUD strip and +// the TopBar are hidden, the scene gets a black bar top and bottom, and a line +// at the foot of the screen says a tape would like to speak — but it does not +// speak on its own. The floor is the player's: a tape signals, never interrupts. + +import ( + inkwell "git.teletypegames.org/engines/inkwell" + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/inpututil" + "github.com/hajimehoshi/ebiten/v2/vector" + + "realworld/internal/names" + "realworld/internal/theme" + "realworld/internal/world" +) + +type Letterbox struct { + Name string + W *world.World + Bar float64 // height of the black bar, top and bottom +} + +func (l *Letterbox) GetName() string { return l.Name } + +func (l *Letterbox) Tick(ctx *inkwell.UICtx) { + if l.W.Mode() != world.ModeCutscene || !l.W.TapeWaiting() { + return + } + // inkwell's Input only covers the mouse; read the key directly. + if inpututil.IsKeyJustPressed(ebiten.KeySpace) { + l.W.TakeTapeOffer() + } +} + +func (l *Letterbox) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) { + if l.W.Mode() != world.ModeCutscene { + return + } + g := ctx.Game + th := g.Theme() + bar := l.Bar + if bar <= 0 { + bar = 18 + } + w, h := float32(g.Width), float32(g.Height) + black := theme.RGB(0x000000) + vector.DrawFilledRect(dst, 0, 0, w, float32(bar), black, false) + vector.DrawFilledRect(dst, 0, h-float32(bar), w, float32(bar), black, false) + + if l.W.TapeWaiting() { + msg := "SPACE — " + names.TapeDisplayName(names.Dex) + " has something to say" + drawTextC(dst, msg, g.Width-textW(msg)-4, g.Height-int(bar)+4, th.ChatLogResponse) + } +} diff --git a/internal/ui/tape_channel.go b/internal/ui/tape_channel.go new file mode 100644 index 0000000..d1e1524 --- /dev/null +++ b/internal/ui/tape_channel.go @@ -0,0 +1,123 @@ +package ui + +// TapeChannel — the tape channel down the right-hand side. +// +// Not assistant UI: this is the voice of CHARACTERS. The wiki is explicit that +// ACPs and Personal Tapes are not props but characters with their own voice. +// That is why we do not use the built-in ChatLog — it paints every LogResponse +// in one Theme.ChatLogResponse colour, whereas here three or four tapes can +// speak into the same strip (Dex, the mystery tape, the tech support tape) and +// the colour comes per speaker from Character.SpeechColor. +// +// Paul and the NPCs never appear here: they speak in the scene, through the +// SpeechBubble. The channel is what the tapes said, plus — quietly — what you +// did to prompt it. That gives the "joke book, not a scoreboard" log for free. + +import ( + "image/color" + + inkwell "git.teletypegames.org/engines/inkwell" + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" + + "realworld/internal/names" + "realworld/internal/world" +) + +type TapeChannel struct { + Name string + W *world.World + Bounds inkwell.Rectangle +} + +func (t *TapeChannel) GetName() string { return t.Name } + +func (t *TapeChannel) Tick(ctx *inkwell.UICtx) {} + +// BlocksClickAt — the channel is not interactive, but it is a solid input +// zone: the verb coin must not open over it. +func (t *TapeChannel) BlocksClickAt(p inkwell.Point) bool { + return t.W.HUDVisible() && t.Bounds.Contains(p) +} + +func (t *TapeChannel) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) { + if !t.W.HUDVisible() { + return + } + g := ctx.Game + th := g.Theme() + b := t.Bounds + if b.W <= 0 || b.H <= 0 { + return + } + const lh, pad = LineH, Pad + + vector.DrawFilledRect(dst, float32(b.X), float32(b.Y), float32(b.W), float32(b.H), th.ChatLogBG, false) + + // Header: the name of whichever tape spoke last — not "console". + speaker, speakerCol := t.lastSpeaker(g, th) + drawTextC(dst, speaker, int(b.X)+pad, int(b.Y)+pad, speakerCol) + hy := float32(b.Y+pad+lh) + 2 + vector.StrokeLine(dst, float32(b.X)+pad, hy, + float32(b.X+b.W)-pad, hy, 1, th.CharacterPanelBorder, false) + + type line struct { + text string + col color.Color + } + wrapW := int(b.W) - pad*2 + var rendered []line + for _, m := range g.Messages() { + var col color.Color + var txt string + switch m.Kind { + case inkwell.LogAction: + // Your own actions — quiet, but visible: that is what makes the + // joke book readable. + col, txt = th.ChatLogPrompt, m.Text + case inkwell.LogResponse: + if !names.IsTape(m.Speaker) { + continue // Paul and the NPCs speak in the scene, not here + } + col, txt = speechColor(g, m.Speaker, th.ChatLogResponse), m.Text + default: + col, txt = th.ChatLogSystem, m.Text + } + for _, wl := range wrap(txt, wrapW) { + rendered = append(rendered, line{text: wl, col: col}) + } + } + + top := int(b.Y) + pad + lh + 6 + maxLines := (int(b.Y+b.H) - pad - top) / lh + if maxLines <= 0 { + return + } + if start := len(rendered) - maxLines; start > 0 { + rendered = rendered[start:] + } + for i, ln := range rendered { + drawTextC(dst, ln.text, int(b.X)+pad, top+i*lh, ln.col) + } +} + +// lastSpeaker returns the display name and colour of whichever tape spoke +// last, defaulting to the occupant of slot 1. +func (t *TapeChannel) lastSpeaker(g *inkwell.Game, th inkwell.Theme) (string, color.Color) { + msgs := g.Messages() + for i := len(msgs) - 1; i >= 0; i-- { + m := msgs[i] + if m.Kind == inkwell.LogResponse && names.IsTape(m.Speaker) { + return names.TapeDisplayName(m.Speaker), speechColor(g, m.Speaker, th.ChatLogResponse) + } + } + return names.TapeDisplayName(names.Dex), speechColor(g, names.Dex, th.ChatLogResponse) +} + +// speechColor is the character's voice colour, falling back to the theme's. +func speechColor(g *inkwell.Game, name string, fallback color.Color) color.Color { + if c, ok := g.CharacterManager.Get(name); ok && c.SpeechColor != nil { + return c.SpeechColor + } + return fallback +} diff --git a/internal/ui/tape_slots.go b/internal/ui/tape_slots.go new file mode 100644 index 0000000..e65de10 --- /dev/null +++ b/internal/ui/tape_slots.go @@ -0,0 +1,155 @@ +package ui + +// TapeSlots — the two slots of Paul's Armilla. +// +// In canon Paul's Armilla has TWO slots where the standard has one: Dex sits +// permanently in the first, and tapes picked up during the game go into the +// second. The wireframe deck does not know about this at all — this is the one +// HUD element that comes purely from the wiki. +// +// Interaction follows the selected verb, like everything else: +// +// talk → converse with whoever occupies the slot +// look → describe the slot +// use + a selected tape → load it into slot 2 +// +// Nothing here touches the world: this is the Vox side of the two channels. + +import ( + inkwell "git.teletypegames.org/engines/inkwell" + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" + + "realworld/internal/names" + "realworld/internal/world" +) + +type TapeSlots struct { + Name string + W *world.World + Bounds inkwell.Rectangle +} + +func (t *TapeSlots) GetName() string { return t.Name } + +func (t *TapeSlots) BlocksClickAt(p inkwell.Point) bool { + return t.W.HUDVisible() && t.Bounds.Contains(p) +} + +// slotRects splits the widget bounds into the two slot rectangles. +func (t *TapeSlots) slotRects() (inkwell.Rectangle, inkwell.Rectangle) { + b := t.Bounds + w := (b.W - Pad) / 2 + return inkwell.Rect(b.X, b.Y, w, b.H), inkwell.Rect(b.X+w+Pad, b.Y, w, b.H) +} + +func (t *TapeSlots) Tick(ctx *inkwell.UICtx) { + if !t.W.HUDVisible() { + return + } + g := ctx.Game + mp := g.Input.Point() + r1, r2 := t.slotRects() + + // Hover label, so the StatusLine spells out what is about to happen. + switch { + case r1.Contains(mp): + g.SetHoverLabel(names.TapeDisplayName(names.Dex)) + case r2.Contains(mp): + if s := t.W.Slot2(); s != "" { + g.SetHoverLabel(itemLabel(g, s)) + } else { + g.SetHoverLabel("empty tape slot") + } + } + + if !g.Input.LeftClicked() { + return + } + if !r1.Contains(mp) && !r2.Contains(mp) { + return + } + g.Input.ConsumeLeft() + + slot2 := r2.Contains(mp) + sel := g.Inventory.Selected() + + // Loading a tape into slot 2. + if slot2 && sel != "" { + g.Inventory.Select("") + if names.IsTapeItem(sel) { + t.W.SetPending(sel) + t.W.Do(inkwell.RunScript(names.ScriptTapeInsert)) + return + } + t.W.Do(world.TapeSay(names.Dex, "That isn't a tape, Paul. That's an object. There is a difference.")) + return + } + if !slot2 && sel != "" { + g.Inventory.Select("") + t.W.Do(world.TapeSay(names.Dex, "The first slot is mine. I'm not moving.")) + return + } + + switch g.SelectedVerb() { + case "talk": + if !slot2 { + t.W.Do(world.Paused(t.W, inkwell.RunDialogue(names.DlgDex))) + return + } + if s := t.W.Slot2(); s != "" { + t.W.Do(world.Paused(t.W, inkwell.RunDialogue(names.TapeDialogue(s)))) + return + } + t.W.Do(world.TapeSay(names.Dex, "Empty. Nobody to talk to.")) + default: // look, take, use all fall back to a description + if !slot2 { + t.W.Do(world.TapeSay(names.Dex, "Me. Four kilobytes of a dead man. Be grateful.")) + return + } + if s := t.W.Slot2(); s != "" { + t.W.Do(world.TapeSay(names.Dex, "That is the second slot. Be careful what you let in.")) + return + } + t.W.Do(world.TapeSay(names.Dex, "The second slot is empty. That's the rarer state.")) + } +} + +func (t *TapeSlots) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) { + if !t.W.HUDVisible() { + return + } + g := ctx.Game + th := g.Theme() + r1, r2 := t.slotRects() + + // Two text rows per slot: the slot number on top, its occupant below. + // Both rows are LineH tall, so nothing can overlap the border. + drawSlot := func(r inkwell.Rectangle, label, body string, filled bool) { + bg, border, col := th.InventorySlotBG, th.ChatLogSystem, th.ChatLogSystem + if filled { + bg, border, col = th.PanelBG, th.CharacterPanelBorder, th.ChatLogResponse + } + vector.DrawFilledRect(dst, float32(r.X), float32(r.Y), float32(r.W), float32(r.H), bg, false) + vector.StrokeRect(dst, float32(r.X), float32(r.Y), float32(r.W), float32(r.H), 1, border, false) + + x, inner := int(r.X)+Pad, int(r.W)-Pad*2 + drawTextC(dst, label, x, int(r.Y)+Pad/2, th.ChatLogSystem) + drawTextC(dst, clip(body, inner), x, int(r.Y)+Pad/2+LineH, col) + } + + drawSlot(r1, "SLOT 1", names.TapeDisplayName(names.Dex), true) + if s := t.W.Slot2(); s != "" { + drawSlot(r2, "SLOT 2", itemLabel(g, s), true) + } else { + drawSlot(r2, "SLOT 2", "empty", false) + } +} + +// itemLabel is a short display label for an inventory item. +func itemLabel(g *inkwell.Game, name string) string { + if it, ok := g.ItemManager.Get(name); ok && it.Description != "" { + return it.Description + } + return name +} diff --git a/internal/ui/text.go b/internal/ui/text.go new file mode 100644 index 0000000..7eb1879 --- /dev/null +++ b/internal/ui/text.go @@ -0,0 +1,127 @@ +// Package ui is the game's HUD: the layout, the custom widgets and coloured +// text rendering. +package ui + +// Coloured text rendering. +// +// inkwell's drawText (asset.text.go) discards its colour argument (`_ = c`) and +// always renders white through the ebitenutil debug font. The two-channel +// colour rule — "if something is amber, a tape said it" — cannot work through +// it, so the custom widgets do not call Game.DrawText: they render glyphs onto +// a scratch image and blit it tinted with ColorScale. The built-in widgets +// (StatusLine, DialogBox, TopBar) still render white. See README, "Engine +// workarounds". + +import ( + "image" + "image/color" + "unicode/utf8" + + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/ebitenutil" +) + +// glyphW and glyphH are the ebitenutil debug font's cell. The engine's own +// textWidth uses the same 6px but counts bytes, which mismeasures any +// non-ASCII text; we count runes. +const ( + glyphW = 6 + glyphH = 16 +) + +var scratch *ebiten.Image + +// textW is the pixel width of a string, counted in runes. +func textW(s string) int { return utf8.RuneCountInString(s) * glyphW } + +// drawTextC draws one line in colour c, with the same origin as DebugPrintAt. +func drawTextC(dst *ebiten.Image, s string, x, y int, c color.Color) { + if s == "" { + return + } + w, h := textW(s)+glyphW, glyphH + if scratch == nil || scratch.Bounds().Dx() < w || scratch.Bounds().Dy() < h { + nw, nh := w, h + if nw < 320 { + nw = 320 + } + scratch = ebiten.NewImage(nw, nh) + } + scratch.Clear() + ebitenutil.DebugPrintAt(scratch, s, 0, 0) + + if c == nil { + c = color.White + } + r, g, b, a := c.RGBA() + op := &ebiten.DrawImageOptions{} + op.GeoM.Translate(float64(x), float64(y)) + op.ColorScale.Scale( + float32(r)/0xffff, float32(g)/0xffff, float32(b)/0xffff, float32(a)/0xffff, + ) + dst.DrawImage(scratch.SubImage(image.Rect(0, 0, w, h)).(*ebiten.Image), op) +} + +// wrap breaks s at word boundaries into lines no wider than maxPx. The engine's +// wrapText is unexported and byte-based, hence our own. +func wrap(s string, maxPx int) []string { + if maxPx <= glyphW || textW(s) <= maxPx { + return []string{s} + } + var out []string + line, word := "", "" + flush := func() { + if line != "" { + out = append(out, line) + line = "" + } + } + emit := func() { + if word == "" { + return + } + switch { + case line == "": + line = word + case textW(line+" "+word) <= maxPx: + line += " " + word + default: + flush() + line = word + } + word = "" + } + for _, r := range s { + switch r { + case ' ': + emit() + case '\n': + emit() + flush() + default: + word += string(r) + } + } + emit() + flush() + if len(out) == 0 { + return []string{s} + } + return out +} + +// clip truncates a string so it fits into maxPx. +func clip(s string, maxPx int) string { + r := []rune(s) + max := maxPx / glyphW + switch { + case max <= 0: + return "" + case len(r) <= max: + return s + case max == 1: + return string(r[:1]) + default: + return string(r[:max-1]) + "…" + } +} diff --git a/internal/ui/text_test.go b/internal/ui/text_test.go new file mode 100644 index 0000000..0fa2b5f --- /dev/null +++ b/internal/ui/text_test.go @@ -0,0 +1,73 @@ +package ui + +import "testing" + +// The engine's textWidth counts bytes, which mismeasures any non-ASCII string. +// Ours counts runes — pin that down, because wrapping depends on it. +func TestTextWidthCountsRunes(t *testing.T) { + if got, want := textW("naïve"), 5*glyphW; got != want { + t.Errorf("textW(naïve) = %d, want %d", got, want) + } + if got, want := textW("ab"), 2*glyphW; got != want { + t.Errorf("textW(ab) = %d, want %d", got, want) + } +} + +func TestWrapRespectsWidth(t *testing.T) { + const maxPx = 60 + lines := wrap("An Armilla without a tape is a stupid watch, remember that.", maxPx) + if len(lines) < 2 { + t.Fatalf("did not wrap: %v", lines) + } + for _, l := range lines { + if textW(l) > maxPx && len(wrap(l, maxPx)) > 1 { + t.Errorf("line too long (%d px): %q", textW(l), l) + } + } +} + +func TestClipFits(t *testing.T) { + if got := clip("black-market Armilla", 8*glyphW); textW(got) > 8*glyphW { + t.Errorf("clip too wide: %q", got) + } + if got := clip("dex", 8*glyphW); got != "dex" { + t.Errorf("clip shortened a fitting string: %q", got) + } +} + +// Regression guard for the bug that made the first build unreadable: the +// layout was derived from the wireframe deck's ratios, where text is 2.8% of +// the screen height, while the engine's debug font is a fixed 6×16 cell. At +// 320×200 that is 8% — so rows overlapped and the top bar overflowed into the +// scene. Every text row must be at least as tall as the glyph cell. +func TestLayoutFitsTheFont(t *testing.T) { + if LineH < glyphH { + t.Errorf("LineH (%d) is shorter than the glyph cell (%d): rows will overlap", LineH, glyphH) + } + if TopBarH < glyphH { + t.Errorf("TopBarH (%d) cannot hold a %dpx glyph", TopBarH, glyphH) + } + // The tape channel must fit a header, a rule and at least four body rows — + // below that the dialogue is unreadable. + const channelH = ScreenH - HUDTop - 4 + body := (channelH - Pad*2 - LineH - 6) / LineH + if body < 4 { + t.Errorf("tape channel only fits %d body rows, want >= 4", body) + } + // The two tape slots must fit their two rows inside their border. + if slotsH < LineH*2+Pad { + t.Errorf("slotsH (%d) cannot hold two %dpx rows", slotsH, LineH) + } +} + +// The HUD must not reach outside the screen, and the scene must keep a usable +// aspect ratio for a point & click background. +func TestSceneProportions(t *testing.T) { + if invY+invSlot*2+invGap > ScreenH { + t.Errorf("inventory runs off the bottom: %d > %d", invY+invSlot*2+invGap, ScreenH) + } + sceneH := HUDTop - TopBarH + if ratio := float64(ScreenW) / float64(sceneH); ratio > 3.0 { + t.Errorf("scene is too letterboxed for a background: %.2f:1", ratio) + } +} diff --git a/internal/ui/ui.go b/internal/ui/ui.go new file mode 100644 index 0000000..701bf87 --- /dev/null +++ b/internal/ui/ui.go @@ -0,0 +1,219 @@ +package ui + +// HUD layout. +// +// 0 394│396 639 +// ┌────────────────────────────────────────────────────────────────────┐ +// 0 │ ALLEY — paused SRP: 1 tape │ TopBar (20) +// 20 ├────────────────────────────────────────────────────────────────────┤ +// │ │ +// │ scene — hotspots, characters, SpeechBubble │ Scene (244) +// │ │ +// 264├─────────────────────────────────────────┬──────────────────────────┤ +// │ Use hook on: floor grate │ DEX │ +// │ ┌───┬───┬───┬───┐ ┌───────┐┌────────┐ │ "The hook is a hand │ HUD (135) +// │ ├───┼───┼───┼───┤ │ 1 DEX ││ 2 — │ │ shorter than the gap." │ +// │ └───┴───┴───┴───┘ └───────┘└────────┘ │ │ +// 399└─────────────────────────────────────────┴──────────────────────────┘ +// 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. +// +// 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. + +import ( + inkwell "git.teletypegames.org/engines/inkwell" + "github.com/hajimehoshi/ebiten/v2" + "github.com/hajimehoshi/ebiten/v2/vector" + + "realworld/internal/names" + "realworld/internal/world" +) + +// Screen and layout, in internal (unscaled) pixels. +const ( + ScreenW = 640 + ScreenH = 400 + + // 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. + LineH = glyphH + 2 // 18 + 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) + + // Derived HUD rows. + statusY = HUDTop + Pad // 270 + slotsY = HUDTop + Pad + LineH // 288 + slotsH = LineH*2 + Pad*2 // 48 — two rows plus padding + invY = slotsY + 4 // 292 + invSlot = 40 + invGap = 4 +) + +// Register assembles the whole HUD in the conventional Z-order: the debug +// overlay at the back, the cursor in front. +func Register(w *world.World) { + g := w.G + + // The guard goes FIRST so it ticks LAST among the widgets — widgets tick + // in reverse registration order, so every HUD widget gets the click before + // 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(&windowSizer{Name: "window", Scale: 2}) + g.UIManager.Register(&inkwell.HotspotDebug{Name: "hotspot_debug"}) + + top := &inkwell.TopBar{ + Name: "topbar", + Height: TopBarH, + // The right section prints State.Var(TimeVar). We deliberately reuse + // it for the Armilla's single-line phosphor strip: in canon the device + // display is one line of text, not a phone screen. + TimeVar: names.VarArmillaStrip, + } + w.SetTopBar(top) + g.UIManager.Register(gated(w, "topbar_gate", top)) + + g.UIManager.Register(&Letterbox{Name: "letterbox", W: w, Bar: 36}) + g.UIManager.Register(&hudFrame{Name: "hudframe", W: w}) + + g.UIManager.Register(gated(w, "status_gate", &inkwell.StatusLine{ + Name: "status", Y: statusY, Align: inkwell.AlignLeft, ScreenWidth: DividerX, + })) + g.UIManager.Register(gated(w, "inventory_gate", &inkwell.InventoryBar{ + Name: "inventory", Origin: inkwell.Point{X: Pad + 2, Y: invY}, + Slots: 8, Cols: 4, SlotSize: invSlot, Gap: invGap, + })) + g.UIManager.Register(&TapeSlots{ + Name: "tapes", W: w, + Bounds: inkwell.Rect(192, slotsY, 194, slotsH), + }) + g.UIManager.Register(&TapeChannel{ + Name: "channel", W: w, + Bounds: inkwell.Rect(DividerX+2, HUDTop+2, ScreenW-DividerX-4, ScreenH-HUDTop-4), + }) + + g.UIManager.Register(&inkwell.SpeechBubble{ + Name: "speech", MaxWidth: 360, Padding: Pad, OffsetY: 8, FallbackY: TopBarH + Pad, + }) + // The dialogue box deliberately covers only the left region, so a tape can + // comment ALONGSIDE the conversation. It consumes every click while active, + // so the tape channel stays visible but inert — which is what we want. + g.UIManager.Register(&inkwell.DialogBox{ + Name: "dialog", Bounds: inkwell.Rect(0, ScreenH-LineH*9, DividerX, LineH*9), + LineHeight: LineH, Padding: Pad, + }) + // A verb coin, not a verb bar: a permanent VerbBar would eat the left + // region, leaving room for neither the inventory nor the tape slots. + g.UIManager.Register(&inkwell.RadialVerbs{ + Name: "verbs", Trigger: inkwell.MouseButtonRight, Radius: 58, + Labels: map[string]string{ + "look": "Look", "use": "Use", "talk": "Talk", "take": "Take", + }, + }) + g.UIManager.Register(&inkwell.EndCard{Name: "endcard"}) + g.UIManager.Register(&inkwell.Cursor{Name: "cursor"}) +} + +// ----- windowSizer ------------------------------------------------------ + +// 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 +// entering the loop, so the only place to override it is the first tick. +type windowSizer struct { + Name string + Scale int + done bool +} + +func (s *windowSizer) GetName() string { return s.Name } +func (s *windowSizer) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {} + +func (s *windowSizer) Tick(ctx *inkwell.UICtx) { + if s.done { + return + } + s.done = true + scale := s.Scale + if scale <= 0 { + scale = 2 + } + ebiten.SetWindowSize(ctx.Game.Width*scale, ctx.Game.Height*scale) +} + +// ----- gate ------------------------------------------------------------- + +// gate switches built-in widgets off in cutscene and menu mode. inkwell's +// widgets have no Visible field and the Manager cannot unregister, so we wrap. +// BlocksClickAt is forwarded, otherwise the verb coin would open over the HUD. +type gate struct { + Name string + W *world.World + Inner inkwell.Widget +} + +func gated(w *world.World, name string, inner inkwell.Widget) inkwell.Widget { + return &gate{Name: name, W: w, Inner: inner} +} + +func (n *gate) GetName() string { return n.Name } + +func (n *gate) Tick(ctx *inkwell.UICtx) { + if n.W.HUDVisible() { + n.Inner.Tick(ctx) + } +} + +func (n *gate) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) { + if n.W.HUDVisible() { + n.Inner.Draw(dst, ctx) + } +} + +func (n *gate) BlocksClickAt(p inkwell.Point) bool { + if !n.W.HUDVisible() { + return false + } + b, ok := n.Inner.(interface{ BlocksClickAt(inkwell.Point) bool }) + return ok && b.BlocksClickAt(p) +} + +// ----- hudFrame --------------------------------------------------------- + +// hudFrame paints the HUD strip's backdrop and the two dividers. The rule: the +// two channels always have a visible, continuous separator between them. +type hudFrame struct { + Name string + W *world.World +} + +func (h *hudFrame) GetName() string { return h.Name } +func (h *hudFrame) Tick(ctx *inkwell.UICtx) {} + +func (h *hudFrame) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) { + if !h.W.HUDVisible() { + return + } + th := ctx.Game.Theme() + vector.DrawFilledRect(dst, 0, HUDTop+1, ScreenW, ScreenH-HUDTop-1, th.PanelBG, false) + // horizontal: scene | HUD + vector.StrokeLine(dst, 0, HUDTop, ScreenW, HUDTop, 1, th.CharacterPanelBorder, false) + // vertical: world | tape + vector.StrokeLine(dst, DividerX, HUDTop+1, DividerX, ScreenH, 1, th.CharacterPanelBorder, false) +} + +func (h *hudFrame) BlocksClickAt(p inkwell.Point) bool { + return h.W.HUDVisible() && p.Y >= HUDTop +} diff --git a/internal/ui/usewith_guard.go b/internal/ui/usewith_guard.go new file mode 100644 index 0000000..fa6f254 --- /dev/null +++ b/internal/ui/usewith_guard.go @@ -0,0 +1,128 @@ +package ui + +// UseWithGuard — enforcing authored failure. +// +// The rule: a failed interaction fails IN CHARACTER, never with an error +// message. But inkwell hardcodes a "Nem ehhez." flash in core.engine.go when an +// item/hotspot pair has no OnUseWith handler, and that string cannot be +// replaced from the domain. +// +// The fix, without touching the engine: widgets tick BEFORE the engine's +// handleSceneInput and may consume the click. So this widget catches the +// "selected item + hotspot with no authored pair" case and fails in Paul's +// voice with a remark from Dex — costing nothing, since nothing is consumed and +// nothing breaks. +// +// IMPORTANT: it must be registered FIRST so that it ticks LAST among the +// widgets. That way every HUD widget (inventory, tape slots, dialogue) sees the +// click first, and this only ever catches clicks that land on the scene. + +import ( + "math/rand" + + inkwell "git.teletypegames.org/engines/inkwell" + "github.com/hajimehoshi/ebiten/v2" + + "realworld/internal/names" + "realworld/internal/world" +) + +type UseWithGuard struct { + Name string + W *world.World +} + +func (u *UseWithGuard) GetName() string { return u.Name } +func (u *UseWithGuard) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {} + +func (u *UseWithGuard) Tick(ctx *inkwell.UICtx) { + g := ctx.Game + if !u.W.HUDVisible() || !g.Input.LeftClicked() { + return + } + sel := g.Inventory.Selected() + if sel == "" { + return + } + h := g.HotspotAt(g.Input.Point()) + if h == nil || hasAuthoredPair(g, h, sel) { + return // leave authored pairs to the engine + } + + g.Input.ConsumeLeft() + g.Inventory.Select("") + + label := h.Label + if label == "" { + label = h.Name + } + g.LogAction("> Use " + itemLabel(g, sel) + " on: " + label) + + // The tone escalates on repeats: dry, then teasing, then a real nudge. + key := "fail." + sel + "." + h.Name + g.State.NoteTalked(key) + n := g.State.Talked(key) + + u.W.Do(inkwell.Seq( + inkwell.Say(names.Paul, pick(paulFails, n)), + world.TapeSay(names.Dex, dexFail(n)), + )) +} + +// hasAuthoredPair mirrors the engine's lookup order in invokeHotspotVerb. +func hasAuthoredPair(g *inkwell.Game, h *inkwell.Hotspot, item string) bool { + if h.OnUseWith != nil { + if a, ok := h.OnUseWith[item]; ok && a != nil { + return true + } + } + if it, ok := g.ItemManager.Get(item); ok && it.OnUseWith != nil { + if a, ok := it.OnUseWith[h.Name]; ok && a != nil { + return true + } + } + return false +} + +var paulFails = []string{ + "No. That's not going to work.", + "Tried that. It didn't improve.", + "All right, I know that one doesn't work.", + "Now it's personal.", +} + +var dexDry = []string{ + "No. Not like that.", + "I heard it. Nothing happened.", +} + +var dexTease = []string{ + "Twice the same. The second one rarely goes better.", + "Do it a few more times, maybe physics reconsiders.", +} + +// dexFail escalates: dry, then teasing, then an actual hint — so the joke +// never becomes the wall. +func dexFail(n int) string { + switch { + case n <= 1: + return pick(dexDry, n) + case n == 2: + return pick(dexTease, n) + default: + return "Paul. Leave it. Look again at what you're carrying — that's where it is." + } +} + +func pick(s []string, n int) string { + switch { + case len(s) == 0: + return "" + case n <= 0: + return s[0] + case n-1 < len(s): + return s[n-1] + default: + return s[rand.Intn(len(s))] + } +} diff --git a/internal/world/action.go b/internal/world/action.go new file mode 100644 index 0000000..906e31a --- /dev/null +++ b/internal/world/action.go @@ -0,0 +1,204 @@ +package world + +// The game's own actions, and the domain action pump. +// +// inkwell's queueAction is unexported, so domain widgets (TapeSlots) cannot put +// an action on the engine's queue — the built-in widgets only manage it because +// they live inside the package. inkwell.Ctx, however, is exported with exported +// fields, so the domain can drive a Runner itself. That is what the pump does: +// it runs HUD-originated actions (starting a dialogue, a tape line) alongside +// the engine's own script runner. See README, "Engine workarounds". + +import ( + inkwell "git.teletypegames.org/engines/inkwell" + "github.com/hajimehoshi/ebiten/v2" +) + +// ----- pump ------------------------------------------------------------- + +// Do queues a HUD-originated action. +func (w *World) Do(a inkwell.Action) { + if a != nil { + w.queue = append(w.queue, a) + } +} + +// PumpTick advances the pump. The ActionPump widget calls it every frame. +func (w *World) PumpTick(dt float64) { + // The TopBar's left section: location plus a single word for a stopped + // world. No pause overlay, no blur — one word says time has stopped. + if w.top != nil { + title := w.sceneTitle() + if w.paused { + title += " — paused" + } + w.top.LeftText = title + } + + if w.running == nil { + if len(w.queue) == 0 { + return + } + w.running = w.queue[0].Start() + w.queue = w.queue[1:] + } + ctx := &inkwell.Ctx{Game: w.G, DT: dt} + if s, ok := w.G.SceneManager.Get(w.scene); ok { + ctx.Scene = &s + } + if w.running.Tick(ctx) != inkwell.StatusRunning { + w.running = nil + } +} + +func (w *World) sceneTitle() string { + s, ok := w.G.SceneManager.Get(w.scene) + if !ok { + return "" + } + if s.Title != "" { + return s.Title + } + return s.Name +} + +// ActionPump is an invisible widget that advances the pump. The HUD registers +// it; the world itself knows nothing about the widget tree. +type ActionPump struct { + Name string + W *World +} + +func (p *ActionPump) GetName() string { return p.Name } +func (p *ActionPump) Tick(ctx *inkwell.UICtx) { p.W.PumpTick(ctx.DT) } +func (p *ActionPump) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {} + +// ----- helpers ---------------------------------------------------------- + +type fnAction struct{ fn func(*inkwell.Ctx) } + +func (a *fnAction) Start() inkwell.Runner { return &fnRunner{spec: a} } + +type fnRunner struct{ spec *fnAction } + +func (r *fnRunner) Tick(ctx *inkwell.Ctx) inkwell.Status { + r.spec.fn(ctx) + return inkwell.StatusDone +} + +// Fn turns a function into a one-shot action that completes immediately. +func Fn(f func(*inkwell.Ctx)) inkwell.Action { return &fnAction{fn: f} } + +// UseTheme switches the theme at runtime. This is the single line that makes +// the Nokia-punk texture break in during the finale. inkwell saves ActiveTheme, +// so the switch survives a save without needing a flag of its own. +func UseTheme(name string) inkwell.Action { + return Fn(func(ctx *inkwell.Ctx) { ctx.Game.UseTheme(name) }) +} + +// SetMode switches the HUD mode (play / cutscene / menu). +func SetMode(w *World, m Mode) inkwell.Action { + return Fn(func(*inkwell.Ctx) { w.SetMode(m) }) +} + +// 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 }) +} + +// ----- TapeSay ---------------------------------------------------------- + +// TapeSay is a line spoken by a tape. +// +// It is deliberately not inkwell.Say: Say puts a SpeechBubble above the +// speaker's head, but tapes have no body in the scene — they speak into your +// ear. This is the two-channel rule enforced in code: a tape can only reach the +// tape channel, never the scene. +func TapeSay(speaker, text string) inkwell.Action { + return &tapeSayAction{speaker: speaker, text: text} +} + +type tapeSayAction struct{ speaker, text string } + +func (a *tapeSayAction) Start() inkwell.Runner { return &tapeSayRunner{spec: a} } + +type tapeSayRunner struct { + spec *tapeSayAction + started bool + elapsed float64 + duration float64 +} + +func (r *tapeSayRunner) Tick(ctx *inkwell.Ctx) inkwell.Status { + if !r.started { + r.started = true + // Same pacing as Say, so the two breathe together inside a Seq. + r.duration = 1.2 + float64(len([]rune(r.spec.text)))*0.05 + ctx.Game.LogResponse(r.spec.speaker, r.spec.text) + } + r.elapsed += ctx.DT + if r.elapsed >= r.duration { + return inkwell.StatusDone + } + return inkwell.StatusRunning +} + +// ----- TapeOffer -------------------------------------------------------- + +// TapeOffer offers a tape line during a cutscene: the Letterbox shows that a +// tape would speak, but it only speaks if the player asks. A tape never +// interrupts — the floor is the player's. +func TapeOffer(w *World, line inkwell.Action) inkwell.Action { + return Fn(func(*inkwell.Ctx) { + w.tapeLine = line + w.tapeWaiting = true + }) +} + +// TakeTapeOffer queues the offered line and clears the prompt. +func (w *World) TakeTapeOffer() { + if !w.tapeWaiting { + return + } + w.tapeWaiting = false + line := w.tapeLine + w.tapeLine = nil + w.Do(line) +} + +// ----- Paused ----------------------------------------------------------- + +// Paused marks the world as stopped for the duration of the wrapped action. +// inkwell exposes no dialogueActive accessor, so we mark it on the content +// side: every dialogue start is wrapped in this. +func Paused(w *World, inner inkwell.Action) inkwell.Action { + return &pausedAction{w: w, inner: inner} +} + +type pausedAction struct { + w *World + inner inkwell.Action +} + +func (a *pausedAction) Start() inkwell.Runner { + return &pausedRunner{spec: a, inner: a.inner.Start()} +} + +type pausedRunner struct { + spec *pausedAction + inner inkwell.Runner + started bool +} + +func (r *pausedRunner) Tick(ctx *inkwell.Ctx) inkwell.Status { + if !r.started { + r.started = true + r.spec.w.paused = true + } + s := r.inner.Tick(ctx) + if s != inkwell.StatusRunning { + r.spec.w.paused = false + } + return s +} diff --git a/internal/world/world.go b/internal/world/world.go new file mode 100644 index 0000000..db6a12a --- /dev/null +++ b/internal/world/world.go @@ -0,0 +1,98 @@ +// Package world is the domain aggregate: a thin layer around the inkwell Game +// holding the runtime state that must NOT be saved, plus the game's own +// actions and the action pump. +// +// This package knows nothing about the HUD or the content, so ui and content +// can both build on it without creating an import cycle. +package world + +import ( + inkwell "git.teletypegames.org/engines/inkwell" + + "realworld/internal/names" +) + +// Mode is the HUD mode. +// +// IMPORTANT: this is deliberately NOT a State.Var. inkwell's state.save.go +// serialises State.Vars but drops the in-flight script ("saves capture state at +// idle/ready boundaries"), so a save taken mid-cutscene would reload into a +// permanently letterboxed, HUD-less game with no script running. The mode is +// therefore an unsaved runtime field. +type Mode string + +const ( + ModePlay Mode = "play" // full HUD + ModeCutscene Mode = "cutscene" // letterbox, HUD hidden + ModeMenu Mode = "menu" // main menu, HUD hidden +) + +// World holds the runtime state that does not belong in a save file. +type World struct { + G *inkwell.Game + + mode Mode + + scene string // current scene — inkwell exposes no accessor for it + 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 + running inkwell.Runner + top *inkwell.TopBar // the location bar, so the pump can write its suffix + + // During a cutscene a tape signals but never interrupts: the line waits + // here until the player asks for it with SPACE. + tapeWaiting bool + tapeLine inkwell.Action +} + +// New wraps an existing inkwell game. +func New(g *inkwell.Game) *World { + return &World{G: g, mode: ModePlay} +} + +// Mode returns the current HUD mode. +func (w *World) Mode() Mode { return w.mode } + +// SetMode switches the HUD mode. +func (w *World) SetMode(m Mode) { w.mode = m } + +// HUDVisible reports whether the HUD should draw: only in play mode. +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 } + +// Paused reports whether the world is stopped (during dialogue). +func (w *World) Paused() bool { return w.paused } + +// SetTopBar hands over the location bar so the pump can write its suffix. +func (w *World) SetTopBar(t *inkwell.TopBar) { w.top = t } + +// Slot2 returns the item name in the second tape slot, empty if there is none. +// +// Unlike Mode, this is REAL game state and lives in State.Var so that it is +// saved. +func (w *World) Slot2() string { + if v, ok := w.G.State.Var(names.VarSlot2).(string); ok { + return v + } + return "" +} + +// SetSlot2 sets the contents of the second tape slot. +func (w *World) SetSlot2(item string) { w.G.State.SetVar(names.VarSlot2, item) } + +// SetPending marks which tape is going into slot 2. The tape-insert script +// performs the actual load; the widget only marks. +func (w *World) SetPending(item string) { w.pending = item } + +// TakePending returns and clears the tape waiting to be loaded. +func (w *World) TakePending() string { + item := w.pending + w.pending = "" + return item +} + +// TapeWaiting reports whether a tape line has been offered but not yet spoken. +func (w *World) TapeWaiting() bool { return w.tapeWaiting } diff --git a/internal/world/world_test.go b/internal/world/world_test.go new file mode 100644 index 0000000..eec1aa7 --- /dev/null +++ b/internal/world/world_test.go @@ -0,0 +1,45 @@ +package world_test + +import ( + "testing" + + inkwell "git.teletypegames.org/engines/inkwell" + + "realworld/internal/names" + "realworld/internal/world" +) + +// The mode must not leak into saved state: inkwell saves State.Vars but drops +// the in-flight script, so a save taken mid-cutscene would otherwise reload +// into a letterboxed, HUD-less game with nothing running. +func TestModeIsNotSavedState(t *testing.T) { + w := world.New(inkwell.NewGame("t", 320, 200)) + + w.SetMode(world.ModeCutscene) + for _, k := range []string{"ui.mode", "mode"} { + if v := w.G.State.Var(k); v != nil { + t.Errorf("mode leaked into State.Vars (%q = %v)", k, v) + } + } + + // Slot 2, by contrast, is real game state and must be saved. + w.SetSlot2(names.ItemMysteryTape) + if w.G.State.Var(names.VarSlot2) != names.ItemMysteryTape { + t.Error("slot 2 is not in State.Vars") + } + if got := w.Slot2(); got != names.ItemMysteryTape { + t.Errorf("Slot2() = %q", got) + } +} + +// The widget marks a tape as pending; the script consumes it — once. +func TestPendingIsConsumedOnce(t *testing.T) { + w := world.New(inkwell.NewGame("t", 320, 200)) + w.SetPending(names.ItemMysteryTape) + if got := w.TakePending(); got != names.ItemMysteryTape { + t.Fatalf("TakePending() = %q", got) + } + if got := w.TakePending(); got != "" { + t.Errorf("pending was not cleared: %q", got) + } +} diff --git a/main.go b/main.go new file mode 100644 index 0000000..47b7cfe --- /dev/null +++ b/main.go @@ -0,0 +1,22 @@ +package main + +import ( + "flag" + "log" + + inkwell "git.teletypegames.org/engines/inkwell" + + "realworld/internal/boot" +) + +func main() { + var o boot.Opts + flag.StringVar(&o.Scene, "scene", "", "starting scene (empty: the alley)") + flag.BoolVar(&o.Finale, "finale", false, + "start with the finale, to try the Nokia-punk theme switch") + flag.Parse() + + if err := inkwell.Run(boot.New(o)); err != nil { + log.Fatal(err) + } +}