game skeleton
This commit is contained in:
@@ -0,0 +1,4 @@
|
|||||||
|
/realworld
|
||||||
|
/saves/
|
||||||
|
*.png.tmp
|
||||||
|
screenshot*.png
|
||||||
@@ -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.
|
||||||
@@ -1,3 +1,25 @@
|
|||||||
module realworld
|
module realworld
|
||||||
|
|
||||||
go 1.26.5
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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."),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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]) + "…"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 }
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user