A cross-cutting pass, each fix with a test that failed first. Veils: MyGo still hands a key to a base that turned off this frame, so every element key now goes through pixel.Shortcut, which an archtest enforces. Input handlers record and the build applies them only while enabled; the editor turns away a veil-frame letter or copy; value bases get a copy and keep only enabled turns. Popups give a held focus back to their opener when the veil lifts. Motion: S5 is enforced at the clock. Every Tick, Since, Step, Toward and Shot runs at 4-15 fps; blinks keep 1 Hz on 4 fps steps, fast animations step at 15, and drawn time is quantized to frames. Clocks stepping back: Since holds at zero without overflow, and k.Elapsed moves a stored start to now, so nothing goes negative, runs backward or indexes out of range. Also: logs count typed text instead of holding it, gallery names use glyphs the font has, removable rows are keyed, components take their own Options (archtest), and sweeps over all 508 demos check veiled keys, clicks, wheels, the clock stepping back and names.
348 lines
11 KiB
Go
348 lines
11 KiB
Go
package game_test
|
|
|
|
import (
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/egoist/mygo/ui"
|
|
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/data"
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/game"
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
|
|
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
|
|
)
|
|
|
|
func TestPressStartBlinksAndReports(t *testing.T) {
|
|
for _, reduce := range []bool{false, true} {
|
|
wd := &world{clk: newClock(), reduce: reduce}
|
|
pressed := 0
|
|
tt := view(300, 60, wd, func(c *ui.Context) {
|
|
if game.PressStart(c, game.PressStartOptions{}).Pressed {
|
|
pressed++
|
|
}
|
|
})
|
|
var ink []int
|
|
for range 2 {
|
|
ink = append(ink, count(t, tt.Image(), 0, 0, 100, 20, pixel.Ink))
|
|
wd.clk.t = wd.clk.t.Add(500 * time.Millisecond)
|
|
tt.Frame()
|
|
}
|
|
if blinked := (ink[0] == 0) != (ink[1] == 0); blinked == reduce {
|
|
t.Fatalf("reduced %v: PRESS START showed %v", reduce, ink)
|
|
}
|
|
tt.Click("PRESS START")
|
|
tt.Key(0, ui.KeyEnter)
|
|
if pressed != 2 {
|
|
t.Fatalf("a click and Enter pressed %d times", pressed)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestTitleScreenStartsThenChooses(t *testing.T) {
|
|
started, chosen := false, -1
|
|
tt := view(600, 300, &world{clk: newClock()}, func(c *ui.Context) {
|
|
if r := game.TitleScreen(c, "SLIME QUEST", &started, game.TitleScreenOptions{}); r.Chosen >= 0 {
|
|
chosen = r.Chosen
|
|
}
|
|
})
|
|
tt.Click("PRESS START")
|
|
if !started || tt.HasText("PRESS START") {
|
|
t.Fatalf("PRESS START left %q", tt.Texts())
|
|
}
|
|
// The menu takes focus; no save turns CONTINUE off.
|
|
tt.Key(0, ui.KeyDown)
|
|
tt.Key(0, ui.KeyEnter)
|
|
if chosen != -1 {
|
|
t.Fatalf("CONTINUE with no save chose %d", chosen)
|
|
}
|
|
tt.Key(0, ui.KeyDown)
|
|
tt.Key(0, ui.KeyEnter)
|
|
if chosen != 2 {
|
|
t.Fatalf("OPTIONS chose %d", chosen)
|
|
}
|
|
tt.Key(0, ui.KeyEscape)
|
|
if started || !tt.HasText("PRESS START") {
|
|
t.Fatal("Escape did not go back to PRESS START")
|
|
}
|
|
keeps(t, tt.Image())
|
|
// Shown first thing, the menu still takes the keys.
|
|
again, picked := true, -1
|
|
tt = view(600, 300, &world{clk: newClock()}, func(c *ui.Context) {
|
|
if r := game.TitleScreen(c, "SLIME QUEST", &again, game.TitleScreenOptions{}); r.Chosen >= 0 {
|
|
picked = r.Chosen
|
|
}
|
|
})
|
|
tt.Key(0, ui.KeyEnter)
|
|
if picked != 0 {
|
|
t.Fatalf("Enter on a fresh menu chose %d", picked)
|
|
}
|
|
}
|
|
|
|
func TestSaveSlotsChooseByClickAndKeys(t *testing.T) {
|
|
saves := []game.Save{{Place: "Woods", Played: 90 * time.Minute, Portrait: game.Hero}, {}, {Place: "Castle", Played: time.Minute}}
|
|
sel, chosen := 0, -1
|
|
tt := view(400, 300, &world{clk: newClock()}, func(c *ui.Context) {
|
|
if r := game.SaveSlots(c, "Saves", saves, &sel, game.SaveSlotsOptions{}); r.Chosen >= 0 {
|
|
chosen = r.Chosen
|
|
}
|
|
})
|
|
r := find(t, tt, "Saves")
|
|
// Cards are 32 dots tall with a 3-dot gap.
|
|
tt.ClickAt(r.X+40, r.Y+float32(2*(2*35+10)))
|
|
if sel != 2 || chosen != 2 {
|
|
t.Fatalf("clicking card 3 chose %d, %d", sel, chosen)
|
|
}
|
|
tt.Key(0, ui.KeyUp)
|
|
tt.Key(0, ui.KeyEnter)
|
|
if chosen != 1 {
|
|
t.Fatalf("Up, Enter chose %d", chosen)
|
|
}
|
|
keeps(t, tt.Image())
|
|
// A long place name stays on its card.
|
|
long := []game.Save{{Place: "The Very Long Forest of Many Wobbly Slimes", Played: time.Hour, Portrait: game.Hero}}
|
|
first := 0
|
|
tt = view(500, 100, &world{clk: newClock()}, func(c *ui.Context) {
|
|
game.SaveSlots(c, "Long", long, &first, game.SaveSlotsOptions{})
|
|
})
|
|
if n := count(t, tt.Image(), 180, 0, 70, 40, pixel.Ink); n != 0 {
|
|
t.Fatalf("%d place dots past the card", n)
|
|
}
|
|
panics(t, "an empty save with play time", func() {
|
|
view(400, 300, &world{clk: newClock()}, func(c *ui.Context) {
|
|
game.SaveSlots(c, "x", []game.Save{{Played: time.Hour}}, &first, game.SaveSlotsOptions{})
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestOptionsMenuStepsAndClamps(t *testing.T) {
|
|
speed, bgm, row, changes := 0, 9, 0, 0
|
|
tt := view(500, 100, &world{clk: newClock()}, func(c *ui.Context) {
|
|
if game.OptionsMenu(c, "Options", []game.Setting{{Name: "TEXT SPEED", Choices: []string{"SLOW", "FAST"}, Value: &speed}, {Name: "BGM", Max: 10, Value: &bgm}}, &row, game.OptionsMenuOptions{}).Changed {
|
|
changes++
|
|
}
|
|
})
|
|
// The focusing click lands on the second row.
|
|
tt.Click("Options")
|
|
for _, k := range []ui.Key{ui.KeyUp, ui.KeyLeft, ui.KeyRight, ui.KeyRight, ui.KeyDown, ui.KeyRight, ui.KeyRight, ui.KeyDown, ui.KeyLeft} {
|
|
tt.Key(0, k)
|
|
}
|
|
// SLOW stays, one step to FAST, BGM tops at 10.
|
|
if speed != 1 || bgm != 9 || row != 1 || changes != 3 {
|
|
t.Fatalf("speed %d, bgm %d, row %d, %d changes", speed, bgm, row, changes)
|
|
}
|
|
keeps(t, tt.Image())
|
|
panics(t, "a setting with both kinds", func() {
|
|
view(500, 100, &world{clk: newClock()}, func(c *ui.Context) {
|
|
game.OptionsMenu(c, "x", []game.Setting{{Name: "x", Choices: []string{"A"}, Max: 3, Value: &speed}}, &row, game.OptionsMenuOptions{})
|
|
})
|
|
})
|
|
}
|
|
|
|
func TestOptionsMenuKeysDoNotReplayClicks(t *testing.T) {
|
|
level, row := 5, 0
|
|
tt := view(500, 100, &world{clk: newClock()}, func(c *ui.Context) {
|
|
game.OptionsMenu(c, "Options", []game.Setting{{Name: "BGM", Max: 10, Value: &level}}, &row, game.OptionsMenuOptions{})
|
|
})
|
|
r := find(t, tt, "Options")
|
|
// ▶ follows the hand, name, ◀ and ten blocks.
|
|
bx := pixel.Hand.W + 4 + pixel.Measure("BGM") + 10 + pixel.Measure("◀") + 3 + 40 + 3
|
|
tt.ClickAt(r.X+float32(2*(bx+2)), r.Y+16)
|
|
tt.Key(0, ui.KeyEnter)
|
|
tt.Key(0, ui.KeySpace)
|
|
if level != 6 {
|
|
t.Fatalf("a click on ▶, Enter and Space left BGM at %d", level)
|
|
}
|
|
}
|
|
|
|
func TestGameOverDithersInAndAnswers(t *testing.T) {
|
|
for _, reduce := range []bool{false, true} {
|
|
wd := &world{clk: newClock(), reduce: reduce}
|
|
quit := false
|
|
tt := view(600, 300, wd, func(c *ui.Context) {
|
|
quit = quit || game.GameOver(c, game.GameOverOptions{}).Quit
|
|
})
|
|
r := find(t, tt, "GAME OVER")
|
|
x, y := dots(r)
|
|
first := count(t, tt.Image(), x, y, int(r.W)/2, int(r.H)/2, pixel.Bad)
|
|
wd.clk.t = wd.clk.t.Add(3 * time.Second)
|
|
tt.Frame()
|
|
full := count(t, tt.Image(), x, y, int(r.W)/2, int(r.H)/2, pixel.Bad)
|
|
if full == 0 || (first == full) != reduce {
|
|
t.Fatalf("reduced %v: GAME OVER drew %d dots, then %d", reduce, first, full)
|
|
}
|
|
tt.Key(0, ui.KeyDown)
|
|
tt.Key(0, ui.KeyEnter)
|
|
if !quit {
|
|
t.Fatalf("reduced %v: Down, Enter did not quit", reduce)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestCreditsRollOnceAndAgainOnCue(t *testing.T) {
|
|
for _, reduce := range []bool{false, true} {
|
|
wd := &world{clk: newClock(), reduce: reduce}
|
|
cue, done := 0, 0
|
|
tt := view(400, 400, wd, func(c *ui.Context) {
|
|
if game.Credits(c, "Credits", []game.Credit{{Heading: "DIRECTOR", Names: []string{"Aria"}}, {Heading: "CAST", Names: []string{"A", "B", "C", "D", "E", "F"}}}, game.CreditsOptions{Cue: cue}).Done {
|
|
done++
|
|
}
|
|
})
|
|
// Rolling, it starts empty; reduced, it shows all ten lines.
|
|
if lit := count(t, tt.Image(), 0, 0, 160, 96, pixel.Warn) > 0; lit != reduce || (done == 1) != reduce {
|
|
t.Fatalf("reduced %v: first frame lit %v, done %d", reduce, lit, done)
|
|
}
|
|
if h := find(t, tt, "Credits").H; (h == 2*160) != reduce {
|
|
t.Fatalf("reduced %v: the window is %v px tall", reduce, h)
|
|
}
|
|
for range 3 {
|
|
wd.clk.t = wd.clk.t.Add(5 * time.Second)
|
|
tt.Frame()
|
|
}
|
|
if done != 1 || count(t, tt.Image(), 0, 0, 160, 96, pixel.Ink) == 0 {
|
|
t.Fatalf("reduced %v: done %d times; the last name not resting", reduce, done)
|
|
}
|
|
cue = 1
|
|
tt.Frame()
|
|
for range 3 {
|
|
wd.clk.t = wd.clk.t.Add(5 * time.Second)
|
|
tt.Frame()
|
|
}
|
|
if done != 2 {
|
|
t.Fatalf("reduced %v: a new cue rolled %d times in all", reduce, done)
|
|
}
|
|
// An old cue mid-roll rolls anew; reduced, both end.
|
|
cue = 2
|
|
tt.Frame()
|
|
cue = 1
|
|
tt.Frame()
|
|
for range 3 {
|
|
wd.clk.t = wd.clk.t.Add(5 * time.Second)
|
|
tt.Frame()
|
|
}
|
|
if want := map[bool]int{false: 3, true: 4}[reduce]; done != want {
|
|
t.Fatalf("reduced %v: going back to cue 1 rolled %d times in all", reduce, done)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestEndingCardDithersIn(t *testing.T) {
|
|
wd := &world{clk: newClock()}
|
|
tt := view(400, 200, wd, func(c *ui.Context) { game.EndingCard(c, game.EndingCardOptions{}) })
|
|
first := count(t, tt.Image(), 0, 0, 200, 80, pixel.Back)
|
|
wd.clk.t = wd.clk.t.Add(time.Second)
|
|
tt.Frame()
|
|
half := count(t, tt.Image(), 0, 0, 200, 80, pixel.Back)
|
|
wd.clk.t = wd.clk.t.Add(2 * time.Second)
|
|
tt.Frame()
|
|
if full := count(t, tt.Image(), 0, 0, 200, 80, pixel.Back); !(first < half && half < full) {
|
|
t.Fatalf("THE END dots %d, %d, %d", first, half, full)
|
|
}
|
|
}
|
|
|
|
func TestEndingCardRefusesNoRoom(t *testing.T) {
|
|
panics(t, "a card narrower than THE END", func() {
|
|
view(400, 200, &world{clk: newClock()}, func(c *ui.Context) { game.EndingCard(c, game.EndingCardOptions{Width: 40}) })
|
|
})
|
|
}
|
|
|
|
func TestLoadingTipsTurnEveryFourSeconds(t *testing.T) {
|
|
wd := &world{clk: newClock()}
|
|
tt := view(500, 100, wd, func(c *ui.Context) {
|
|
game.LoadingTips(c, []string{"Tip one.", "Tip two."}, 0.5, game.LoadingTipsOptions{})
|
|
})
|
|
a := tt.HasText("TIP: Tip one.") || tt.HasText("TIP: Tip two.")
|
|
before := tt.HasText("TIP: Tip one.")
|
|
wd.clk.t = wd.clk.t.Add(4 * time.Second)
|
|
tt.Frame()
|
|
if !a || tt.HasText("TIP: Tip one.") == before {
|
|
t.Fatalf("tips did not turn: %q", tt.Texts())
|
|
}
|
|
}
|
|
|
|
func TestInteractPromptBobsOnceASecond(t *testing.T) {
|
|
wd := &world{clk: newClock()}
|
|
tt := view(300, 200, wd, func(c *ui.Context) {
|
|
var target *ui.Element
|
|
ui.Column(c).Padding(60).Children(func() { target = ui.Box(c).Size(20, 20) })
|
|
game.InteractPrompt(c, target, "OPEN", game.InteractPromptOptions{})
|
|
})
|
|
start := wd.clk.t
|
|
var ys []float32
|
|
for _, at := range []time.Duration{100, 350, 600} {
|
|
wd.clk.t = start.Add(at * time.Millisecond)
|
|
tt.Frame()
|
|
// The bottom edge bobs; the top may sit clipped.
|
|
r := find(t, tt, "A OPEN")
|
|
ys = append(ys, r.Y+r.H)
|
|
}
|
|
if ys[0] != ys[1] || ys[1] == ys[2] {
|
|
t.Fatalf("the prompt sat at %v at .1, .35 and .6 s", ys)
|
|
}
|
|
}
|
|
|
|
func TestHealthBarTrailStepsAtFifteen(t *testing.T) {
|
|
wd, hp := &world{clk: newClock()}, 100
|
|
tt := view(300, 100, wd, func(c *ui.Context) { game.HealthBar(c, "HP", hp, 100, game.HealthBarOptions{}) })
|
|
hp = 50
|
|
tt.Frame()
|
|
wd.clk.t = wd.clk.t.Add(350 * time.Millisecond)
|
|
// Frames each 10 ms show two steps at most.
|
|
seen := map[string]bool{}
|
|
for range 6 {
|
|
wd.clk.t = wd.clk.t.Add(10 * time.Millisecond)
|
|
tt.Frame()
|
|
seen[string(tt.Image().Pix)] = true
|
|
}
|
|
if len(seen) > 2 {
|
|
t.Fatalf("the trail drew %d pictures in 60 ms", len(seen))
|
|
}
|
|
}
|
|
|
|
func TestDamageNumbersRiseInWholeFrames(t *testing.T) {
|
|
wd := &world{clk: newClock()}
|
|
hits := []game.Hit{{Value: 999, X: 60, Y: 60}}
|
|
tt := view(300, 200, wd, func(c *ui.Context) { game.DamageNumbers(c, "Damage", &hits, game.DamageNumbersOptions{}) })
|
|
wd.clk.t = wd.clk.t.Add(500 * time.Millisecond)
|
|
tt.Frame()
|
|
seen := map[string]bool{}
|
|
for range 7 {
|
|
seen[string(tt.Image().Pix)] = true
|
|
wd.clk.t = wd.clk.t.Add(10 * time.Millisecond)
|
|
tt.Frame()
|
|
}
|
|
if len(seen) > 2 {
|
|
t.Fatalf("%d pictures in 60 ms", len(seen))
|
|
}
|
|
}
|
|
|
|
func TestSpinsAndHitsRestartWhenTheClockStepsBack(t *testing.T) {
|
|
wd := &world{clk: newClock()}
|
|
faces := []data.Item{{Sprite: icon.Star, Name: "Star"}, {Sprite: icon.Coin, Name: "Coin"}}
|
|
spin, done := 0, 0
|
|
tt := view(300, 100, wd, func(c *ui.Context) {
|
|
if game.GachaReel(c, "Reels", faces, [3]int{1, 1, 1}, spin, game.GachaReelOptions{}).Done {
|
|
done++
|
|
}
|
|
})
|
|
spin = 1
|
|
tt.Frame()
|
|
// An hour back, the spin times from now and lands.
|
|
wd.clk.t = wd.clk.t.Add(-time.Hour)
|
|
tt.Frame()
|
|
for range 30 {
|
|
wd.clk.t = wd.clk.t.Add(200 * time.Millisecond)
|
|
tt.Frame()
|
|
}
|
|
if done != 1 {
|
|
t.Fatalf("the reels landed %d times", done)
|
|
}
|
|
hits := []game.Hit{{Value: 7, X: 60, Y: 40}}
|
|
tt = view(300, 200, wd, func(c *ui.Context) { game.DamageNumbers(c, "Damage", &hits, game.DamageNumbersOptions{}) })
|
|
fresh := string(tt.Image().Pix)
|
|
wd.clk.t = wd.clk.t.Add(-time.Second)
|
|
tt.Frame()
|
|
if string(tt.Image().Pix) != fresh {
|
|
t.Fatal("a hit moved as the clock stepped back")
|
|
}
|
|
}
|