new file structure

This commit is contained in:
2026-08-29 23:25:36 +02:00
parent bbc3faf3d7
commit ec48cd6b37
96 changed files with 805 additions and 969 deletions
+17
View File
@@ -0,0 +1,17 @@
package inc
import (
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 {
return inkwell.Asset{
Name: BgAlley,
Path: "assets/bg/alley.png",
Kind: inkwell.AssetImage,
}
}
+14
View File
@@ -0,0 +1,14 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// bbsTerminal — deck 11.
func backgroundBbsTerminal() inkwell.Asset {
return inkwell.Asset{
Name: BgBBSTerminal,
Path: "assets/bg/bbs_terminal.png",
Kind: inkwell.AssetImage,
}
}
+14
View File
@@ -0,0 +1,14 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// bvkBranch — deck 18.
func backgroundBvkBranch() inkwell.Asset {
return inkwell.Asset{
Name: BgBVKBranch,
Path: "assets/bg/bvk_branch.png",
Kind: inkwell.AssetImage,
}
}
+14
View File
@@ -0,0 +1,14 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// columbarium — deck 23.
func backgroundColumbarium() inkwell.Asset {
return inkwell.Asset{
Name: BgColumbarium,
Path: "assets/bg/columbarium.png",
Kind: inkwell.AssetImage,
}
}
+14
View File
@@ -0,0 +1,14 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// curatorShop — deck 19.
func backgroundCuratorShop() inkwell.Asset {
return inkwell.Asset{
Name: BgCuratorShop,
Path: "assets/bg/curator_shop.png",
Kind: inkwell.AssetImage,
}
}
+14
View File
@@ -0,0 +1,14 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// hackerspace — deck 06.
func backgroundHackerspace() inkwell.Asset {
return inkwell.Asset{
Name: BgHackerspace,
Path: "assets/bg/hackerspace.png",
Kind: inkwell.AssetImage,
}
}
+14
View File
@@ -0,0 +1,14 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// hospital — deck 13.
func backgroundHospital() inkwell.Asset {
return inkwell.Asset{
Name: BgHospital,
Path: "assets/bg/hospital.png",
Kind: inkwell.AssetImage,
}
}
+14
View File
@@ -0,0 +1,14 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// iceCreamShop — deck 07.
func backgroundIceCreamShop() inkwell.Asset {
return inkwell.Asset{
Name: BgIceCreamShop,
Path: "assets/bg/ice_cream_shop.png",
Kind: inkwell.AssetImage,
}
}
+47
View File
@@ -0,0 +1,47 @@
package inc
// Backgrounds — one image asset per screen, one file each.
//
// A background is the whole of a screen's art: inkwell scales it over the
// entire window, so every file here points at a 640×380 PNG under assets/bg/,
// the size the concept-art deck is drawn at. Two of them are not painted yet
// and fall back to inkwell's placeholder.
//
// The order below is the deck's, which is the wiki's location-catalogue order
// (projects/realworld/entities#helyszinek).
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// registerBackground adds every background to the game.
func registerBackground(w *World) {
for _, a := range []inkwell.Asset{
backgroundSelector(),
backgroundPaulShop(),
backgroundNoodleHouse(),
backgroundNormanApartment(),
backgroundPoliceStation(),
backgroundAlley(),
backgroundHackerspace(),
backgroundIceCreamShop(),
backgroundTrinketShop(),
backgroundSmallRestaurant(),
backgroundSecretClub(),
backgroundBbsTerminal(),
backgroundStreet(),
backgroundHospital(),
backgroundServerFarm(),
backgroundSecretLab(),
backgroundRooftopHideout(),
backgroundPublicBBS(),
backgroundBvkBranch(),
backgroundCuratorShop(),
backgroundScrapMarket(),
backgroundSamizdatPress(),
backgroundShowroom(),
backgroundColumbarium(),
} {
w.G.AssetManager.Register(a)
}
}
+14
View File
@@ -0,0 +1,14 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// noodleHouse — deck 02.
func backgroundNoodleHouse() inkwell.Asset {
return inkwell.Asset{
Name: BgNoodleHouse,
Path: "assets/bg/noodle_house.png",
Kind: inkwell.AssetImage,
}
}
+14
View File
@@ -0,0 +1,14 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// normanApartment — deck 03.
func backgroundNormanApartment() inkwell.Asset {
return inkwell.Asset{
Name: BgNormanApartment,
Path: "assets/bg/norman_apartment.png",
Kind: inkwell.AssetImage,
}
}
+14
View File
@@ -0,0 +1,14 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// paulShop — deck 01.
func backgroundPaulShop() inkwell.Asset {
return inkwell.Asset{
Name: BgPaulShop,
Path: "assets/bg/paul_shop.png",
Kind: inkwell.AssetImage,
}
}
+14
View File
@@ -0,0 +1,14 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// policeStation — deck 04.
func backgroundPoliceStation() inkwell.Asset {
return inkwell.Asset{
Name: BgPoliceStation,
Path: "assets/bg/police_station.png",
Kind: inkwell.AssetImage,
}
}
+14
View File
@@ -0,0 +1,14 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// publicBbs — deck 17.
func backgroundPublicBBS() inkwell.Asset {
return inkwell.Asset{
Name: BgPublicBBS,
Path: "assets/bg/public_bbs.png",
Kind: inkwell.AssetImage,
}
}
+14
View File
@@ -0,0 +1,14 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// rooftopHideout — deck 16.
func backgroundRooftopHideout() inkwell.Asset {
return inkwell.Asset{
Name: BgRooftopHideout,
Path: "assets/bg/rooftop_hideout.png",
Kind: inkwell.AssetImage,
}
}
+14
View File
@@ -0,0 +1,14 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// samizdatPress — deck 21.
func backgroundSamizdatPress() inkwell.Asset {
return inkwell.Asset{
Name: BgSamizdatPress,
Path: "assets/bg/samizdat_press.png",
Kind: inkwell.AssetImage,
}
}
+14
View File
@@ -0,0 +1,14 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// scrapMarket — deck 20.
func backgroundScrapMarket() inkwell.Asset {
return inkwell.Asset{
Name: BgScrapMarket,
Path: "assets/bg/scrap_market.png",
Kind: inkwell.AssetImage,
}
}
+14
View File
@@ -0,0 +1,14 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// secretClub — deck 10.
func backgroundSecretClub() inkwell.Asset {
return inkwell.Asset{
Name: BgSecretClub,
Path: "assets/bg/secret_club.png",
Kind: inkwell.AssetImage,
}
}
+14
View File
@@ -0,0 +1,14 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// secretLab — deck 15.
func backgroundSecretLab() inkwell.Asset {
return inkwell.Asset{
Name: BgSecretLab,
Path: "assets/bg/secret_lab.png",
Kind: inkwell.AssetImage,
}
}
+17
View File
@@ -0,0 +1,17 @@
package inc
import (
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 {
return inkwell.Asset{
Name: BgSelector,
Path: "assets/bg/selector.png",
Kind: inkwell.AssetImage,
}
}
+14
View File
@@ -0,0 +1,14 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// serverFarm — deck 14.
func backgroundServerFarm() inkwell.Asset {
return inkwell.Asset{
Name: BgServerFarm,
Path: "assets/bg/server_farm.png",
Kind: inkwell.AssetImage,
}
}
+14
View File
@@ -0,0 +1,14 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// showroom — deck 22.
func backgroundShowroom() inkwell.Asset {
return inkwell.Asset{
Name: BgShowroom,
Path: "assets/bg/showroom.png",
Kind: inkwell.AssetImage,
}
}
+14
View File
@@ -0,0 +1,14 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// smallRestaurant — deck 09.
func backgroundSmallRestaurant() inkwell.Asset {
return inkwell.Asset{
Name: BgSmallRestaurant,
Path: "assets/bg/small_restaurant.png",
Kind: inkwell.AssetImage,
}
}
+14
View File
@@ -0,0 +1,14 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// street — deck 12.
func backgroundStreet() inkwell.Asset {
return inkwell.Asset{
Name: BgStreet,
Path: "assets/bg/street.png",
Kind: inkwell.AssetImage,
}
}
+14
View File
@@ -0,0 +1,14 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// trinketShop — deck 08.
func backgroundTrinketShop() inkwell.Asset {
return inkwell.Asset{
Name: BgTrinketShop,
Path: "assets/bg/trinket_shop.png",
Kind: inkwell.AssetImage,
}
}
+59
View File
@@ -0,0 +1,59 @@
// Package inc is the whole game, in one flat package.
//
// Every file is named [category].[name].go: one category per concern, one
// entity per file. A category's <category>.manager.go holds what ties that
// category together — its Register, its shared types — and the files beside it
// hold one screen, one background, one item each.
//
// This file is the boot category: it wires the layers together, and it is the
// only place where the world, the content, the theme and the HUD meet. main.go
// knows nothing else.
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// Opts are the run parameters.
type Opts struct {
Screen string // starting screen; empty means the alley
Finale bool // start with the finale, to try the Nokia-punk theme switch
}
// New builds a ready-to-run game.
func New(o Opts) *inkwell.Game {
start := o.Screen
if start == "" {
start = ScreenAlley
}
g := inkwell.NewGame("Real World", ScreenW, ScreenH)
g.MaxLogLines = 64
w := newWorld(g)
registerTheme(g)
registerContent(w)
g.UseTheme(RealWorld)
registerUI(w)
g.StartAt(start)
g.OnStart(inkwell.Seq(bootOpening(w, start, o.Finale)...))
return g
}
func bootOpening(w *World, start string, finale bool) []inkwell.Action {
acts := []inkwell.Action{
EnterScene(w, start),
// Beat 2 (the police station) will set the tip flag. Until that beat
// is written we set it here so the alley slice is playable end to end —
// the conditional branch itself is real code, not bypassed.
inkwell.SetFlag(FlagPoliceTip),
inkwell.Say(Paul, "Back alley. Just like the clerk said."),
TapeSay(Dex, "A government office tipped you off to remove something from a scene. That doesn't strike you as odd?"),
}
if finale {
acts = append(acts, inkwell.RunScript(ScriptFinale))
}
return acts
}
+256
View File
@@ -0,0 +1,256 @@
package inc
import (
"image/png"
"os"
"path/filepath"
"strings"
"testing"
inkwell "git.teletypegames.org/engines/inkwell"
)
func newTestGame(t *testing.T) *inkwell.Game {
t.Helper()
g := New(Opts{})
if err := g.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
return g
}
// The engine's Validate only checks scene→asset/character references. A typo in
// a dialogue, script or item name would surface at runtime as a silent no-op
// (RunDialogue and RunScript fail quietly), so check them here.
func TestReferencesResolve(t *testing.T) {
g := newTestGame(t)
for _, n := range []string{DlgDex, DlgMysteryTape} {
if !g.DialogueManager.Has(n) {
t.Errorf("missing dialogue: %q", n)
}
}
for _, n := range []string{ScriptTapeInsert, ScriptFinale} {
if !g.ScriptManager.Has(n) {
t.Errorf("missing script: %q", n)
}
}
for _, n := range []string{ItemNoodleLetter, ItemMysteryTape, ItemBlackArmilla} {
if !g.ItemManager.Has(n) {
t.Errorf("missing item: %q", n)
}
}
for n := range Tapes {
if !g.CharacterManager.Has(n) {
t.Errorf("tape character not registered: %q", n)
}
}
// Every loadable tape needs an item, a character and a dialogue.
for item, char := range TapeItems {
if !g.ItemManager.Has(item) {
t.Errorf("tape item not registered: %q", item)
}
if !g.CharacterManager.Has(char) {
t.Errorf("tape character not registered: %q", char)
}
if !g.DialogueManager.Has(TapeDialogue(item)) {
t.Errorf("tape %q has no dialogue: %q", item, TapeDialogue(item))
}
}
}
// The two-channel rule: every tape needs its own voice colour, distinct from
// the world's. Without that, "if something is amber, a tape said it" is not
// readable.
func TestTapeVoicesAreDistinct(t *testing.T) {
g := newTestGame(t)
paul, _ := g.CharacterManager.Get(Paul)
seen := map[[4]uint32]string{}
for n := range Tapes {
c, _ := g.CharacterManager.Get(n)
if c.SpeechColor == nil {
t.Errorf("%s: no SpeechColor", n)
continue
}
if c.SpeechColor == paul.SpeechColor {
t.Errorf("%s speaks in the same colour as Paul", n)
}
r, gr, b, a := c.SpeechColor.RGBA()
key := [4]uint32{r, gr, b, a}
if other, dup := seen[key]; dup {
t.Errorf("%s and %s share a voice colour", n, other)
}
seen[key] = n
}
}
// HUD geometry: the two channels must not overlap, and both must stay inside
// the HUD strip.
func TestHUDGeometry(t *testing.T) {
g := newTestGame(t)
ch, ok := g.UIManager.Get("channel")
if !ok {
t.Fatal("no 'channel' widget")
}
tc := ch.(*TapeChannel)
if tc.Bounds.X < DividerX {
t.Errorf("tape channel crosses into the world side: X=%v < %v", tc.Bounds.X, DividerX)
}
if tc.Bounds.X+tc.Bounds.W > ScreenW {
t.Errorf("tape channel runs off screen: %v", tc.Bounds.X+tc.Bounds.W)
}
if tc.Bounds.Y < HUDTop {
t.Errorf("tape channel reaches into the scene: Y=%v < %v", tc.Bounds.Y, HUDTop)
}
sl, ok := g.UIManager.Get("tapes")
if !ok {
t.Fatal("no 'tapes' widget")
}
if ts := sl.(*TapeSlots); ts.Bounds.X+ts.Bounds.W > DividerX {
t.Errorf("tape slots cross the divider: %v > %v", ts.Bounds.X+ts.Bounds.W, DividerX)
}
// The dialogue box deliberately covers only the left region, so a tape can
// comment alongside the conversation.
db, ok := g.UIManager.Get("dialog")
if !ok {
t.Fatal("no 'dialog' widget")
}
if box := db.(*inkwell.DialogBox); box.Bounds.X+box.Bounds.W > DividerX {
t.Errorf("dialogue box would cover the tape channel: %v > %v",
box.Bounds.X+box.Bounds.W, DividerX)
}
}
// The guard must be registered FIRST so it ticks LAST among the widgets —
// otherwise it would swallow HUD clicks.
func TestWidgetOrder(t *testing.T) {
g := newTestGame(t)
n := g.UIManager.Names()
if len(n) == 0 || n[0] != "usewith_guard" {
t.Errorf("usewith_guard is not registered first: %v", n)
}
if n[len(n)-1] != "cursor" {
t.Errorf("cursor is not registered last: %v", n)
}
}
// 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) {
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}
for _, n := range g.SceneManager.Names() {
s := g.SceneManager.MustGet(n)
a, ok := g.AssetManager.Get(s.Background)
if !ok {
t.Errorf("scene %q: unknown background %q", n, s.Background)
continue
}
if undrawn[a.Name] {
continue
}
f, err := os.Open(filepath.Join("..", a.Path))
if err != nil {
t.Errorf("scene %q: background file missing: %v", n, err)
continue
}
cfg, err := png.DecodeConfig(f)
f.Close()
if err != nil {
t.Errorf("scene %q: unreadable background: %v", n, err)
continue
}
if cfg.Width != ScreenW || cfg.Height != ScreenH {
t.Errorf("scene %q: background is %d×%d, want %d×%d — it will be stretched",
n, cfg.Width, cfg.Height, ScreenW, ScreenH)
}
}
}
// exits reads the connection graph back out of the registered screens. The
// exit hotspots are named "exit:<target>", which is what keeps the graph
// machine-readable — see screen.exit.go.
func exits(g *inkwell.Game, screen string) []string {
var out []string
for _, h := range g.SceneManager.MustGet(screen).Hotspots {
if to, ok := strings.CutPrefix(h.Name, "exit:"); ok && to != "back" {
out = append(out, to)
}
}
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) {
g := newTestGame(t)
for _, n := range g.SceneManager.Names() {
for _, to := range exits(g, n) {
if !g.SceneManager.Has(to) {
t.Errorf("screen %q: exit to unknown screen %q", n, to)
}
}
}
}
// 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) {
g := newTestGame(t)
seen := map[string]bool{ScreenPaulShop: true}
queue := []string{ScreenPaulShop}
for len(queue) > 0 {
at := queue[0]
queue = queue[1:]
for _, to := range exits(g, at) {
if !seen[to] && g.SceneManager.Has(to) {
seen[to] = true
queue = append(queue, to)
}
}
}
for _, n := range g.SceneManager.Names() {
if !seen[n] {
t.Errorf("screen %q cannot be reached from %q by its exits", n, ScreenPaulShop)
}
}
}
// A screen with no way out at all is a trap: the player walks in and the only
// way on is the debug walk.
func TestEveryScreenHasAWayOut(t *testing.T) {
g := newTestGame(t)
for _, n := range g.SceneManager.Names() {
var ways int
for _, h := range g.SceneManager.MustGet(n).Hotspots {
if strings.HasPrefix(h.Name, "exit:") {
ways++
}
}
if ways == 0 {
t.Errorf("screen %q has no exit", n)
}
}
}
+14
View File
@@ -0,0 +1,14 @@
package inc
import (
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 {
return inkwell.Character{
Name: Dex,
SpeechColor: Amber,
}
}
+23
View File
@@ -0,0 +1,23 @@
package inc
// Characters — the game's cast, one file each.
//
// Tapes are characters too, not props — the wiki is explicit about it. They
// have no body in the scene, so they carry no W/H and never appear in
// Scene.Actors; their voice lives in the tape channel (see TapeSay).
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// registerCharacter adds every character to the game.
func registerCharacter(w *World) {
for _, c := range []inkwell.Character{
characterPaul(),
characterDex(),
characterMysteryTape(),
characterSupportTape(),
} {
w.G.CharacterManager.Register(c)
}
}
+17
View File
@@ -0,0 +1,17 @@
package inc
import (
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 {
return inkwell.Character{
Name: TapeMystery,
SpeechColor: RGB(0xB9A98A),
}
}
+20
View File
@@ -0,0 +1,20 @@
package inc
import (
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 {
return inkwell.Character{
Name: Paul,
Speed: 96,
W: 28,
H: 68,
Start: inkwell.Point{X: 120, Y: 232},
// Bone white: the world and Paul. If something is amber, a tape said
// it — see the colour rule in README.
SpeechColor: Ink,
}
}
+14
View File
@@ -0,0 +1,14 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// supportTape answers everything with basic information, insufferably. Dex
// hates it. Acquired in Chinatown (beat 5) — not reachable yet.
func characterSupportTape() inkwell.Character {
return inkwell.Character{
Name: TapeSupport,
SpeechColor: RGB(0xE8C86A),
}
}
+19
View File
@@ -0,0 +1,19 @@
package inc
// Content — the composition root for everything the game declares:
// assets, characters, items, dialogues, scripts and scenes.
//
// Each registered entity lives in its own file, named after its category:
// adding a screen means adding screen.<name>.go and background.<name>.go, plus
// one line in each category's manager list — nothing else moves.
// registerContent adds every entity to the game. Order matters only in that
// screens reference backgrounds and characters, which Game.Validate cross-checks.
func registerContent(w *World) {
registerBackground(w)
registerCharacter(w)
registerItem(w)
registerDialog(w)
registerScript(w)
registerScreen(w)
}
+55
View File
@@ -0,0 +1,55 @@
package inc
import (
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 {
return inkwell.Dialogue{
Name: DlgDex,
Start: "root",
Nodes: []inkwell.DialogueNode{{
Name: "root",
Lines: []inkwell.DialogueLine{{Speaker: Dex, Text: "Talk."}},
Choices: []inkwell.DialogueChoice{
{
Text: "What am I doing here, Dex?",
Actions: []inkwell.Action{
inkwell.Say(Dex, "You got a letter from a man you hadn't seen in twenty years. And you came. That says more about you than it does about him."),
inkwell.GotoNode("root"),
},
},
{
Text: "What do you know about Noodle?",
Show: inkwell.Not(inkwell.Flag(FlagHasTape)),
Actions: []inkwell.Action{
inkwell.Say(Dex, "He traded cracked Armillas. That isn't charity, Paul, that's a business. The kind they take you in for."),
inkwell.GotoNode("root"),
},
},
{
Text: "What do you make of this tape?",
Show: inkwell.Flag(FlagHasTape),
Actions: []inkwell.Action{
inkwell.Say(Dex, "I make of it that you found a password-locked tape in a gap between two bins, on a tip from a government office. Count how many times that has ended well."),
inkwell.GotoNode("root"),
},
},
{
Text: "I miss you.",
Once: true,
Actions: []inkwell.Action{
inkwell.Say(Dex, "I'm four kilobytes of a dead man. Don't do this to yourself."),
inkwell.GotoNode("root"),
},
},
{
Text: "Nothing. Forget it.",
Actions: []inkwell.Action{inkwell.EndDialogue()},
},
},
}},
}
}
+25
View File
@@ -0,0 +1,25 @@
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 (
inkwell "git.teletypegames.org/engines/inkwell"
)
// registerDialog adds every dialogue to the game.
func registerDialog(w *World) {
for _, d := range []inkwell.Dialogue{
dialogDexTalk(),
dialogMysteryTapeSilent(),
} {
w.G.DialogueManager.Register(d)
}
}
+32
View File
@@ -0,0 +1,32 @@
package inc
import (
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 {
return inkwell.Dialogue{
Name: DlgMysteryTape,
Start: "root",
Nodes: []inkwell.DialogueNode{{
Name: "root",
Lines: []inkwell.DialogueLine{{Speaker: Dex, Text: "Nothing. Warm, spinning, and silent."}},
Choices: []inkwell.DialogueChoice{
{
Text: "Are you sure it works?",
Actions: []inkwell.Action{
inkwell.Say(Dex, "It works. It just isn't talking to you. That is not the same as silence."),
inkwell.GotoNode("root"),
},
},
{
Text: "Fine. It'll speak when it speaks.",
Actions: []inkwell.Action{inkwell.EndDialogue()},
},
},
}},
}
}
+26
View File
@@ -0,0 +1,26 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// blackMarketArmilla is flavour, not a quest trigger: the gap between
// legitimised technology and street reality.
func itemBlackMarketArmilla() inkwell.Item {
return inkwell.Item{
Name: ItemBlackArmilla,
Description: "black-market Armilla",
OnUseSelf: inkwell.Seq(
inkwell.Say(Paul, "Jailbroken. Pushes ads, but it was cheap."),
TapeSay(Dex, "Two slots, and both of them lie about the temperature. Don't trade me in for it."),
),
OnUseWith: map[string]inkwell.Action{
// An AUTHORED failure. Nothing is consumed, the character reacts,
// the tape remarks. Never a generic error line.
ItemNoodleLetter: inkwell.Seq(
inkwell.Say(Paul, "A letter and a bracelet. Brilliant."),
TapeSay(Dex, "Nothing. Which is what I'd charge for it."),
),
},
}
}
+21
View File
@@ -0,0 +1,21 @@
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 (
inkwell "git.teletypegames.org/engines/inkwell"
)
// registerItem adds every item to the game.
func registerItem(w *World) {
for _, i := range []inkwell.Item{
itemNoodleLetter(),
itemBlackMarketArmilla(),
itemMysteryTape(),
} {
w.G.ItemManager.Register(i)
}
}
+18
View File
@@ -0,0 +1,18 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// mysteryTape is the engine of the investigation. Password-locked; it only
// starts speaking after the club trigger (beat 6).
func itemMysteryTape() inkwell.Item {
return inkwell.Item{
Name: ItemMysteryTape,
Description: "unmarked Personal Tape",
OnUseSelf: inkwell.Seq(
inkwell.Say(Paul, "Password-locked. Of course."),
TapeSay(Dex, "Put it in the second slot if you care that much. I did warn you."),
),
}
}
+17
View File
@@ -0,0 +1,17 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// noodleLetter starts the story: it is what brings Paul to the city.
func itemNoodleLetter() inkwell.Item {
return inkwell.Item{
Name: ItemNoodleLetter,
Description: "Noodle's letter",
OnUseSelf: inkwell.Seq(
inkwell.Say(Paul, "“Come out, Paul. Not a phone conversation.” That's all it says."),
TapeSay(Dex, "And now you're here. And he isn't."),
),
}
}
+160
View File
@@ -0,0 +1,160 @@
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 (
Paul = "paul"
Dex = "dex" // Personal Tape, permanently in slot 1 of Paul's Armilla
// Tapes (Personal Tapes) are characters, not props: the wiki is explicit
// that they have their own personality, motivation and voice.
TapeMystery = "tape_mystery" // = The AI, but that only surfaces in the finale
TapeSupport = "tape_support" // the "Indian tech support" tape
)
// Inventory items.
const (
ItemNoodleLetter = "noodle_letter"
ItemMysteryTape = "mystery_tape"
ItemBlackArmilla = "black_market_armilla"
)
// Dialogues.
const (
DlgDex = "dex_talk"
DlgMysteryTape = "mystery_tape_silent"
)
// Scripts.
const (
ScriptTapeInsert = "tape_insert"
ScriptFinale = "awakening_finale"
)
// World state — flags and variables.
const (
FlagPoliceTip = "police_tip" // wiki: hatosagi_tipp
FlagHasTape = "has_mystery_tape" // wiki: titokzatos_tape_megvan
FlagTapeSpoke = "tape_spoke" // wiki: tape_megszolalt
FlagClubEntry = "club_entry" // wiki: klub_bejutas — the shopkeeper let Paul through
VarSlot2 = "armilla.slot2"
VarArmillaStrip = "armilla.strip"
VarDecay = "decay_level" // wiki: romlas_szint
)
// 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 (
ScreenSelector = "selector" // wiki: Helyszínválasztó
ScreenPaulShop = "paul_shop" // wiki: Paul boltja / garázsa
ScreenNoodleHouse = "noodle_house" // wiki: Noodle háza
ScreenNormanApartment = "norman_apartment" // wiki: Norman lakása / műhelye
ScreenPoliceStation = "police_station" // wiki: rendőrség / fogda
ScreenAlley = "alley" // wiki: sikátor (Noodle háza mögött)
ScreenHackerspace = "hackerspace" // wiki: hackerspace
ScreenIceCreamShop = "ice_cream_shop" // wiki: fagyizó
ScreenTrinketShop = "trinket_shop" // wiki: chinatowni csecsebecse-bolt
ScreenSmallRestaurant = "small_restaurant" // wiki: kajálda
ScreenSecretClub = "secret_club" // wiki: titkos klub („vízalatti nap")
ScreenBBSTerminal = "bbs_terminal" // wiki: a terminál / BBS-hozzáférési pont
ScreenStreet = "street" // wiki: köztér / utca
ScreenHospital = "hospital" // wiki: kórház / pszichiátria
ScreenServerFarm = "server_farm" // wiki: szerverfarm / adatközpont
ScreenSecretLab = "secret_lab" // wiki: titkos kutatólabor
ScreenRooftopHideout = "rooftop_hideout" // wiki: Norman tetőterasz-rejteke
ScreenPublicBBS = "public_bbs" // wiki 💡: nyilvános BBS-terminál
ScreenBVKBranch = "bvk_branch" // wiki 💡: BVK-kirendeltség (San Francisco)
ScreenCuratorShop = "curator_shop" // wiki 💡: kurátor-szerviz („a Mester")
ScreenScrapMarket = "scrap_market" // wiki 💡: selejt-piac
ScreenSamizdatPress = "samizdat_press" // wiki 💡: samizdat-nyomda / P2P-csomópont
ScreenShowroom = "showroom" // wiki 💡: Neumatronic márkakereskedés
ScreenColumbarium = "columbarium" // wiki 💡: kolumbárium / Dex emlékhelye
)
// Backgrounds — one per screen, same order.
const (
BgSelector = "bg_selector"
BgPaulShop = "bg_paul_shop"
BgNoodleHouse = "bg_noodle_house"
BgNormanApartment = "bg_norman_apartment"
BgPoliceStation = "bg_police_station"
BgAlley = "bg_alley"
BgHackerspace = "bg_hackerspace"
BgIceCreamShop = "bg_ice_cream_shop"
BgTrinketShop = "bg_trinket_shop"
BgSmallRestaurant = "bg_small_restaurant"
BgSecretClub = "bg_secret_club"
BgBBSTerminal = "bg_bbs_terminal"
BgStreet = "bg_street"
BgHospital = "bg_hospital"
BgServerFarm = "bg_server_farm"
BgSecretLab = "bg_secret_lab"
BgRooftopHideout = "bg_rooftop_hideout"
BgPublicBBS = "bg_public_bbs"
BgBVKBranch = "bg_bvk_branch"
BgCuratorShop = "bg_curator_shop"
BgScrapMarket = "bg_scrap_market"
BgSamizdatPress = "bg_samizdat_press"
BgShowroom = "bg_showroom"
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{
Dex: "DEX",
TapeMystery: "UNKNOWN TAPE",
TapeSupport: "TECH SUPPORT",
}
// IsTape reports whether a character is a tape.
func IsTape(name string) bool { _, ok := Tapes[name]; return ok }
// TapeDisplayName is the label shown in the tape channel header.
func TapeDisplayName(name string) string {
if d, ok := Tapes[name]; ok {
return d
}
return name
}
// TapeItems maps an inventory item that can go into slot 2 to the character
// living on it.
var TapeItems = map[string]string{
ItemMysteryTape: TapeMystery,
}
// IsTapeItem reports whether an item can be loaded into slot 2.
func IsTapeItem(item string) bool { _, ok := TapeItems[item]; return ok }
// TapeDialogue is the dialogue belonging to a tape in slot 2.
func TapeDialogue(item string) string {
switch item {
case ItemMysteryTape:
return DlgMysteryTape
}
return DlgDex
}
+104
View File
@@ -0,0 +1,104 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// alley is the alley behind Noodle's house — beat 3, and the vertical slice the
// whole HUD is measured against: hotspots, a gated pickup, the two-slot
// Armilla, tape dialogue and an authored failure.
//
// The wiki keeps it in the same catalogue row as the house ("Noodle háza & a
// sikátor") and its graph has one arrow in, NoodleUtcafront → Sikátor, so the
// only way out is back to the street. The alley's own locked back door is the
// New Game+ hook and leads to a room the deck has not painted.
func screenAlley() screen {
return screen{
name: ScreenAlley,
title: "ALLEY — BEHIND NOODLE'S HOUSE",
background: BgAlley,
exits: []exit{
// On the left: the back door hotspot below owns the other end.
{to: ScreenNoodleHouse, label: "back out to the street", side: sideLeft},
},
actors: []inkwell.SceneActor{
{CharacterName: Paul, At: inkwell.Point{X: 120, Y: 232}},
},
onEnter: setVarIfEmpty(VarArmillaStrip, "SRP: 1 tape"),
hotspots: []inkwell.Hotspot{hidingPlace(), backDoor(), bin(), fireEscape()},
}
}
// hidingPlace holds the mystery tape. It only opens once Paul has the tip from
// the police station (beat 2) — without it he does not know what to look for.
func hidingPlace() inkwell.Hotspot {
return inkwell.Hotspot{
Name: "hiding_place",
Label: "gap at the foot of the wall",
Area: inkwell.Rect(416, 150, 68, 52),
OnLook: inkwell.If(inkwell.Flag(FlagHasTape),
inkwell.Say(Paul, "Empty. Whatever was in there is on me now."),
inkwell.Say(Paul, "Loose brick. There's a gap behind it."),
),
OnUse: inkwell.If(inkwell.Flag(FlagPoliceTip),
inkwell.If(inkwell.Flag(FlagHasTape),
inkwell.Say(Paul, "There's nothing else in there."),
inkwell.Seq(
inkwell.Say(Paul, "“Behind the brick.” All right then."),
inkwell.Give(ItemMysteryTape),
inkwell.SetFlag(FlagHasTape),
inkwell.Say(Paul, "A Personal Tape. No label, no seal."),
TapeSay(Dex, "Password-locked. And somebody very much did not want it found."),
),
),
inkwell.Say(Paul, "One loose brick. I'm not taking the alley apart over it."),
),
OnTake: inkwell.Say(Paul, "I'm not carrying a wall."),
}
}
// backDoor is the New Game+ hook: the code can be entered on the first visit
// too, if the player already knows it.
func backDoor() inkwell.Hotspot {
return inkwell.Hotspot{
Name: "back_door",
Label: "locked back door",
Area: inkwell.Rect(72, 106, 80, 124),
OnLook: inkwell.Say(Paul, "Keypad. Four digits, worn keys."),
OnUse: inkwell.Seq(
inkwell.Say(Paul, "Locked. And I'm not guessing four digits."),
TapeSay(Dex, "The wear is heaviest on the two and the seven. That isn't a code. It's fewer options."),
),
OnTalk: inkwell.Say(Paul, "To the door? I'm not there yet."),
OnUseWith: map[string]inkwell.Action{
// An AUTHORED failure — not the engine's hardcoded flash.
ItemBlackArmilla: inkwell.Seq(
inkwell.Say(Paul, "A black-market bracelet doesn't open a keypad."),
TapeSay(Dex, "But you do get an advert out of it."),
),
},
}
}
func bin() inkwell.Hotspot {
return inkwell.Hotspot{
Name: "bin",
Label: "toppled bin",
Area: inkwell.Rect(240, 170, 88, 60),
OnLook: inkwell.Say(Paul, "Somebody's been through it. Thoroughly."),
OnUse: inkwell.Seq(
inkwell.Say(Paul, "Already turned out. I'm not going to be the second one."),
TapeSay(Dex, "The police don't go through bins. So who did?"),
),
}
}
func fireEscape() inkwell.Hotspot {
return inkwell.Hotspot{
Name: "fire_escape",
Label: "fire escape",
Area: inkwell.Rect(528, 44, 88, 160),
OnLook: inkwell.Say(Paul, "Runs all the way to the roof. Bottom rung is two metres over my head."),
OnUse: inkwell.Say(Paul, "Can't reach it. I'd need something to stand on."),
}
}
+19
View File
@@ -0,0 +1,19 @@
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 {
return screen{
name: ScreenBBSTerminal,
title: "TERMINAL — BBS ACCESS POINT",
background: BgBBSTerminal,
exits: []exit{
{label: "step back from the terminal", side: sideNear},
},
}
}
+14
View File
@@ -0,0 +1,14 @@
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 {
return screen{
name: ScreenBVKBranch,
title: "BVK — SAN FRANCISCO BRANCH",
background: BgBVKBranch,
onSelector: true,
}
}
+14
View File
@@ -0,0 +1,14 @@
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 {
return screen{
name: ScreenColumbarium,
title: "COLUMBARIUM — DEX'S MEMORIAL",
background: BgColumbarium,
onSelector: true,
}
}
+13
View File
@@ -0,0 +1,13 @@
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 {
return screen{
name: ScreenCuratorShop,
title: "CURATOR SERVICE — THE MASTER'S WORKSHOP",
background: BgCuratorShop,
onSelector: true,
}
}
+106
View File
@@ -0,0 +1,106 @@
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 (
inkwell "git.teletypegames.org/engines/inkwell"
)
type side int
const (
sideLeft side = iota // off the left edge
sideRight // off the right edge
sideBack // into the depth of the picture
sideNear // out towards the camera
)
// 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 {
switch s {
case sideLeft:
return inkwell.Rect(0, 40, 56, 212)
case sideRight:
return inkwell.Rect(584, 40, 56, 212)
case sideBack:
return inkwell.Rect(232, 28, 176, 84)
default: // sideNear
return inkwell.Rect(232, 214, 176, 48)
}
}
// exit is one way out of a screen.
type exit struct {
to string // the screen it leads to; empty means "back the way you came"
label string // what the status line calls it
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
// 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
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 {
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 {
if to == "" {
return "exit:back"
}
return "exit:" + to
}
func (e exit) hotspot(w *World) inkwell.Hotspot {
var travel inkwell.Action = inkwell.GoTo(e.to)
if e.to == "" {
travel = Back(w)
}
if e.needs != "" {
travel = inkwell.If(inkwell.Flag(e.needs), travel, inkwell.Say(Paul, e.blocked))
}
area := e.area
if area == nil {
area = e.side.area()
}
return inkwell.Hotspot{
Name: exitName(e.to),
Label: e.label,
Area: area,
Cursor: inkwell.CursorExit,
OnLook: inkwell.Say(Paul, "That way: "+e.label+"."),
OnUse: travel,
OnTake: inkwell.Say(Paul, "It's a way out, not a thing."),
}
}
+16
View File
@@ -0,0 +1,16 @@
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 {
return screen{
name: ScreenHackerspace,
title: "HACKERSPACE",
background: BgHackerspace,
onSelector: true,
}
}
+12
View File
@@ -0,0 +1,12 @@
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 {
return screen{
name: ScreenHospital,
title: "HOSPITAL — PSYCHIATRIC WING",
background: BgHospital,
onSelector: true,
}
}
+17
View File
@@ -0,0 +1,17 @@
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 {
return screen{
name: ScreenIceCreamShop,
title: "ICE CREAM SHOP — WHERE THE BAR WAS",
background: BgIceCreamShop,
onSelector: true,
}
}
+147
View File
@@ -0,0 +1,147 @@
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 (
inkwell "git.teletypegames.org/engines/inkwell"
)
// screen is what each file in this package fills in.
type screen struct {
name string
title string
background string
// exits: where you can get to from here. See screen.exit.go.
exits []exit
// onSelector marks a main location — one of those the wiki's
// Helyszínválasztó lists (story#helyszín-gráf-vázlat). It gets a way back
// to the selector for free, and the selector gets a way here.
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
actors []inkwell.SceneActor
walkboxes []inkwell.Polygon
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{
screenPaulShop, // 01
screenNoodleHouse, // 02
screenAlley, // 02, same catalogue row
screenNormanApartment, // 03
screenPoliceStation, // 04
screenHackerspace, // 06
screenIceCreamShop, // 07
screenTrinketShop, // 08
screenSmallRestaurant, // 09
screenSecretClub, // 10
screenBbsTerminal, // 11
screenStreet, // 12
screenHospital, // 13
screenServerFarm, // 14
screenSecretLab, // 15
screenRooftopHideout, // 16
screenPublicBBS, // 17
screenBvkBranch, // 18
screenCuratorShop, // 19
screenScrapMarket, // 20
screenSamizdatPress, // 21
screenShowroom, // 22
screenColumbarium, // 23
}
// 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) {
screens := make([]screen, 0, len(screenDeck)+1)
for _, f := range screenDeck {
screens = append(screens, f())
}
for _, s := range append([]screen{screenSelector(screens)}, screens...) {
w.G.SceneManager.Register(screenBuild(w, s))
}
}
// The default floor: the bottom of the scene region, above the HUD divider.
// A screen with a painted floor of its own overrides it.
var screenFloor = []inkwell.Polygon{
inkwell.Poly(
inkwell.Point{X: 16, Y: 196}, inkwell.Point{X: 624, Y: 196},
inkwell.Point{X: 624, Y: 258}, inkwell.Point{X: 16, Y: 258},
),
}
// 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 {
exits := s.exits
if s.onSelector {
exits = append(exits, toSelector())
}
hotspots := make([]inkwell.Hotspot, 0, len(exits)+len(s.hotspots))
hotspots = append(hotspots, s.hotspots...)
for _, e := range exits {
hotspots = append(hotspots, e.hotspot(w))
}
actors := s.actors
if actors == nil {
actors = []inkwell.SceneActor{
{CharacterName: Paul, At: inkwell.Point{X: 320, Y: 232}},
}
}
walkboxes := s.walkboxes
if walkboxes == nil {
walkboxes = screenFloor
}
onEnter := inkwell.Action(EnterScene(w, s.name))
if s.onEnter != nil {
onEnter = inkwell.Seq(onEnter, s.onEnter)
}
return inkwell.Scene{
Name: s.name,
Title: s.title,
Background: s.background,
Actors: actors,
Walkboxes: walkboxes,
Hotspots: hotspots,
OnEnter: onEnter,
}
}
// setVarIfEmpty only writes when the variable is still unset, so re-entering a
// screen does not clobber what the game has set in the meantime.
func setVarIfEmpty(name string, v any) inkwell.Action {
return Fn(func(ctx *inkwell.Ctx) {
if ctx.Game.State.Var(name) == nil {
ctx.Game.State.SetVar(name, v)
}
})
}
+19
View File
@@ -0,0 +1,19 @@
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 {
return screen{
name: ScreenNoodleHouse,
title: "NOODLE'S HOUSE — SEALED",
background: BgNoodleHouse,
onSelector: true,
exits: []exit{
{to: ScreenAlley, label: "the alley behind the house", side: sideRight},
},
}
}
+21
View File
@@ -0,0 +1,21 @@
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 {
return screen{
name: ScreenNormanApartment,
title: "NORMAN'S APARTMENT",
background: BgNormanApartment,
onSelector: true,
exits: []exit{
{to: ScreenRooftopHideout, label: "the stairs up to the roof", side: sideBack},
{to: ScreenBBSTerminal, label: "Norman's terminal", side: sideRight},
},
}
}
+20
View File
@@ -0,0 +1,20 @@
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 {
return screen{
name: ScreenPaulShop,
title: "PAUL'S SHOP — JUNK AND GARAGE",
background: BgPaulShop,
exits: []exit{
{to: ScreenNoodleHouse, label: "the bus to San Francisco", side: sideNear},
},
}
}
+18
View File
@@ -0,0 +1,18 @@
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 {
return screen{
name: ScreenPoliceStation,
title: "SFPD — STATION AND HOLDING",
background: BgPoliceStation,
onSelector: true,
}
}
+14
View File
@@ -0,0 +1,14 @@
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 {
return screen{
name: ScreenPublicBBS,
title: "PUBLIC BBS TERMINAL",
background: BgPublicBBS,
onSelector: true,
}
}
+19
View File
@@ -0,0 +1,19 @@
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 {
return screen{
name: ScreenRooftopHideout,
title: "NORMAN'S ROOFTOP HIDEOUT",
background: BgRooftopHideout,
exits: []exit{
{to: ScreenNormanApartment, label: "back down into the flat", side: sideNear},
{to: ScreenBBSTerminal, label: "the old terminal", side: sideRight},
},
}
}
+14
View File
@@ -0,0 +1,14 @@
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 {
return screen{
name: ScreenSamizdatPress,
title: "SAMIZDAT PRINTING HOUSE",
background: BgSamizdatPress,
onSelector: true,
}
}
+13
View File
@@ -0,0 +1,13 @@
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 {
return screen{
name: ScreenScrapMarket,
title: "SCRAP MARKET",
background: BgScrapMarket,
onSelector: true,
}
}
+21
View File
@@ -0,0 +1,21 @@
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 {
return screen{
name: ScreenSecretClub,
title: "SECRET CLUB — THE UNDERWATER SUN",
background: BgSecretClub,
exits: []exit{
{to: ScreenBBSTerminal, label: "the terminal in the corner", side: sideBack},
{to: ScreenTrinketShop, label: "back out through the shop", side: sideNear},
},
}
}
+18
View File
@@ -0,0 +1,18 @@
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 {
return screen{
name: ScreenSecretLab,
title: "SECRET RESEARCH LABORATORY",
background: BgSecretLab,
onSelector: true,
}
}
+64
View File
@@ -0,0 +1,64 @@
package inc
import (
"strings"
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 {
var exits []exit
for _, s := range rest {
if !s.onSelector {
continue
}
exits = append(exits, exit{
to: s.name,
label: pinLabel(s.title),
area: pin(len(exits)),
})
}
return screen{
name: ScreenSelector,
title: "SAN FRANCISCO",
background: BgSelector,
exits: exits,
// Nobody stands on a map.
actors: []inkwell.SceneActor{},
walkboxes: []inkwell.Polygon{},
}
}
// The pin grid: three columns down the scene region, filled top to bottom.
const (
pinCols = 3
pinW = 192.0
pinH = 32.0
pinX = 16.0
pinY = 28.0
pinGapX = 204.0
pinGapY = 38.0
)
func pin(i int) inkwell.Shape {
col, row := i%pinCols, i/pinCols
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 {
name, _, _ := strings.Cut(title, " — ")
return name
}
+13
View File
@@ -0,0 +1,13 @@
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 {
return screen{
name: ScreenServerFarm,
title: "SERVER FARM",
background: BgServerFarm,
onSelector: true,
}
}
+14
View File
@@ -0,0 +1,14 @@
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 {
return screen{
name: ScreenShowroom,
title: "NEUMATRONIC SHOWROOM",
background: BgShowroom,
onSelector: true,
}
}
+19
View File
@@ -0,0 +1,19 @@
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 {
return screen{
name: ScreenSmallRestaurant,
title: "SMALL RESTAURANT — NEXT DOOR",
background: BgSmallRestaurant,
exits: []exit{
{to: ScreenTrinketShop, label: "back to the trinket shop", side: sideLeft},
},
}
}
+17
View File
@@ -0,0 +1,17 @@
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 {
return screen{
name: ScreenStreet,
title: "STREET",
background: BgStreet,
onSelector: true,
}
}
+25
View File
@@ -0,0 +1,25 @@
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 {
return screen{
name: ScreenTrinketShop,
title: "CHINATOWN — TRINKET SHOP",
background: BgTrinketShop,
onSelector: true,
exits: []exit{
{to: ScreenSmallRestaurant, label: "the eating place next door", side: sideRight},
{
to: ScreenSecretClub, label: "the way in at the back", side: sideBack,
needs: FlagClubEntry,
blocked: "Just a shop, as far as the man behind the counter is concerned.",
},
},
}
}
+29
View File
@@ -0,0 +1,29 @@
package inc
import (
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 {
return inkwell.Script{
Name: ScriptFinale,
Actions: inkwell.Seq(
SetMode(w, ModeCutscene),
inkwell.Wait(0.6),
inkwell.Say(Paul, "I'm putting it in. That's all this is."),
inkwell.Wait(0.4),
UseTheme(NokiaPunk),
inkwell.Say("norman", "No — this isn't what you were supposed to do!"),
inkwell.Wait(0.6),
TapeSay(TapeMystery, "Thank you, Paul. You did exactly what I asked, the whole way through."),
inkwell.Wait(1.0),
inkwell.ShowEnd("REAL WORLD — end of game two"),
),
}
}
+18
View File
@@ -0,0 +1,18 @@
package inc
// Scripts — the game's named, reusable action sequences:
// cutscenes and recurring logic, built from the inkwell action set.
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// registerScript adds every script to the game.
func registerScript(w *World) {
for _, s := range []inkwell.Script{
scriptTapeInsert(w),
scriptAwakeningFinale(w),
} {
w.G.ScriptManager.Register(s)
}
}
+28
View File
@@ -0,0 +1,28 @@
package inc
import (
inkwell "git.teletypegames.org/engines/inkwell"
)
// tapeInsert runs whenever a tape goes into slot 2: it activates the tape and
// makes Dex comment. The wiki calls the comment mandatory — Dex remarks on
// every newly loaded tape, without exception.
func scriptTapeInsert(w *World) inkwell.Script {
return inkwell.Script{
Name: ScriptTapeInsert,
Actions: inkwell.Seq(
Fn(func(ctx *inkwell.Ctx) {
item := w.TakePending()
if item == "" {
return
}
w.SetSlot2(item)
ctx.Game.Inventory.Remove(item)
ctx.Game.State.SetVar(VarArmillaStrip, "SLOT2: READING")
}),
inkwell.Say(Paul, "Right. Let's see who you are."),
TapeSay(Dex, "Clicked in. Spinning. And now: nothing."),
TapeSay(Dex, "A stranger's tape in your own Armilla, Paul. I hope you know what you're doing."),
),
}
}
+155
View File
@@ -0,0 +1,155 @@
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 (
"image/color"
inkwell "git.teletypegames.org/engines/inkwell"
)
// The two theme names. Runtime switching (inkwell UseTheme) refers to these.
const (
RealWorld = "realworld-93"
NokiaPunk = "nokia-punk"
)
// RGB builds an opaque colour from a hex literal.
func RGB(hex uint32) color.Color {
return color.RGBA{R: uint8(hex >> 16), G: uint8(hex >> 8), B: uint8(hex), A: 0xff}
}
// RGBA builds a colour with the given alpha from a hex literal.
func RGBA(hex uint32, a uint8) color.Color {
return color.RGBA{R: uint8(hex >> 16), G: uint8(hex >> 8), B: uint8(hex), A: a}
}
// realworld-93 — 95% of the game. Muted earth tones, black HUD, amber tape
// channel. The Neumatronic layer is not visible yet.
// The realworld-93 palette. Ink and Amber are the two sides of the two-channel
// colour rule: the world speaks in bone white, tapes speak in amber.
var (
Ink = RGB(0xDCD5C4) // bone white — the voice of the world and of Paul
InkDim = RGB(0x8A806B)
Amber = RGB(0xE0A33E) // tape voice
AmberLo = RGB(0xA8701A)
black = RGB(0x14120E)
panel = RGB(0x0D0B08)
sceneBG = RGB(0x241F18)
)
func realWorldTheme() inkwell.Theme {
return inkwell.Theme{
Name: RealWorld,
PanelBG: black,
StatusText: Ink,
FlashText: Amber,
VerbButtonBG: RGB(0x1E1A14),
VerbButtonSelectedBG: AmberLo,
VerbButtonText: Ink,
InventorySlotBG: RGB(0x1E1A14),
InventorySlotSelectedBG: AmberLo,
SpeechBubbleBG: RGBA(0x14120E, 0xDC),
SpeechDefaultText: Ink,
DialogBG: RGBA(0x0D0B08, 0xF0),
DialogBorder: AmberLo,
DialogChoiceBG: RGB(0x1E1A14),
DialogChoiceHover: Amber,
DialogSpeaker: Amber,
DialogText: Ink,
EndCardBG: RGBA(0x000000, 0xFA),
EndCardText: Ink,
CursorColor: Amber,
HotspotOutline: RGB(0x7A6A44),
SceneBackdrop: sceneBG,
TopBarBG: panel,
TopBarText: InkDim,
TopBarAccent: Amber,
ChatLogBG: panel,
ChatLogPrompt: InkDim, // your own actions — quiet
ChatLogResponse: Amber, // tape voice
ChatLogSystem: RGB(0x6B6353),
CharacterPanelBG: panel,
CharacterPanelBorder: AmberLo,
CharacterPanelTitle: Amber,
}
}
// nokia-punk — at peak decay, on BBS/ECHO terminals, and in the finale.
// Matte black and phosphor green: a tuned variant of the engine's
// terminal-green preset.
// The nokia-punk palette — phosphor green on black glass.
var (
Green = RGB(0x3BE86B)
GreenLo = RGB(0x1F7A39)
npBlack = RGB(0x030603)
)
func nokiaPunkTheme() inkwell.Theme {
return inkwell.Theme{
Name: NokiaPunk,
PanelBG: npBlack,
StatusText: Green,
FlashText: RGB(0xD8F06A),
VerbButtonBG: RGB(0x08140A),
VerbButtonSelectedBG: GreenLo,
VerbButtonText: Green,
InventorySlotBG: RGB(0x08140A),
InventorySlotSelectedBG: GreenLo,
SpeechBubbleBG: RGBA(0x030603, 0xDC),
SpeechDefaultText: Green,
DialogBG: RGBA(0x030603, 0xF0),
DialogBorder: Green,
DialogChoiceBG: RGB(0x08140A),
DialogChoiceHover: RGB(0xB4FFC8),
DialogSpeaker: RGB(0xB4FFC8),
DialogText: Green,
EndCardBG: RGBA(0x000000, 0xFA),
EndCardText: Green,
CursorColor: Green,
HotspotOutline: GreenLo,
SceneBackdrop: npBlack,
TopBarBG: RGB(0x061006),
TopBarText: GreenLo,
TopBarAccent: Green,
ChatLogBG: RGB(0x040A04),
ChatLogPrompt: GreenLo,
ChatLogResponse: Green,
ChatLogSystem: RGB(0x156030),
CharacterPanelBG: RGB(0x040A04),
CharacterPanelBorder: Green,
CharacterPanelTitle: RGB(0xB4FFC8),
}
}
// registerTheme adds both themes to the game.
func registerTheme(g *inkwell.Game) {
g.ThemeManager.Register(realWorldTheme())
g.ThemeManager.Register(nokiaPunkTheme())
}
+54
View File
@@ -0,0 +1,54 @@
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 (
inkwell "git.teletypegames.org/engines/inkwell"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
"github.com/hajimehoshi/ebiten/v2/vector"
)
type Letterbox struct {
Name string
W *World
Bar float64 // height of the black bar, top and bottom
}
func (l *Letterbox) GetName() string { return l.Name }
func (l *Letterbox) Tick(ctx *inkwell.UICtx) {
if l.W.Mode() != ModeCutscene || !l.W.TapeWaiting() {
return
}
// inkwell's Input only covers the mouse; read the key directly.
if inpututil.IsKeyJustPressed(ebiten.KeySpace) {
l.W.TakeTapeOffer()
}
}
func (l *Letterbox) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
if l.W.Mode() != ModeCutscene {
return
}
g := ctx.Game
th := g.Theme()
bar := l.Bar
if bar <= 0 {
bar = 18
}
w, h := float32(g.Width), float32(g.Height)
black := RGB(0x000000)
vector.DrawFilledRect(dst, 0, 0, w, float32(bar), black, false)
vector.DrawFilledRect(dst, 0, h-float32(bar), w, float32(bar), black, false)
if l.W.TapeWaiting() {
msg := "SPACE — " + TapeDisplayName(Dex) + " has something to say"
drawTextC(dst, msg, g.Width-textW(msg)-4, g.Height-int(bar)+4, th.ChatLogResponse)
}
}
+232
View File
@@ -0,0 +1,232 @@
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 (
inkwell "git.teletypegames.org/engines/inkwell"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
)
// Screen and layout, in internal (unscaled) pixels.
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
ScreenH = 380
// LineH is one text row: the glyph cell plus leading. Every text-bearing
// box is sized as a multiple of this, so rows can never overlap.
LineH = glyphH + 2 // 18
Pad = 6 // inner padding for every panel
TopBarH = LineH + 2 // 20 — one row plus a hairline of breathing room
DividerX = 394 // world | tape channel (61.6% of the width)
// The HUD strip is as short as its own contents allow, and is measured
// from the bottom of the screen — every pixel it takes is a pixel of the
// 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 // 268
slotsY = HUDTop + Pad + LineH // 286
slotsH = LineH*2 + Pad*2 // 48 — two rows plus padding
invY = slotsY + 4 // 290
invSlot = 40
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) {
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(&ActionPump{Name: "pump", W: w})
g.UIManager.Register(&screenNav{Name: "screen_nav", W: w})
g.UIManager.Register(&windowSizer{Name: "window", Scale: 2})
g.UIManager.Register(&inkwell.HotspotDebug{Name: "hotspot_debug"})
top := &inkwell.TopBar{
Name: "topbar",
Height: TopBarH,
// The right section prints State.Var(TimeVar). We deliberately reuse
// it for the Armilla's single-line phosphor strip: in canon the device
// display is one line of text, not a phone screen.
TimeVar: VarArmillaStrip,
}
w.SetTopBar(top)
g.UIManager.Register(gated(w, "topbar_gate", top))
g.UIManager.Register(&Letterbox{Name: "letterbox", W: w, Bar: 36})
g.UIManager.Register(&hudFrame{Name: "hudframe", W: w})
g.UIManager.Register(gated(w, "status_gate", &inkwell.StatusLine{
Name: "status", Y: statusY, Align: inkwell.AlignLeft, ScreenWidth: DividerX,
}))
g.UIManager.Register(gated(w, "inventory_gate", &inkwell.InventoryBar{
Name: "inventory", Origin: inkwell.Point{X: Pad + 2, Y: invY},
Slots: 8, Cols: 4, SlotSize: invSlot, Gap: invGap,
}))
g.UIManager.Register(&TapeSlots{
Name: "tapes", W: w,
Bounds: inkwell.Rect(192, slotsY, 194, slotsH),
})
g.UIManager.Register(&TapeChannel{
Name: "channel", W: w,
Bounds: inkwell.Rect(DividerX+2, HUDTop+2, ScreenW-DividerX-4, ScreenH-HUDTop-4),
})
g.UIManager.Register(&inkwell.SpeechBubble{
Name: "speech", MaxWidth: 360, Padding: Pad, OffsetY: 8, FallbackY: TopBarH + Pad,
})
// The dialogue box deliberately covers only the left region, so a tape can
// comment ALONGSIDE the conversation. It consumes every click while active,
// so the tape channel stays visible but inert — which is what we want.
g.UIManager.Register(&inkwell.DialogBox{
Name: "dialog", Bounds: inkwell.Rect(0, ScreenH-LineH*9, DividerX, LineH*9),
LineHeight: LineH, Padding: Pad,
})
// A verb coin, not a verb bar: a permanent VerbBar would eat the left
// region, leaving room for neither the inventory nor the tape slots.
g.UIManager.Register(&inkwell.RadialVerbs{
Name: "verbs", Trigger: inkwell.MouseButtonRight, Radius: 58,
Labels: map[string]string{
"look": "Look", "use": "Use", "talk": "Talk", "take": "Take",
},
})
g.UIManager.Register(&inkwell.EndCard{Name: "endcard"})
g.UIManager.Register(&inkwell.Cursor{Name: "cursor"})
}
// ----- windowSizer ------------------------------------------------------
// windowSizer resizes the window once, on the first frame.
//
// inkwell.Run hardcodes a 4× window (core.dsl.go), which at 640×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 {
Name string
Scale int
done bool
}
func (s *windowSizer) GetName() string { return s.Name }
func (s *windowSizer) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {}
func (s *windowSizer) Tick(ctx *inkwell.UICtx) {
if s.done {
return
}
s.done = true
scale := s.Scale
if scale <= 0 {
scale = 2
}
ebiten.SetWindowSize(ctx.Game.Width*scale, ctx.Game.Height*scale)
}
// ----- gate -------------------------------------------------------------
// gate switches built-in widgets off in cutscene and menu mode. inkwell's
// widgets have no Visible field and the Manager cannot unregister, so we wrap.
// BlocksClickAt is forwarded, otherwise the verb coin would open over the HUD.
type gate struct {
Name string
W *World
Inner inkwell.Widget
}
func gated(w *World, name string, inner inkwell.Widget) inkwell.Widget {
return &gate{Name: name, W: w, Inner: inner}
}
func (n *gate) GetName() string { return n.Name }
func (n *gate) Tick(ctx *inkwell.UICtx) {
if n.W.HUDVisible() {
n.Inner.Tick(ctx)
}
}
func (n *gate) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
if n.W.HUDVisible() {
n.Inner.Draw(dst, ctx)
}
}
func (n *gate) BlocksClickAt(p inkwell.Point) bool {
if !n.W.HUDVisible() {
return false
}
b, ok := n.Inner.(interface{ BlocksClickAt(inkwell.Point) bool })
return ok && b.BlocksClickAt(p)
}
// ----- hudFrame ---------------------------------------------------------
// hudFrame paints the HUD strip's backdrop and the two dividers. The rule: the
// two channels always have a visible, continuous separator between them.
type hudFrame struct {
Name string
W *World
}
func (h *hudFrame) GetName() string { return h.Name }
func (h *hudFrame) Tick(ctx *inkwell.UICtx) {}
func (h *hudFrame) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
if !h.W.HUDVisible() {
return
}
th := ctx.Game.Theme()
vector.DrawFilledRect(dst, 0, HUDTop+1, ScreenW, ScreenH-HUDTop-1, th.PanelBG, false)
// horizontal: scene | HUD
vector.StrokeLine(dst, 0, HUDTop, ScreenW, HUDTop, 1, th.CharacterPanelBorder, false)
// vertical: world | tape
vector.StrokeLine(dst, DividerX, HUDTop+1, DividerX, ScreenH, 1, th.CharacterPanelBorder, false)
}
func (h *hudFrame) BlocksClickAt(p inkwell.Point) bool {
return h.W.HUDVisible() && p.Y >= HUDTop
}
+62
View File
@@ -0,0 +1,62 @@
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 (
inkwell "git.teletypegames.org/engines/inkwell"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/inpututil"
)
type screenNav struct {
Name string
W *World
}
func (n *screenNav) GetName() string { return n.Name }
func (n *screenNav) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {}
func (n *screenNav) Tick(ctx *inkwell.UICtx) {
if !n.W.HUDVisible() || n.W.Paused() {
return
}
step := 0
// inkwell's Input only covers the mouse; read the keys directly.
switch {
case inpututil.IsKeyJustPressed(ebiten.KeyArrowRight):
step = 1
case inpututil.IsKeyJustPressed(ebiten.KeyArrowLeft):
step = -1
default:
return
}
if next := n.neighbour(ctx.Game, step); next != "" {
n.W.Do(inkwell.GoTo(next))
}
}
// 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 {
deck := g.SceneManager.Names()
if len(deck) < 2 {
return ""
}
for i, name := range deck {
if name == n.W.Scene() {
return deck[((i+step)%len(deck)+len(deck))%len(deck)]
}
}
return deck[0]
}
+34
View File
@@ -0,0 +1,34 @@
package inc
import (
"testing"
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) {
g := inkwell.NewGame("t", ScreenW, ScreenH)
for _, n := range []string{"a", "b", "c"} {
g.SceneManager.Register(inkwell.Scene{Name: n, Background: "bg"})
}
w := newWorld(g)
nav := &screenNav{Name: "screen_nav", W: w}
at := func(name string) { EnterScene(w, name).Start().Tick(&inkwell.Ctx{Game: g}) }
for _, tc := range []struct {
from string
step int
want string
}{
{"a", 1, "b"}, {"b", 1, "c"}, {"c", 1, "a"},
{"c", -1, "b"}, {"b", -1, "a"}, {"a", -1, "c"},
} {
at(tc.from)
if got := nav.neighbour(g, tc.step); got != tc.want {
t.Errorf("from %q step %+d = %q, want %q", tc.from, tc.step, got, tc.want)
}
}
}
+120
View File
@@ -0,0 +1,120 @@
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 (
"image/color"
inkwell "git.teletypegames.org/engines/inkwell"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
)
type TapeChannel struct {
Name string
W *World
Bounds inkwell.Rectangle
}
func (t *TapeChannel) GetName() string { return t.Name }
func (t *TapeChannel) Tick(ctx *inkwell.UICtx) {}
// BlocksClickAt — the channel is not interactive, but it is a solid input
// zone: the verb coin must not open over it.
func (t *TapeChannel) BlocksClickAt(p inkwell.Point) bool {
return t.W.HUDVisible() && t.Bounds.Contains(p)
}
func (t *TapeChannel) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
if !t.W.HUDVisible() {
return
}
g := ctx.Game
th := g.Theme()
b := t.Bounds
if b.W <= 0 || b.H <= 0 {
return
}
const lh, pad = LineH, Pad
vector.DrawFilledRect(dst, float32(b.X), float32(b.Y), float32(b.W), float32(b.H), th.ChatLogBG, false)
// Header: the name of whichever tape spoke last — not "console".
speaker, speakerCol := t.lastSpeaker(g, th)
drawTextC(dst, speaker, int(b.X)+pad, int(b.Y)+pad, speakerCol)
hy := float32(b.Y+pad+lh) + 2
vector.StrokeLine(dst, float32(b.X)+pad, hy,
float32(b.X+b.W)-pad, hy, 1, th.CharacterPanelBorder, false)
type line struct {
text string
col color.Color
}
wrapW := int(b.W) - pad*2
var rendered []line
for _, m := range g.Messages() {
var col color.Color
var txt string
switch m.Kind {
case inkwell.LogAction:
// Your own actions — quiet, but visible: that is what makes the
// joke book readable.
col, txt = th.ChatLogPrompt, m.Text
case inkwell.LogResponse:
if !IsTape(m.Speaker) {
continue // Paul and the NPCs speak in the scene, not here
}
col, txt = speechColor(g, m.Speaker, th.ChatLogResponse), m.Text
default:
col, txt = th.ChatLogSystem, m.Text
}
for _, wl := range wrap(txt, wrapW) {
rendered = append(rendered, line{text: wl, col: col})
}
}
top := int(b.Y) + pad + lh + 6
maxLines := (int(b.Y+b.H) - pad - top) / lh
if maxLines <= 0 {
return
}
if start := len(rendered) - maxLines; start > 0 {
rendered = rendered[start:]
}
for i, ln := range rendered {
drawTextC(dst, ln.text, int(b.X)+pad, top+i*lh, ln.col)
}
}
// lastSpeaker returns the display name and colour of whichever tape spoke
// last, defaulting to the occupant of slot 1.
func (t *TapeChannel) lastSpeaker(g *inkwell.Game, th inkwell.Theme) (string, color.Color) {
msgs := g.Messages()
for i := len(msgs) - 1; i >= 0; i-- {
m := msgs[i]
if m.Kind == inkwell.LogResponse && IsTape(m.Speaker) {
return TapeDisplayName(m.Speaker), speechColor(g, m.Speaker, 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 {
if c, ok := g.CharacterManager.Get(name); ok && c.SpeechColor != nil {
return c.SpeechColor
}
return fallback
}
+152
View File
@@ -0,0 +1,152 @@
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 (
inkwell "git.teletypegames.org/engines/inkwell"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
)
type TapeSlots struct {
Name string
W *World
Bounds inkwell.Rectangle
}
func (t *TapeSlots) GetName() string { return t.Name }
func (t *TapeSlots) BlocksClickAt(p inkwell.Point) bool {
return t.W.HUDVisible() && t.Bounds.Contains(p)
}
// slotRects splits the widget bounds into the two slot rectangles.
func (t *TapeSlots) slotRects() (inkwell.Rectangle, inkwell.Rectangle) {
b := t.Bounds
w := (b.W - Pad) / 2
return inkwell.Rect(b.X, b.Y, w, b.H), inkwell.Rect(b.X+w+Pad, b.Y, w, b.H)
}
func (t *TapeSlots) Tick(ctx *inkwell.UICtx) {
if !t.W.HUDVisible() {
return
}
g := ctx.Game
mp := g.Input.Point()
r1, r2 := t.slotRects()
// Hover label, so the StatusLine spells out what is about to happen.
switch {
case r1.Contains(mp):
g.SetHoverLabel(TapeDisplayName(Dex))
case r2.Contains(mp):
if s := t.W.Slot2(); s != "" {
g.SetHoverLabel(itemLabel(g, s))
} else {
g.SetHoverLabel("empty tape slot")
}
}
if !g.Input.LeftClicked() {
return
}
if !r1.Contains(mp) && !r2.Contains(mp) {
return
}
g.Input.ConsumeLeft()
slot2 := r2.Contains(mp)
sel := g.Inventory.Selected()
// Loading a tape into slot 2.
if slot2 && sel != "" {
g.Inventory.Select("")
if IsTapeItem(sel) {
t.W.SetPending(sel)
t.W.Do(inkwell.RunScript(ScriptTapeInsert))
return
}
t.W.Do(TapeSay(Dex, "That isn't a tape, Paul. That's an object. There is a difference."))
return
}
if !slot2 && sel != "" {
g.Inventory.Select("")
t.W.Do(TapeSay(Dex, "The first slot is mine. I'm not moving."))
return
}
switch g.SelectedVerb() {
case "talk":
if !slot2 {
t.W.Do(Paused(t.W, inkwell.RunDialogue(DlgDex)))
return
}
if s := t.W.Slot2(); s != "" {
t.W.Do(Paused(t.W, inkwell.RunDialogue(TapeDialogue(s))))
return
}
t.W.Do(TapeSay(Dex, "Empty. Nobody to talk to."))
default: // look, take, use all fall back to a description
if !slot2 {
t.W.Do(TapeSay(Dex, "Me. Four kilobytes of a dead man. Be grateful."))
return
}
if s := t.W.Slot2(); s != "" {
t.W.Do(TapeSay(Dex, "That is the second slot. Be careful what you let in."))
return
}
t.W.Do(TapeSay(Dex, "The second slot is empty. That's the rarer state."))
}
}
func (t *TapeSlots) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {
if !t.W.HUDVisible() {
return
}
g := ctx.Game
th := g.Theme()
r1, r2 := t.slotRects()
// Two text rows per slot: the slot number on top, its occupant below.
// Both rows are LineH tall, so nothing can overlap the border.
drawSlot := func(r inkwell.Rectangle, label, body string, filled bool) {
bg, border, col := th.InventorySlotBG, th.ChatLogSystem, th.ChatLogSystem
if filled {
bg, border, col = th.PanelBG, th.CharacterPanelBorder, th.ChatLogResponse
}
vector.DrawFilledRect(dst, float32(r.X), float32(r.Y), float32(r.W), float32(r.H), bg, false)
vector.StrokeRect(dst, float32(r.X), float32(r.Y), float32(r.W), float32(r.H), 1, border, false)
x, inner := int(r.X)+Pad, int(r.W)-Pad*2
drawTextC(dst, label, x, int(r.Y)+Pad/2, th.ChatLogSystem)
drawTextC(dst, clip(body, inner), x, int(r.Y)+Pad/2+LineH, col)
}
drawSlot(r1, "SLOT 1", TapeDisplayName(Dex), true)
if s := t.W.Slot2(); s != "" {
drawSlot(r2, "SLOT 2", itemLabel(g, s), true)
} else {
drawSlot(r2, "SLOT 2", "empty", false)
}
}
// itemLabel is a short display label for an inventory item.
func itemLabel(g *inkwell.Game, name string) string {
if it, ok := g.ItemManager.Get(name); ok && it.Description != "" {
return it.Description
}
return name
}
+125
View File
@@ -0,0 +1,125 @@
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 (
"image"
"image/color"
"unicode/utf8"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/ebitenutil"
)
// glyphW and glyphH are the ebitenutil debug font's cell. The engine's own
// textWidth uses the same 6px but counts bytes, which mismeasures any
// non-ASCII text; we count runes.
const (
glyphW = 6
glyphH = 16
)
var scratch *ebiten.Image
// textW is the pixel width of a string, counted in runes.
func textW(s string) int { return utf8.RuneCountInString(s) * glyphW }
// drawTextC draws one line in colour c, with the same origin as DebugPrintAt.
func drawTextC(dst *ebiten.Image, s string, x, y int, c color.Color) {
if s == "" {
return
}
w, h := textW(s)+glyphW, glyphH
if scratch == nil || scratch.Bounds().Dx() < w || scratch.Bounds().Dy() < h {
nw, nh := w, h
if nw < 320 {
nw = 320
}
scratch = ebiten.NewImage(nw, nh)
}
scratch.Clear()
ebitenutil.DebugPrintAt(scratch, s, 0, 0)
if c == nil {
c = color.White
}
r, g, b, a := c.RGBA()
op := &ebiten.DrawImageOptions{}
op.GeoM.Translate(float64(x), float64(y))
op.ColorScale.Scale(
float32(r)/0xffff, float32(g)/0xffff, float32(b)/0xffff, float32(a)/0xffff,
)
dst.DrawImage(scratch.SubImage(image.Rect(0, 0, w, h)).(*ebiten.Image), op)
}
// wrap breaks s at word boundaries into lines no wider than maxPx. The engine's
// wrapText is unexported and byte-based, hence our own.
func wrap(s string, maxPx int) []string {
if maxPx <= glyphW || textW(s) <= maxPx {
return []string{s}
}
var out []string
line, word := "", ""
flush := func() {
if line != "" {
out = append(out, line)
line = ""
}
}
emit := func() {
if word == "" {
return
}
switch {
case line == "":
line = word
case textW(line+" "+word) <= maxPx:
line += " " + word
default:
flush()
line = word
}
word = ""
}
for _, r := range s {
switch r {
case ' ':
emit()
case '\n':
emit()
flush()
default:
word += string(r)
}
}
emit()
flush()
if len(out) == 0 {
return []string{s}
}
return out
}
// clip truncates a string so it fits into maxPx.
func clip(s string, maxPx int) string {
r := []rune(s)
max := maxPx / glyphW
switch {
case max <= 0:
return ""
case len(r) <= max:
return s
case max == 1:
return string(r[:1])
default:
return string(r[:max-1]) + "…"
}
}
+73
View File
@@ -0,0 +1,73 @@
package inc
import "testing"
// The engine's textWidth counts bytes, which mismeasures any non-ASCII string.
// Ours counts runes — pin that down, because wrapping depends on it.
func TestTextWidthCountsRunes(t *testing.T) {
if got, want := textW("naïve"), 5*glyphW; got != want {
t.Errorf("textW(naïve) = %d, want %d", got, want)
}
if got, want := textW("ab"), 2*glyphW; got != want {
t.Errorf("textW(ab) = %d, want %d", got, want)
}
}
func TestWrapRespectsWidth(t *testing.T) {
const maxPx = 60
lines := wrap("An Armilla without a tape is a stupid watch, remember that.", maxPx)
if len(lines) < 2 {
t.Fatalf("did not wrap: %v", lines)
}
for _, l := range lines {
if textW(l) > maxPx && len(wrap(l, maxPx)) > 1 {
t.Errorf("line too long (%d px): %q", textW(l), l)
}
}
}
func TestClipFits(t *testing.T) {
if got := clip("black-market Armilla", 8*glyphW); textW(got) > 8*glyphW {
t.Errorf("clip too wide: %q", got)
}
if got := clip("dex", 8*glyphW); got != "dex" {
t.Errorf("clip shortened a fitting string: %q", got)
}
}
// Regression guard for the bug that made the first build unreadable: the
// layout was derived from the wireframe deck's ratios, where text is 2.8% of
// the screen height, while the engine's debug font is a fixed 6×16 cell. At
// 320×200 that is 8% — so rows overlapped and the top bar overflowed into the
// scene. Every text row must be at least as tall as the glyph cell.
func TestLayoutFitsTheFont(t *testing.T) {
if LineH < glyphH {
t.Errorf("LineH (%d) is shorter than the glyph cell (%d): rows will overlap", LineH, glyphH)
}
if TopBarH < glyphH {
t.Errorf("TopBarH (%d) cannot hold a %dpx glyph", TopBarH, glyphH)
}
// The tape channel must fit a header, a rule and at least four body rows —
// below that the dialogue is unreadable.
const channelH = ScreenH - HUDTop - 4
body := (channelH - Pad*2 - LineH - 6) / LineH
if body < 4 {
t.Errorf("tape channel only fits %d body rows, want >= 4", body)
}
// The two tape slots must fit their two rows inside their border.
if slotsH < LineH*2+Pad {
t.Errorf("slotsH (%d) cannot hold two %dpx rows", slotsH, LineH)
}
}
// The HUD must not reach outside the screen, and the scene must keep a usable
// aspect ratio for a point & click background.
func TestSceneProportions(t *testing.T) {
if invY+invSlot*2+invGap > ScreenH {
t.Errorf("inventory runs off the bottom: %d > %d", invY+invSlot*2+invGap, ScreenH)
}
sceneH := HUDTop - TopBarH
if ratio := float64(ScreenW) / float64(sceneH); ratio > 3.0 {
t.Errorf("scene is too letterboxed for a background: %.2f:1", ratio)
}
}
+125
View File
@@ -0,0 +1,125 @@
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 (
"math/rand"
inkwell "git.teletypegames.org/engines/inkwell"
"github.com/hajimehoshi/ebiten/v2"
)
type UseWithGuard struct {
Name string
W *World
}
func (u *UseWithGuard) GetName() string { return u.Name }
func (u *UseWithGuard) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {}
func (u *UseWithGuard) Tick(ctx *inkwell.UICtx) {
g := ctx.Game
if !u.W.HUDVisible() || !g.Input.LeftClicked() {
return
}
sel := g.Inventory.Selected()
if sel == "" {
return
}
h := g.HotspotAt(g.Input.Point())
if h == nil || hasAuthoredPair(g, h, sel) {
return // leave authored pairs to the engine
}
g.Input.ConsumeLeft()
g.Inventory.Select("")
label := h.Label
if label == "" {
label = h.Name
}
g.LogAction("> Use " + itemLabel(g, sel) + " on: " + label)
// The tone escalates on repeats: dry, then teasing, then a real nudge.
key := "fail." + sel + "." + h.Name
g.State.NoteTalked(key)
n := g.State.Talked(key)
u.W.Do(inkwell.Seq(
inkwell.Say(Paul, pick(paulFails, n)),
TapeSay(Dex, dexFail(n)),
))
}
// hasAuthoredPair mirrors the engine's lookup order in invokeHotspotVerb.
func hasAuthoredPair(g *inkwell.Game, h *inkwell.Hotspot, item string) bool {
if h.OnUseWith != nil {
if a, ok := h.OnUseWith[item]; ok && a != nil {
return true
}
}
if it, ok := g.ItemManager.Get(item); ok && it.OnUseWith != nil {
if a, ok := it.OnUseWith[h.Name]; ok && a != nil {
return true
}
}
return false
}
var paulFails = []string{
"No. That's not going to work.",
"Tried that. It didn't improve.",
"All right, I know that one doesn't work.",
"Now it's personal.",
}
var dexDry = []string{
"No. Not like that.",
"I heard it. Nothing happened.",
}
var dexTease = []string{
"Twice the same. The second one rarely goes better.",
"Do it a few more times, maybe physics reconsiders.",
}
// dexFail escalates: dry, then teasing, then an actual hint — so the joke
// never becomes the wall.
func dexFail(n int) string {
switch {
case n <= 1:
return pick(dexDry, n)
case n == 2:
return pick(dexTease, n)
default:
return "Paul. Leave it. Look again at what you're carrying — that's where it is."
}
}
func pick(s []string, n int) string {
switch {
case len(s) == 0:
return ""
case n <= 0:
return s[0]
case n-1 < len(s):
return s[n-1]
default:
return s[rand.Intn(len(s))]
}
}
+224
View File
@@ -0,0 +1,224 @@
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 (
inkwell "git.teletypegames.org/engines/inkwell"
"github.com/hajimehoshi/ebiten/v2"
)
// ----- pump -------------------------------------------------------------
// Do queues a HUD-originated action.
func (w *World) Do(a inkwell.Action) {
if a != nil {
w.queue = append(w.queue, a)
}
}
// PumpTick advances the pump. The ActionPump widget calls it every frame.
func (w *World) PumpTick(dt float64) {
// The TopBar's left section: location plus a single word for a stopped
// world. No pause overlay, no blur — one word says time has stopped.
if w.top != nil {
title := w.sceneTitle()
if w.paused {
title += " — paused"
}
w.top.LeftText = title
}
if w.running == nil {
if len(w.queue) == 0 {
return
}
w.running = w.queue[0].Start()
w.queue = w.queue[1:]
}
ctx := &inkwell.Ctx{Game: w.G, DT: dt}
if s, ok := w.G.SceneManager.Get(w.scene); ok {
ctx.Scene = &s
}
if w.running.Tick(ctx) != inkwell.StatusRunning {
w.running = nil
}
}
func (w *World) sceneTitle() string {
s, ok := w.G.SceneManager.Get(w.scene)
if !ok {
return ""
}
if s.Title != "" {
return s.Title
}
return s.Name
}
// ActionPump is an invisible widget that advances the pump. The HUD registers
// it; the world itself knows nothing about the widget tree.
type ActionPump struct {
Name string
W *World
}
func (p *ActionPump) GetName() string { return p.Name }
func (p *ActionPump) Tick(ctx *inkwell.UICtx) { p.W.PumpTick(ctx.DT) }
func (p *ActionPump) Draw(dst *ebiten.Image, ctx *inkwell.UICtx) {}
// ----- helpers ----------------------------------------------------------
type fnAction struct{ fn func(*inkwell.Ctx) }
func (a *fnAction) Start() inkwell.Runner { return &fnRunner{spec: a} }
type fnRunner struct{ spec *fnAction }
func (r *fnRunner) Tick(ctx *inkwell.Ctx) inkwell.Status {
r.spec.fn(ctx)
return inkwell.StatusDone
}
// Fn turns a function into a one-shot action that completes immediately.
func Fn(f func(*inkwell.Ctx)) inkwell.Action { return &fnAction{fn: f} }
// UseTheme switches the theme at runtime. This is the single line that makes
// the Nokia-punk texture break in during the finale. inkwell saves ActiveTheme,
// so the switch survives a save without needing a flag of its own.
func UseTheme(name string) inkwell.Action {
return Fn(func(ctx *inkwell.Ctx) { ctx.Game.UseTheme(name) })
}
// SetMode switches the HUD mode (play / cutscene / menu).
func SetMode(w *World, m Mode) inkwell.Action {
return Fn(func(*inkwell.Ctx) { w.SetMode(m) })
}
// EnterScene tells the domain which scene we are in. inkwell exposes no
// CurrentScene accessor, and the pump needs Ctx.Scene.
func EnterScene(w *World, name string) inkwell.Action {
return Fn(func(*inkwell.Ctx) {
if name != w.scene {
w.prev = w.scene
}
w.scene = name
})
}
// 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 {
return Fn(func(ctx *inkwell.Ctx) {
if w.prev == "" {
return
}
inkwell.GoTo(w.prev).Start().Tick(ctx)
})
}
// ----- TapeSay ----------------------------------------------------------
// TapeSay is a line spoken by a tape.
//
// It is deliberately not inkwell.Say: Say puts a SpeechBubble above the
// speaker's head, but tapes have no body in the scene — they speak into your
// ear. This is the two-channel rule enforced in code: a tape can only reach the
// tape channel, never the scene.
func TapeSay(speaker, text string) inkwell.Action {
return &tapeSayAction{speaker: speaker, text: text}
}
type tapeSayAction struct{ speaker, text string }
func (a *tapeSayAction) Start() inkwell.Runner { return &tapeSayRunner{spec: a} }
type tapeSayRunner struct {
spec *tapeSayAction
started bool
elapsed float64
duration float64
}
func (r *tapeSayRunner) Tick(ctx *inkwell.Ctx) inkwell.Status {
if !r.started {
r.started = true
// Same pacing as Say, so the two breathe together inside a Seq.
r.duration = 1.2 + float64(len([]rune(r.spec.text)))*0.05
ctx.Game.LogResponse(r.spec.speaker, r.spec.text)
}
r.elapsed += ctx.DT
if r.elapsed >= r.duration {
return inkwell.StatusDone
}
return inkwell.StatusRunning
}
// ----- TapeOffer --------------------------------------------------------
// TapeOffer offers a tape line during a cutscene: the Letterbox shows that a
// tape would speak, but it only speaks if the player asks. A tape never
// interrupts — the floor is the player's.
func TapeOffer(w *World, line inkwell.Action) inkwell.Action {
return Fn(func(*inkwell.Ctx) {
w.tapeLine = line
w.tapeWaiting = true
})
}
// TakeTapeOffer queues the offered line and clears the prompt.
func (w *World) TakeTapeOffer() {
if !w.tapeWaiting {
return
}
w.tapeWaiting = false
line := w.tapeLine
w.tapeLine = nil
w.Do(line)
}
// ----- Paused -----------------------------------------------------------
// Paused marks the world as stopped for the duration of the wrapped action.
// inkwell exposes no dialogueActive accessor, so we mark it on the content
// side: every dialogue start is wrapped in this.
func Paused(w *World, inner inkwell.Action) inkwell.Action {
return &pausedAction{w: w, inner: inner}
}
type pausedAction struct {
w *World
inner inkwell.Action
}
func (a *pausedAction) Start() inkwell.Runner {
return &pausedRunner{spec: a, inner: a.inner.Start()}
}
type pausedRunner struct {
spec *pausedAction
inner inkwell.Runner
started bool
}
func (r *pausedRunner) Tick(ctx *inkwell.Ctx) inkwell.Status {
if !r.started {
r.started = true
r.spec.w.paused = true
}
s := r.inner.Tick(ctx)
if s != inkwell.StatusRunning {
r.spec.w.paused = false
}
return s
}
+101
View File
@@ -0,0 +1,101 @@
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 (
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
const (
ModePlay Mode = "play" // full HUD
ModeCutscene Mode = "cutscene" // letterbox, HUD hidden
ModeMenu Mode = "menu" // main menu, HUD hidden
)
// World holds the runtime state that does not belong in a save file.
type World struct {
G *inkwell.Game
mode Mode
scene string // current scene — inkwell exposes no accessor for it
prev string // the one before it, for screens with no fixed way out
paused bool // is the world stopped (during dialogue), for the TopBar suffix
pending string // the tape currently being loaded into slot 2
queue []inkwell.Action // HUD-originated actions waiting to run
running inkwell.Runner
top *inkwell.TopBar // the location bar, so the pump can write its suffix
// During a cutscene a tape signals but never interrupts: the line waits
// here until the player asks for it with SPACE.
tapeWaiting bool
tapeLine inkwell.Action
}
// New wraps an existing inkwell game.
func newWorld(g *inkwell.Game) *World {
return &World{G: g, mode: ModePlay}
}
// Mode returns the current HUD mode.
func (w *World) Mode() Mode { return w.mode }
// SetMode switches the HUD mode.
func (w *World) SetMode(m Mode) { w.mode = m }
// HUDVisible reports whether the HUD should draw: only in play mode.
func (w *World) HUDVisible() bool { return w.mode == ModePlay }
// Scene returns the name of the current scene.
func (w *World) Scene() string { return w.scene }
// Previous returns the screen we came from, empty at the start of the game.
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 }
// SetTopBar hands over the location bar so the pump can write its suffix.
func (w *World) SetTopBar(t *inkwell.TopBar) { w.top = t }
// Slot2 returns the item name in the second tape slot, empty if there is none.
//
// Unlike Mode, this is REAL game state and lives in State.Var so that it is
// saved.
func (w *World) Slot2() string {
if v, ok := w.G.State.Var(VarSlot2).(string); ok {
return v
}
return ""
}
// SetSlot2 sets the contents of the second tape slot.
func (w *World) SetSlot2(item string) { w.G.State.SetVar(VarSlot2, item) }
// SetPending marks which tape is going into slot 2. The tape-insert script
// performs the actual load; the widget only marks.
func (w *World) SetPending(item string) { w.pending = item }
// TakePending returns and clears the tape waiting to be loaded.
func (w *World) TakePending() string {
item := w.pending
w.pending = ""
return item
}
// TapeWaiting reports whether a tape line has been offered but not yet spoken.
func (w *World) TapeWaiting() bool { return w.tapeWaiting }
+42
View File
@@ -0,0 +1,42 @@
package inc
import (
"testing"
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) {
w := newWorld(inkwell.NewGame("t", 320, 200))
w.SetMode(ModeCutscene)
for _, k := range []string{"mode", "mode"} {
if v := w.G.State.Var(k); v != nil {
t.Errorf("mode leaked into State.Vars (%q = %v)", k, v)
}
}
// Slot 2, by contrast, is real game state and must be saved.
w.SetSlot2(ItemMysteryTape)
if w.G.State.Var(VarSlot2) != ItemMysteryTape {
t.Error("slot 2 is not in State.Vars")
}
if got := w.Slot2(); got != ItemMysteryTape {
t.Errorf("Slot2() = %q", got)
}
}
// The widget marks a tape as pending; the script consumes it — once.
func TestPendingIsConsumedOnce(t *testing.T) {
w := newWorld(inkwell.NewGame("t", 320, 200))
w.SetPending(ItemMysteryTape)
if got := w.TakePending(); got != ItemMysteryTape {
t.Fatalf("TakePending() = %q", got)
}
if got := w.TakePending(); got != "" {
t.Errorf("pending was not cleared: %q", got)
}
}