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
+57
View File
@@ -0,0 +1,57 @@
// Package boot wires the layers together: this is the only place where the
// world, the content, the theme and the HUD meet. main.go knows nothing else.
package boot
import (
inkwell "git.teletypegames.org/engines/inkwell"
"realworld/internal/content"
"realworld/internal/names"
"realworld/internal/theme"
"realworld/internal/ui"
"realworld/internal/world"
)
// Opts are the run parameters.
type Opts struct {
Scene string // starting scene; empty means the alley
Finale bool // start with the finale, to try the Nokia-punk theme switch
}
// New builds a ready-to-run game.
func New(o Opts) *inkwell.Game {
scene := o.Scene
if scene == "" {
scene = names.SceneAlley
}
g := inkwell.NewGame("Real World", ui.ScreenW, ui.ScreenH)
g.MaxLogLines = 64
w := world.New(g)
theme.Register(g)
content.Register(w)
g.UseTheme(theme.RealWorld)
ui.Register(w)
g.StartAt(scene)
g.OnStart(inkwell.Seq(opening(w, scene, o.Finale)...))
return g
}
func opening(w *world.World, scene string, finale bool) []inkwell.Action {
acts := []inkwell.Action{
world.EnterScene(w, scene),
// Beat 2 (the police station) will set the tip flag. Until that scene
// exists we set it here so the alley slice is playable end to end —
// the conditional branch itself is real code, not bypassed.
inkwell.SetFlag(names.FlagPoliceTip),
inkwell.Say(names.Paul, "Back alley. Just like the clerk said."),
world.TapeSay(names.Dex, "A government office tipped you off to remove something from a scene. That doesn't strike you as odd?"),
}
if finale {
acts = append(acts, inkwell.RunScript(names.ScriptFinale))
}
return acts
}
+138
View File
@@ -0,0 +1,138 @@
package boot_test
import (
"testing"
inkwell "git.teletypegames.org/engines/inkwell"
"realworld/internal/boot"
"realworld/internal/names"
"realworld/internal/ui"
)
func build(t *testing.T) *inkwell.Game {
t.Helper()
g := boot.New(boot.Opts{})
if err := g.Validate(); err != nil {
t.Fatalf("Validate: %v", err)
}
return g
}
// The engine's Validate only checks scene→asset/character references. A typo in
// a dialogue, script or item name would surface at runtime as a silent no-op
// (RunDialogue and RunScript fail quietly), so check them here.
func TestReferencesResolve(t *testing.T) {
g := build(t)
for _, n := range []string{names.DlgDex, names.DlgMysteryTape} {
if !g.DialogueManager.Has(n) {
t.Errorf("missing dialogue: %q", n)
}
}
for _, n := range []string{names.ScriptTapeInsert, names.ScriptFinale} {
if !g.ScriptManager.Has(n) {
t.Errorf("missing script: %q", n)
}
}
for _, n := range []string{names.ItemNoodleLetter, names.ItemMysteryTape, names.ItemBlackArmilla} {
if !g.ItemManager.Has(n) {
t.Errorf("missing item: %q", n)
}
}
for n := range names.Tapes {
if !g.CharacterManager.Has(n) {
t.Errorf("tape character not registered: %q", n)
}
}
// Every loadable tape needs an item, a character and a dialogue.
for item, char := range names.TapeItems {
if !g.ItemManager.Has(item) {
t.Errorf("tape item not registered: %q", item)
}
if !g.CharacterManager.Has(char) {
t.Errorf("tape character not registered: %q", char)
}
if !g.DialogueManager.Has(names.TapeDialogue(item)) {
t.Errorf("tape %q has no dialogue: %q", item, names.TapeDialogue(item))
}
}
}
// The two-channel rule: every tape needs its own voice colour, distinct from
// the world's. Without that, "if something is amber, a tape said it" is not
// readable.
func TestTapeVoicesAreDistinct(t *testing.T) {
g := build(t)
paul, _ := g.CharacterManager.Get(names.Paul)
seen := map[[4]uint32]string{}
for n := range names.Tapes {
c, _ := g.CharacterManager.Get(n)
if c.SpeechColor == nil {
t.Errorf("%s: no SpeechColor", n)
continue
}
if c.SpeechColor == paul.SpeechColor {
t.Errorf("%s speaks in the same colour as Paul", n)
}
r, gr, b, a := c.SpeechColor.RGBA()
key := [4]uint32{r, gr, b, a}
if other, dup := seen[key]; dup {
t.Errorf("%s and %s share a voice colour", n, other)
}
seen[key] = n
}
}
// HUD geometry: the two channels must not overlap, and both must stay inside
// the HUD strip.
func TestHUDGeometry(t *testing.T) {
g := build(t)
ch, ok := g.UIManager.Get("channel")
if !ok {
t.Fatal("no 'channel' widget")
}
tc := ch.(*ui.TapeChannel)
if tc.Bounds.X < ui.DividerX {
t.Errorf("tape channel crosses into the world side: X=%v < %v", tc.Bounds.X, ui.DividerX)
}
if tc.Bounds.X+tc.Bounds.W > ui.ScreenW {
t.Errorf("tape channel runs off screen: %v", tc.Bounds.X+tc.Bounds.W)
}
if tc.Bounds.Y < ui.HUDTop {
t.Errorf("tape channel reaches into the scene: Y=%v < %v", tc.Bounds.Y, ui.HUDTop)
}
sl, ok := g.UIManager.Get("tapes")
if !ok {
t.Fatal("no 'tapes' widget")
}
if ts := sl.(*ui.TapeSlots); ts.Bounds.X+ts.Bounds.W > ui.DividerX {
t.Errorf("tape slots cross the divider: %v > %v", ts.Bounds.X+ts.Bounds.W, ui.DividerX)
}
// The dialogue box deliberately covers only the left region, so a tape can
// comment alongside the conversation.
db, ok := g.UIManager.Get("dialog")
if !ok {
t.Fatal("no 'dialog' widget")
}
if box := db.(*inkwell.DialogBox); box.Bounds.X+box.Bounds.W > ui.DividerX {
t.Errorf("dialogue box would cover the tape channel: %v > %v",
box.Bounds.X+box.Bounds.W, ui.DividerX)
}
}
// The guard must be registered FIRST so it ticks LAST among the widgets —
// otherwise it would swallow HUD clicks.
func TestWidgetOrder(t *testing.T) {
g := build(t)
n := g.UIManager.Names()
if len(n) == 0 || n[0] != "usewith_guard" {
t.Errorf("usewith_guard is not registered first: %v", n)
}
if n[len(n)-1] != "cursor" {
t.Errorf("cursor is not registered last: %v", n)
}
}