new file structure

This commit is contained in:
2026-08-29 23:25:36 +02:00
parent bbc3faf3d7
commit ec48cd6b37
96 changed files with 805 additions and 969 deletions
+35 -25
View File
@@ -29,7 +29,7 @@ go run . -screen selector # the location selector: the map the city hangs off
go run . -screen hospital # any screen in the deck, e.g. to look at the art
```
Screen names are the constants in `internal/names` (`selector`, `paul_shop`,
Screen names are the constants in `names.manager.go` (`selector`, `paul_shop`,
`noodle_house`, `alley`, `norman_apartment`, `police_station`, `hackerspace`,
`ice_cream_shop`, `trinket_shop`, `small_restaurant`, `secret_club`,
`bbs_terminal`, `street`, `hospital`, `server_farm`, `secret_lab`,
@@ -151,18 +151,24 @@ licence.
## Structure
The game is one flat package, `inc`. There are no subdirectories: a file's
name says where it belongs, in the form `[category].[name].go`. The category is
always singular, and `[category].manager.go` is the file that ties that category
together — its `register…`, its shared types, its list.
```
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
inc/names.manager.go entity names and world-state keys
inc/theme.manager.go realworld-93 + nokia-punk
inc/world.*.go unsaved runtime state, custom actions, action pump
inc/ui.*.go HUD: layout, custom widgets, coloured text
inc/<kind>.<name>.go one file per registered entity, by kind
inc/boot.manager.go wiring
```
`world` deliberately knows nothing about the HUD or the content — both build on
it, so the reverse would be a cycle:
Nothing is enforced by the compiler any more, so the layering is a rule the
code keeps by hand: the world knows nothing about the HUD or the content, and
both build on it, never the other way round.
```
names ← theme ← world ← ui
@@ -170,26 +176,30 @@ names ← theme ← world ← ui
content ──┴── boot ← main
```
Content is one registered entity per file, grouped by kind:
Content is one registered entity per file, and the category prefix groups them
the way directories used to:
```
internal/content/
background/ one file per screen: paul_shop.go, noodle_house.go, …
character/ paul.go dex.go mystery_tape.go support_tape.go
item/ noodle_letter.go black_market_armilla.go mystery_tape.go
dialog/ dex_talk.go mystery_tape_silent.go
script/ tape_insert.go awakening_finale.go
screen/ one file per screen: alley.go, trinket_shop.go, … + exit.go
background.*.go one file per screen: background.paul_shop.go, …
character.*.go character.paul.go character.dex.go character.mystery_tape.go
item.*.go item.noodle_letter.go item.black_market_armilla.go …
dialog.*.go dialog.dex_talk.go dialog.mystery_tape_silent.go
script.*.go script.tape_insert.go script.awakening_finale.go
screen.*.go one file per screen: screen.alley.go, … + screen.exit.go
```
Adding a screen means adding `screen/<name>.go` and `background/<name>.go`, and
one line in each package's list. Nothing else moves.
Adding a screen means adding `screen.<name>.go` and `background.<name>.go`, and
one line in each category's manager list. Nothing else moves.
Since everything shares one namespace, an entity's constructor carries its
category: `screenAlley()` is the screen, `backgroundAlley()` the painting behind
it, `itemMysteryTape()` the prop and `characterMysteryTape()` the voice on it.
**On the word "screen".** inkwell's entity is called a `Scene`, and that is the
type every file in `screen/` returns — but the wiki, the concept-art deck and
the beat tables all count *screens*, so the package, the files and the flag say
type every `screen.*.go` file returns — but the wiki, the concept-art deck and
the beat tables all count *screens*, so the category, the files and the flag say
screen. "Scene" survives only where the code is talking to the engine
(`world.EnterScene`, `SceneManager`).
(`EnterScene`, `SceneManager`).
Art lives in `assets/bg/` and is embedded into the binary (`main.go`), because
js/wasm has no OS filesystem and `make binaries` packages the executable alone.
@@ -234,13 +244,13 @@ 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
`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
*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.
@@ -251,7 +261,7 @@ for a small engine change.
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
*Workaround:* `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
@@ -1,18 +1,16 @@
package background
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// alley — part of the catalogue's "Noodle háza & a sikátor" row — not in the deck.
//
// Not painted yet: the file is missing on purpose, and inkwell draws its
// placeholder instead — which is exactly the greybox convention.
func alley() inkwell.Asset {
func backgroundAlley() inkwell.Asset {
return inkwell.Asset{
Name: names.BgAlley,
Name: BgAlley,
Path: "assets/bg/alley.png",
Kind: inkwell.AssetImage,
}
@@ -1,15 +1,13 @@
package background
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// bbsTerminal — deck 11.
func bbsTerminal() inkwell.Asset {
func backgroundBbsTerminal() inkwell.Asset {
return inkwell.Asset{
Name: names.BgBBSTerminal,
Name: BgBBSTerminal,
Path: "assets/bg/bbs_terminal.png",
Kind: inkwell.AssetImage,
}
@@ -1,15 +1,13 @@
package background
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// bvkBranch — deck 18.
func bvkBranch() inkwell.Asset {
func backgroundBvkBranch() inkwell.Asset {
return inkwell.Asset{
Name: names.BgBVKBranch,
Name: BgBVKBranch,
Path: "assets/bg/bvk_branch.png",
Kind: inkwell.AssetImage,
}
@@ -1,15 +1,13 @@
package background
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// columbarium — deck 23.
func columbarium() inkwell.Asset {
func backgroundColumbarium() inkwell.Asset {
return inkwell.Asset{
Name: names.BgColumbarium,
Name: BgColumbarium,
Path: "assets/bg/columbarium.png",
Kind: inkwell.AssetImage,
}
@@ -1,15 +1,13 @@
package background
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// curatorShop — deck 19.
func curatorShop() inkwell.Asset {
func backgroundCuratorShop() inkwell.Asset {
return inkwell.Asset{
Name: names.BgCuratorShop,
Name: BgCuratorShop,
Path: "assets/bg/curator_shop.png",
Kind: inkwell.AssetImage,
}
@@ -1,15 +1,13 @@
package background
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// hackerspace — deck 06.
func hackerspace() inkwell.Asset {
func backgroundHackerspace() inkwell.Asset {
return inkwell.Asset{
Name: names.BgHackerspace,
Name: BgHackerspace,
Path: "assets/bg/hackerspace.png",
Kind: inkwell.AssetImage,
}
@@ -1,15 +1,13 @@
package background
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// hospital — deck 13.
func hospital() inkwell.Asset {
func backgroundHospital() inkwell.Asset {
return inkwell.Asset{
Name: names.BgHospital,
Name: BgHospital,
Path: "assets/bg/hospital.png",
Kind: inkwell.AssetImage,
}
@@ -1,15 +1,13 @@
package background
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// iceCreamShop — deck 07.
func iceCreamShop() inkwell.Asset {
func backgroundIceCreamShop() inkwell.Asset {
return inkwell.Asset{
Name: names.BgIceCreamShop,
Name: BgIceCreamShop,
Path: "assets/bg/ice_cream_shop.png",
Kind: inkwell.AssetImage,
}
+47
View File
@@ -0,0 +1,47 @@
package inc
// Backgrounds — one image asset per screen, one file each.
//
// A background is the whole of a screen's art: inkwell scales it over the
// entire window, so every file here points at a 640×380 PNG under assets/bg/,
// the size the concept-art deck is drawn at. Two of them are not painted yet
// and fall back to inkwell's placeholder.
//
// The order below is the deck's, which is the wiki's location-catalogue order
// (projects/realworld/entities#helyszinek).
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// registerBackground adds every background to the game.
func registerBackground(w *World) {
for _, a := range []inkwell.Asset{
backgroundSelector(),
backgroundPaulShop(),
backgroundNoodleHouse(),
backgroundNormanApartment(),
backgroundPoliceStation(),
backgroundAlley(),
backgroundHackerspace(),
backgroundIceCreamShop(),
backgroundTrinketShop(),
backgroundSmallRestaurant(),
backgroundSecretClub(),
backgroundBbsTerminal(),
backgroundStreet(),
backgroundHospital(),
backgroundServerFarm(),
backgroundSecretLab(),
backgroundRooftopHideout(),
backgroundPublicBBS(),
backgroundBvkBranch(),
backgroundCuratorShop(),
backgroundScrapMarket(),
backgroundSamizdatPress(),
backgroundShowroom(),
backgroundColumbarium(),
} {
w.G.AssetManager.Register(a)
}
}
@@ -1,15 +1,13 @@
package background
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// noodleHouse — deck 02.
func noodleHouse() inkwell.Asset {
func backgroundNoodleHouse() inkwell.Asset {
return inkwell.Asset{
Name: names.BgNoodleHouse,
Name: BgNoodleHouse,
Path: "assets/bg/noodle_house.png",
Kind: inkwell.AssetImage,
}
@@ -1,15 +1,13 @@
package background
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// normanApartment — deck 03.
func normanApartment() inkwell.Asset {
func backgroundNormanApartment() inkwell.Asset {
return inkwell.Asset{
Name: names.BgNormanApartment,
Name: BgNormanApartment,
Path: "assets/bg/norman_apartment.png",
Kind: inkwell.AssetImage,
}
@@ -1,15 +1,13 @@
package background
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// paulShop — deck 01.
func paulShop() inkwell.Asset {
func backgroundPaulShop() inkwell.Asset {
return inkwell.Asset{
Name: names.BgPaulShop,
Name: BgPaulShop,
Path: "assets/bg/paul_shop.png",
Kind: inkwell.AssetImage,
}
@@ -1,15 +1,13 @@
package background
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// policeStation — deck 04.
func policeStation() inkwell.Asset {
func backgroundPoliceStation() inkwell.Asset {
return inkwell.Asset{
Name: names.BgPoliceStation,
Name: BgPoliceStation,
Path: "assets/bg/police_station.png",
Kind: inkwell.AssetImage,
}
@@ -1,15 +1,13 @@
package background
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// publicBbs — deck 17.
func publicBbs() inkwell.Asset {
func backgroundPublicBBS() inkwell.Asset {
return inkwell.Asset{
Name: names.BgPublicBBS,
Name: BgPublicBBS,
Path: "assets/bg/public_bbs.png",
Kind: inkwell.AssetImage,
}
@@ -1,15 +1,13 @@
package background
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// rooftopHideout — deck 16.
func rooftopHideout() inkwell.Asset {
func backgroundRooftopHideout() inkwell.Asset {
return inkwell.Asset{
Name: names.BgRooftopHideout,
Name: BgRooftopHideout,
Path: "assets/bg/rooftop_hideout.png",
Kind: inkwell.AssetImage,
}
@@ -1,15 +1,13 @@
package background
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// samizdatPress — deck 21.
func samizdatPress() inkwell.Asset {
func backgroundSamizdatPress() inkwell.Asset {
return inkwell.Asset{
Name: names.BgSamizdatPress,
Name: BgSamizdatPress,
Path: "assets/bg/samizdat_press.png",
Kind: inkwell.AssetImage,
}
@@ -1,15 +1,13 @@
package background
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// scrapMarket — deck 20.
func scrapMarket() inkwell.Asset {
func backgroundScrapMarket() inkwell.Asset {
return inkwell.Asset{
Name: names.BgScrapMarket,
Name: BgScrapMarket,
Path: "assets/bg/scrap_market.png",
Kind: inkwell.AssetImage,
}
@@ -1,15 +1,13 @@
package background
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// secretClub — deck 10.
func secretClub() inkwell.Asset {
func backgroundSecretClub() inkwell.Asset {
return inkwell.Asset{
Name: names.BgSecretClub,
Name: BgSecretClub,
Path: "assets/bg/secret_club.png",
Kind: inkwell.AssetImage,
}
@@ -1,15 +1,13 @@
package background
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// secretLab — deck 15.
func secretLab() inkwell.Asset {
func backgroundSecretLab() inkwell.Asset {
return inkwell.Asset{
Name: names.BgSecretLab,
Name: BgSecretLab,
Path: "assets/bg/secret_lab.png",
Kind: inkwell.AssetImage,
}
@@ -1,18 +1,16 @@
package background
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// selector — the wiki's Helyszínválasztó — no deck number, and not drawn.
//
// Not painted yet: the file is missing on purpose, and inkwell draws its
// placeholder instead — which is exactly the greybox convention.
func selector() inkwell.Asset {
func backgroundSelector() inkwell.Asset {
return inkwell.Asset{
Name: names.BgSelector,
Name: BgSelector,
Path: "assets/bg/selector.png",
Kind: inkwell.AssetImage,
}
@@ -1,15 +1,13 @@
package background
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// serverFarm — deck 14.
func serverFarm() inkwell.Asset {
func backgroundServerFarm() inkwell.Asset {
return inkwell.Asset{
Name: names.BgServerFarm,
Name: BgServerFarm,
Path: "assets/bg/server_farm.png",
Kind: inkwell.AssetImage,
}
@@ -1,15 +1,13 @@
package background
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// showroom — deck 22.
func showroom() inkwell.Asset {
func backgroundShowroom() inkwell.Asset {
return inkwell.Asset{
Name: names.BgShowroom,
Name: BgShowroom,
Path: "assets/bg/showroom.png",
Kind: inkwell.AssetImage,
}
@@ -1,15 +1,13 @@
package background
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// smallRestaurant — deck 09.
func smallRestaurant() inkwell.Asset {
func backgroundSmallRestaurant() inkwell.Asset {
return inkwell.Asset{
Name: names.BgSmallRestaurant,
Name: BgSmallRestaurant,
Path: "assets/bg/small_restaurant.png",
Kind: inkwell.AssetImage,
}
@@ -1,15 +1,13 @@
package background
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// street — deck 12.
func street() inkwell.Asset {
func backgroundStreet() inkwell.Asset {
return inkwell.Asset{
Name: names.BgStreet,
Name: BgStreet,
Path: "assets/bg/street.png",
Kind: inkwell.AssetImage,
}
@@ -1,15 +1,13 @@
package background
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// trinketShop — deck 08.
func trinketShop() inkwell.Asset {
func backgroundTrinketShop() inkwell.Asset {
return inkwell.Asset{
Name: names.BgTrinketShop,
Name: BgTrinketShop,
Path: "assets/bg/trinket_shop.png",
Kind: inkwell.AssetImage,
}
+59
View File
@@ -0,0 +1,59 @@
// Package inc is the whole game, in one flat package.
//
// Every file is named [category].[name].go: one category per concern, one
// entity per file. A category's <category>.manager.go holds what ties that
// category together — its Register, its shared types — and the files beside it
// hold one screen, one background, one item each.
//
// This file is the boot category: it wires the layers together, and it is the
// only place where the world, the content, the theme and the HUD meet. main.go
// knows nothing else.
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// Opts are the run parameters.
type Opts struct {
Screen string // starting screen; empty means the alley
Finale bool // start with the finale, to try the Nokia-punk theme switch
}
// New builds a ready-to-run game.
func New(o Opts) *inkwell.Game {
start := o.Screen
if start == "" {
start = ScreenAlley
}
g := inkwell.NewGame("Real World", ScreenW, ScreenH)
g.MaxLogLines = 64
w := newWorld(g)
registerTheme(g)
registerContent(w)
g.UseTheme(RealWorld)
registerUI(w)
g.StartAt(start)
g.OnStart(inkwell.Seq(bootOpening(w, start, o.Finale)...))
return g
}
func bootOpening(w *World, start string, finale bool) []inkwell.Action {
acts := []inkwell.Action{
EnterScene(w, start),
// Beat 2 (the police station) will set the tip flag. Until that beat
// is written we set it here so the alley slice is playable end to end —
// the conditional branch itself is real code, not bypassed.
inkwell.SetFlag(FlagPoliceTip),
inkwell.Say(Paul, "Back alley. Just like the clerk said."),
TapeSay(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(ScriptFinale))
}
return acts
}
@@ -1,4 +1,4 @@
package boot_test
package inc
import (
"image/png"
@@ -8,15 +8,11 @@ import (
"testing"
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/boot"
"git.teletypegames.org/games/realworld/internal/names"
"git.teletypegames.org/games/realworld/internal/ui"
)
func build(t *testing.T) *inkwell.Game {
func newTestGame(t *testing.T) *inkwell.Game {
t.Helper()
g := boot.New(boot.Opts{})
g := New(Opts{})
if err := g.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
@@ -27,38 +23,38 @@ func build(t *testing.T) *inkwell.Game {
// 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)
g := newTestGame(t)
for _, n := range []string{names.DlgDex, names.DlgMysteryTape} {
for _, n := range []string{DlgDex, DlgMysteryTape} {
if !g.DialogueManager.Has(n) {
t.Errorf("missing dialogue: %q", n)
}
}
for _, n := range []string{names.ScriptTapeInsert, names.ScriptFinale} {
for _, n := range []string{ScriptTapeInsert, ScriptFinale} {
if !g.ScriptManager.Has(n) {
t.Errorf("missing script: %q", n)
}
}
for _, n := range []string{names.ItemNoodleLetter, names.ItemMysteryTape, names.ItemBlackArmilla} {
for _, n := range []string{ItemNoodleLetter, ItemMysteryTape, ItemBlackArmilla} {
if !g.ItemManager.Has(n) {
t.Errorf("missing item: %q", n)
}
}
for n := range names.Tapes {
for n := range 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 {
for item, char := range 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))
if !g.DialogueManager.Has(TapeDialogue(item)) {
t.Errorf("tape %q has no dialogue: %q", item, TapeDialogue(item))
}
}
}
@@ -67,10 +63,10 @@ func TestReferencesResolve(t *testing.T) {
// 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)
g := newTestGame(t)
paul, _ := g.CharacterManager.Get(Paul)
seen := map[[4]uint32]string{}
for n := range names.Tapes {
for n := range Tapes {
c, _ := g.CharacterManager.Get(n)
if c.SpeechColor == nil {
t.Errorf("%s: no SpeechColor", n)
@@ -91,29 +87,29 @@ func TestTapeVoicesAreDistinct(t *testing.T) {
// HUD geometry: the two channels must not overlap, and both must stay inside
// the HUD strip.
func TestHUDGeometry(t *testing.T) {
g := build(t)
g := newTestGame(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)
tc := ch.(*TapeChannel)
if tc.Bounds.X < DividerX {
t.Errorf("tape channel crosses into the world side: X=%v < %v", tc.Bounds.X, DividerX)
}
if tc.Bounds.X+tc.Bounds.W > ui.ScreenW {
if tc.Bounds.X+tc.Bounds.W > 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)
if tc.Bounds.Y < HUDTop {
t.Errorf("tape channel reaches into the scene: Y=%v < %v", tc.Bounds.Y, 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)
if ts := sl.(*TapeSlots); ts.Bounds.X+ts.Bounds.W > DividerX {
t.Errorf("tape slots cross the divider: %v > %v", ts.Bounds.X+ts.Bounds.W, DividerX)
}
// The dialogue box deliberately covers only the left region, so a tape can
@@ -122,16 +118,16 @@ func TestHUDGeometry(t *testing.T) {
if !ok {
t.Fatal("no 'dialog' widget")
}
if box := db.(*inkwell.DialogBox); box.Bounds.X+box.Bounds.W > ui.DividerX {
if box := db.(*inkwell.DialogBox); box.Bounds.X+box.Bounds.W > DividerX {
t.Errorf("dialogue box would cover the tape channel: %v > %v",
box.Bounds.X+box.Bounds.W, ui.DividerX)
box.Bounds.X+box.Bounds.W, 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)
g := newTestGame(t)
n := g.UIManager.Names()
if len(n) == 0 || n[0] != "usewith_guard" {
t.Errorf("usewith_guard is not registered first: %v", n)
@@ -152,12 +148,12 @@ func TestWidgetOrder(t *testing.T) {
// painting that is not ScreenW×ScreenH is silently squashed to fit. That is why
// the screen is 640×380 in the first place; this keeps the two in step.
func TestSceneBackgroundsResolve(t *testing.T) {
g := build(t)
g := newTestGame(t)
// Not drawn yet: these render as placeholders on purpose. The alley is
// missing from the concept-art deck; the selector is the wiki's menu
// screen, which the deck does not cover at all.
undrawn := map[string]bool{names.BgAlley: true, names.BgSelector: true}
undrawn := map[string]bool{BgAlley: true, BgSelector: true}
for _, n := range g.SceneManager.Names() {
s := g.SceneManager.MustGet(n)
@@ -169,7 +165,7 @@ func TestSceneBackgroundsResolve(t *testing.T) {
if undrawn[a.Name] {
continue
}
f, err := os.Open(filepath.Join("..", "..", a.Path))
f, err := os.Open(filepath.Join("..", a.Path))
if err != nil {
t.Errorf("scene %q: background file missing: %v", n, err)
continue
@@ -180,16 +176,16 @@ func TestSceneBackgroundsResolve(t *testing.T) {
t.Errorf("scene %q: unreadable background: %v", n, err)
continue
}
if cfg.Width != ui.ScreenW || cfg.Height != ui.ScreenH {
if cfg.Width != ScreenW || cfg.Height != ScreenH {
t.Errorf("scene %q: background is %d×%d, want %d×%d — it will be stretched",
n, cfg.Width, cfg.Height, ui.ScreenW, ui.ScreenH)
n, cfg.Width, cfg.Height, ScreenW, ScreenH)
}
}
}
// exits reads the connection graph back out of the registered screens. The
// exit hotspots are named "exit:<target>", which is what keeps the graph
// machine-readable — see content/screen/exit.go.
// machine-readable — see screen.exit.go.
func exits(g *inkwell.Game, screen string) []string {
var out []string
for _, h := range g.SceneManager.MustGet(screen).Hotspots {
@@ -203,7 +199,7 @@ func exits(g *inkwell.Game, screen string) []string {
// An exit that names a screen nobody registered is a dead end the player walks
// into: the engine logs "changeScene: unknown" and nothing happens on screen.
func TestExitsResolve(t *testing.T) {
g := build(t)
g := newTestGame(t)
for _, n := range g.SceneManager.Names() {
for _, to := range exits(g, n) {
if !g.SceneManager.Has(to) {
@@ -220,10 +216,10 @@ func TestExitsResolve(t *testing.T) {
// The walk starts at Paul's shop, where the letter arrives, and mostly runs
// through the selector: that is what the wiki's location graph is centred on.
func TestEveryScreenIsReachable(t *testing.T) {
g := build(t)
g := newTestGame(t)
seen := map[string]bool{names.ScreenPaulShop: true}
queue := []string{names.ScreenPaulShop}
seen := map[string]bool{ScreenPaulShop: true}
queue := []string{ScreenPaulShop}
for len(queue) > 0 {
at := queue[0]
queue = queue[1:]
@@ -237,7 +233,7 @@ func TestEveryScreenIsReachable(t *testing.T) {
for _, n := range g.SceneManager.Names() {
if !seen[n] {
t.Errorf("screen %q cannot be reached from %q by its exits", n, names.ScreenPaulShop)
t.Errorf("screen %q cannot be reached from %q by its exits", n, ScreenPaulShop)
}
}
}
@@ -245,7 +241,7 @@ func TestEveryScreenIsReachable(t *testing.T) {
// A screen with no way out at all is a trap: the player walks in and the only
// way on is the debug walk.
func TestEveryScreenHasAWayOut(t *testing.T) {
g := build(t)
g := newTestGame(t)
for _, n := range g.SceneManager.Names() {
var ways int
for _, h := range g.SceneManager.MustGet(n).Hotspots {
@@ -1,17 +1,14 @@
package character
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
"git.teletypegames.org/games/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 {
func characterDex() inkwell.Character {
return inkwell.Character{
Name: names.Dex,
SpeechColor: theme.Amber,
Name: Dex,
SpeechColor: Amber,
}
}
+23
View File
@@ -0,0 +1,23 @@
package inc
// Characters — the game's cast, one file each.
//
// 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 TapeSay).
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// registerCharacter adds every character to the game.
func registerCharacter(w *World) {
for _, c := range []inkwell.Character{
characterPaul(),
characterDex(),
characterMysteryTape(),
characterSupportTape(),
} {
w.G.CharacterManager.Register(c)
}
}
@@ -1,10 +1,7 @@
package character
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
"git.teletypegames.org/games/realworld/internal/theme"
)
// mysteryTape is the tape found in the alley — in truth Norman's Personal
@@ -12,9 +9,9 @@ import (
//
// 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 {
func characterMysteryTape() inkwell.Character {
return inkwell.Character{
Name: names.TapeMystery,
SpeechColor: theme.RGB(0xB9A98A),
Name: TapeMystery,
SpeechColor: RGB(0xB9A98A),
}
}
@@ -1,23 +1,20 @@
package character
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
"git.teletypegames.org/games/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 {
func characterPaul() inkwell.Character {
return inkwell.Character{
Name: names.Paul,
Name: 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,
SpeechColor: Ink,
}
}
+14
View File
@@ -0,0 +1,14 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// supportTape answers everything with basic information, insufferably. Dex
// hates it. Acquired in Chinatown (beat 5) — not reachable yet.
func characterSupportTape() inkwell.Character {
return inkwell.Character{
Name: TapeSupport,
SpeechColor: RGB(0xE8C86A),
}
}
+19
View File
@@ -0,0 +1,19 @@
package inc
// Content — the composition root for everything the game declares:
// assets, characters, items, dialogues, scripts and scenes.
//
// Each registered entity lives in its own file, named after its category:
// adding a screen means adding screen.<name>.go and background.<name>.go, plus
// one line in each category's manager list — nothing else moves.
// registerContent adds every entity to the game. Order matters only in that
// screens reference backgrounds and characters, which Game.Validate cross-checks.
func registerContent(w *World) {
registerBackground(w)
registerCharacter(w)
registerItem(w)
registerDialog(w)
registerScript(w)
registerScreen(w)
}
@@ -1,41 +1,39 @@
package dialog
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/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 {
func dialogDexTalk() inkwell.Dialogue {
return inkwell.Dialogue{
Name: names.DlgDex,
Name: DlgDex,
Start: "root",
Nodes: []inkwell.DialogueNode{{
Name: "root",
Lines: []inkwell.DialogueLine{{Speaker: names.Dex, Text: "Talk."}},
Lines: []inkwell.DialogueLine{{Speaker: 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.Say(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)),
Show: inkwell.Not(inkwell.Flag(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.Say(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),
Show: inkwell.Flag(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.Say(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"),
},
},
@@ -43,7 +41,7 @@ func dexTalk() inkwell.Dialogue {
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.Say(Dex, "I'm four kilobytes of a dead man. Don't do this to yourself."),
inkwell.GotoNode("root"),
},
},
@@ -1,4 +1,6 @@
// Package dialog registers the game's dialogue trees.
package inc
// Dialogues — the game's dialogue trees, one file each.
//
// 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.
@@ -7,19 +9,16 @@
// 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"
"git.teletypegames.org/games/realworld/internal/world"
)
// Register adds every dialogue to the game.
func Register(w *world.World) {
// registerDialog adds every dialogue to the game.
func registerDialog(w *World) {
for _, d := range []inkwell.Dialogue{
dexTalk(),
mysteryTapeSilent(),
dialogDexTalk(),
dialogMysteryTapeSilent(),
} {
w.G.DialogueManager.Register(d)
}
@@ -1,26 +1,24 @@
package dialog
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/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 {
func dialogMysteryTapeSilent() inkwell.Dialogue {
return inkwell.Dialogue{
Name: names.DlgMysteryTape,
Name: DlgMysteryTape,
Start: "root",
Nodes: []inkwell.DialogueNode{{
Name: "root",
Lines: []inkwell.DialogueLine{{Speaker: names.Dex, Text: "Nothing. Warm, spinning, and silent."}},
Lines: []inkwell.DialogueLine{{Speaker: 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.Say(Dex, "It works. It just isn't talking to you. That is not the same as silence."),
inkwell.GotoNode("root"),
},
},
+26
View File
@@ -0,0 +1,26 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// blackMarketArmilla is flavour, not a quest trigger: the gap between
// legitimised technology and street reality.
func itemBlackMarketArmilla() inkwell.Item {
return inkwell.Item{
Name: ItemBlackArmilla,
Description: "black-market Armilla",
OnUseSelf: inkwell.Seq(
inkwell.Say(Paul, "Jailbroken. Pushes ads, but it was cheap."),
TapeSay(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.
ItemNoodleLetter: inkwell.Seq(
inkwell.Say(Paul, "A letter and a bracelet. Brilliant."),
TapeSay(Dex, "Nothing. Which is what I'd charge for it."),
),
},
}
}
@@ -1,21 +1,20 @@
// Package item registers the game's inventory items.
package inc
// Items — the game's inventory, one file each.
//
// 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"
"git.teletypegames.org/games/realworld/internal/world"
)
// Register adds every item to the game.
func Register(w *world.World) {
// registerItem adds every item to the game.
func registerItem(w *World) {
for _, i := range []inkwell.Item{
noodleLetter(),
blackMarketArmilla(),
mysteryTape(),
itemNoodleLetter(),
itemBlackMarketArmilla(),
itemMysteryTape(),
} {
w.G.ItemManager.Register(i)
}
+18
View File
@@ -0,0 +1,18 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// mysteryTape is the engine of the investigation. Password-locked; it only
// starts speaking after the club trigger (beat 6).
func itemMysteryTape() inkwell.Item {
return inkwell.Item{
Name: ItemMysteryTape,
Description: "unmarked Personal Tape",
OnUseSelf: inkwell.Seq(
inkwell.Say(Paul, "Password-locked. Of course."),
TapeSay(Dex, "Put it in the second slot if you care that much. I did warn you."),
),
}
}
+17
View File
@@ -0,0 +1,17 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// noodleLetter starts the story: it is what brings Paul to the city.
func itemNoodleLetter() inkwell.Item {
return inkwell.Item{
Name: ItemNoodleLetter,
Description: "Noodle's letter",
OnUseSelf: inkwell.Seq(
inkwell.Say(Paul, "“Come out, Paul. Not a phone conversation.” That's all it says."),
TapeSay(Dex, "And now you're here. And he isn't."),
),
}
}
@@ -1,4 +1,6 @@
// Package names is the single source of entity names and world-state keys.
package inc
// Names — 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
@@ -7,9 +9,8 @@
// 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
// These constants are the vocabulary every other category speaks in; nothing
// here depends on anything else in the game.
// Characters.
const (
+104
View File
@@ -0,0 +1,104 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// 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.
//
// The wiki keeps it in the same catalogue row as the house ("Noodle háza & a
// sikátor") and its graph has one arrow in, NoodleUtcafront → Sikátor, so the
// only way out is back to the street. The alley's own locked back door is the
// New Game+ hook and leads to a room the deck has not painted.
func screenAlley() screen {
return screen{
name: ScreenAlley,
title: "ALLEY — BEHIND NOODLE'S HOUSE",
background: BgAlley,
exits: []exit{
// On the left: the back door hotspot below owns the other end.
{to: ScreenNoodleHouse, label: "back out to the street", side: sideLeft},
},
actors: []inkwell.SceneActor{
{CharacterName: Paul, At: inkwell.Point{X: 120, Y: 232}},
},
onEnter: setVarIfEmpty(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(FlagHasTape),
inkwell.Say(Paul, "Empty. Whatever was in there is on me now."),
inkwell.Say(Paul, "Loose brick. There's a gap behind it."),
),
OnUse: inkwell.If(inkwell.Flag(FlagPoliceTip),
inkwell.If(inkwell.Flag(FlagHasTape),
inkwell.Say(Paul, "There's nothing else in there."),
inkwell.Seq(
inkwell.Say(Paul, "“Behind the brick.” All right then."),
inkwell.Give(ItemMysteryTape),
inkwell.SetFlag(FlagHasTape),
inkwell.Say(Paul, "A Personal Tape. No label, no seal."),
TapeSay(Dex, "Password-locked. And somebody very much did not want it found."),
),
),
inkwell.Say(Paul, "One loose brick. I'm not taking the alley apart over it."),
),
OnTake: inkwell.Say(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(Paul, "Keypad. Four digits, worn keys."),
OnUse: inkwell.Seq(
inkwell.Say(Paul, "Locked. And I'm not guessing four digits."),
TapeSay(Dex, "The wear is heaviest on the two and the seven. That isn't a code. It's fewer options."),
),
OnTalk: inkwell.Say(Paul, "To the door? I'm not there yet."),
OnUseWith: map[string]inkwell.Action{
// An AUTHORED failure — not the engine's hardcoded flash.
ItemBlackArmilla: inkwell.Seq(
inkwell.Say(Paul, "A black-market bracelet doesn't open a keypad."),
TapeSay(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(Paul, "Somebody's been through it. Thoroughly."),
OnUse: inkwell.Seq(
inkwell.Say(Paul, "Already turned out. I'm not going to be the second one."),
TapeSay(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(Paul, "Runs all the way to the roof. Bottom rung is two metres over my head."),
OnUse: inkwell.Say(Paul, "Can't reach it. I'd need something to stand on."),
}
}
@@ -1,6 +1,4 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
package inc
// bbsTerminal is the screen you get when Paul sits down at a terminal: the
// catalogue calls it a "Tárgy/portál", a physical object in the world that
@@ -9,11 +7,11 @@ import "git.teletypegames.org/games/realworld/internal/names"
// It is the same screen wherever the terminal stands — the club's ECHO box,
// Norman's machine at the flat, the old one on the roof — which is why its way
// out is "back", and not a place.
func bbsTerminal() screen {
func screenBbsTerminal() screen {
return screen{
name: names.ScreenBBSTerminal,
name: ScreenBBSTerminal,
title: "TERMINAL — BBS ACCESS POINT",
background: names.BgBBSTerminal,
background: BgBBSTerminal,
exits: []exit{
{label: "step back from the terminal", side: sideNear},
},
@@ -1,16 +1,14 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
package inc
// bvkBranch is a 💡 suggestion: the local certification office, imprimatur
// stamps and licence checks and numbered waiting rooms — everything a man with
// a jailbroken two-slot Armilla should stay away from
// (wiki: entities#potenciális-további-helyszínek).
func bvkBranch() screen {
func screenBvkBranch() screen {
return screen{
name: names.ScreenBVKBranch,
name: ScreenBVKBranch,
title: "BVK — SAN FRANCISCO BRANCH",
background: names.BgBVKBranch,
background: BgBVKBranch,
onSelector: true,
}
}
@@ -1,16 +1,14 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
package inc
// columbarium is a 💡 suggestion: where Dex is buried. A quiet place, away
// from the noise of the terminals — the loss-and-letting-go thread, which is
// the same thread as Norman and The AI
// (wiki: entities#potenciális-további-helyszínek).
func columbarium() screen {
func screenColumbarium() screen {
return screen{
name: names.ScreenColumbarium,
name: ScreenColumbarium,
title: "COLUMBARIUM — DEX'S MEMORIAL",
background: names.BgColumbarium,
background: BgColumbarium,
onSelector: true,
}
}
@@ -1,15 +1,13 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
package inc
// curatorShop is a 💡 suggestion: the last workshop of the declining curators'
// guild — quartz tuning, tape repair, craft handed down for generations. The
// man who built Paul's second slot (wiki: entities#potenciális-további-helyszínek).
func curatorShop() screen {
func screenCuratorShop() screen {
return screen{
name: names.ScreenCuratorShop,
name: ScreenCuratorShop,
title: "CURATOR SERVICE — THE MASTER'S WORKSHOP",
background: names.BgCuratorShop,
background: BgCuratorShop,
onSelector: true,
}
}
@@ -1,4 +1,4 @@
package screen
package inc
// Exits — the connections between screens.
//
@@ -25,9 +25,6 @@ package screen
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
"git.teletypegames.org/games/realworld/internal/world"
)
type side int
@@ -73,7 +70,7 @@ type exit struct {
// toSelector is the way back out to the rest of the city, on every screen the
// wiki's Helyszínválasztó lists.
func toSelector() exit {
return exit{to: names.ScreenSelector, label: "the rest of the city", side: sideNear}
return exit{to: ScreenSelector, label: "the rest of the city", side: sideNear}
}
// exitName is how a connection stays machine-readable: the graph can be read
@@ -85,13 +82,13 @@ func exitName(to string) string {
return "exit:" + to
}
func (e exit) hotspot(w *world.World) inkwell.Hotspot {
func (e exit) hotspot(w *World) inkwell.Hotspot {
var travel inkwell.Action = inkwell.GoTo(e.to)
if e.to == "" {
travel = world.Back(w)
travel = Back(w)
}
if e.needs != "" {
travel = inkwell.If(inkwell.Flag(e.needs), travel, inkwell.Say(names.Paul, e.blocked))
travel = inkwell.If(inkwell.Flag(e.needs), travel, inkwell.Say(Paul, e.blocked))
}
area := e.area
if area == nil {
@@ -102,8 +99,8 @@ func (e exit) hotspot(w *world.World) inkwell.Hotspot {
Label: e.label,
Area: area,
Cursor: inkwell.CursorExit,
OnLook: inkwell.Say(names.Paul, "That way: "+e.label+"."),
OnLook: inkwell.Say(Paul, "That way: "+e.label+"."),
OnUse: travel,
OnTake: inkwell.Say(names.Paul, "It's a way out, not a thing."),
OnTake: inkwell.Say(Paul, "It's a way out, not a thing."),
}
}
@@ -1,6 +1,4 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
package inc
// hackerspace: the community workshop at the edge of the Mission, printers and
// stripped servers and paranoid amateur cryptographers. Where the server-farm
@@ -8,11 +6,11 @@ import "git.teletypegames.org/games/realworld/internal/names"
//
// The wiki's graph gives it no physical neighbour, so it hangs off the
// selector like every other main location.
func hackerspace() screen {
func screenHackerspace() screen {
return screen{
name: names.ScreenHackerspace,
name: ScreenHackerspace,
title: "HACKERSPACE",
background: names.BgHackerspace,
background: BgHackerspace,
onSelector: true,
}
}
@@ -1,14 +1,12 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
package inc
// hospital is the clinical dead end: Dr. Maren looked for a medical reason for
// Norman's state and did not find one (wiki: entities, timeline 21).
func hospital() screen {
func screenHospital() screen {
return screen{
name: names.ScreenHospital,
name: ScreenHospital,
title: "HOSPITAL — PSYCHIATRIC WING",
background: names.BgHospital,
background: BgHospital,
onSelector: true,
}
}
@@ -1,6 +1,4 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
package inc
// iceCreamShop stands where Paul's old bar used to be, and they insist there
// never was a bar. The BVK-registered terminal in the corner is the one the
@@ -9,11 +7,11 @@ import "git.teletypegames.org/games/realworld/internal/names"
// The search he eventually allows is what names the club — a lead, not a door:
// the way there is the trinket shop's address, so this screen has no exit of
// its own.
func iceCreamShop() screen {
func screenIceCreamShop() screen {
return screen{
name: names.ScreenIceCreamShop,
name: ScreenIceCreamShop,
title: "ICE CREAM SHOP — WHERE THE BAR WAS",
background: names.BgIceCreamShop,
background: BgIceCreamShop,
onSelector: true,
}
}
@@ -1,4 +1,6 @@
// Package screen registers the game's screens — one file per screen.
package inc
// Screens — one file per screen.
//
// "Screen" is this project's word: the wiki's catalogue, the concept-art deck
// and the beat tables all count screens. inkwell's word for the same thing is
@@ -7,16 +9,12 @@
// engine.
//
// A file here holds everything that is true of one screen: its picture, the
// name on the top bar, where you can get to from it (exit.go), and — once its
// name on the top bar, where you can get to from it (screen.exit.go), and — once its
// beat is written — its hotspots, NPCs and dialogue. Only the alley has that
// last part so far; the rest are greybox.
package screen
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
"git.teletypegames.org/games/realworld/internal/world"
)
// screen is what each file in this package fills in.
@@ -25,7 +23,7 @@ type screen struct {
title string
background string
// exits: where you can get to from here. See exit.go.
// exits: where you can get to from here. See screen.exit.go.
exits []exit
// onSelector marks a main location — one of those the wiki's
// Helyszínválasztó lists (story#helyszín-gráf-vázlat). It gets a way back
@@ -40,68 +38,68 @@ type screen struct {
onEnter inkwell.Action
}
// deck is every screen in the game, in the order of the wiki's location
// screenDeck is every screen in the game, in the order of the wiki's location
// catalogue (entities#helyszinek) — which the concept-art deck follows number
// for number. Registration order is this order, and it is also the order the
// arrow keys walk (ui/screen_nav.go).
// arrow keys walk (ui.screen_nav.go).
//
// Deck 05, Norman's workplace, is in the catalogue but has not been drawn, so
// it has no file yet. The alley has no deck number: the catalogue keeps it in
// the same row as Noodle's house, and that is where it sits here too.
var deck = []func() screen{
paulShop, // 01
noodleHouse, // 02
alley, // 02, same catalogue row
normanApartment, // 03
policeStation, // 04
hackerspace, // 06
iceCreamShop, // 07
trinketShop, // 08
smallRestaurant, // 09
secretClub, // 10
bbsTerminal, // 11
street, // 12
hospital, // 13
serverFarm, // 14
secretLab, // 15
rooftopHideout, // 16
publicBBS, // 17
bvkBranch, // 18
curatorShop, // 19
scrapMarket, // 20
samizdatPress, // 21
showroom, // 22
columbarium, // 23
var screenDeck = []func() screen{
screenPaulShop, // 01
screenNoodleHouse, // 02
screenAlley, // 02, same catalogue row
screenNormanApartment, // 03
screenPoliceStation, // 04
screenHackerspace, // 06
screenIceCreamShop, // 07
screenTrinketShop, // 08
screenSmallRestaurant, // 09
screenSecretClub, // 10
screenBbsTerminal, // 11
screenStreet, // 12
screenHospital, // 13
screenServerFarm, // 14
screenSecretLab, // 15
screenRooftopHideout, // 16
screenPublicBBS, // 17
screenBvkBranch, // 18
screenCuratorShop, // 19
screenScrapMarket, // 20
screenSamizdatPress, // 21
screenShowroom, // 22
screenColumbarium, // 23
}
// Register adds every screen to the game, the selector first: it is the centre
// of the wiki's location graph, and it is built from the deck rather than
// registerScreen adds every screen to the game, the selector first: it is the centre
// of the wiki's location graph, and it is built from the screenDeck rather than
// listed in it.
func Register(w *world.World) {
screens := make([]screen, 0, len(deck)+1)
for _, f := range deck {
func registerScreen(w *World) {
screens := make([]screen, 0, len(screenDeck)+1)
for _, f := range screenDeck {
screens = append(screens, f())
}
for _, s := range append([]screen{selector(screens)}, screens...) {
w.G.SceneManager.Register(build(w, s))
for _, s := range append([]screen{screenSelector(screens)}, screens...) {
w.G.SceneManager.Register(screenBuild(w, s))
}
}
// The default floor: the bottom of the scene region, above the HUD divider.
// A screen with a painted floor of its own overrides it.
var floor = []inkwell.Polygon{
var screenFloor = []inkwell.Polygon{
inkwell.Poly(
inkwell.Point{X: 16, Y: 196}, inkwell.Point{X: 624, Y: 196},
inkwell.Point{X: 624, Y: 258}, inkwell.Point{X: 16, Y: 258},
),
}
// build turns a screen into the engine's scene.
// screenBuild turns a screen into the engine's scene.
//
// The authored hotspots go in front of the exits, because the engine takes the
// first one whose area contains the click (HotspotAt): a painted thing beats
// the edge strip an exit sits on, wherever the two overlap.
func build(w *world.World, s screen) inkwell.Scene {
func screenBuild(w *World, s screen) inkwell.Scene {
exits := s.exits
if s.onSelector {
exits = append(exits, toSelector())
@@ -115,14 +113,14 @@ func build(w *world.World, s screen) inkwell.Scene {
actors := s.actors
if actors == nil {
actors = []inkwell.SceneActor{
{CharacterName: names.Paul, At: inkwell.Point{X: 320, Y: 232}},
{CharacterName: Paul, At: inkwell.Point{X: 320, Y: 232}},
}
}
walkboxes := s.walkboxes
if walkboxes == nil {
walkboxes = floor
walkboxes = screenFloor
}
onEnter := inkwell.Action(world.EnterScene(w, s.name))
onEnter := inkwell.Action(EnterScene(w, s.name))
if s.onEnter != nil {
onEnter = inkwell.Seq(onEnter, s.onEnter)
}
@@ -141,7 +139,7 @@ func build(w *world.World, s screen) inkwell.Scene {
// setVarIfEmpty only writes when the variable is still unset, so re-entering a
// screen does not clobber what the game has set in the meantime.
func setVarIfEmpty(name string, v any) inkwell.Action {
return world.Fn(func(ctx *inkwell.Ctx) {
return Fn(func(ctx *inkwell.Ctx) {
if ctx.Game.State.Var(name) == nil {
ctx.Game.State.SetVar(name, v)
}
@@ -1,6 +1,4 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
package inc
// noodleHouse is the street front outside Noodle's place. By the time Paul
// gets there the police have sealed the house and taken Noodle in, and a
@@ -8,14 +6,14 @@ import "git.teletypegames.org/games/realworld/internal/names"
//
// The wiki's graph runs NoodleUtcafront → Sikátor: the alley behind the house
// is reached from here, and from nowhere else.
func noodleHouse() screen {
func screenNoodleHouse() screen {
return screen{
name: names.ScreenNoodleHouse,
name: ScreenNoodleHouse,
title: "NOODLE'S HOUSE — SEALED",
background: names.BgNoodleHouse,
background: BgNoodleHouse,
onSelector: true,
exits: []exit{
{to: names.ScreenAlley, label: "the alley behind the house", side: sideRight},
{to: ScreenAlley, label: "the alley behind the house", side: sideRight},
},
}
}
@@ -1,6 +1,4 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
package inc
// normanApartment is the flat Norman was taken from: his Armilla left behind
// on the side, his Personal Tape missing from it, his terminal full of the
@@ -9,15 +7,15 @@ import "git.teletypegames.org/games/realworld/internal/names"
// Two ways on from here. His terminal is one of the BBS access points the
// catalogue lists ("a titkos klub ECHO-terminálja; később Norman lakása /
// tetőterasz"), and the roof hideout is up his own stairs.
func normanApartment() screen {
func screenNormanApartment() screen {
return screen{
name: names.ScreenNormanApartment,
name: ScreenNormanApartment,
title: "NORMAN'S APARTMENT",
background: names.BgNormanApartment,
background: BgNormanApartment,
onSelector: true,
exits: []exit{
{to: names.ScreenRooftopHideout, label: "the stairs up to the roof", side: sideBack},
{to: names.ScreenBBSTerminal, label: "Norman's terminal", side: sideRight},
{to: ScreenRooftopHideout, label: "the stairs up to the roof", side: sideBack},
{to: ScreenBBSTerminal, label: "Norman's terminal", side: sideRight},
},
}
}
@@ -1,6 +1,4 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
package inc
// paulShop is where the game starts: the junk shop and garage Paul lives
// behind (wiki: entities#helyszinek). It is the only screen outside San
@@ -10,13 +8,13 @@ import "git.teletypegames.org/games/realworld/internal/names"
// The bus is a screen the deck does not have, so the exit is the journey
// itself: one way, with no coming back to the shop mid-story. That is also why
// the shop is not on the selector — it is not one of the city's locations.
func paulShop() screen {
func screenPaulShop() screen {
return screen{
name: names.ScreenPaulShop,
name: ScreenPaulShop,
title: "PAUL'S SHOP — JUNK AND GARAGE",
background: names.BgPaulShop,
background: BgPaulShop,
exits: []exit{
{to: names.ScreenNoodleHouse, label: "the bus to San Francisco", side: sideNear},
{to: ScreenNoodleHouse, label: "the bus to San Francisco", side: sideNear},
},
}
}
@@ -1,6 +1,4 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
package inc
// policeStation is where Noodle is held for dealing in cracked Armillas, and
// where a clerk quietly tips Paul off about the hiding place
@@ -10,11 +8,11 @@ import "git.teletypegames.org/games/realworld/internal/names"
// but the deck paints it as one screen, so the reception desk, the waiting
// room and the interview window are hotspots inside this one, not screens of
// their own.
func policeStation() screen {
func screenPoliceStation() screen {
return screen{
name: names.ScreenPoliceStation,
name: ScreenPoliceStation,
title: "SFPD — STATION AND HOLDING",
background: names.BgPoliceStation,
background: BgPoliceStation,
onSelector: true,
}
}
@@ -1,16 +1,14 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
package inc
// publicBBS is one of the wiki's 💡 suggestions: the canon has BBS terminals
// standing in cafés, post offices and bus stops — live infrastructure, not
// relics. An alternative way to read the boards, in a place where somebody can
// read over your shoulder (wiki: entities#potenciális-további-helyszínek).
func publicBBS() screen {
func screenPublicBBS() screen {
return screen{
name: names.ScreenPublicBBS,
name: ScreenPublicBBS,
title: "PUBLIC BBS TERMINAL",
background: names.BgPublicBBS,
background: BgPublicBBS,
onSelector: true,
}
}
@@ -1,6 +1,4 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
package inc
// rooftopHideout is Norman's hiding place: an abandoned roof terrace with his
// personal things, his notes and an old BBS terminal — the emotional high point
@@ -8,14 +6,14 @@ import "git.teletypegames.org/games/realworld/internal/names"
//
// It is up the stairs from his flat, and it is one of the places the catalogue
// puts a terminal.
func rooftopHideout() screen {
func screenRooftopHideout() screen {
return screen{
name: names.ScreenRooftopHideout,
name: ScreenRooftopHideout,
title: "NORMAN'S ROOFTOP HIDEOUT",
background: names.BgRooftopHideout,
background: BgRooftopHideout,
exits: []exit{
{to: names.ScreenNormanApartment, label: "back down into the flat", side: sideNear},
{to: names.ScreenBBSTerminal, label: "the old terminal", side: sideRight},
{to: ScreenNormanApartment, label: "back down into the flat", side: sideNear},
{to: ScreenBBSTerminal, label: "the old terminal", side: sideRight},
},
}
}
@@ -1,16 +1,14 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
package inc
// samizdatPress is a 💡 suggestion: the Resistance's physical node — parody
// BVK databases, underground publications, a P2P node humming in the cellar.
// The samizdat intellectuals are the first to write down that the world is
// slipping (wiki: entities#potenciális-további-helyszínek).
func samizdatPress() screen {
func screenSamizdatPress() screen {
return screen{
name: names.ScreenSamizdatPress,
name: ScreenSamizdatPress,
title: "SAMIZDAT PRINTING HOUSE",
background: names.BgSamizdatPress,
background: BgSamizdatPress,
onSelector: true,
}
}
@@ -1,15 +1,13 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
package inc
// scrapMarket is a 💡 suggestion: the black market in condemned ACPs and bare,
// unstamped tape. Canon says the leak of scrap is not an accident — "something
// wants to live on" (wiki: entities#potenciális-további-helyszínek).
func scrapMarket() screen {
func screenScrapMarket() screen {
return screen{
name: names.ScreenScrapMarket,
name: ScreenScrapMarket,
title: "SCRAP MARKET",
background: names.BgScrapMarket,
background: BgScrapMarket,
onSelector: true,
}
}
@@ -1,6 +1,4 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
package inc
// secretClub is the empty room behind the shop where the "underwater sun"
// company gave their talk, with a secretary who knows about the BVK's plans for
@@ -10,14 +8,14 @@ import "git.teletypegames.org/games/realworld/internal/names"
// The terminal in here is the one that matters: not under BVK control, ECHO
// running under the standard LECTOR surface. Reading it is where the mystery
// tape finally speaks.
func secretClub() screen {
func screenSecretClub() screen {
return screen{
name: names.ScreenSecretClub,
name: ScreenSecretClub,
title: "SECRET CLUB — THE UNDERWATER SUN",
background: names.BgSecretClub,
background: BgSecretClub,
exits: []exit{
{to: names.ScreenBBSTerminal, label: "the terminal in the corner", side: sideBack},
{to: names.ScreenTrinketShop, label: "back out through the shop", side: sideNear},
{to: ScreenBBSTerminal, label: "the terminal in the corner", side: sideBack},
{to: ScreenTrinketShop, label: "back out through the shop", side: sideNear},
},
}
}
@@ -1,6 +1,4 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
package inc
// secretLab is the lab the BVK's secret section runs, where Norman lies wired
// into the simulator: the two-thirds point where Paul finds him, and the
@@ -10,11 +8,11 @@ import "git.teletypegames.org/games/realworld/internal/names"
// the story puts the whole thing inside the Golden Gate Bridge. The deck paints
// it as one screen, so the door at the foot of the bridge, the observation room
// and the glass wall are hotspots in here.
func secretLab() screen {
func screenSecretLab() screen {
return screen{
name: names.ScreenSecretLab,
name: ScreenSecretLab,
title: "SECRET RESEARCH LABORATORY",
background: names.BgSecretLab,
background: BgSecretLab,
onSelector: true,
}
}
@@ -1,11 +1,9 @@
package screen
package inc
import (
"strings"
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// selector is the wiki's Helyszínválasztó: "nem valódi helyszín, hanem a
@@ -17,9 +15,9 @@ import (
// It is not in the concept-art deck and has no painting, so it renders as an
// inkwell placeholder with one labelled pin per destination, laid out on a
// grid. Whatever the map ends up looking like, the connections it carries are
// the ones the screens declare, not a list kept here: selector() is built from
// the ones the screens declare, not a list kept here: screenSelector() is built from
// whichever screens marked themselves onSelector.
func selector(rest []screen) screen {
func screenSelector(rest []screen) screen {
var exits []exit
for _, s := range rest {
if !s.onSelector {
@@ -32,9 +30,9 @@ func selector(rest []screen) screen {
})
}
return screen{
name: names.ScreenSelector,
name: ScreenSelector,
title: "SAN FRANCISCO",
background: names.BgSelector,
background: BgSelector,
exits: exits,
// Nobody stands on a map.
actors: []inkwell.SceneActor{},
@@ -1,15 +1,13 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
package inc
// serverFarm is the cooled, humming corridors where The AI's physical
// infrastructure can be seen for what it is. Getting in needs the access tape
// from the hackerspace or from Noodle (wiki: entities, timeline 24).
func serverFarm() screen {
func screenServerFarm() screen {
return screen{
name: names.ScreenServerFarm,
name: ScreenServerFarm,
title: "SERVER FARM",
background: names.BgServerFarm,
background: BgServerFarm,
onSelector: true,
}
}
@@ -1,16 +1,14 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
package inc
// showroom is a 💡 suggestion: the legitimate, glittering side — Armilla
// cabinets, licensed operators, matte-black Nokia-punk under showroom lights.
// The contrast with Paul's edge of the world
// (wiki: entities#potenciális-további-helyszínek).
func showroom() screen {
func screenShowroom() screen {
return screen{
name: names.ScreenShowroom,
name: ScreenShowroom,
title: "NEUMATRONIC SHOWROOM",
background: names.BgShowroom,
background: BgShowroom,
onSelector: true,
}
}
@@ -1,6 +1,4 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
package inc
// smallRestaurant is the little Chinese place beside the trinket shop, and the
// lit mirror in its toilet is the puzzle: it flickers, and something shows
@@ -9,13 +7,13 @@ import "git.teletypegames.org/games/realworld/internal/names"
// The wiki's graph runs Kajálda → étterem rész → WC; the deck paints all three
// as one screen, so the mirror is a hotspot here. The only way out is back to
// the shop — this is an inner room, not a location on the selector.
func smallRestaurant() screen {
func screenSmallRestaurant() screen {
return screen{
name: names.ScreenSmallRestaurant,
name: ScreenSmallRestaurant,
title: "SMALL RESTAURANT — NEXT DOOR",
background: names.BgSmallRestaurant,
background: BgSmallRestaurant,
exits: []exit{
{to: names.ScreenTrinketShop, label: "back to the trinket shop", side: sideLeft},
{to: ScreenTrinketShop, label: "back to the trinket shop", side: sideLeft},
},
}
}
@@ -1,6 +1,4 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
package inc
// street is the city itself: pavements, a square, a metro entrance, and the
// environmental decay that tracks The AI's spread — glitching systems, odd
@@ -9,11 +7,11 @@ import "git.teletypegames.org/games/realworld/internal/names"
// The wiki's own street front is the one with the hotdog stand and Sumphor on
// it (story#norman-lakása); that is a beat for this screen, not a screen of its
// own.
func street() screen {
func screenStreet() screen {
return screen{
name: names.ScreenStreet,
name: ScreenStreet,
title: "STREET",
background: names.BgStreet,
background: BgStreet,
onSelector: true,
}
}
@@ -1,6 +1,4 @@
package screen
import "git.teletypegames.org/games/realworld/internal/names"
package inc
// trinketShop is the Chinatown shop at the club's address, whose owner sells
// Paul junk he does not need and is, in fact, the club's doorman
@@ -9,17 +7,17 @@ import "git.teletypegames.org/games/realworld/internal/names"
// Both of the wiki's arrows out of it are here: Bolt → Kajálda, the eating
// place next door, and Bolt →|beengedés| Klub, which stays shut until the
// shopkeeper has been shown the underwater sun.
func trinketShop() screen {
func screenTrinketShop() screen {
return screen{
name: names.ScreenTrinketShop,
name: ScreenTrinketShop,
title: "CHINATOWN — TRINKET SHOP",
background: names.BgTrinketShop,
background: BgTrinketShop,
onSelector: true,
exits: []exit{
{to: names.ScreenSmallRestaurant, label: "the eating place next door", side: sideRight},
{to: ScreenSmallRestaurant, label: "the eating place next door", side: sideRight},
{
to: names.ScreenSecretClub, label: "the way in at the back", side: sideBack,
needs: names.FlagClubEntry,
to: ScreenSecretClub, label: "the way in at the back", side: sideBack,
needs: FlagClubEntry,
blocked: "Just a shop, as far as the man behind the counter is concerned.",
},
},
@@ -1,11 +1,7 @@
package script
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
"git.teletypegames.org/games/realworld/internal/theme"
"git.teletypegames.org/games/realworld/internal/world"
)
// awakeningFinale is the end of game two. The point of it, mechanically, is
@@ -14,18 +10,18 @@ import (
// 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 {
func scriptAwakeningFinale(w *World) inkwell.Script {
return inkwell.Script{
Name: names.ScriptFinale,
Name: ScriptFinale,
Actions: inkwell.Seq(
world.SetMode(w, world.ModeCutscene),
SetMode(w, ModeCutscene),
inkwell.Wait(0.6),
inkwell.Say(names.Paul, "I'm putting it in. That's all this is."),
inkwell.Say(Paul, "I'm putting it in. That's all this is."),
inkwell.Wait(0.4),
world.UseTheme(theme.NokiaPunk),
UseTheme(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."),
TapeSay(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"),
),
+18
View File
@@ -0,0 +1,18 @@
package inc
// Scripts — the game's named, reusable action sequences:
// cutscenes and recurring logic, built from the inkwell action set.
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// registerScript adds every script to the game.
func registerScript(w *World) {
for _, s := range []inkwell.Script{
scriptTapeInsert(w),
scriptAwakeningFinale(w),
} {
w.G.ScriptManager.Register(s)
}
}
+28
View File
@@ -0,0 +1,28 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// 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 scriptTapeInsert(w *World) inkwell.Script {
return inkwell.Script{
Name: ScriptTapeInsert,
Actions: inkwell.Seq(
Fn(func(ctx *inkwell.Ctx) {
item := w.TakePending()
if item == "" {
return
}
w.SetSlot2(item)
ctx.Game.Inventory.Remove(item)
ctx.Game.State.SetVar(VarArmillaStrip, "SLOT2: READING")
}),
inkwell.Say(Paul, "Right. Let's see who you are."),
TapeSay(Dex, "Clicked in. Spinning. And now: nothing."),
TapeSay(Dex, "A stranger's tape in your own Armilla, Paul. I hope you know what you're doing."),
),
}
}
@@ -1,13 +1,14 @@
// Package theme holds the game's two colour themes and its colour tokens.
package inc
// Themes — 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).
// line (see UseTheme and scriptAwakeningFinale).
//
// 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"
@@ -147,8 +148,8 @@ func nokiaPunkTheme() inkwell.Theme {
}
}
// Register adds both themes to the game.
func Register(g *inkwell.Game) {
// registerTheme adds both themes to the game.
func registerTheme(g *inkwell.Game) {
g.ThemeManager.Register(realWorldTheme())
g.ThemeManager.Register(nokiaPunkTheme())
}
@@ -1,4 +1,4 @@
package ui
package inc
// Letterbox — the cutscene frame.
//
@@ -12,22 +12,18 @@ import (
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
"github.com/hajimehoshi/ebiten/v2/vector"
"git.teletypegames.org/games/realworld/internal/names"
"git.teletypegames.org/games/realworld/internal/theme"
"git.teletypegames.org/games/realworld/internal/world"
)
type Letterbox struct {
Name string
W *world.World
W *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() {
if l.W.Mode() != ModeCutscene || !l.W.TapeWaiting() {
return
}
// inkwell's Input only covers the mouse; read the key directly.
@@ -37,7 +33,7 @@ func (l *Letterbox) Tick(ctx *inkwell.UICtx) {
}
func (l *Letterbox) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
if l.W.Mode() != world.ModeCutscene {
if l.W.Mode() != ModeCutscene {
return
}
g := ctx.Game
@@ -47,12 +43,12 @@ func (l *Letterbox) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
bar = 18
}
w, h := float32(g.Width), float32(g.Height)
black := theme.RGB(0x000000)
black := 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"
msg := "SPACE — " + TapeDisplayName(Dex) + " has something to say"
drawTextC(dst, msg, g.Width-textW(msg)-4, g.Height-int(bar)+4, th.ChatLogResponse)
}
}
+8 -11
View File
@@ -1,4 +1,4 @@
package ui
package inc
// HUD layout.
//
@@ -37,9 +37,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"git.teletypegames.org/games/realworld/internal/names"
"git.teletypegames.org/games/realworld/internal/world"
)
// Screen and layout, in internal (unscaled) pixels.
@@ -75,16 +72,16 @@ const (
invGap = 4
)
// Register assembles the whole HUD in the conventional Z-order: the debug
// registerUI assembles the whole HUD in the conventional Z-order: the debug
// overlay at the back, the cursor in front.
func Register(w *world.World) {
func registerUI(w *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(&ActionPump{Name: "pump", W: w})
g.UIManager.Register(&screenNav{Name: "screen_nav", W: w})
g.UIManager.Register(&windowSizer{Name: "window", Scale: 2})
g.UIManager.Register(&inkwell.HotspotDebug{Name: "hotspot_debug"})
@@ -95,7 +92,7 @@ func Register(w *world.World) {
// 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,
TimeVar: VarArmillaStrip,
}
w.SetTopBar(top)
g.UIManager.Register(gated(w, "topbar_gate", top))
@@ -176,11 +173,11 @@ func (s *windowSizer) Tick(ctx *inkwell.UICtx) {
// BlocksClickAt is forwarded, otherwise the verb coin would open over the HUD.
type gate struct {
Name string
W *world.World
W *World
Inner inkwell.Widget
}
func gated(w *world.World, name string, inner inkwell.Widget) inkwell.Widget {
func gated(w *World, name string, inner inkwell.Widget) inkwell.Widget {
return &gate{Name: name, W: w, Inner: inner}
}
@@ -212,7 +209,7 @@ func (n *gate) BlocksClickAt(p inkwell.Point) bool {
// two channels always have a visible, continuous separator between them.
type hudFrame struct {
Name string
W *world.World
W *World
}
func (h *hudFrame) GetName() string { return h.Name }
@@ -1,9 +1,9 @@
package ui
package inc
// screenNav — walking the deck with the arrow keys.
//
// The screens are connected — each one records where you can get to from it
// (content/screen/exit.go) — but walking the map is the player's job, and a
// (screen.exit.go) — but walking the map is the player's job, and a
// screen whose beat is not written yet has nothing in it to walk towards. LEFT
// and RIGHT step to the previous and next screen so the deck can be reviewed as
// a deck: in concept-art order, wrapping at both ends. It is a tool for looking
@@ -17,13 +17,11 @@ import (
inkwell "git.teletypegames.org/engines/inkwell"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
"git.teletypegames.org/games/realworld/internal/world"
)
type screenNav struct {
Name string
W *world.World
W *World
}
func (n *screenNav) GetName() string { return n.Name }
@@ -49,7 +47,7 @@ func (n *screenNav) Tick(ctx *inkwell.UICtx) {
}
// neighbour returns the screen step places from the current one, wrapping.
// Registration order is the deck order — see content/screen/screen.go.
// Registration order is the deck order — see screen.manager.go.
func (n *screenNav) neighbour(g *inkwell.Game, step int) string {
deck := g.SceneManager.Names()
if len(deck) < 2 {
@@ -1,11 +1,9 @@
package ui
package inc
import (
"testing"
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/world"
)
// The arrow keys walk the deck in registration order and wrap at both ends —
@@ -16,9 +14,9 @@ func TestScreenNavWrapsBothWays(t *testing.T) {
for _, n := range []string{"a", "b", "c"} {
g.SceneManager.Register(inkwell.Scene{Name: n, Background: "bg"})
}
w := world.New(g)
w := newWorld(g)
nav := &screenNav{Name: "screen_nav", W: w}
at := func(name string) { world.EnterScene(w, name).Start().Tick(&inkwell.Ctx{Game: g}) }
at := func(name string) { EnterScene(w, name).Start().Tick(&inkwell.Ctx{Game: g}) }
for _, tc := range []struct {
from string
@@ -1,4 +1,4 @@
package ui
package inc
// TapeChannel — the tape channel down the right-hand side.
//
@@ -19,14 +19,11 @@ import (
inkwell "git.teletypegames.org/engines/inkwell"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"git.teletypegames.org/games/realworld/internal/names"
"git.teletypegames.org/games/realworld/internal/world"
)
type TapeChannel struct {
Name string
W *world.World
W *World
Bounds inkwell.Rectangle
}
@@ -76,7 +73,7 @@ func (t *TapeChannel) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
// joke book readable.
col, txt = th.ChatLogPrompt, m.Text
case inkwell.LogResponse:
if !names.IsTape(m.Speaker) {
if !IsTape(m.Speaker) {
continue // Paul and the NPCs speak in the scene, not here
}
col, txt = speechColor(g, m.Speaker, th.ChatLogResponse), m.Text
@@ -107,11 +104,11 @@ func (t *TapeChannel) lastSpeaker(g *inkwell.Game, th inkwell.Theme) (string, co
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)
if m.Kind == inkwell.LogResponse && IsTape(m.Speaker) {
return TapeDisplayName(m.Speaker), speechColor(g, m.Speaker, th.ChatLogResponse)
}
}
return names.TapeDisplayName(names.Dex), speechColor(g, names.Dex, th.ChatLogResponse)
return TapeDisplayName(Dex), speechColor(g, Dex, th.ChatLogResponse)
}
// speechColor is the character's voice colour, falling back to the theme's.
@@ -1,4 +1,4 @@
package ui
package inc
// TapeSlots — the two slots of Paul's Armilla.
//
@@ -19,14 +19,11 @@ import (
inkwell "git.teletypegames.org/engines/inkwell"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
"git.teletypegames.org/games/realworld/internal/names"
"git.teletypegames.org/games/realworld/internal/world"
)
type TapeSlots struct {
Name string
W *world.World
W *World
Bounds inkwell.Rectangle
}
@@ -54,7 +51,7 @@ func (t *TapeSlots) Tick(ctx *inkwell.UICtx) {
// Hover label, so the StatusLine spells out what is about to happen.
switch {
case r1.Contains(mp):
g.SetHoverLabel(names.TapeDisplayName(names.Dex))
g.SetHoverLabel(TapeDisplayName(Dex))
case r2.Contains(mp):
if s := t.W.Slot2(); s != "" {
g.SetHoverLabel(itemLabel(g, s))
@@ -77,41 +74,41 @@ func (t *TapeSlots) Tick(ctx *inkwell.UICtx) {
// Loading a tape into slot 2.
if slot2 && sel != "" {
g.Inventory.Select("")
if names.IsTapeItem(sel) {
if IsTapeItem(sel) {
t.W.SetPending(sel)
t.W.Do(inkwell.RunScript(names.ScriptTapeInsert))
t.W.Do(inkwell.RunScript(ScriptTapeInsert))
return
}
t.W.Do(world.TapeSay(names.Dex, "That isn't a tape, Paul. That's an object. There is a difference."))
t.W.Do(TapeSay(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."))
t.W.Do(TapeSay(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)))
t.W.Do(Paused(t.W, inkwell.RunDialogue(DlgDex)))
return
}
if s := t.W.Slot2(); s != "" {
t.W.Do(world.Paused(t.W, inkwell.RunDialogue(names.TapeDialogue(s))))
t.W.Do(Paused(t.W, inkwell.RunDialogue(TapeDialogue(s))))
return
}
t.W.Do(world.TapeSay(names.Dex, "Empty. Nobody to talk to."))
t.W.Do(TapeSay(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."))
t.W.Do(TapeSay(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."))
t.W.Do(TapeSay(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."))
t.W.Do(TapeSay(Dex, "The second slot is empty. That's the rarer state."))
}
}
@@ -138,7 +135,7 @@ func (t *TapeSlots) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
drawTextC(dst, clip(body, inner), x, int(r.Y)+Pad/2+LineH, col)
}
drawSlot(r1, "SLOT 1", names.TapeDisplayName(names.Dex), true)
drawSlot(r1, "SLOT 1", TapeDisplayName(Dex), true)
if s := t.W.Slot2(); s != "" {
drawSlot(r2, "SLOT 2", itemLabel(g, s), true)
} else {
+2 -4
View File
@@ -1,8 +1,6 @@
// Package ui is the game's HUD: the layout, the custom widgets and coloured
// text rendering.
package ui
package inc
// Coloured text rendering.
// The HUD's 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
@@ -1,4 +1,4 @@
package ui
package inc
import "testing"
@@ -1,4 +1,4 @@
package ui
package inc
// UseWithGuard — enforcing authored failure.
//
@@ -22,14 +22,11 @@ import (
inkwell "git.teletypegames.org/engines/inkwell"
"github.com/hajimehoshi/ebiten/v2"
"git.teletypegames.org/games/realworld/internal/names"
"git.teletypegames.org/games/realworld/internal/world"
)
type UseWithGuard struct {
Name string
W *world.World
W *World
}
func (u *UseWithGuard) GetName() string { return u.Name }
@@ -64,8 +61,8 @@ func (u *UseWithGuard) Tick(ctx *inkwell.UICtx) {
n := g.State.Talked(key)
u.W.Do(inkwell.Seq(
inkwell.Say(names.Paul, pick(paulFails, n)),
world.TapeSay(names.Dex, dexFail(n)),
inkwell.Say(Paul, pick(paulFails, n)),
TapeSay(Dex, dexFail(n)),
))
}
@@ -1,4 +1,4 @@
package world
package inc
// The game's own actions, and the domain action pump.
//
@@ -1,15 +1,14 @@
// Package world is the domain aggregate: a thin layer around the inkwell Game
package inc
// World — 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
// The world knows nothing about the HUD or the content: the ui and content
// categories both build on it, never the other way round.
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
)
// Mode is the HUD mode.
@@ -48,7 +47,7 @@ type World struct {
}
// New wraps an existing inkwell game.
func New(g *inkwell.Game) *World {
func newWorld(g *inkwell.Game) *World {
return &World{G: g, mode: ModePlay}
}
@@ -78,14 +77,14 @@ func (w *World) SetTopBar(t *inkwell.TopBar) { w.top = t }
// 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 {
if v, ok := w.G.State.Var(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) }
func (w *World) SetSlot2(item string) { w.G.State.SetVar(VarSlot2, item) }
// SetPending marks which tape is going into slot 2. The tape-insert script
// performs the actual load; the widget only marks.
@@ -1,42 +1,39 @@
package world_test
package inc
import (
"testing"
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
"git.teletypegames.org/games/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 := newWorld(inkwell.NewGame("t", 320, 200))
w.SetMode(world.ModeCutscene)
for _, k := range []string{"ui.mode", "mode"} {
w.SetMode(ModeCutscene)
for _, k := range []string{"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 {
w.SetSlot2(ItemMysteryTape)
if w.G.State.Var(VarSlot2) != ItemMysteryTape {
t.Error("slot 2 is not in State.Vars")
}
if got := w.Slot2(); got != names.ItemMysteryTape {
if got := w.Slot2(); got != 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 {
w := newWorld(inkwell.NewGame("t", 320, 200))
w.SetPending(ItemMysteryTape)
if got := w.TakePending(); got != ItemMysteryTape {
t.Fatalf("TakePending() = %q", got)
}
if got := w.TakePending(); got != "" {
-57
View File
@@ -1,57 +0,0 @@
// 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"
"git.teletypegames.org/games/realworld/internal/content"
"git.teletypegames.org/games/realworld/internal/names"
"git.teletypegames.org/games/realworld/internal/theme"
"git.teletypegames.org/games/realworld/internal/ui"
"git.teletypegames.org/games/realworld/internal/world"
)
// Opts are the run parameters.
type Opts struct {
Screen string // starting screen; empty means the alley
Finale bool // start with the finale, to try the Nokia-punk theme switch
}
// New builds a ready-to-run game.
func New(o Opts) *inkwell.Game {
screen := o.Screen
if screen == "" {
screen = names.ScreenAlley
}
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(screen)
g.OnStart(inkwell.Seq(opening(w, screen, o.Finale)...))
return g
}
func opening(w *world.World, screen string, finale bool) []inkwell.Action {
acts := []inkwell.Action{
world.EnterScene(w, screen),
// Beat 2 (the police station) will set the tip flag. Until that beat
// is written we set it here so the alley slice is playable end to end —
// the conditional branch itself is real code, not bypassed.
inkwell.SetFlag(names.FlagPoliceTip),
inkwell.Say(names.Paul, "Back alley. Just like the clerk said."),
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
}
-48
View File
@@ -1,48 +0,0 @@
// Package background registers one image asset per screen — one file each.
//
// A background is the whole of a screen's art: inkwell scales it over the
// entire window, so every file here points at a 640×380 PNG under assets/bg/,
// the size the concept-art deck is drawn at. Two of them are not painted yet
// and fall back to inkwell's placeholder.
//
// The order below is the deck's, which is the wiki's location-catalogue order
// (projects/realworld/entities#helyszinek).
package background
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/world"
)
// Register adds every background to the game.
func Register(w *world.World) {
for _, a := range []inkwell.Asset{
selector(),
paulShop(),
noodleHouse(),
normanApartment(),
policeStation(),
alley(),
hackerspace(),
iceCreamShop(),
trinketShop(),
smallRestaurant(),
secretClub(),
bbsTerminal(),
street(),
hospital(),
serverFarm(),
secretLab(),
rooftopHideout(),
publicBbs(),
bvkBranch(),
curatorShop(),
scrapMarket(),
samizdatPress(),
showroom(),
columbarium(),
} {
w.G.AssetManager.Register(a)
}
}
-24
View File
@@ -1,24 +0,0 @@
// 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"
"git.teletypegames.org/games/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)
}
}
@@ -1,17 +0,0 @@
package character
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
"git.teletypegames.org/games/realworld/internal/theme"
)
// supportTape answers everything with basic information, insufferably. Dex
// hates it. Acquired in Chinatown (beat 5) — not reachable yet.
func supportTape() inkwell.Character {
return inkwell.Character{
Name: names.TapeSupport,
SpeechColor: theme.RGB(0xE8C86A),
}
}
-28
View File
@@ -1,28 +0,0 @@
// 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 screen means adding screen/<name>.go, background/<name>.go
// and one line in each package's list — nothing else moves.
package content
import (
"git.teletypegames.org/games/realworld/internal/content/background"
"git.teletypegames.org/games/realworld/internal/content/character"
"git.teletypegames.org/games/realworld/internal/content/dialog"
"git.teletypegames.org/games/realworld/internal/content/item"
"git.teletypegames.org/games/realworld/internal/content/screen"
"git.teletypegames.org/games/realworld/internal/content/script"
"git.teletypegames.org/games/realworld/internal/world"
)
// Register adds every entity to the game. Order matters only in that screens
// reference backgrounds and characters, which Game.Validate cross-checks.
func Register(w *world.World) {
background.Register(w)
character.Register(w)
item.Register(w)
dialog.Register(w)
script.Register(w)
screen.Register(w)
}
@@ -1,29 +0,0 @@
package item
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
"git.teletypegames.org/games/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."),
),
},
}
}
-21
View File
@@ -1,21 +0,0 @@
package item
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
"git.teletypegames.org/games/realworld/internal/world"
)
// mysteryTape is the engine of the investigation. Password-locked; it only
// starts speaking after the club trigger (beat 6).
func mysteryTape() inkwell.Item {
return inkwell.Item{
Name: names.ItemMysteryTape,
Description: "unmarked Personal Tape",
OnUseSelf: inkwell.Seq(
inkwell.Say(names.Paul, "Password-locked. Of course."),
world.TapeSay(names.Dex, "Put it in the second slot if you care that much. I did warn you."),
),
}
}
-20
View File
@@ -1,20 +0,0 @@
package item
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
"git.teletypegames.org/games/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."),
),
}
}
-107
View File
@@ -1,107 +0,0 @@
package screen
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
"git.teletypegames.org/games/realworld/internal/world"
)
// alley is the alley behind Noodle's house — beat 3, and the vertical slice the
// whole HUD is measured against: hotspots, a gated pickup, the two-slot
// Armilla, tape dialogue and an authored failure.
//
// The wiki keeps it in the same catalogue row as the house ("Noodle háza & a
// sikátor") and its graph has one arrow in, NoodleUtcafront → Sikátor, so the
// only way out is back to the street. The alley's own locked back door is the
// New Game+ hook and leads to a room the deck has not painted.
func alley() screen {
return screen{
name: names.ScreenAlley,
title: "ALLEY — BEHIND NOODLE'S HOUSE",
background: names.BgAlley,
exits: []exit{
// On the left: the back door hotspot below owns the other end.
{to: names.ScreenNoodleHouse, label: "back out to the street", side: sideLeft},
},
actors: []inkwell.SceneActor{
{CharacterName: names.Paul, At: inkwell.Point{X: 120, Y: 232}},
},
onEnter: 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."),
}
}
-19
View File
@@ -1,19 +0,0 @@
// 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"
"git.teletypegames.org/games/realworld/internal/world"
)
// Register adds every script to the game.
func Register(w *world.World) {
for _, s := range []inkwell.Script{
tapeInsert(w),
awakeningFinale(w),
} {
w.G.ScriptManager.Register(s)
}
}
-31
View File
@@ -1,31 +0,0 @@
package script
import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/names"
"git.teletypegames.org/games/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."),
),
}
}
+3 -3
View File
@@ -7,7 +7,7 @@ import (
inkwell "git.teletypegames.org/engines/inkwell"
"git.teletypegames.org/games/realworld/internal/boot"
"git.teletypegames.org/games/realworld/inc"
)
// The art ships inside the binary. js/wasm has no OS filesystem, so without
@@ -21,13 +21,13 @@ var assets embed.FS
func init() { inkwell.AssetFS = assets }
func main() {
var o boot.Opts
var o inc.Opts
flag.StringVar(&o.Screen, "screen", "", "starting screen (empty: the alley)")
flag.BoolVar(&o.Finale, "finale", false,
"start with the finale, to try the Nokia-punk theme switch")
flag.Parse()
if err := inkwell.Run(boot.New(o)); err != nil {
if err := inkwell.Run(inc.New(o)); err != nil {
log.Fatal(err)
}
}