remove comments

This commit is contained in:
2026-08-29 23:27:15 +02:00
parent ec48cd6b37
commit 954bd762f7
84 changed files with 111 additions and 838 deletions
-4
View File
@@ -4,10 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// 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 backgroundAlley() inkwell.Asset { func backgroundAlley() inkwell.Asset {
return inkwell.Asset{ return inkwell.Asset{
Name: BgAlley, Name: BgAlley,
-1
View File
@@ -4,7 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// bbsTerminal — deck 11.
func backgroundBbsTerminal() inkwell.Asset { func backgroundBbsTerminal() inkwell.Asset {
return inkwell.Asset{ return inkwell.Asset{
Name: BgBBSTerminal, Name: BgBBSTerminal,
-1
View File
@@ -4,7 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// bvkBranch — deck 18.
func backgroundBvkBranch() inkwell.Asset { func backgroundBvkBranch() inkwell.Asset {
return inkwell.Asset{ return inkwell.Asset{
Name: BgBVKBranch, Name: BgBVKBranch,
-1
View File
@@ -4,7 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// columbarium — deck 23.
func backgroundColumbarium() inkwell.Asset { func backgroundColumbarium() inkwell.Asset {
return inkwell.Asset{ return inkwell.Asset{
Name: BgColumbarium, Name: BgColumbarium,
-1
View File
@@ -4,7 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// curatorShop — deck 19.
func backgroundCuratorShop() inkwell.Asset { func backgroundCuratorShop() inkwell.Asset {
return inkwell.Asset{ return inkwell.Asset{
Name: BgCuratorShop, Name: BgCuratorShop,
-1
View File
@@ -4,7 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// hackerspace — deck 06.
func backgroundHackerspace() inkwell.Asset { func backgroundHackerspace() inkwell.Asset {
return inkwell.Asset{ return inkwell.Asset{
Name: BgHackerspace, Name: BgHackerspace,
-1
View File
@@ -4,7 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// hospital — deck 13.
func backgroundHospital() inkwell.Asset { func backgroundHospital() inkwell.Asset {
return inkwell.Asset{ return inkwell.Asset{
Name: BgHospital, Name: BgHospital,
-1
View File
@@ -4,7 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// iceCreamShop — deck 07.
func backgroundIceCreamShop() inkwell.Asset { func backgroundIceCreamShop() inkwell.Asset {
return inkwell.Asset{ return inkwell.Asset{
Name: BgIceCreamShop, Name: BgIceCreamShop,
-11
View File
@@ -1,20 +1,9 @@
package inc 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 ( import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// registerBackground adds every background to the game.
func registerBackground(w *World) { func registerBackground(w *World) {
for _, a := range []inkwell.Asset{ for _, a := range []inkwell.Asset{
backgroundSelector(), backgroundSelector(),
-1
View File
@@ -4,7 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// noodleHouse — deck 02.
func backgroundNoodleHouse() inkwell.Asset { func backgroundNoodleHouse() inkwell.Asset {
return inkwell.Asset{ return inkwell.Asset{
Name: BgNoodleHouse, Name: BgNoodleHouse,
-1
View File
@@ -4,7 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// normanApartment — deck 03.
func backgroundNormanApartment() inkwell.Asset { func backgroundNormanApartment() inkwell.Asset {
return inkwell.Asset{ return inkwell.Asset{
Name: BgNormanApartment, Name: BgNormanApartment,
-1
View File
@@ -4,7 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// paulShop — deck 01.
func backgroundPaulShop() inkwell.Asset { func backgroundPaulShop() inkwell.Asset {
return inkwell.Asset{ return inkwell.Asset{
Name: BgPaulShop, Name: BgPaulShop,
-1
View File
@@ -4,7 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// policeStation — deck 04.
func backgroundPoliceStation() inkwell.Asset { func backgroundPoliceStation() inkwell.Asset {
return inkwell.Asset{ return inkwell.Asset{
Name: BgPoliceStation, Name: BgPoliceStation,
-1
View File
@@ -4,7 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// publicBbs — deck 17.
func backgroundPublicBBS() inkwell.Asset { func backgroundPublicBBS() inkwell.Asset {
return inkwell.Asset{ return inkwell.Asset{
Name: BgPublicBBS, Name: BgPublicBBS,
-1
View File
@@ -4,7 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// rooftopHideout — deck 16.
func backgroundRooftopHideout() inkwell.Asset { func backgroundRooftopHideout() inkwell.Asset {
return inkwell.Asset{ return inkwell.Asset{
Name: BgRooftopHideout, Name: BgRooftopHideout,
-1
View File
@@ -4,7 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// samizdatPress — deck 21.
func backgroundSamizdatPress() inkwell.Asset { func backgroundSamizdatPress() inkwell.Asset {
return inkwell.Asset{ return inkwell.Asset{
Name: BgSamizdatPress, Name: BgSamizdatPress,
-1
View File
@@ -4,7 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// scrapMarket — deck 20.
func backgroundScrapMarket() inkwell.Asset { func backgroundScrapMarket() inkwell.Asset {
return inkwell.Asset{ return inkwell.Asset{
Name: BgScrapMarket, Name: BgScrapMarket,
-1
View File
@@ -4,7 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// secretClub — deck 10.
func backgroundSecretClub() inkwell.Asset { func backgroundSecretClub() inkwell.Asset {
return inkwell.Asset{ return inkwell.Asset{
Name: BgSecretClub, Name: BgSecretClub,
-1
View File
@@ -4,7 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// secretLab — deck 15.
func backgroundSecretLab() inkwell.Asset { func backgroundSecretLab() inkwell.Asset {
return inkwell.Asset{ return inkwell.Asset{
Name: BgSecretLab, Name: BgSecretLab,
-4
View File
@@ -4,10 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// 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 backgroundSelector() inkwell.Asset { func backgroundSelector() inkwell.Asset {
return inkwell.Asset{ return inkwell.Asset{
Name: BgSelector, Name: BgSelector,
-1
View File
@@ -4,7 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// serverFarm — deck 14.
func backgroundServerFarm() inkwell.Asset { func backgroundServerFarm() inkwell.Asset {
return inkwell.Asset{ return inkwell.Asset{
Name: BgServerFarm, Name: BgServerFarm,
-1
View File
@@ -4,7 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// showroom — deck 22.
func backgroundShowroom() inkwell.Asset { func backgroundShowroom() inkwell.Asset {
return inkwell.Asset{ return inkwell.Asset{
Name: BgShowroom, Name: BgShowroom,
-1
View File
@@ -4,7 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// smallRestaurant — deck 09.
func backgroundSmallRestaurant() inkwell.Asset { func backgroundSmallRestaurant() inkwell.Asset {
return inkwell.Asset{ return inkwell.Asset{
Name: BgSmallRestaurant, Name: BgSmallRestaurant,
-1
View File
@@ -4,7 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// street — deck 12.
func backgroundStreet() inkwell.Asset { func backgroundStreet() inkwell.Asset {
return inkwell.Asset{ return inkwell.Asset{
Name: BgStreet, Name: BgStreet,
-1
View File
@@ -4,7 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// trinketShop — deck 08.
func backgroundTrinketShop() inkwell.Asset { func backgroundTrinketShop() inkwell.Asset {
return inkwell.Asset{ return inkwell.Asset{
Name: BgTrinketShop, Name: BgTrinketShop,
+3 -17
View File
@@ -1,26 +1,14 @@
// 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 package inc
import ( import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// Opts are the run parameters.
type Opts struct { type Opts struct {
Screen string // starting screen; empty means the alley Screen string
Finale bool // start with the finale, to try the Nokia-punk theme switch Finale bool
} }
// New builds a ready-to-run game.
func New(o Opts) *inkwell.Game { func New(o Opts) *inkwell.Game {
start := o.Screen start := o.Screen
if start == "" { if start == "" {
@@ -45,9 +33,7 @@ func New(o Opts) *inkwell.Game {
func bootOpening(w *World, start string, finale bool) []inkwell.Action { func bootOpening(w *World, start string, finale bool) []inkwell.Action {
acts := []inkwell.Action{ acts := []inkwell.Action{
EnterScene(w, start), 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.SetFlag(FlagPoliceTip),
inkwell.Say(Paul, "Back alley. Just like the clerk said."), 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?"), TapeSay(Dex, "A government office tipped you off to remove something from a scene. That doesn't strike you as odd?"),
+1 -39
View File
@@ -19,9 +19,6 @@ func newTestGame(t *testing.T) *inkwell.Game {
return g return g
} }
// The engine's Validate only checks scene→asset/character references. A typo in
// a dialogue, script or item name would surface at runtime as a silent no-op
// (RunDialogue and RunScript fail quietly), so check them here.
func TestReferencesResolve(t *testing.T) { func TestReferencesResolve(t *testing.T) {
g := newTestGame(t) g := newTestGame(t)
@@ -45,7 +42,7 @@ func TestReferencesResolve(t *testing.T) {
t.Errorf("tape character not registered: %q", n) t.Errorf("tape character not registered: %q", n)
} }
} }
// Every loadable tape needs an item, a character and a dialogue.
for item, char := range TapeItems { for item, char := range TapeItems {
if !g.ItemManager.Has(item) { if !g.ItemManager.Has(item) {
t.Errorf("tape item not registered: %q", item) t.Errorf("tape item not registered: %q", item)
@@ -59,9 +56,6 @@ func TestReferencesResolve(t *testing.T) {
} }
} }
// The two-channel rule: every tape needs its own voice colour, distinct from
// the world's. Without that, "if something is amber, a tape said it" is not
// readable.
func TestTapeVoicesAreDistinct(t *testing.T) { func TestTapeVoicesAreDistinct(t *testing.T) {
g := newTestGame(t) g := newTestGame(t)
paul, _ := g.CharacterManager.Get(Paul) paul, _ := g.CharacterManager.Get(Paul)
@@ -84,8 +78,6 @@ 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) { func TestHUDGeometry(t *testing.T) {
g := newTestGame(t) g := newTestGame(t)
@@ -112,8 +104,6 @@ func TestHUDGeometry(t *testing.T) {
t.Errorf("tape slots cross the divider: %v > %v", 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
// comment alongside the conversation.
db, ok := g.UIManager.Get("dialog") db, ok := g.UIManager.Get("dialog")
if !ok { if !ok {
t.Fatal("no 'dialog' widget") t.Fatal("no 'dialog' widget")
@@ -124,8 +114,6 @@ func TestHUDGeometry(t *testing.T) {
} }
} }
// The guard must be registered FIRST so it ticks LAST among the widgets —
// otherwise it would swallow HUD clicks.
func TestWidgetOrder(t *testing.T) { func TestWidgetOrder(t *testing.T) {
g := newTestGame(t) g := newTestGame(t)
n := g.UIManager.Names() n := g.UIManager.Names()
@@ -137,22 +125,9 @@ func TestWidgetOrder(t *testing.T) {
} }
} }
// Every screen needs a background, the drawn ones need the file to be where the
// asset says it is, and the file has to be exactly screen-sized.
//
// The path check: a renamed or mistyped path shows up in the running game only
// as an inkwell placeholder, which is exactly what a still-greyboxed screen
// looks like — invisible as a bug.
//
// The size check: the engine scales a background over the WHOLE screen, so a
// painting that is not ScreenW×ScreenH is silently squashed to fit. That is why
// the screen is 640×380 in the first place; this keeps the two in step.
func TestSceneBackgroundsResolve(t *testing.T) { func TestSceneBackgroundsResolve(t *testing.T) {
g := newTestGame(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{BgAlley: true, BgSelector: true} undrawn := map[string]bool{BgAlley: true, BgSelector: true}
for _, n := range g.SceneManager.Names() { for _, n := range g.SceneManager.Names() {
@@ -183,9 +158,6 @@ func TestSceneBackgroundsResolve(t *testing.T) {
} }
} }
// 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 screen.exit.go.
func exits(g *inkwell.Game, screen string) []string { func exits(g *inkwell.Game, screen string) []string {
var out []string var out []string
for _, h := range g.SceneManager.MustGet(screen).Hotspots { for _, h := range g.SceneManager.MustGet(screen).Hotspots {
@@ -196,8 +168,6 @@ func exits(g *inkwell.Game, screen string) []string {
return out return out
} }
// An exit that names a screen nobody registered is a dead end the player walks
// into: the engine logs "changeScene: unknown" and nothing happens on screen.
func TestExitsResolve(t *testing.T) { func TestExitsResolve(t *testing.T) {
g := newTestGame(t) g := newTestGame(t)
for _, n := range g.SceneManager.Names() { for _, n := range g.SceneManager.Names() {
@@ -209,12 +179,6 @@ func TestExitsResolve(t *testing.T) {
} }
} }
// Every screen has to be walkable to from the start of the game, through the
// exits alone — the arrow keys are a browsing tool, not the map. A screen that
// only the arrow keys can reach is content no player would ever see.
//
// The walk starts at Paul's shop, where the letter arrives, and mostly runs
// through the selector: that is what the wiki's location graph is centred on.
func TestEveryScreenIsReachable(t *testing.T) { func TestEveryScreenIsReachable(t *testing.T) {
g := newTestGame(t) g := newTestGame(t)
@@ -238,8 +202,6 @@ 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) { func TestEveryScreenHasAWayOut(t *testing.T) {
g := newTestGame(t) g := newTestGame(t)
for _, n := range g.SceneManager.Names() { for _, n := range g.SceneManager.Names() {
-2
View File
@@ -4,8 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// dex is the personality imprint of a dead friend, permanently in slot 1 of
// Paul's Armilla. Constant commentary, hint system, dialogue partner.
func characterDex() inkwell.Character { func characterDex() inkwell.Character {
return inkwell.Character{ return inkwell.Character{
Name: Dex, Name: Dex,
-7
View File
@@ -1,16 +1,9 @@
package inc 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 ( import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// registerCharacter adds every character to the game.
func registerCharacter(w *World) { func registerCharacter(w *World) {
for _, c := range []inkwell.Character{ for _, c := range []inkwell.Character{
characterPaul(), characterPaul(),
-5
View File
@@ -4,11 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// mysteryTape is the tape found in the alley — in truth Norman's Personal
// Tape, but the player only learns that in the finale.
//
// Its voice is a colder, greyer amber than Dex's. That pays off in reverse:
// it never once spoke in the same voice as the friend.
func characterMysteryTape() inkwell.Character { func characterMysteryTape() inkwell.Character {
return inkwell.Character{ return inkwell.Character{
Name: TapeMystery, Name: TapeMystery,
+1 -4
View File
@@ -4,8 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// paul is the player character: a junk dealer and street survivor, technically
// competent, living on the edge of the city.
func characterPaul() inkwell.Character { func characterPaul() inkwell.Character {
return inkwell.Character{ return inkwell.Character{
Name: Paul, Name: Paul,
@@ -13,8 +11,7 @@ func characterPaul() inkwell.Character {
W: 28, W: 28,
H: 68, H: 68,
Start: inkwell.Point{X: 120, Y: 232}, 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: Ink, SpeechColor: Ink,
} }
} }
-2
View File
@@ -4,8 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" 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 { func characterSupportTape() inkwell.Character {
return inkwell.Character{ return inkwell.Character{
Name: TapeSupport, Name: TapeSupport,
-9
View File
@@ -1,14 +1,5 @@
package inc 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) { func registerContent(w *World) {
registerBackground(w) registerBackground(w)
registerCharacter(w) registerCharacter(w)
-2
View File
@@ -4,8 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// 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 dialogDexTalk() inkwell.Dialogue { func dialogDexTalk() inkwell.Dialogue {
return inkwell.Dialogue{ return inkwell.Dialogue{
Name: DlgDex, Name: DlgDex,
-11
View File
@@ -1,20 +1,9 @@
package inc 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.
//
// A note on DialogueChoice.Once: inkwell HIDES a spent choice. The wireframe
// deck wanted it struck through but still visible, so the list becomes a
// memory of what you already tried. That is still open — see README, "Known
// gaps".
import ( import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// registerDialog adds every dialogue to the game.
func registerDialog(w *World) { func registerDialog(w *World) {
for _, d := range []inkwell.Dialogue{ for _, d := range []inkwell.Dialogue{
dialogDexTalk(), dialogDexTalk(),
-3
View File
@@ -4,9 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// 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 dialogMysteryTapeSilent() inkwell.Dialogue { func dialogMysteryTapeSilent() inkwell.Dialogue {
return inkwell.Dialogue{ return inkwell.Dialogue{
Name: DlgMysteryTape, Name: DlgMysteryTape,
+1 -4
View File
@@ -4,8 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" 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 { func itemBlackMarketArmilla() inkwell.Item {
return inkwell.Item{ return inkwell.Item{
Name: ItemBlackArmilla, Name: ItemBlackArmilla,
@@ -15,8 +13,7 @@ func itemBlackMarketArmilla() inkwell.Item {
TapeSay(Dex, "Two slots, and both of them lie about the temperature. Don't trade me in for it."), TapeSay(Dex, "Two slots, and both of them lie about the temperature. Don't trade me in for it."),
), ),
OnUseWith: map[string]inkwell.Action{ 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( ItemNoodleLetter: inkwell.Seq(
inkwell.Say(Paul, "A letter and a bracelet. Brilliant."), inkwell.Say(Paul, "A letter and a bracelet. Brilliant."),
TapeSay(Dex, "Nothing. Which is what I'd charge for it."), TapeSay(Dex, "Nothing. Which is what I'd charge for it."),
-6
View File
@@ -1,15 +1,9 @@
package inc 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.
import ( import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// registerItem adds every item to the game.
func registerItem(w *World) { func registerItem(w *World) {
for _, i := range []inkwell.Item{ for _, i := range []inkwell.Item{
itemNoodleLetter(), itemNoodleLetter(),
-2
View File
@@ -4,8 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" 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 { func itemMysteryTape() inkwell.Item {
return inkwell.Item{ return inkwell.Item{
Name: ItemMysteryTape, Name: ItemMysteryTape,
-1
View File
@@ -4,7 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// noodleLetter starts the story: it is what brings Paul to the city.
func itemNoodleLetter() inkwell.Item { func itemNoodleLetter() inkwell.Item {
return inkwell.Item{ return inkwell.Item{
Name: ItemNoodleLetter, Name: ItemNoodleLetter,
+32 -73
View File
@@ -1,99 +1,67 @@
package inc 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
// design share one vocabulary. Note that the wiki's world-state table spells
// its keys in Hungarian (hatosagi_tipp, titokzatos_tape_megvan, romlas_szint,
// tape_behelyezes, sikator); the constants below are their English
// equivalents — the mapping is one-to-one, in catalogue order.
//
// These constants are the vocabulary every other category speaks in; nothing
// here depends on anything else in the game.
// Characters.
const ( const (
Paul = "paul" Paul = "paul"
Dex = "dex" // Personal Tape, permanently in slot 1 of Paul's Armilla Dex = "dex"
// Tapes (Personal Tapes) are characters, not props: the wiki is explicit TapeMystery = "tape_mystery"
// that they have their own personality, motivation and voice. TapeSupport = "tape_support"
TapeMystery = "tape_mystery" // = The AI, but that only surfaces in the finale
TapeSupport = "tape_support" // the "Indian tech support" tape
) )
// Inventory items.
const ( const (
ItemNoodleLetter = "noodle_letter" ItemNoodleLetter = "noodle_letter"
ItemMysteryTape = "mystery_tape" ItemMysteryTape = "mystery_tape"
ItemBlackArmilla = "black_market_armilla" ItemBlackArmilla = "black_market_armilla"
) )
// Dialogues.
const ( const (
DlgDex = "dex_talk" DlgDex = "dex_talk"
DlgMysteryTape = "mystery_tape_silent" DlgMysteryTape = "mystery_tape_silent"
) )
// Scripts.
const ( const (
ScriptTapeInsert = "tape_insert" ScriptTapeInsert = "tape_insert"
ScriptFinale = "awakening_finale" ScriptFinale = "awakening_finale"
) )
// World state — flags and variables.
const ( const (
FlagPoliceTip = "police_tip" // wiki: hatosagi_tipp FlagPoliceTip = "police_tip"
FlagHasTape = "has_mystery_tape" // wiki: titokzatos_tape_megvan FlagHasTape = "has_mystery_tape"
FlagTapeSpoke = "tape_spoke" // wiki: tape_megszolalt FlagTapeSpoke = "tape_spoke"
FlagClubEntry = "club_entry" // wiki: klub_bejutas — the shopkeeper let Paul through FlagClubEntry = "club_entry"
VarSlot2 = "armilla.slot2" VarSlot2 = "armilla.slot2"
VarArmillaStrip = "armilla.strip" VarArmillaStrip = "armilla.strip"
VarDecay = "decay_level" // wiki: romlas_szint VarDecay = "decay_level"
) )
// Screens.
//
// One constant per screen in the wiki's location catalogue
// (entities#helyszinek), whose order the concept-art deck follows number for
// number (games/realworld_screen_assets); the trailing comment is the wiki's
// Hungarian name. The last seven are the catalogue's "javaslat" locations —
// drawn, so they are wired up, but no beat lands on them yet.
//
// Two screens have no deck number. The alley shares the catalogue's "Noodle
// háza & a sikátor" row with the house; the selector is the wiki's
// Helyszínválasztó (story#helyszínválasztó), the menu screen the location
// graph is centred on.
const ( const (
ScreenSelector = "selector" // wiki: Helyszínválasztó ScreenSelector = "selector"
ScreenPaulShop = "paul_shop" // wiki: Paul boltja / garázsa ScreenPaulShop = "paul_shop"
ScreenNoodleHouse = "noodle_house" // wiki: Noodle háza ScreenNoodleHouse = "noodle_house"
ScreenNormanApartment = "norman_apartment" // wiki: Norman lakása / műhelye ScreenNormanApartment = "norman_apartment"
ScreenPoliceStation = "police_station" // wiki: rendőrség / fogda ScreenPoliceStation = "police_station"
ScreenAlley = "alley" // wiki: sikátor (Noodle háza mögött) ScreenAlley = "alley"
ScreenHackerspace = "hackerspace" // wiki: hackerspace ScreenHackerspace = "hackerspace"
ScreenIceCreamShop = "ice_cream_shop" // wiki: fagyizó ScreenIceCreamShop = "ice_cream_shop"
ScreenTrinketShop = "trinket_shop" // wiki: chinatowni csecsebecse-bolt ScreenTrinketShop = "trinket_shop"
ScreenSmallRestaurant = "small_restaurant" // wiki: kajálda ScreenSmallRestaurant = "small_restaurant"
ScreenSecretClub = "secret_club" // wiki: titkos klub („vízalatti nap") ScreenSecretClub = "secret_club"
ScreenBBSTerminal = "bbs_terminal" // wiki: a terminál / BBS-hozzáférési pont ScreenBBSTerminal = "bbs_terminal"
ScreenStreet = "street" // wiki: köztér / utca ScreenStreet = "street"
ScreenHospital = "hospital" // wiki: kórház / pszichiátria ScreenHospital = "hospital"
ScreenServerFarm = "server_farm" // wiki: szerverfarm / adatközpont ScreenServerFarm = "server_farm"
ScreenSecretLab = "secret_lab" // wiki: titkos kutatólabor ScreenSecretLab = "secret_lab"
ScreenRooftopHideout = "rooftop_hideout" // wiki: Norman tetőterasz-rejteke ScreenRooftopHideout = "rooftop_hideout"
ScreenPublicBBS = "public_bbs" // wiki 💡: nyilvános BBS-terminál ScreenPublicBBS = "public_bbs"
ScreenBVKBranch = "bvk_branch" // wiki 💡: BVK-kirendeltség (San Francisco) ScreenBVKBranch = "bvk_branch"
ScreenCuratorShop = "curator_shop" // wiki 💡: kurátor-szerviz („a Mester") ScreenCuratorShop = "curator_shop"
ScreenScrapMarket = "scrap_market" // wiki 💡: selejt-piac ScreenScrapMarket = "scrap_market"
ScreenSamizdatPress = "samizdat_press" // wiki 💡: samizdat-nyomda / P2P-csomópont ScreenSamizdatPress = "samizdat_press"
ScreenShowroom = "showroom" // wiki 💡: Neumatronic márkakereskedés ScreenShowroom = "showroom"
ScreenColumbarium = "columbarium" // wiki 💡: kolumbárium / Dex emlékhelye ScreenColumbarium = "columbarium"
) )
// Backgrounds — one per screen, same order.
const ( const (
BgSelector = "bg_selector" BgSelector = "bg_selector"
BgPaulShop = "bg_paul_shop" BgPaulShop = "bg_paul_shop"
@@ -121,19 +89,14 @@ const (
BgColumbarium = "bg_columbarium" BgColumbarium = "bg_columbarium"
) )
// Tapes maps a character name to the label shown in the tape channel header.
// TapeChannel uses this to decide whether a line belongs in the tape channel
// or stays in the scene.
var Tapes = map[string]string{ var Tapes = map[string]string{
Dex: "DEX", Dex: "DEX",
TapeMystery: "UNKNOWN TAPE", TapeMystery: "UNKNOWN TAPE",
TapeSupport: "TECH SUPPORT", TapeSupport: "TECH SUPPORT",
} }
// IsTape reports whether a character is a tape.
func IsTape(name string) bool { _, ok := Tapes[name]; return ok } func IsTape(name string) bool { _, ok := Tapes[name]; return ok }
// TapeDisplayName is the label shown in the tape channel header.
func TapeDisplayName(name string) string { func TapeDisplayName(name string) string {
if d, ok := Tapes[name]; ok { if d, ok := Tapes[name]; ok {
return d return d
@@ -141,16 +104,12 @@ func TapeDisplayName(name string) string {
return name return name
} }
// TapeItems maps an inventory item that can go into slot 2 to the character
// living on it.
var TapeItems = map[string]string{ var TapeItems = map[string]string{
ItemMysteryTape: TapeMystery, ItemMysteryTape: TapeMystery,
} }
// IsTapeItem reports whether an item can be loaded into slot 2.
func IsTapeItem(item string) bool { _, ok := TapeItems[item]; return ok } func IsTapeItem(item string) bool { _, ok := TapeItems[item]; return ok }
// TapeDialogue is the dialogue belonging to a tape in slot 2.
func TapeDialogue(item string) string { func TapeDialogue(item string) string {
switch item { switch item {
case ItemMysteryTape: case ItemMysteryTape:
+2 -14
View File
@@ -4,21 +4,13 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" 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 { func screenAlley() screen {
return screen{ return screen{
name: ScreenAlley, name: ScreenAlley,
title: "ALLEY — BEHIND NOODLE'S HOUSE", title: "ALLEY — BEHIND NOODLE'S HOUSE",
background: BgAlley, background: BgAlley,
exits: []exit{ exits: []exit{
// On the left: the back door hotspot below owns the other end.
{to: ScreenNoodleHouse, label: "back out to the street", side: sideLeft}, {to: ScreenNoodleHouse, label: "back out to the street", side: sideLeft},
}, },
actors: []inkwell.SceneActor{ actors: []inkwell.SceneActor{
@@ -29,8 +21,6 @@ func screenAlley() screen {
} }
} }
// 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 { func hidingPlace() inkwell.Hotspot {
return inkwell.Hotspot{ return inkwell.Hotspot{
Name: "hiding_place", Name: "hiding_place",
@@ -57,8 +47,6 @@ func hidingPlace() inkwell.Hotspot {
} }
} }
// 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 { func backDoor() inkwell.Hotspot {
return inkwell.Hotspot{ return inkwell.Hotspot{
Name: "back_door", Name: "back_door",
@@ -71,7 +59,7 @@ func backDoor() inkwell.Hotspot {
), ),
OnTalk: inkwell.Say(Paul, "To the door? I'm not there yet."), OnTalk: inkwell.Say(Paul, "To the door? I'm not there yet."),
OnUseWith: map[string]inkwell.Action{ OnUseWith: map[string]inkwell.Action{
// An AUTHORED failure — not the engine's hardcoded flash.
ItemBlackArmilla: inkwell.Seq( ItemBlackArmilla: inkwell.Seq(
inkwell.Say(Paul, "A black-market bracelet doesn't open a keypad."), inkwell.Say(Paul, "A black-market bracelet doesn't open a keypad."),
TapeSay(Dex, "But you do get an advert out of it."), TapeSay(Dex, "But you do get an advert out of it."),
-7
View File
@@ -1,12 +1,5 @@
package inc 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
// opens the SPOKE — BBS surface (wiki: entities#helyszinek).
//
// It is the same screen wherever the terminal stands — the club's ECHO box,
// Norman's machine at the flat, the old one on the roof — which is why its way
// out is "back", and not a place.
func screenBbsTerminal() screen { func screenBbsTerminal() screen {
return screen{ return screen{
name: ScreenBBSTerminal, name: ScreenBBSTerminal,
-4
View File
@@ -1,9 +1,5 @@
package inc 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 screenBvkBranch() screen { func screenBvkBranch() screen {
return screen{ return screen{
name: ScreenBVKBranch, name: ScreenBVKBranch,
-4
View File
@@ -1,9 +1,5 @@
package inc 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 screenColumbarium() screen { func screenColumbarium() screen {
return screen{ return screen{
name: ScreenColumbarium, name: ScreenColumbarium,
-3
View File
@@ -1,8 +1,5 @@
package inc 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 screenCuratorShop() screen { func screenCuratorShop() screen {
return screen{ return screen{
name: ScreenCuratorShop, name: ScreenCuratorShop,
+7 -41
View File
@@ -1,28 +1,5 @@
package inc package inc
// Exits — the connections between screens.
//
// Where a screen leads is recorded in that screen's own file, and it comes from
// the wiki's location graph (projects/realworld/story#helyszín-gráf-vázlat).
// That graph has two kinds of edge, and so does this file:
//
// - Physical adjacency. The alley is behind Noodle's house; the club is
// through the back of the trinket shop; the noodle place is next door to
// it. These are the arrows the wiki draws between locations, and they are
// declared with `to`.
// - The selector. The wiki centres its map on a "Helyszínválasztó" — not a
// real location but the menu screen every main location connects to both
// ways. A screen marks itself with onSelector; selector.go builds the other
// half of each of those edges.
//
// Some of the wiki's arrows are one-way with a condition on them ("beengedés",
// "NG+ kód", "Sumphor liftkódja"). `needs` carries that: the exit is there, and
// says so, but will not open until the flag is set.
//
// Until the doors are measured on the paintings, an exit is a strip along one
// edge of the picture — the convention every 1990s point & click used, and a
// one-field change to re-aim at a real door later.
import ( import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
@@ -30,14 +7,12 @@ import (
type side int type side int
const ( const (
sideLeft side = iota // off the left edge sideLeft side = iota
sideRight // off the right edge sideRight
sideBack // into the depth of the picture sideBack
sideNear // out towards the camera sideNear
) )
// area is the strip of picture the exit occupies, inside the scene region
// (below the top bar at y=20, above the HUD divider at y=262).
func (s side) area() inkwell.Shape { func (s side) area() inkwell.Shape {
switch s { switch s {
case sideLeft: case sideLeft:
@@ -46,35 +21,26 @@ func (s side) area() inkwell.Shape {
return inkwell.Rect(584, 40, 56, 212) return inkwell.Rect(584, 40, 56, 212)
case sideBack: case sideBack:
return inkwell.Rect(232, 28, 176, 84) return inkwell.Rect(232, 28, 176, 84)
default: // sideNear default:
return inkwell.Rect(232, 214, 176, 48) return inkwell.Rect(232, 214, 176, 48)
} }
} }
// exit is one way out of a screen.
type exit struct { type exit struct {
to string // the screen it leads to; empty means "back the way you came" to string
label string // what the status line calls it label string
side side side side
// area overrides the edge strip. The selector's map pins use it; nothing
// else should need to until the art arrives.
area inkwell.Shape area inkwell.Shape
// needs is a flag that has to be set before the exit opens, and blocked is
// what Paul says while it is not.
needs string needs string
blocked string blocked string
} }
// toSelector is the way back out to the rest of the city, on every screen the
// wiki's Helyszínválasztó lists.
func toSelector() exit { func toSelector() exit {
return exit{to: 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
// straight back out of the registered scenes, so a test can walk it.
func exitName(to string) string { func exitName(to string) string {
if to == "" { if to == "" {
return "exit:back" return "exit:back"
-6
View File
@@ -1,11 +1,5 @@
package inc package inc
// hackerspace: the community workshop at the edge of the Mission, printers and
// stripped servers and paranoid amateur cryptographers. Where the server-farm
// access tape can be had (wiki: entities#helyszinek, timeline 24).
//
// The wiki's graph gives it no physical neighbour, so it hangs off the
// selector like every other main location.
func screenHackerspace() screen { func screenHackerspace() screen {
return screen{ return screen{
name: ScreenHackerspace, name: ScreenHackerspace,
-2
View File
@@ -1,7 +1,5 @@
package inc 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 screenHospital() screen { func screenHospital() screen {
return screen{ return screen{
name: ScreenHospital, name: ScreenHospital,
-7
View File
@@ -1,12 +1,5 @@
package inc 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
// candy-crush guy will not give up (wiki: story, the Fagyizó beat).
//
// The search he eventually allows is what names the club — a lead, not a door:
// the way there is the trinket shop's address, so this screen has no exit of
// its own.
func screenIceCreamShop() screen { func screenIceCreamShop() screen {
return screen{ return screen{
name: ScreenIceCreamShop, name: ScreenIceCreamShop,
+24 -63
View File
@@ -1,80 +1,50 @@
package inc 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
// Scene, which is the type every file here returns — a screen IS the engine's
// scene, and the rest of the code says "scene" only where it is talking to the
// engine.
//
// A file here holds everything that is true of one screen: its picture, the
// name on the top bar, where you can get to from it (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.
import ( import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// screen is what each file in this package fills in.
type screen struct { type screen struct {
name string name string
title string title string
background string background string
// exits: where you can get to from here. See screen.exit.go.
exits []exit exits []exit
// onSelector marks a main location — one of those the wiki's
// Helyszínválasztó lists (story#helyszín-gráf-vázlat). It gets a way back
// to the selector for free, and the selector gets a way here.
onSelector bool onSelector bool
// What an authored beat adds on top. Zero values are fine: Paul lands in
// the middle of the default floor strip and nothing else happens.
hotspots []inkwell.Hotspot hotspots []inkwell.Hotspot
actors []inkwell.SceneActor actors []inkwell.SceneActor
walkboxes []inkwell.Polygon walkboxes []inkwell.Polygon
onEnter inkwell.Action onEnter inkwell.Action
} }
// 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).
//
// 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 screenDeck = []func() screen{ var screenDeck = []func() screen{
screenPaulShop, // 01 screenPaulShop,
screenNoodleHouse, // 02 screenNoodleHouse,
screenAlley, // 02, same catalogue row screenAlley,
screenNormanApartment, // 03 screenNormanApartment,
screenPoliceStation, // 04 screenPoliceStation,
screenHackerspace, // 06 screenHackerspace,
screenIceCreamShop, // 07 screenIceCreamShop,
screenTrinketShop, // 08 screenTrinketShop,
screenSmallRestaurant, // 09 screenSmallRestaurant,
screenSecretClub, // 10 screenSecretClub,
screenBbsTerminal, // 11 screenBbsTerminal,
screenStreet, // 12 screenStreet,
screenHospital, // 13 screenHospital,
screenServerFarm, // 14 screenServerFarm,
screenSecretLab, // 15 screenSecretLab,
screenRooftopHideout, // 16 screenRooftopHideout,
screenPublicBBS, // 17 screenPublicBBS,
screenBvkBranch, // 18 screenBvkBranch,
screenCuratorShop, // 19 screenCuratorShop,
screenScrapMarket, // 20 screenScrapMarket,
screenSamizdatPress, // 21 screenSamizdatPress,
screenShowroom, // 22 screenShowroom,
screenColumbarium, // 23 screenColumbarium,
} }
// 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 registerScreen(w *World) { func registerScreen(w *World) {
screens := make([]screen, 0, len(screenDeck)+1) screens := make([]screen, 0, len(screenDeck)+1)
for _, f := range screenDeck { for _, f := range screenDeck {
@@ -85,8 +55,6 @@ func registerScreen(w *World) {
} }
} }
// 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 screenFloor = []inkwell.Polygon{ var screenFloor = []inkwell.Polygon{
inkwell.Poly( inkwell.Poly(
inkwell.Point{X: 16, Y: 196}, inkwell.Point{X: 624, Y: 196}, inkwell.Point{X: 16, Y: 196}, inkwell.Point{X: 624, Y: 196},
@@ -94,11 +62,6 @@ var screenFloor = []inkwell.Polygon{
), ),
} }
// 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 screenBuild(w *World, s screen) inkwell.Scene { func screenBuild(w *World, s screen) inkwell.Scene {
exits := s.exits exits := s.exits
if s.onSelector { if s.onSelector {
@@ -136,8 +99,6 @@ func screenBuild(w *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 { func setVarIfEmpty(name string, v any) inkwell.Action {
return Fn(func(ctx *inkwell.Ctx) { return Fn(func(ctx *inkwell.Ctx) {
if ctx.Game.State.Var(name) == nil { if ctx.Game.State.Var(name) == nil {
-6
View File
@@ -1,11 +1,5 @@
package inc 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
// uniform stands in front of the tape (wiki: story#noodle-szál).
//
// The wiki's graph runs NoodleUtcafront → Sikátor: the alley behind the house
// is reached from here, and from nowhere else.
func screenNoodleHouse() screen { func screenNoodleHouse() screen {
return screen{ return screen{
name: ScreenNoodleHouse, name: ScreenNoodleHouse,
-7
View File
@@ -1,12 +1,5 @@
package inc 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
// correspondence and the diary (wiki: story#norman-lakása).
//
// Two ways on from here. His terminal is one of the BBS access points the
// catalogue lists ("a titkos klub ECHO-terminálja; később Norman lakása /
// tetőterasz"), and the roof hideout is up his own stairs.
func screenNormanApartment() screen { func screenNormanApartment() screen {
return screen{ return screen{
name: ScreenNormanApartment, name: ScreenNormanApartment,
-8
View File
@@ -1,13 +1,5 @@
package inc 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
// Francisco — Noodle's letter arrives here and puts him on a bus
// (wiki: entities, Paul's timeline 12).
//
// 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 screenPaulShop() screen { func screenPaulShop() screen {
return screen{ return screen{
name: ScreenPaulShop, name: ScreenPaulShop,
-8
View File
@@ -1,13 +1,5 @@
package inc 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
// (wiki: entities, Paul's timeline 4 and 15).
//
// The wiki's graph splits it three ways — utcafront → recepció → kihallgató —
// but the deck paints it as one screen, so the reception desk, the waiting
// room and the interview window are hotspots inside this one, not screens of
// their own.
func screenPoliceStation() screen { func screenPoliceStation() screen {
return screen{ return screen{
name: ScreenPoliceStation, name: ScreenPoliceStation,
-4
View File
@@ -1,9 +1,5 @@
package inc 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 screenPublicBBS() screen { func screenPublicBBS() screen {
return screen{ return screen{
name: ScreenPublicBBS, name: ScreenPublicBBS,
-6
View File
@@ -1,11 +1,5 @@
package inc 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
// of the investigation (wiki: entities#helyszinek, timeline 23).
//
// It is up the stairs from his flat, and it is one of the places the catalogue
// puts a terminal.
func screenRooftopHideout() screen { func screenRooftopHideout() screen {
return screen{ return screen{
name: ScreenRooftopHideout, name: ScreenRooftopHideout,
-4
View File
@@ -1,9 +1,5 @@
package inc 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 screenSamizdatPress() screen { func screenSamizdatPress() screen {
return screen{ return screen{
name: ScreenSamizdatPress, name: ScreenSamizdatPress,
-3
View File
@@ -1,8 +1,5 @@
package inc 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 screenScrapMarket() screen { func screenScrapMarket() screen {
return screen{ return screen{
name: ScreenScrapMarket, name: ScreenScrapMarket,
-8
View File
@@ -1,13 +1,5 @@
package inc 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
// people's minds and nothing about the key or the tape
// (wiki: story, the club beat).
//
// The terminal in here is the one that matters: not under BVK control, ECHO
// running under the standard LECTOR surface. Reading it is where the mystery
// tape finally speaks.
func screenSecretClub() screen { func screenSecretClub() screen {
return screen{ return screen{
name: ScreenSecretClub, name: ScreenSecretClub,
-8
View File
@@ -1,13 +1,5 @@
package inc 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
// screen the finale plays on (wiki: entities#helyszinek, timeline 22 and 25).
//
// The wiki's graph runs Híd → security szoba → Norman szimulációs szobája, and
// the story puts the whole thing inside the Golden Gate Bridge. The deck paints
// it as one screen, so the door at the foot of the bridge, the observation room
// and the glass wall are hotspots in here.
func screenSecretLab() screen { func screenSecretLab() screen {
return screen{ return screen{
name: ScreenSecretLab, name: ScreenSecretLab,
+1 -15
View File
@@ -6,17 +6,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// selector is the wiki's Helyszínválasztó: "nem valódi helyszín, hanem a
// menü-képernyő, ahonnan az összes fő helyszín elérhető"
// (story#helyszínválasztó). The location graph is centred on it — every main
// location connects to it both ways, and physical adjacency takes over from
// there inwards — so without it the city is a handful of disconnected rooms.
//
// It is not in the concept-art deck and has no painting, so it renders as an
// inkwell placeholder with one labelled pin per destination, laid out on a
// grid. Whatever the map ends up looking like, the connections it carries are
// the ones the screens declare, not a list kept here: screenSelector() is built from
// whichever screens marked themselves onSelector.
func screenSelector(rest []screen) screen { func screenSelector(rest []screen) screen {
var exits []exit var exits []exit
for _, s := range rest { for _, s := range rest {
@@ -34,13 +23,12 @@ func screenSelector(rest []screen) screen {
title: "SAN FRANCISCO", title: "SAN FRANCISCO",
background: BgSelector, background: BgSelector,
exits: exits, exits: exits,
// Nobody stands on a map.
actors: []inkwell.SceneActor{}, actors: []inkwell.SceneActor{},
walkboxes: []inkwell.Polygon{}, walkboxes: []inkwell.Polygon{},
} }
} }
// The pin grid: three columns down the scene region, filled top to bottom.
const ( const (
pinCols = 3 pinCols = 3
pinW = 192.0 pinW = 192.0
@@ -56,8 +44,6 @@ func pin(i int) inkwell.Shape {
return inkwell.Rect(pinX+float64(col)*pinGapX, pinY+float64(row)*pinGapY, pinW, pinH) return inkwell.Rect(pinX+float64(col)*pinGapX, pinY+float64(row)*pinGapY, pinW, pinH)
} }
// pinLabel is the screen's title without the em-dash subtitle: on a map a
// location wants its name, not its description.
func pinLabel(title string) string { func pinLabel(title string) string {
name, _, _ := strings.Cut(title, " — ") name, _, _ := strings.Cut(title, " — ")
return name return name
-3
View File
@@ -1,8 +1,5 @@
package inc 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 screenServerFarm() screen { func screenServerFarm() screen {
return screen{ return screen{
name: ScreenServerFarm, name: ScreenServerFarm,
-4
View File
@@ -1,9 +1,5 @@
package inc 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 screenShowroom() screen { func screenShowroom() screen {
return screen{ return screen{
name: ScreenShowroom, name: ScreenShowroom,
-7
View File
@@ -1,12 +1,5 @@
package inc 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
// behind it (wiki: story, the Kajálda beat).
//
// The wiki's graph runs Kajálda → étterem rész → WC; the deck paints all three
// as one screen, so the mirror is a hotspot here. The only way out is back to
// the shop — this is an inner room, not a location on the selector.
func screenSmallRestaurant() screen { func screenSmallRestaurant() screen {
return screen{ return screen{
name: ScreenSmallRestaurant, name: ScreenSmallRestaurant,
-7
View File
@@ -1,12 +1,5 @@
package inc 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
// news, infrastructure that slips (wiki: entities#helyszinek).
//
// The wiki's own street front is the one with the hotdog stand and Sumphor on
// it (story#norman-lakása); that is a beat for this screen, not a screen of its
// own.
func screenStreet() screen { func screenStreet() screen {
return screen{ return screen{
name: ScreenStreet, name: ScreenStreet,
-7
View File
@@ -1,12 +1,5 @@
package inc 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
// (wiki: story#csecsebecse-bolt--kajálda-szál).
//
// Both of the wiki's arrows out of it are here: Bolt → Kajálda, the eating
// place next door, and Bolt →|beengedés| Klub, which stays shut until the
// shopkeeper has been shown the underwater sun.
func screenTrinketShop() screen { func screenTrinketShop() screen {
return screen{ return screen{
name: ScreenTrinketShop, name: ScreenTrinketShop,
-6
View File
@@ -4,12 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// awakeningFinale is the end of game two. The point of it, mechanically, is
// the theme switch: this is where the Nokia-punk texture breaks in.
//
// Run it early and often during development (`go run . -finale`) — it is the
// single most important visual beat in the game, so you want to be looking at
// it long before the surrounding scene exists.
func scriptAwakeningFinale(w *World) inkwell.Script { func scriptAwakeningFinale(w *World) inkwell.Script {
return inkwell.Script{ return inkwell.Script{
Name: ScriptFinale, Name: ScriptFinale,
-4
View File
@@ -1,13 +1,9 @@
package inc package inc
// Scripts — the game's named, reusable action sequences:
// cutscenes and recurring logic, built from the inkwell action set.
import ( import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// registerScript adds every script to the game.
func registerScript(w *World) { func registerScript(w *World) {
for _, s := range []inkwell.Script{ for _, s := range []inkwell.Script{
scriptTapeInsert(w), scriptTapeInsert(w),
-3
View File
@@ -4,9 +4,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" 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 { func scriptTapeInsert(w *World) inkwell.Script {
return inkwell.Script{ return inkwell.Script{
Name: ScriptTapeInsert, Name: ScriptTapeInsert,
+4 -26
View File
@@ -1,45 +1,28 @@
package inc 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 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.
import ( import (
"image/color" "image/color"
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// The two theme names. Runtime switching (inkwell UseTheme) refers to these.
const ( const (
RealWorld = "realworld-93" RealWorld = "realworld-93"
NokiaPunk = "nokia-punk" NokiaPunk = "nokia-punk"
) )
// RGB builds an opaque colour from a hex literal.
func RGB(hex uint32) color.Color { func RGB(hex uint32) color.Color {
return color.RGBA{R: uint8(hex >> 16), G: uint8(hex >> 8), B: uint8(hex), A: 0xff} return color.RGBA{R: uint8(hex >> 16), G: uint8(hex >> 8), B: uint8(hex), A: 0xff}
} }
// RGBA builds a colour with the given alpha from a hex literal.
func RGBA(hex uint32, a uint8) color.Color { func RGBA(hex uint32, a uint8) color.Color {
return color.RGBA{R: uint8(hex >> 16), G: uint8(hex >> 8), B: uint8(hex), A: a} return color.RGBA{R: uint8(hex >> 16), G: uint8(hex >> 8), B: uint8(hex), A: a}
} }
// realworld-93 — 95% of the game. Muted earth tones, black HUD, amber tape
// channel. The Neumatronic layer is not visible yet.
// The realworld-93 palette. Ink and Amber are the two sides of the two-channel
// colour rule: the world speaks in bone white, tapes speak in amber.
var ( var (
Ink = RGB(0xDCD5C4) // bone white — the voice of the world and of Paul Ink = RGB(0xDCD5C4)
InkDim = RGB(0x8A806B) InkDim = RGB(0x8A806B)
Amber = RGB(0xE0A33E) // tape voice Amber = RGB(0xE0A33E)
AmberLo = RGB(0xA8701A) AmberLo = RGB(0xA8701A)
black = RGB(0x14120E) black = RGB(0x14120E)
panel = RGB(0x0D0B08) panel = RGB(0x0D0B08)
@@ -82,8 +65,8 @@ func realWorldTheme() inkwell.Theme {
TopBarAccent: Amber, TopBarAccent: Amber,
ChatLogBG: panel, ChatLogBG: panel,
ChatLogPrompt: InkDim, // your own actions — quiet ChatLogPrompt: InkDim,
ChatLogResponse: Amber, // tape voice ChatLogResponse: Amber,
ChatLogSystem: RGB(0x6B6353), ChatLogSystem: RGB(0x6B6353),
CharacterPanelBG: panel, CharacterPanelBG: panel,
@@ -92,10 +75,6 @@ func realWorldTheme() inkwell.Theme {
} }
} }
// nokia-punk — at peak decay, on BBS/ECHO terminals, and in the finale.
// Matte black and phosphor green: a tuned variant of the engine's
// terminal-green preset.
// The nokia-punk palette — phosphor green on black glass.
var ( var (
Green = RGB(0x3BE86B) Green = RGB(0x3BE86B)
GreenLo = RGB(0x1F7A39) GreenLo = RGB(0x1F7A39)
@@ -148,7 +127,6 @@ func nokiaPunkTheme() inkwell.Theme {
} }
} }
// registerTheme adds both themes to the game.
func registerTheme(g *inkwell.Game) { func registerTheme(g *inkwell.Game) {
g.ThemeManager.Register(realWorldTheme()) g.ThemeManager.Register(realWorldTheme())
g.ThemeManager.Register(nokiaPunkTheme()) g.ThemeManager.Register(nokiaPunkTheme())
+2 -9
View File
@@ -1,12 +1,5 @@
package inc package inc
// Letterbox — the cutscene frame.
//
// The one exception to the fixed layout. In cutscene mode the HUD strip and
// the TopBar are hidden, the scene gets a black bar top and bottom, and a line
// at the foot of the screen says a tape would like to speak — but it does not
// speak on its own. The floor is the player's: a tape signals, never interrupts.
import ( import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
"github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2"
@@ -17,7 +10,7 @@ import (
type Letterbox struct { type Letterbox struct {
Name string Name string
W *World W *World
Bar float64 // height of the black bar, top and bottom Bar float64
} }
func (l *Letterbox) GetName() string { return l.Name } func (l *Letterbox) GetName() string { return l.Name }
@@ -26,7 +19,7 @@ func (l *Letterbox) Tick(ctx *inkwell.UICtx) {
if l.W.Mode() != ModeCutscene || !l.W.TapeWaiting() { if l.W.Mode() != ModeCutscene || !l.W.TapeWaiting() {
return return
} }
// inkwell's Input only covers the mouse; read the key directly.
if inpututil.IsKeyJustPressed(ebiten.KeySpace) { if inpututil.IsKeyJustPressed(ebiten.KeySpace) {
l.W.TakeTapeOffer() l.W.TakeTapeOffer()
} }
+15 -86
View File
@@ -1,85 +1,35 @@
package inc package inc
// HUD layout.
//
// 0 394│396 639
// ┌────────────────────────────────────────────────────────────────────┐
// 0 │ ALLEY — paused SRP: 1 tape │ TopBar (20)
// 20 ├────────────────────────────────────────────────────────────────────┤
// │ │
// │ scene — hotspots, characters, SpeechBubble │ Scene (242)
// │ │
// 262├─────────────────────────────────────────┬──────────────────────────┤
// │ Use hook on: floor grate │ DEX │
// │ ┌───┬───┬───┬───┐ ┌───────┐┌────────┐ │ "The hook is a hand │ HUD (118)
// │ ├───┼───┼───┼───┤ │ 1 DEX ││ 2 — │ │ shorter than the gap." │
// 379└─────────────────────────────────────────┴──────────────────────────┘
// InventoryBar TapeSlots TapeChannel
//
// On the resolution: the screen is exactly one painted background. inkwell
// stretches a scene background over the WHOLE screen (core.engine.go), not over
// the region left free by the HUD, so any screen size other than the art's own
// squashes every painting — and the deck is drawn at 640×380
// (games/realworld_screen_assets). Hence 640×380: the art lands pixel for
// pixel, and the HUD is an opaque strip laid over its foot.
//
// That still satisfies what the wiki's "320×200 VGA look" brief was after — a
// low, wide VGA frame at an exact 2× of a 320-wide canvas — and it still keeps
// the engine's fixed 6×16 debug font readable: a 16px glyph is 4% of 380, the
// proportion the design was drawn for. (At 320×190 it would be 8%, rows would
// overlap and the top bar could not hold a line.)
//
// Everything vertical is derived from LineH and from ScreenH below, never from
// the wireframe deck's ratios, so the layout cannot drift out of step with the
// font — or with the art — again.
import ( import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
"github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector" "github.com/hajimehoshi/ebiten/v2/vector"
) )
// Screen and layout, in internal (unscaled) pixels.
const ( const (
// The screen is the size of one painted background, because the engine
// scales a background to the full screen: at any other size the art is
// squashed. See the note above.
ScreenW = 640 ScreenW = 640
ScreenH = 380 ScreenH = 380
// LineH is one text row: the glyph cell plus leading. Every text-bearing LineH = glyphH + 2
// box is sized as a multiple of this, so rows can never overlap. Pad = 6
LineH = glyphH + 2 // 18
Pad = 6 // inner padding for every panel
TopBarH = LineH + 2 // 20 — one row plus a hairline of breathing room TopBarH = LineH + 2
DividerX = 394 // world | tape channel (61.6% of the width) DividerX = 394
// The HUD strip is as short as its own contents allow, and is measured HUDH = Pad + LineH + 4 + invSlot*2 + invGap + Pad
// from the bottom of the screen — every pixel it takes is a pixel of the HUDTop = ScreenH - HUDH
// painting it covers. It has to hold the status row and, under it, the two
// rows of inventory slots (the tallest thing in the strip), padded above
// and below.
HUDH = Pad + LineH + 4 + invSlot*2 + invGap + Pad // 118
HUDTop = ScreenH - HUDH // 262 — the scene | HUD divider
// Derived HUD rows. statusY = HUDTop + Pad
statusY = HUDTop + Pad // 268 slotsY = HUDTop + Pad + LineH
slotsY = HUDTop + Pad + LineH // 286 slotsH = LineH*2 + Pad*2
slotsH = LineH*2 + Pad*2 // 48 — two rows plus padding invY = slotsY + 4
invY = slotsY + 4 // 290
invSlot = 40 invSlot = 40
invGap = 4 invGap = 4
) )
// registerUI assembles the whole HUD in the conventional Z-order: the debug
// overlay at the back, the cursor in front.
func registerUI(w *World) { func registerUI(w *World) {
g := w.G 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(&UseWithGuard{Name: "usewith_guard", W: w})
g.UIManager.Register(&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(&screenNav{Name: "screen_nav", W: w})
@@ -89,9 +39,7 @@ func registerUI(w *World) {
top := &inkwell.TopBar{ top := &inkwell.TopBar{
Name: "topbar", Name: "topbar",
Height: TopBarH, Height: TopBarH,
// The right section prints State.Var(TimeVar). We deliberately reuse
// it for the Armilla's single-line phosphor strip: in canon the device
// display is one line of text, not a phone screen.
TimeVar: VarArmillaStrip, TimeVar: VarArmillaStrip,
} }
w.SetTopBar(top) w.SetTopBar(top)
@@ -119,15 +67,12 @@ func registerUI(w *World) {
g.UIManager.Register(&inkwell.SpeechBubble{ g.UIManager.Register(&inkwell.SpeechBubble{
Name: "speech", MaxWidth: 360, Padding: Pad, OffsetY: 8, FallbackY: TopBarH + Pad, Name: "speech", MaxWidth: 360, Padding: Pad, OffsetY: 8, FallbackY: TopBarH + Pad,
}) })
// The dialogue box deliberately covers only the left region, so a tape can
// comment ALONGSIDE the conversation. It consumes every click while active,
// so the tape channel stays visible but inert — which is what we want.
g.UIManager.Register(&inkwell.DialogBox{ g.UIManager.Register(&inkwell.DialogBox{
Name: "dialog", Bounds: inkwell.Rect(0, ScreenH-LineH*9, DividerX, LineH*9), Name: "dialog", Bounds: inkwell.Rect(0, ScreenH-LineH*9, DividerX, LineH*9),
LineHeight: LineH, Padding: Pad, LineHeight: LineH, Padding: Pad,
}) })
// A verb coin, not a verb bar: a permanent VerbBar would eat the left
// region, leaving room for neither the inventory nor the tape slots.
g.UIManager.Register(&inkwell.RadialVerbs{ g.UIManager.Register(&inkwell.RadialVerbs{
Name: "verbs", Trigger: inkwell.MouseButtonRight, Radius: 58, Name: "verbs", Trigger: inkwell.MouseButtonRight, Radius: 58,
Labels: map[string]string{ Labels: map[string]string{
@@ -138,13 +83,6 @@ func registerUI(w *World) {
g.UIManager.Register(&inkwell.Cursor{Name: "cursor"}) g.UIManager.Register(&inkwell.Cursor{Name: "cursor"})
} }
// ----- windowSizer ------------------------------------------------------
// windowSizer resizes the window once, on the first frame.
//
// inkwell.Run hardcodes a 4× window (core.dsl.go), which at 640×380 would be
// 2560×1520 — larger than most laptop screens. Run sets the size before
// entering the loop, so the only place to override it is the first tick.
type windowSizer struct { type windowSizer struct {
Name string Name string
Scale int Scale int
@@ -166,11 +104,6 @@ func (s *windowSizer) Tick(ctx *inkwell.UICtx) {
ebiten.SetWindowSize(ctx.Game.Width*scale, ctx.Game.Height*scale) ebiten.SetWindowSize(ctx.Game.Width*scale, ctx.Game.Height*scale)
} }
// ----- gate -------------------------------------------------------------
// gate switches built-in widgets off in cutscene and menu mode. inkwell's
// widgets have no Visible field and the Manager cannot unregister, so we wrap.
// BlocksClickAt is forwarded, otherwise the verb coin would open over the HUD.
type gate struct { type gate struct {
Name string Name string
W *World W *World
@@ -203,10 +136,6 @@ func (n *gate) BlocksClickAt(p inkwell.Point) bool {
return ok && b.BlocksClickAt(p) return ok && b.BlocksClickAt(p)
} }
// ----- hudFrame ---------------------------------------------------------
// hudFrame paints the HUD strip's backdrop and the two dividers. The rule: the
// two channels always have a visible, continuous separator between them.
type hudFrame struct { type hudFrame struct {
Name string Name string
W *World W *World
@@ -221,9 +150,9 @@ func (h *hudFrame) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
} }
th := ctx.Game.Theme() th := ctx.Game.Theme()
vector.DrawFilledRect(dst, 0, HUDTop+1, ScreenW, ScreenH-HUDTop-1, th.PanelBG, false) vector.DrawFilledRect(dst, 0, HUDTop+1, ScreenW, ScreenH-HUDTop-1, th.PanelBG, false)
// horizontal: scene | HUD
vector.StrokeLine(dst, 0, HUDTop, ScreenW, HUDTop, 1, th.CharacterPanelBorder, false) vector.StrokeLine(dst, 0, HUDTop, ScreenW, HUDTop, 1, th.CharacterPanelBorder, false)
// vertical: world | tape
vector.StrokeLine(dst, DividerX, HUDTop+1, DividerX, ScreenH, 1, th.CharacterPanelBorder, false) vector.StrokeLine(dst, DividerX, HUDTop+1, DividerX, ScreenH, 1, th.CharacterPanelBorder, false)
} }
+1 -16
View File
@@ -1,18 +1,5 @@
package inc package inc
// screenNav — walking the deck with the arrow keys.
//
// The screens are connected — each one records where you can get to from it
// (screen.exit.go) — but walking the map is the player's job, and a
// screen whose beat is not written yet has nothing in it to walk towards. LEFT
// and RIGHT step to the previous and next screen so the deck can be reviewed as
// a deck: in concept-art order, wrapping at both ends. It is a tool for looking
// at the game, not a way through it.
//
// It is deliberately inert whenever the game is actually doing something: no
// stepping out of a cutscene, a menu, or a stopped world during dialogue. So
// once a beat owns a screen, this cannot cut across it.
import ( import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
"github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2"
@@ -32,7 +19,7 @@ func (n *screenNav) Tick(ctx *inkwell.UICtx) {
return return
} }
step := 0 step := 0
// inkwell's Input only covers the mouse; read the keys directly.
switch { switch {
case inpututil.IsKeyJustPressed(ebiten.KeyArrowRight): case inpututil.IsKeyJustPressed(ebiten.KeyArrowRight):
step = 1 step = 1
@@ -46,8 +33,6 @@ 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 screen.manager.go.
func (n *screenNav) neighbour(g *inkwell.Game, step int) string { func (n *screenNav) neighbour(g *inkwell.Game, step int) string {
deck := g.SceneManager.Names() deck := g.SceneManager.Names()
if len(deck) < 2 { if len(deck) < 2 {
-3
View File
@@ -6,9 +6,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// The arrow keys walk the deck in registration order and wrap at both ends —
// until the beats grow doors, that walk is the only way through most screens,
// so a dead end at either end would strand the player on a greybox screen.
func TestScreenNavWrapsBothWays(t *testing.T) { func TestScreenNavWrapsBothWays(t *testing.T) {
g := inkwell.NewGame("t", ScreenW, ScreenH) g := inkwell.NewGame("t", ScreenW, ScreenH)
for _, n := range []string{"a", "b", "c"} { for _, n := range []string{"a", "b", "c"} {
+2 -22
View File
@@ -1,18 +1,5 @@
package inc package inc
// TapeChannel — the tape channel down the right-hand side.
//
// Not assistant UI: this is the voice of CHARACTERS. The wiki is explicit that
// ACPs and Personal Tapes are not props but characters with their own voice.
// That is why we do not use the built-in ChatLog — it paints every LogResponse
// in one Theme.ChatLogResponse colour, whereas here three or four tapes can
// speak into the same strip (Dex, the mystery tape, the tech support tape) and
// the colour comes per speaker from Character.SpeechColor.
//
// Paul and the NPCs never appear here: they speak in the scene, through the
// SpeechBubble. The channel is what the tapes said, plus — quietly — what you
// did to prompt it. That gives the "joke book, not a scoreboard" log for free.
import ( import (
"image/color" "image/color"
@@ -31,8 +18,6 @@ func (t *TapeChannel) GetName() string { return t.Name }
func (t *TapeChannel) Tick(ctx *inkwell.UICtx) {} func (t *TapeChannel) Tick(ctx *inkwell.UICtx) {}
// BlocksClickAt — the channel is not interactive, but it is a solid input
// zone: the verb coin must not open over it.
func (t *TapeChannel) BlocksClickAt(p inkwell.Point) bool { func (t *TapeChannel) BlocksClickAt(p inkwell.Point) bool {
return t.W.HUDVisible() && t.Bounds.Contains(p) return t.W.HUDVisible() && t.Bounds.Contains(p)
} }
@@ -51,7 +36,6 @@ func (t *TapeChannel) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
vector.DrawFilledRect(dst, float32(b.X), float32(b.Y), float32(b.W), float32(b.H), th.ChatLogBG, false) vector.DrawFilledRect(dst, float32(b.X), float32(b.Y), float32(b.W), float32(b.H), th.ChatLogBG, false)
// Header: the name of whichever tape spoke last — not "console".
speaker, speakerCol := t.lastSpeaker(g, th) speaker, speakerCol := t.lastSpeaker(g, th)
drawTextC(dst, speaker, int(b.X)+pad, int(b.Y)+pad, speakerCol) drawTextC(dst, speaker, int(b.X)+pad, int(b.Y)+pad, speakerCol)
hy := float32(b.Y+pad+lh) + 2 hy := float32(b.Y+pad+lh) + 2
@@ -69,12 +53,11 @@ func (t *TapeChannel) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
var txt string var txt string
switch m.Kind { switch m.Kind {
case inkwell.LogAction: case inkwell.LogAction:
// Your own actions — quiet, but visible: that is what makes the
// joke book readable.
col, txt = th.ChatLogPrompt, m.Text col, txt = th.ChatLogPrompt, m.Text
case inkwell.LogResponse: case inkwell.LogResponse:
if !IsTape(m.Speaker) { if !IsTape(m.Speaker) {
continue // Paul and the NPCs speak in the scene, not here continue
} }
col, txt = speechColor(g, m.Speaker, th.ChatLogResponse), m.Text col, txt = speechColor(g, m.Speaker, th.ChatLogResponse), m.Text
default: default:
@@ -98,8 +81,6 @@ func (t *TapeChannel) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
} }
} }
// lastSpeaker returns the display name and colour of whichever tape spoke
// last, defaulting to the occupant of slot 1.
func (t *TapeChannel) lastSpeaker(g *inkwell.Game, th inkwell.Theme) (string, color.Color) { func (t *TapeChannel) lastSpeaker(g *inkwell.Game, th inkwell.Theme) (string, color.Color) {
msgs := g.Messages() msgs := g.Messages()
for i := len(msgs) - 1; i >= 0; i-- { for i := len(msgs) - 1; i >= 0; i-- {
@@ -111,7 +92,6 @@ func (t *TapeChannel) lastSpeaker(g *inkwell.Game, th inkwell.Theme) (string, co
return TapeDisplayName(Dex), speechColor(g, Dex, th.ChatLogResponse) return TapeDisplayName(Dex), speechColor(g, Dex, th.ChatLogResponse)
} }
// speechColor is the character's voice colour, falling back to the theme's.
func speechColor(g *inkwell.Game, name string, fallback color.Color) color.Color { func speechColor(g *inkwell.Game, name string, fallback color.Color) color.Color {
if c, ok := g.CharacterManager.Get(name); ok && c.SpeechColor != nil { if c, ok := g.CharacterManager.Get(name); ok && c.SpeechColor != nil {
return c.SpeechColor return c.SpeechColor
+1 -22
View File
@@ -1,20 +1,5 @@
package inc package inc
// TapeSlots — the two slots of Paul's Armilla.
//
// In canon Paul's Armilla has TWO slots where the standard has one: Dex sits
// permanently in the first, and tapes picked up during the game go into the
// second. The wireframe deck does not know about this at all — this is the one
// HUD element that comes purely from the wiki.
//
// Interaction follows the selected verb, like everything else:
//
// talk → converse with whoever occupies the slot
// look → describe the slot
// use + a selected tape → load it into slot 2
//
// Nothing here touches the world: this is the Vox side of the two channels.
import ( import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
"github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2"
@@ -33,7 +18,6 @@ func (t *TapeSlots) BlocksClickAt(p inkwell.Point) bool {
return t.W.HUDVisible() && t.Bounds.Contains(p) return t.W.HUDVisible() && t.Bounds.Contains(p)
} }
// slotRects splits the widget bounds into the two slot rectangles.
func (t *TapeSlots) slotRects() (inkwell.Rectangle, inkwell.Rectangle) { func (t *TapeSlots) slotRects() (inkwell.Rectangle, inkwell.Rectangle) {
b := t.Bounds b := t.Bounds
w := (b.W - Pad) / 2 w := (b.W - Pad) / 2
@@ -48,7 +32,6 @@ func (t *TapeSlots) Tick(ctx *inkwell.UICtx) {
mp := g.Input.Point() mp := g.Input.Point()
r1, r2 := t.slotRects() r1, r2 := t.slotRects()
// Hover label, so the StatusLine spells out what is about to happen.
switch { switch {
case r1.Contains(mp): case r1.Contains(mp):
g.SetHoverLabel(TapeDisplayName(Dex)) g.SetHoverLabel(TapeDisplayName(Dex))
@@ -71,7 +54,6 @@ func (t *TapeSlots) Tick(ctx *inkwell.UICtx) {
slot2 := r2.Contains(mp) slot2 := r2.Contains(mp)
sel := g.Inventory.Selected() sel := g.Inventory.Selected()
// Loading a tape into slot 2.
if slot2 && sel != "" { if slot2 && sel != "" {
g.Inventory.Select("") g.Inventory.Select("")
if IsTapeItem(sel) { if IsTapeItem(sel) {
@@ -99,7 +81,7 @@ func (t *TapeSlots) Tick(ctx *inkwell.UICtx) {
return return
} }
t.W.Do(TapeSay(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 default:
if !slot2 { if !slot2 {
t.W.Do(TapeSay(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 return
@@ -120,8 +102,6 @@ func (t *TapeSlots) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
th := g.Theme() th := g.Theme()
r1, r2 := t.slotRects() r1, r2 := t.slotRects()
// Two text rows per slot: the slot number on top, its occupant below.
// Both rows are LineH tall, so nothing can overlap the border.
drawSlot := func(r inkwell.Rectangle, label, body string, filled bool) { drawSlot := func(r inkwell.Rectangle, label, body string, filled bool) {
bg, border, col := th.InventorySlotBG, th.ChatLogSystem, th.ChatLogSystem bg, border, col := th.InventorySlotBG, th.ChatLogSystem, th.ChatLogSystem
if filled { if filled {
@@ -143,7 +123,6 @@ func (t *TapeSlots) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
} }
} }
// itemLabel is a short display label for an inventory item.
func itemLabel(g *inkwell.Game, name string) string { func itemLabel(g *inkwell.Game, name string) string {
if it, ok := g.ItemManager.Get(name); ok && it.Description != "" { if it, ok := g.ItemManager.Get(name); ok && it.Description != "" {
return it.Description return it.Description
-18
View File
@@ -1,15 +1,5 @@
package inc package inc
// 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
// colour rule — "if something is amber, a tape said it" — cannot work through
// it, so the custom widgets do not call Game.DrawText: they render glyphs onto
// a scratch image and blit it tinted with ColorScale. The built-in widgets
// (StatusLine, DialogBox, TopBar) still render white. See README, "Engine
// workarounds".
import ( import (
"image" "image"
"image/color" "image/color"
@@ -19,9 +9,6 @@ import (
"github.com/hajimehoshi/ebiten/v2/ebitenutil" "github.com/hajimehoshi/ebiten/v2/ebitenutil"
) )
// glyphW and glyphH are the ebitenutil debug font's cell. The engine's own
// textWidth uses the same 6px but counts bytes, which mismeasures any
// non-ASCII text; we count runes.
const ( const (
glyphW = 6 glyphW = 6
glyphH = 16 glyphH = 16
@@ -29,10 +16,8 @@ const (
var scratch *ebiten.Image var scratch *ebiten.Image
// textW is the pixel width of a string, counted in runes.
func textW(s string) int { return utf8.RuneCountInString(s) * glyphW } func textW(s string) int { return utf8.RuneCountInString(s) * glyphW }
// drawTextC draws one line in colour c, with the same origin as DebugPrintAt.
func drawTextC(dst *ebiten.Image, s string, x, y int, c color.Color) { func drawTextC(dst *ebiten.Image, s string, x, y int, c color.Color) {
if s == "" { if s == "" {
return return
@@ -60,8 +45,6 @@ func drawTextC(dst *ebiten.Image, s string, x, y int, c color.Color) {
dst.DrawImage(scratch.SubImage(image.Rect(0, 0, w, h)).(*ebiten.Image), op) dst.DrawImage(scratch.SubImage(image.Rect(0, 0, w, h)).(*ebiten.Image), op)
} }
// wrap breaks s at word boundaries into lines no wider than maxPx. The engine's
// wrapText is unexported and byte-based, hence our own.
func wrap(s string, maxPx int) []string { func wrap(s string, maxPx int) []string {
if maxPx <= glyphW || textW(s) <= maxPx { if maxPx <= glyphW || textW(s) <= maxPx {
return []string{s} return []string{s}
@@ -108,7 +91,6 @@ func wrap(s string, maxPx int) []string {
return out return out
} }
// clip truncates a string so it fits into maxPx.
func clip(s string, maxPx int) string { func clip(s string, maxPx int) string {
r := []rune(s) r := []rune(s)
max := maxPx / glyphW max := maxPx / glyphW
+2 -12
View File
@@ -2,8 +2,6 @@ package inc
import "testing" import "testing"
// The engine's textWidth counts bytes, which mismeasures any non-ASCII string.
// Ours counts runes — pin that down, because wrapping depends on it.
func TestTextWidthCountsRunes(t *testing.T) { func TestTextWidthCountsRunes(t *testing.T) {
if got, want := textW("naïve"), 5*glyphW; got != want { if got, want := textW("naïve"), 5*glyphW; got != want {
t.Errorf("textW(naïve) = %d, want %d", got, want) t.Errorf("textW(naïve) = %d, want %d", got, want)
@@ -35,11 +33,6 @@ func TestClipFits(t *testing.T) {
} }
} }
// Regression guard for the bug that made the first build unreadable: the
// layout was derived from the wireframe deck's ratios, where text is 2.8% of
// the screen height, while the engine's debug font is a fixed 6×16 cell. At
// 320×200 that is 8% — so rows overlapped and the top bar overflowed into the
// scene. Every text row must be at least as tall as the glyph cell.
func TestLayoutFitsTheFont(t *testing.T) { func TestLayoutFitsTheFont(t *testing.T) {
if LineH < glyphH { if LineH < glyphH {
t.Errorf("LineH (%d) is shorter than the glyph cell (%d): rows will overlap", LineH, glyphH) t.Errorf("LineH (%d) is shorter than the glyph cell (%d): rows will overlap", LineH, glyphH)
@@ -47,21 +40,18 @@ func TestLayoutFitsTheFont(t *testing.T) {
if TopBarH < glyphH { if TopBarH < glyphH {
t.Errorf("TopBarH (%d) cannot hold a %dpx glyph", TopBarH, glyphH) t.Errorf("TopBarH (%d) cannot hold a %dpx glyph", TopBarH, glyphH)
} }
// The tape channel must fit a header, a rule and at least four body rows —
// below that the dialogue is unreadable.
const channelH = ScreenH - HUDTop - 4 const channelH = ScreenH - HUDTop - 4
body := (channelH - Pad*2 - LineH - 6) / LineH body := (channelH - Pad*2 - LineH - 6) / LineH
if body < 4 { if body < 4 {
t.Errorf("tape channel only fits %d body rows, want >= 4", body) t.Errorf("tape channel only fits %d body rows, want >= 4", body)
} }
// The two tape slots must fit their two rows inside their border.
if slotsH < LineH*2+Pad { if slotsH < LineH*2+Pad {
t.Errorf("slotsH (%d) cannot hold two %dpx rows", slotsH, LineH) t.Errorf("slotsH (%d) cannot hold two %dpx rows", slotsH, LineH)
} }
} }
// The HUD must not reach outside the screen, and the scene must keep a usable
// aspect ratio for a point & click background.
func TestSceneProportions(t *testing.T) { func TestSceneProportions(t *testing.T) {
if invY+invSlot*2+invGap > ScreenH { if invY+invSlot*2+invGap > ScreenH {
t.Errorf("inventory runs off the bottom: %d > %d", invY+invSlot*2+invGap, ScreenH) t.Errorf("inventory runs off the bottom: %d > %d", invY+invSlot*2+invGap, ScreenH)
+1 -22
View File
@@ -1,22 +1,5 @@
package inc package inc
// UseWithGuard — enforcing authored failure.
//
// The rule: a failed interaction fails IN CHARACTER, never with an error
// message. But inkwell hardcodes a "Nem ehhez." flash in core.engine.go when an
// item/hotspot pair has no OnUseWith handler, and that string cannot be
// replaced from the domain.
//
// The fix, without touching the engine: widgets tick BEFORE the engine's
// handleSceneInput and may consume the click. So this widget catches the
// "selected item + hotspot with no authored pair" case and fails in Paul's
// voice with a remark from Dex — costing nothing, since nothing is consumed and
// nothing breaks.
//
// IMPORTANT: it must be registered FIRST so that it ticks LAST among the
// widgets. That way every HUD widget (inventory, tape slots, dialogue) sees the
// click first, and this only ever catches clicks that land on the scene.
import ( import (
"math/rand" "math/rand"
@@ -43,7 +26,7 @@ func (u *UseWithGuard) Tick(ctx *inkwell.UICtx) {
} }
h := g.HotspotAt(g.Input.Point()) h := g.HotspotAt(g.Input.Point())
if h == nil || hasAuthoredPair(g, h, sel) { if h == nil || hasAuthoredPair(g, h, sel) {
return // leave authored pairs to the engine return
} }
g.Input.ConsumeLeft() g.Input.ConsumeLeft()
@@ -55,7 +38,6 @@ func (u *UseWithGuard) Tick(ctx *inkwell.UICtx) {
} }
g.LogAction("> Use " + itemLabel(g, sel) + " on: " + label) g.LogAction("> Use " + itemLabel(g, sel) + " on: " + label)
// The tone escalates on repeats: dry, then teasing, then a real nudge.
key := "fail." + sel + "." + h.Name key := "fail." + sel + "." + h.Name
g.State.NoteTalked(key) g.State.NoteTalked(key)
n := g.State.Talked(key) n := g.State.Talked(key)
@@ -66,7 +48,6 @@ func (u *UseWithGuard) Tick(ctx *inkwell.UICtx) {
)) ))
} }
// hasAuthoredPair mirrors the engine's lookup order in invokeHotspotVerb.
func hasAuthoredPair(g *inkwell.Game, h *inkwell.Hotspot, item string) bool { func hasAuthoredPair(g *inkwell.Game, h *inkwell.Hotspot, item string) bool {
if h.OnUseWith != nil { if h.OnUseWith != nil {
if a, ok := h.OnUseWith[item]; ok && a != nil { if a, ok := h.OnUseWith[item]; ok && a != nil {
@@ -98,8 +79,6 @@ var dexTease = []string{
"Do it a few more times, maybe physics reconsiders.", "Do it a few more times, maybe physics reconsiders.",
} }
// dexFail escalates: dry, then teasing, then an actual hint — so the joke
// never becomes the wall.
func dexFail(n int) string { func dexFail(n int) string {
switch { switch {
case n <= 1: case n <= 1:
+2 -52
View File
@@ -1,32 +1,18 @@
package inc package inc
// The game's own actions, and the domain action pump.
//
// inkwell's queueAction is unexported, so domain widgets (TapeSlots) cannot put
// an action on the engine's queue — the built-in widgets only manage it because
// they live inside the package. inkwell.Ctx, however, is exported with exported
// fields, so the domain can drive a Runner itself. That is what the pump does:
// it runs HUD-originated actions (starting a dialogue, a tape line) alongside
// the engine's own script runner. See README, "Engine workarounds".
import ( import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
"github.com/hajimehoshi/ebiten/v2" "github.com/hajimehoshi/ebiten/v2"
) )
// ----- pump -------------------------------------------------------------
// Do queues a HUD-originated action.
func (w *World) Do(a inkwell.Action) { func (w *World) Do(a inkwell.Action) {
if a != nil { if a != nil {
w.queue = append(w.queue, a) w.queue = append(w.queue, a)
} }
} }
// PumpTick advances the pump. The ActionPump widget calls it every frame.
func (w *World) PumpTick(dt float64) { func (w *World) PumpTick(dt float64) {
// The TopBar's left section: location plus a single word for a stopped
// world. No pause overlay, no blur — one word says time has stopped.
if w.top != nil { if w.top != nil {
title := w.sceneTitle() title := w.sceneTitle()
if w.paused { if w.paused {
@@ -62,8 +48,6 @@ func (w *World) sceneTitle() string {
return s.Name return s.Name
} }
// ActionPump is an invisible widget that advances the pump. The HUD registers
// it; the world itself knows nothing about the widget tree.
type ActionPump struct { type ActionPump struct {
Name string Name string
W *World W *World
@@ -73,8 +57,6 @@ func (p *ActionPump) GetName() string { return p.Name
func (p *ActionPump) Tick(ctx *inkwell.UICtx) { p.W.PumpTick(ctx.DT) } func (p *ActionPump) Tick(ctx *inkwell.UICtx) { p.W.PumpTick(ctx.DT) }
func (p *ActionPump) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {} func (p *ActionPump) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {}
// ----- helpers ----------------------------------------------------------
type fnAction struct{ fn func(*inkwell.Ctx) } type fnAction struct{ fn func(*inkwell.Ctx) }
func (a *fnAction) Start() inkwell.Runner { return &fnRunner{spec: a} } func (a *fnAction) Start() inkwell.Runner { return &fnRunner{spec: a} }
@@ -86,23 +68,16 @@ func (r *fnRunner) Tick(ctx *inkwell.Ctx) inkwell.Status {
return inkwell.StatusDone return inkwell.StatusDone
} }
// Fn turns a function into a one-shot action that completes immediately.
func Fn(f func(*inkwell.Ctx)) inkwell.Action { return &fnAction{fn: f} } func Fn(f func(*inkwell.Ctx)) inkwell.Action { return &fnAction{fn: f} }
// UseTheme switches the theme at runtime. This is the single line that makes
// the Nokia-punk texture break in during the finale. inkwell saves ActiveTheme,
// so the switch survives a save without needing a flag of its own.
func UseTheme(name string) inkwell.Action { func UseTheme(name string) inkwell.Action {
return Fn(func(ctx *inkwell.Ctx) { ctx.Game.UseTheme(name) }) return Fn(func(ctx *inkwell.Ctx) { ctx.Game.UseTheme(name) })
} }
// SetMode switches the HUD mode (play / cutscene / menu).
func SetMode(w *World, m Mode) inkwell.Action { func SetMode(w *World, m Mode) inkwell.Action {
return Fn(func(*inkwell.Ctx) { w.SetMode(m) }) return Fn(func(*inkwell.Ctx) { w.SetMode(m) })
} }
// EnterScene tells the domain which scene we are in. inkwell exposes no
// CurrentScene accessor, and the pump needs Ctx.Scene.
func EnterScene(w *World, name string) inkwell.Action { func EnterScene(w *World, name string) inkwell.Action {
return Fn(func(*inkwell.Ctx) { return Fn(func(*inkwell.Ctx) {
if name != w.scene { if name != w.scene {
@@ -112,12 +87,6 @@ func EnterScene(w *World, name string) inkwell.Action {
}) })
} }
// Back returns to the screen we came from.
//
// Most exits name their destination, because a door leads where it leads. A
// screen that is reached from several different rooms cannot: the BBS terminal
// is the same terminal whether you walked to it from the club, from Norman's
// flat or from the roof, so its way out is "back", not a place.
func Back(w *World) inkwell.Action { func Back(w *World) inkwell.Action {
return Fn(func(ctx *inkwell.Ctx) { return Fn(func(ctx *inkwell.Ctx) {
if w.prev == "" { if w.prev == "" {
@@ -127,14 +96,6 @@ func Back(w *World) inkwell.Action {
}) })
} }
// ----- TapeSay ----------------------------------------------------------
// TapeSay is a line spoken by a tape.
//
// It is deliberately not inkwell.Say: Say puts a SpeechBubble above the
// speaker's head, but tapes have no body in the scene — they speak into your
// ear. This is the two-channel rule enforced in code: a tape can only reach the
// tape channel, never the scene.
func TapeSay(speaker, text string) inkwell.Action { func TapeSay(speaker, text string) inkwell.Action {
return &tapeSayAction{speaker: speaker, text: text} return &tapeSayAction{speaker: speaker, text: text}
} }
@@ -153,7 +114,7 @@ type tapeSayRunner struct {
func (r *tapeSayRunner) Tick(ctx *inkwell.Ctx) inkwell.Status { func (r *tapeSayRunner) Tick(ctx *inkwell.Ctx) inkwell.Status {
if !r.started { if !r.started {
r.started = true r.started = true
// Same pacing as Say, so the two breathe together inside a Seq.
r.duration = 1.2 + float64(len([]rune(r.spec.text)))*0.05 r.duration = 1.2 + float64(len([]rune(r.spec.text)))*0.05
ctx.Game.LogResponse(r.spec.speaker, r.spec.text) ctx.Game.LogResponse(r.spec.speaker, r.spec.text)
} }
@@ -164,11 +125,6 @@ func (r *tapeSayRunner) Tick(ctx *inkwell.Ctx) inkwell.Status {
return inkwell.StatusRunning return inkwell.StatusRunning
} }
// ----- TapeOffer --------------------------------------------------------
// TapeOffer offers a tape line during a cutscene: the Letterbox shows that a
// tape would speak, but it only speaks if the player asks. A tape never
// interrupts — the floor is the player's.
func TapeOffer(w *World, line inkwell.Action) inkwell.Action { func TapeOffer(w *World, line inkwell.Action) inkwell.Action {
return Fn(func(*inkwell.Ctx) { return Fn(func(*inkwell.Ctx) {
w.tapeLine = line w.tapeLine = line
@@ -176,7 +132,6 @@ func TapeOffer(w *World, line inkwell.Action) inkwell.Action {
}) })
} }
// TakeTapeOffer queues the offered line and clears the prompt.
func (w *World) TakeTapeOffer() { func (w *World) TakeTapeOffer() {
if !w.tapeWaiting { if !w.tapeWaiting {
return return
@@ -187,11 +142,6 @@ func (w *World) TakeTapeOffer() {
w.Do(line) w.Do(line)
} }
// ----- Paused -----------------------------------------------------------
// Paused marks the world as stopped for the duration of the wrapped action.
// inkwell exposes no dialogueActive accessor, so we mark it on the content
// side: every dialogue start is wrapped in this.
func Paused(w *World, inner inkwell.Action) inkwell.Action { func Paused(w *World, inner inkwell.Action) inkwell.Action {
return &pausedAction{w: w, inner: inner} return &pausedAction{w: w, inner: inner}
} }
+9 -43
View File
@@ -1,81 +1,52 @@
package inc 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.
//
// 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 ( import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// Mode is the HUD mode.
//
// IMPORTANT: this is deliberately NOT a State.Var. inkwell's state.save.go
// serialises State.Vars but drops the in-flight script ("saves capture state at
// idle/ready boundaries"), so a save taken mid-cutscene would reload into a
// permanently letterboxed, HUD-less game with no script running. The mode is
// therefore an unsaved runtime field.
type Mode string type Mode string
const ( const (
ModePlay Mode = "play" // full HUD ModePlay Mode = "play"
ModeCutscene Mode = "cutscene" // letterbox, HUD hidden ModeCutscene Mode = "cutscene"
ModeMenu Mode = "menu" // main menu, HUD hidden ModeMenu Mode = "menu"
) )
// World holds the runtime state that does not belong in a save file.
type World struct { type World struct {
G *inkwell.Game G *inkwell.Game
mode Mode mode Mode
scene string // current scene — inkwell exposes no accessor for it scene string
prev string // the one before it, for screens with no fixed way out prev string
paused bool // is the world stopped (during dialogue), for the TopBar suffix paused bool
pending string // the tape currently being loaded into slot 2 pending string
queue []inkwell.Action // HUD-originated actions waiting to run queue []inkwell.Action
running inkwell.Runner running inkwell.Runner
top *inkwell.TopBar // the location bar, so the pump can write its suffix top *inkwell.TopBar
// During a cutscene a tape signals but never interrupts: the line waits
// here until the player asks for it with SPACE.
tapeWaiting bool tapeWaiting bool
tapeLine inkwell.Action tapeLine inkwell.Action
} }
// New wraps an existing inkwell game.
func newWorld(g *inkwell.Game) *World { func newWorld(g *inkwell.Game) *World {
return &World{G: g, mode: ModePlay} return &World{G: g, mode: ModePlay}
} }
// Mode returns the current HUD mode.
func (w *World) Mode() Mode { return w.mode } func (w *World) Mode() Mode { return w.mode }
// SetMode switches the HUD mode.
func (w *World) SetMode(m Mode) { w.mode = m } func (w *World) SetMode(m Mode) { w.mode = m }
// HUDVisible reports whether the HUD should draw: only in play mode.
func (w *World) HUDVisible() bool { return w.mode == ModePlay } func (w *World) HUDVisible() bool { return w.mode == ModePlay }
// Scene returns the name of the current scene.
func (w *World) Scene() string { return w.scene } func (w *World) Scene() string { return w.scene }
// Previous returns the screen we came from, empty at the start of the game.
func (w *World) Previous() string { return w.prev } func (w *World) Previous() string { return w.prev }
// Paused reports whether the world is stopped (during dialogue).
func (w *World) Paused() bool { return w.paused } func (w *World) Paused() bool { return w.paused }
// SetTopBar hands over the location bar so the pump can write its suffix.
func (w *World) SetTopBar(t *inkwell.TopBar) { w.top = t } func (w *World) SetTopBar(t *inkwell.TopBar) { w.top = t }
// Slot2 returns the item name in the second tape slot, empty if there is none.
//
// Unlike Mode, this is REAL game state and lives in State.Var so that it is
// saved.
func (w *World) Slot2() string { func (w *World) Slot2() string {
if v, ok := w.G.State.Var(VarSlot2).(string); ok { if v, ok := w.G.State.Var(VarSlot2).(string); ok {
return v return v
@@ -83,19 +54,14 @@ func (w *World) Slot2() string {
return "" return ""
} }
// SetSlot2 sets the contents of the second tape slot.
func (w *World) SetSlot2(item string) { w.G.State.SetVar(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.
func (w *World) SetPending(item string) { w.pending = item } func (w *World) SetPending(item string) { w.pending = item }
// TakePending returns and clears the tape waiting to be loaded.
func (w *World) TakePending() string { func (w *World) TakePending() string {
item := w.pending item := w.pending
w.pending = "" w.pending = ""
return item return item
} }
// TapeWaiting reports whether a tape line has been offered but not yet spoken.
func (w *World) TapeWaiting() bool { return w.tapeWaiting } func (w *World) TapeWaiting() bool { return w.tapeWaiting }
-5
View File
@@ -6,9 +6,6 @@ import (
inkwell "git.teletypegames.org/engines/inkwell" inkwell "git.teletypegames.org/engines/inkwell"
) )
// 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) { func TestModeIsNotSavedState(t *testing.T) {
w := newWorld(inkwell.NewGame("t", 320, 200)) w := newWorld(inkwell.NewGame("t", 320, 200))
@@ -19,7 +16,6 @@ func TestModeIsNotSavedState(t *testing.T) {
} }
} }
// Slot 2, by contrast, is real game state and must be saved.
w.SetSlot2(ItemMysteryTape) w.SetSlot2(ItemMysteryTape)
if w.G.State.Var(VarSlot2) != ItemMysteryTape { if w.G.State.Var(VarSlot2) != ItemMysteryTape {
t.Error("slot 2 is not in State.Vars") t.Error("slot 2 is not in State.Vars")
@@ -29,7 +25,6 @@ func TestModeIsNotSavedState(t *testing.T) {
} }
} }
// The widget marks a tape as pending; the script consumes it — once.
func TestPendingIsConsumedOnce(t *testing.T) { func TestPendingIsConsumedOnce(t *testing.T) {
w := newWorld(inkwell.NewGame("t", 320, 200)) w := newWorld(inkwell.NewGame("t", 320, 200))
w.SetPending(ItemMysteryTape) w.SetPending(ItemMysteryTape)
-5
View File
@@ -10,11 +10,6 @@ import (
"git.teletypegames.org/games/realworld/inc" "git.teletypegames.org/games/realworld/inc"
) )
// The art ships inside the binary. js/wasm has no OS filesystem, so without
// this every background in the web build falls back to a placeholder; and
// `make binaries` packages the executable alone, with no assets directory
// beside it.
//
//go:embed assets //go:embed assets
var assets embed.FS var assets embed.FS