game skeleton

This commit is contained in:
2026-08-29 19:07:32 +02:00
parent a04a61ddd0
commit 0d8918e6f5
39 changed files with 2540 additions and 0 deletions
+4
View File
@@ -0,0 +1,4 @@
/realworld
/saves/
*.png.tmp
screenshot*.png
+188
View File
@@ -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/<name>.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.
+22
View File
@@ -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
)
+26
View File
@@ -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=
+57
View File
@@ -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
}
+138
View File
@@ -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)
}
}
@@ -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,
}
}
+22
View File
@@ -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)
}
}
+24
View File
@@ -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)
}
}
+17
View File
@@ -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,
}
}
@@ -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),
}
}
+23
View File
@@ -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,
}
}
@@ -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),
}
}
+28
View File
@@ -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/<name>.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)
}
+57
View File
@@ -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()},
},
},
}},
}
}
+26
View File
@@ -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)
}
}
@@ -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()},
},
},
}},
}
}
@@ -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."),
),
},
}
}
+22
View File
@@ -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)
}
}
+21
View File
@@ -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."),
),
}
}
+20
View File
@@ -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."),
),
}
}
+112
View File
@@ -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."),
}
}
+27
View File
@@ -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)
}
})
}
@@ -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"),
),
}
}
+19
View File
@@ -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)
}
}
+31
View File
@@ -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."),
),
}
}
+101
View File
@@ -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
}
+154
View File
@@ -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())
}
+58
View File
@@ -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)
}
}
+123
View File
@@ -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
}
+155
View File
@@ -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
}
+127
View File
@@ -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]) + "…"
}
}
+73
View File
@@ -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)
}
}
+219
View File
@@ -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
}
+128
View File
@@ -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))]
}
}
+204
View File
@@ -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
}
+98
View File
@@ -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 }
+45
View File
@@ -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)
}
}
+22
View File
@@ -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)
}
}