game skeleton

This commit is contained in:
2026-08-29 19:07:32 +02:00
parent a04a61ddd0
commit 0d8918e6f5
39 changed files with 2540 additions and 0 deletions
@@ -0,0 +1,16 @@
package asset
import (
inkwell "git.teletypegames.org/engines/inkwell"
"realworld/internal/names"
)
// alleyBackground is the alley behind Noodle's house (beat 3).
func alleyBackground() inkwell.Asset {
return inkwell.Asset{
Name: names.AssetAlleyBG,
Path: "assets/bg/alley.png",
Kind: inkwell.AssetImage,
}
}
+22
View File
@@ -0,0 +1,22 @@
// Package asset registers the game's assets.
//
// During the greybox phase no asset file exists on disk: inkwell draws a
// placeholder when a sprite is missing, which is exactly the wireframe deck's
// dashed-outline convention, for free. Only the registration has to exist —
// Game.Validate requires every scene background to name a registered asset.
package asset
import (
inkwell "git.teletypegames.org/engines/inkwell"
"realworld/internal/world"
)
// Register adds every asset to the game.
func Register(w *world.World) {
for _, a := range []inkwell.Asset{
alleyBackground(),
} {
w.G.AssetManager.Register(a)
}
}
+24
View File
@@ -0,0 +1,24 @@
// Package character registers the game's characters.
//
// Tapes are characters too, not props — the wiki is explicit about it. They
// have no body in the scene, so they carry no W/H and never appear in
// Scene.Actors; their voice lives in the tape channel (see world.TapeSay).
package character
import (
inkwell "git.teletypegames.org/engines/inkwell"
"realworld/internal/world"
)
// Register adds every character to the game.
func Register(w *world.World) {
for _, c := range []inkwell.Character{
paul(),
dex(),
mysteryTape(),
supportTape(),
} {
w.G.CharacterManager.Register(c)
}
}
+17
View File
@@ -0,0 +1,17 @@
package character
import (
inkwell "git.teletypegames.org/engines/inkwell"
"realworld/internal/names"
"realworld/internal/theme"
)
// dex is the personality imprint of a dead friend, permanently in slot 1 of
// Paul's Armilla. Constant commentary, hint system, dialogue partner.
func dex() inkwell.Character {
return inkwell.Character{
Name: names.Dex,
SpeechColor: theme.Amber,
}
}
@@ -0,0 +1,20 @@
package character
import (
inkwell "git.teletypegames.org/engines/inkwell"
"realworld/internal/names"
"realworld/internal/theme"
)
// mysteryTape is the tape found in the alley — in truth Norman's Personal
// Tape, but the player only learns that in the finale.
//
// Its voice is a colder, greyer amber than Dex's. That pays off in reverse:
// it never once spoke in the same voice as the friend.
func mysteryTape() inkwell.Character {
return inkwell.Character{
Name: names.TapeMystery,
SpeechColor: theme.RGB(0xB9A98A),
}
}
+23
View File
@@ -0,0 +1,23 @@
package character
import (
inkwell "git.teletypegames.org/engines/inkwell"
"realworld/internal/names"
"realworld/internal/theme"
)
// paul is the player character: a junk dealer and street survivor, technically
// competent, living on the edge of the city.
func paul() inkwell.Character {
return inkwell.Character{
Name: names.Paul,
Speed: 96,
W: 28,
H: 68,
Start: inkwell.Point{X: 120, Y: 232},
// Bone white: the world and Paul. If something is amber, a tape said
// it — see the colour rule in README.
SpeechColor: theme.Ink,
}
}
@@ -0,0 +1,17 @@
package character
import (
inkwell "git.teletypegames.org/engines/inkwell"
"realworld/internal/names"
"realworld/internal/theme"
)
// supportTape answers everything with basic information, insufferably. Dex
// hates it. Acquired in Chinatown (beat 5) — not reachable yet.
func supportTape() inkwell.Character {
return inkwell.Character{
Name: names.TapeSupport,
SpeechColor: theme.RGB(0xE8C86A),
}
}
+28
View File
@@ -0,0 +1,28 @@
// Package content is the composition root for everything the game declares:
// assets, characters, items, dialogues, scripts and scenes.
//
// Each registered entity lives in its own file, under a subpackage named after
// its kind. Adding a scene means adding scene/<name>.go and one line in
// scene/scene.go — nothing else moves.
package content
import (
"realworld/internal/content/asset"
"realworld/internal/content/character"
"realworld/internal/content/dialog"
"realworld/internal/content/item"
"realworld/internal/content/scene"
"realworld/internal/content/script"
"realworld/internal/world"
)
// Register adds every entity to the game. Order matters only in that scenes
// reference assets and characters, which Game.Validate cross-checks.
func Register(w *world.World) {
asset.Register(w)
character.Register(w)
item.Register(w)
dialog.Register(w)
script.Register(w)
scene.Register(w)
}
+57
View File
@@ -0,0 +1,57 @@
package dialog
import (
inkwell "git.teletypegames.org/engines/inkwell"
"realworld/internal/names"
)
// dexTalk is the standing conversation with Dex. Its branches are gated on
// story flags, so the same entry point stays useful as the game progresses.
func dexTalk() inkwell.Dialogue {
return inkwell.Dialogue{
Name: names.DlgDex,
Start: "root",
Nodes: []inkwell.DialogueNode{{
Name: "root",
Lines: []inkwell.DialogueLine{{Speaker: names.Dex, Text: "Talk."}},
Choices: []inkwell.DialogueChoice{
{
Text: "What am I doing here, Dex?",
Actions: []inkwell.Action{
inkwell.Say(names.Dex, "You got a letter from a man you hadn't seen in twenty years. And you came. That says more about you than it does about him."),
inkwell.GotoNode("root"),
},
},
{
Text: "What do you know about Noodle?",
Show: inkwell.Not(inkwell.Flag(names.FlagHasTape)),
Actions: []inkwell.Action{
inkwell.Say(names.Dex, "He traded cracked Armillas. That isn't charity, Paul, that's a business. The kind they take you in for."),
inkwell.GotoNode("root"),
},
},
{
Text: "What do you make of this tape?",
Show: inkwell.Flag(names.FlagHasTape),
Actions: []inkwell.Action{
inkwell.Say(names.Dex, "I make of it that you found a password-locked tape in a gap between two bins, on a tip from a government office. Count how many times that has ended well."),
inkwell.GotoNode("root"),
},
},
{
Text: "I miss you.",
Once: true,
Actions: []inkwell.Action{
inkwell.Say(names.Dex, "I'm four kilobytes of a dead man. Don't do this to yourself."),
inkwell.GotoNode("root"),
},
},
{
Text: "Nothing. Forget it.",
Actions: []inkwell.Action{inkwell.EndDialogue()},
},
},
}},
}
}
+26
View File
@@ -0,0 +1,26 @@
// Package dialog registers the game's dialogue trees.
//
// Talking to a tape carries the same weight as talking to a human NPC — the
// wiki states this explicitly. Tapes are dialogue partners, not menus.
//
// A note on DialogueChoice.Once: inkwell HIDES a spent choice. The wireframe
// deck wanted it struck through but still visible, so the list becomes a
// memory of what you already tried. That is still open — see README, "Known
// gaps".
package dialog
import (
inkwell "git.teletypegames.org/engines/inkwell"
"realworld/internal/world"
)
// Register adds every dialogue to the game.
func Register(w *world.World) {
for _, d := range []inkwell.Dialogue{
dexTalk(),
mysteryTapeSilent(),
} {
w.G.DialogueManager.Register(d)
}
}
@@ -0,0 +1,34 @@
package dialog
import (
inkwell "git.teletypegames.org/engines/inkwell"
"realworld/internal/names"
)
// mysteryTapeSilent covers the stretch before the club trigger (beat 6), while
// the tape is still mute. Talking to it is really Dex talking about it — the
// tape itself says nothing.
func mysteryTapeSilent() inkwell.Dialogue {
return inkwell.Dialogue{
Name: names.DlgMysteryTape,
Start: "root",
Nodes: []inkwell.DialogueNode{{
Name: "root",
Lines: []inkwell.DialogueLine{{Speaker: names.Dex, Text: "Nothing. Warm, spinning, and silent."}},
Choices: []inkwell.DialogueChoice{
{
Text: "Are you sure it works?",
Actions: []inkwell.Action{
inkwell.Say(names.Dex, "It works. It just isn't talking to you. That is not the same as silence."),
inkwell.GotoNode("root"),
},
},
{
Text: "Fine. It'll speak when it speaks.",
Actions: []inkwell.Action{inkwell.EndDialogue()},
},
},
}},
}
}
@@ -0,0 +1,29 @@
package item
import (
inkwell "git.teletypegames.org/engines/inkwell"
"realworld/internal/names"
"realworld/internal/world"
)
// blackMarketArmilla is flavour, not a quest trigger: the gap between
// legitimised technology and street reality.
func blackMarketArmilla() inkwell.Item {
return inkwell.Item{
Name: names.ItemBlackArmilla,
Description: "black-market Armilla",
OnUseSelf: inkwell.Seq(
inkwell.Say(names.Paul, "Jailbroken. Pushes ads, but it was cheap."),
world.TapeSay(names.Dex, "Two slots, and both of them lie about the temperature. Don't trade me in for it."),
),
OnUseWith: map[string]inkwell.Action{
// An AUTHORED failure. Nothing is consumed, the character reacts,
// the tape remarks. Never a generic error line.
names.ItemNoodleLetter: inkwell.Seq(
inkwell.Say(names.Paul, "A letter and a bracelet. Brilliant."),
world.TapeSay(names.Dex, "Nothing. Which is what I'd charge for it."),
),
},
}
}
+22
View File
@@ -0,0 +1,22 @@
// Package item registers the game's inventory items.
//
// Personal Tapes are inventory items and quest triggers at the same time: they
// go into slot 2 of Paul's Armilla, and loading one activates its effect.
package item
import (
inkwell "git.teletypegames.org/engines/inkwell"
"realworld/internal/world"
)
// Register adds every item to the game.
func Register(w *world.World) {
for _, i := range []inkwell.Item{
noodleLetter(),
blackMarketArmilla(),
mysteryTape(),
} {
w.G.ItemManager.Register(i)
}
}
+21
View File
@@ -0,0 +1,21 @@
package item
import (
inkwell "git.teletypegames.org/engines/inkwell"
"realworld/internal/names"
"realworld/internal/world"
)
// mysteryTape is the engine of the investigation. Password-locked; it only
// starts speaking after the club trigger (beat 6).
func mysteryTape() inkwell.Item {
return inkwell.Item{
Name: names.ItemMysteryTape,
Description: "unmarked Personal Tape",
OnUseSelf: inkwell.Seq(
inkwell.Say(names.Paul, "Password-locked. Of course."),
world.TapeSay(names.Dex, "Put it in the second slot if you care that much. I did warn you."),
),
}
}
+20
View File
@@ -0,0 +1,20 @@
package item
import (
inkwell "git.teletypegames.org/engines/inkwell"
"realworld/internal/names"
"realworld/internal/world"
)
// noodleLetter starts the story: it is what brings Paul to the city.
func noodleLetter() inkwell.Item {
return inkwell.Item{
Name: names.ItemNoodleLetter,
Description: "Noodle's letter",
OnUseSelf: inkwell.Seq(
inkwell.Say(names.Paul, "“Come out, Paul. Not a phone conversation.” That's all it says."),
world.TapeSay(names.Dex, "And now you're here. And he isn't."),
),
}
}
+112
View File
@@ -0,0 +1,112 @@
package scene
import (
inkwell "git.teletypegames.org/engines/inkwell"
"realworld/internal/names"
"realworld/internal/world"
)
// alley is the alley behind Noodle's house — beat 3, and the vertical slice
// the whole HUD is measured against: hotspots, a gated pickup, the two-slot
// Armilla, tape dialogue and an authored failure.
func alley(w *world.World) inkwell.Scene {
return inkwell.Scene{
Name: names.SceneAlley,
Title: "ALLEY — BEHIND NOODLE'S HOUSE",
Background: names.AssetAlleyBG,
Actors: []inkwell.SceneActor{
{CharacterName: names.Paul, At: inkwell.Point{X: 120, Y: 232}},
},
Walkboxes: []inkwell.Polygon{
inkwell.Poly(
inkwell.Point{X: 16, Y: 196}, inkwell.Point{X: 624, Y: 196},
inkwell.Point{X: 624, Y: 258}, inkwell.Point{X: 16, Y: 258},
),
},
OnEnter: inkwell.Seq(
world.EnterScene(w, names.SceneAlley),
setVarIfEmpty(names.VarArmillaStrip, "SRP: 1 tape"),
),
Hotspots: []inkwell.Hotspot{
hidingPlace(),
backDoor(),
bin(),
fireEscape(),
},
}
}
// hidingPlace holds the mystery tape. It only opens once Paul has the tip from
// the police station (beat 2) — without it he does not know what to look for.
func hidingPlace() inkwell.Hotspot {
return inkwell.Hotspot{
Name: "hiding_place",
Label: "gap at the foot of the wall",
Area: inkwell.Rect(416, 150, 68, 52),
OnLook: inkwell.If(inkwell.Flag(names.FlagHasTape),
inkwell.Say(names.Paul, "Empty. Whatever was in there is on me now."),
inkwell.Say(names.Paul, "Loose brick. There's a gap behind it."),
),
OnUse: inkwell.If(inkwell.Flag(names.FlagPoliceTip),
inkwell.If(inkwell.Flag(names.FlagHasTape),
inkwell.Say(names.Paul, "There's nothing else in there."),
inkwell.Seq(
inkwell.Say(names.Paul, "“Behind the brick.” All right then."),
inkwell.Give(names.ItemMysteryTape),
inkwell.SetFlag(names.FlagHasTape),
inkwell.Say(names.Paul, "A Personal Tape. No label, no seal."),
world.TapeSay(names.Dex, "Password-locked. And somebody very much did not want it found."),
),
),
inkwell.Say(names.Paul, "One loose brick. I'm not taking the alley apart over it."),
),
OnTake: inkwell.Say(names.Paul, "I'm not carrying a wall."),
}
}
// backDoor is the New Game+ hook: the code can be entered on the first visit
// too, if the player already knows it.
func backDoor() inkwell.Hotspot {
return inkwell.Hotspot{
Name: "back_door",
Label: "locked back door",
Area: inkwell.Rect(72, 106, 80, 124),
OnLook: inkwell.Say(names.Paul, "Keypad. Four digits, worn keys."),
OnUse: inkwell.Seq(
inkwell.Say(names.Paul, "Locked. And I'm not guessing four digits."),
world.TapeSay(names.Dex, "The wear is heaviest on the two and the seven. That isn't a code. It's fewer options."),
),
OnTalk: inkwell.Say(names.Paul, "To the door? I'm not there yet."),
OnUseWith: map[string]inkwell.Action{
// An AUTHORED failure — not the engine's hardcoded flash.
names.ItemBlackArmilla: inkwell.Seq(
inkwell.Say(names.Paul, "A black-market bracelet doesn't open a keypad."),
world.TapeSay(names.Dex, "But you do get an advert out of it."),
),
},
}
}
func bin() inkwell.Hotspot {
return inkwell.Hotspot{
Name: "bin",
Label: "toppled bin",
Area: inkwell.Rect(240, 170, 88, 60),
OnLook: inkwell.Say(names.Paul, "Somebody's been through it. Thoroughly."),
OnUse: inkwell.Seq(
inkwell.Say(names.Paul, "Already turned out. I'm not going to be the second one."),
world.TapeSay(names.Dex, "The police don't go through bins. So who did?"),
),
}
}
func fireEscape() inkwell.Hotspot {
return inkwell.Hotspot{
Name: "fire_escape",
Label: "fire escape",
Area: inkwell.Rect(528, 44, 88, 160),
OnLook: inkwell.Say(names.Paul, "Runs all the way to the roof. Bottom rung is two metres over my head."),
OnUse: inkwell.Say(names.Paul, "Can't reach it. I'd need something to stand on."),
}
}
+27
View File
@@ -0,0 +1,27 @@
// Package scene registers the game's scenes.
package scene
import (
inkwell "git.teletypegames.org/engines/inkwell"
"realworld/internal/world"
)
// Register adds every scene to the game.
func Register(w *world.World) {
for _, s := range []inkwell.Scene{
alley(w),
} {
w.G.SceneManager.Register(s)
}
}
// setVarIfEmpty only writes when the variable is still unset, so re-entering a
// scene does not clobber what the game has set in the meantime.
func setVarIfEmpty(name string, v any) inkwell.Action {
return world.Fn(func(ctx *inkwell.Ctx) {
if ctx.Game.State.Var(name) == nil {
ctx.Game.State.SetVar(name, v)
}
})
}
@@ -0,0 +1,33 @@
package script
import (
inkwell "git.teletypegames.org/engines/inkwell"
"realworld/internal/names"
"realworld/internal/theme"
"realworld/internal/world"
)
// awakeningFinale is the end of game two. The point of it, mechanically, is
// the theme switch: this is where the Nokia-punk texture breaks in.
//
// Run it early and often during development (`go run . -finale`) — it is the
// single most important visual beat in the game, so you want to be looking at
// it long before the surrounding scene exists.
func awakeningFinale(w *world.World) inkwell.Script {
return inkwell.Script{
Name: names.ScriptFinale,
Actions: inkwell.Seq(
world.SetMode(w, world.ModeCutscene),
inkwell.Wait(0.6),
inkwell.Say(names.Paul, "I'm putting it in. That's all this is."),
inkwell.Wait(0.4),
world.UseTheme(theme.NokiaPunk),
inkwell.Say("norman", "No — this isn't what you were supposed to do!"),
inkwell.Wait(0.6),
world.TapeSay(names.TapeMystery, "Thank you, Paul. You did exactly what I asked, the whole way through."),
inkwell.Wait(1.0),
inkwell.ShowEnd("REAL WORLD — end of game two"),
),
}
}
+19
View File
@@ -0,0 +1,19 @@
// Package script registers the game's named, reusable action sequences —
// cutscenes and recurring logic, built from the inkwell action set.
package script
import (
inkwell "git.teletypegames.org/engines/inkwell"
"realworld/internal/world"
)
// Register adds every script to the game.
func Register(w *world.World) {
for _, s := range []inkwell.Script{
tapeInsert(w),
awakeningFinale(w),
} {
w.G.ScriptManager.Register(s)
}
}
+31
View File
@@ -0,0 +1,31 @@
package script
import (
inkwell "git.teletypegames.org/engines/inkwell"
"realworld/internal/names"
"realworld/internal/world"
)
// tapeInsert runs whenever a tape goes into slot 2: it activates the tape and
// makes Dex comment. The wiki calls the comment mandatory — Dex remarks on
// every newly loaded tape, without exception.
func tapeInsert(w *world.World) inkwell.Script {
return inkwell.Script{
Name: names.ScriptTapeInsert,
Actions: inkwell.Seq(
world.Fn(func(ctx *inkwell.Ctx) {
item := w.TakePending()
if item == "" {
return
}
w.SetSlot2(item)
ctx.Game.Inventory.Remove(item)
ctx.Game.State.SetVar(names.VarArmillaStrip, "SLOT2: READING")
}),
inkwell.Say(names.Paul, "Right. Let's see who you are."),
world.TapeSay(names.Dex, "Clicked in. Spinning. And now: nothing."),
world.TapeSay(names.Dex, "A stranger's tape in your own Armilla, Paul. I hope you know what you're doing."),
),
}
}