game skeleton
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user