Files
8bitMyGoUI/game/hud_test.go
T
ZacharyZhang-NY f48075230f Add game components 246-268; data.DrawSlot, Canvas.TextVeil
Health, mana and experience bars, quarter hearts, lives, a spinning
coin counter, score board, stage timer, minimap with fog, compass,
hotbar, paper-doll equipment, skill tree, skill buttons with cooldown
wipes, status effects, a boss bar, damage numbers, quest log and
tracker, party frames, character sheet, level-up screen and shop.
data exports DrawSlot so every slot looks alike; pixel adds TextVeil
for dissolving text. Messages the font cannot draw are fixed and
TestMessagesHaveGlyphs holds every message to the font.
2026-10-12 01:53:20 -04:00

233 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package game_test
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/game"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/icon"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestHealthBarTrailHoldsThenDrains(t *testing.T) {
wd := &world{clk: newClock()}
hp := 80
tt := view(300, 60, wd, func(c *ui.Context) { game.HealthBar(c, "HP", hp, 100, game.HealthBarOptions{}) })
// The fill's middle row is 10; full is 92 dots.
row := func(s pixel.Slot) int { return count(t, tt.Image(), 0, 10, 96, 1, s) }
if row(pixel.Good) != 73 {
t.Fatalf("80 HP fills %d dots", row(pixel.Good))
}
hp = 40
tt.Frame()
if row(pixel.Warn) != 36 || row(pixel.AccentLit) != 37 {
t.Fatalf("at the hit: fill %d, trail %d", row(pixel.Warn), row(pixel.AccentLit))
}
for _, step := range []struct {
after time.Duration
trail int
}{{300 * time.Millisecond, 37}, {200 * time.Millisecond, 19}, {500 * time.Millisecond, 0}} {
wd.clk.t = wd.clk.t.Add(step.after)
tt.Frame()
if row(pixel.AccentLit) != step.trail {
t.Fatalf("%v later the trail is %d dots", step.after, row(pixel.AccentLit))
}
}
hp = 90
tt.Frame()
if row(pixel.Good) != 82 || row(pixel.AccentLit) != 0 {
t.Fatal("a heal left a trail")
}
// A heal during a live trail clears it too.
hp = 30
tt.Frame()
hp = 60
tt.Frame()
if row(pixel.AccentLit) != 0 {
t.Fatal("a heal kept a live trail")
}
keeps(t, tt.Image())
// A new full drops the old trail.
full := 100
tt = view(300, 60, wd, func(c *ui.Context) { game.HealthBar(c, "HP", hp, full, game.HealthBarOptions{}) })
hp, full = 40, 50
tt.Frame()
if row(pixel.AccentLit) != 0 || count(t, tt.Image(), 96, 0, 54, 30, pixel.AccentLit) != 0 {
t.Fatal("the trail outlived a new full")
}
panics(t, "a bar narrower than its labels", func() {
view(200, 60, wd, func(c *ui.Context) { game.HealthBar(c, "HP", 5, 100, game.HealthBarOptions{Width: 20}) })
})
panics(t, "hp past full", func() {
view(200, 60, wd, func(c *ui.Context) { game.HealthBar(c, "HP", 5, 4, game.HealthBarOptions{}) })
})
}
func TestHealthBarReducedHasNoTrailAndNoBlink(t *testing.T) {
for _, reduce := range []bool{false, true} {
wd := &world{clk: newClock(), reduce: reduce}
hp := 90
tt := view(300, 60, wd, func(c *ui.Context) { game.HealthBar(c, "HP", hp, 100, game.HealthBarOptions{}) })
hp = 20
tt.Frame()
if trail := count(t, tt.Image(), 0, 10, 96, 1, pixel.AccentLit); (trail == 0) != reduce {
t.Fatalf("reduced %v: trail of %d dots", reduce, trail)
}
// Low HP blinks at 2 Hz: Bad, then Lit.
seen := map[pixel.Slot]bool{}
for range 2 {
seen[at(t, tt.Image(), 4, 10)] = true
wd.clk.t = wd.clk.t.Add(250 * time.Millisecond)
tt.Frame()
}
if blinked := seen[pixel.Lit] && seen[pixel.Bad]; blinked == reduce {
t.Fatalf("reduced %v: low HP showed %v", reduce, seen)
}
}
}
func TestManaBarTicks(t *testing.T) {
tt := view(300, 60, &world{clk: newClock()}, func(c *ui.Context) { game.ManaBar(c, "MP", 35, 60, game.ManaBarOptions{}) })
img := tt.Image()
// 35 of 60 fills 53 dots; three ticks cut in.
if info, ticks := count(t, img, 0, 10, 96, 1, pixel.Info), count(t, img, 2, 10, 92, 1, pixel.Edge); info != 50 || ticks != 5 {
t.Fatalf("fill %d dots, %d ticks", info, ticks)
}
}
func TestExpBarFlashesWhenItFills(t *testing.T) {
for _, tc := range []struct {
name string
level, xp int
reduce, flash bool
}{
{"a level up", 8, 10, false, true}, {"a fill", 7, 300, false, true},
{"a reduced level up", 8, 10, true, false}, {"a lost level", 6, 10, false, false},
} {
wd := &world{clk: newClock(), reduce: tc.reduce}
level, xp := 7, 290
tt := view(300, 60, wd, func(c *ui.Context) { game.ExpBar(c, "EXP", level, xp, 300, game.ExpBarOptions{}) })
level, xp = tc.level, tc.xp
tt.Frame()
// With no flash, the first frame already shows xp.
if n, want := count(t, tt.Image(), 2, 10, 92, 1, pixel.Warn), tc.xp*92/300; !tc.flash && n != want {
t.Fatalf("%s: the first frame fills %d dots, not %d", tc.name, n, want)
}
seen := map[pixel.Slot]bool{}
for range 2 {
img := tt.Image()
seen[at(t, img, 4, 10)] = true
if full := count(t, img, 2, 10, 92, 1, pixel.Lit) + count(t, img, 2, 10, 92, 1, pixel.Warn); tc.flash && full != 92 {
t.Fatalf("%s: the flash fills %d dots", tc.name, full)
}
wd.clk.t = wd.clk.t.Add(100 * time.Millisecond)
tt.Frame()
}
if flashed := seen[pixel.Lit] && seen[pixel.Warn]; flashed != tc.flash {
t.Fatalf("%s: the bar showed %v", tc.name, seen)
}
// After any flash the bar shows xp.
wd.clk.t = wd.clk.t.Add(time.Second)
tt.Frame()
if n, want := count(t, tt.Image(), 2, 10, 92, 1, pixel.Warn), tc.xp*92/300; n != want {
t.Fatalf("%s: %d of 300 fills %d dots, not %d", tc.name, tc.xp, n, want)
}
}
}
func TestHeartsFillByQuarters(t *testing.T) {
tt := view(300, 60, &world{clk: newClock()}, func(c *ui.Context) {
game.Hearts(c, "Life", 1.75, 3, game.HeartsOptions{})
})
img := tt.Image()
// Quadrant dots: bottom left, top left, top right, bottom right.
quads := [][2]int{{4, 7}, {3, 4}, {8, 4}, {7, 7}}
for i, want := range [][4]bool{{true, true, true, true}, {true, true, true, false}, {false, false, false, false}} {
for q, d := range quads {
if got := at(t, img, i*11+d[0], d[1]) == pixel.Bad; got != want[q] {
t.Fatalf("heart %d quadrant %d filled %v", i, q, got)
}
}
}
keeps(t, img)
// A lone quarter fills the bottom left only.
tt = view(100, 40, &world{clk: newClock()}, func(c *ui.Context) { game.Hearts(c, "Life", 0.25, 1, game.HeartsOptions{}) })
for q, d := range quads {
if got := at(t, tt.Image(), d[0], d[1]) == pixel.Bad; got != (q == 0) {
t.Fatalf("a quarter heart filled quadrant %d: %v", q, got)
}
}
for _, bad := range [][2]float64{{0.3, 3}, {1, 2.5}, {4, 3}, {-1, 3}} {
panics(t, "hearts out of quarters", func() {
view(100, 40, &world{clk: newClock()}, func(c *ui.Context) { game.Hearts(c, "x", bad[0], bad[1], game.HeartsOptions{}) })
})
}
}
func TestLivesShowTheirIcon(t *testing.T) {
tt := view(300, 100, &world{clk: newClock()}, func(c *ui.Context) {
game.Lives(c, "Lives", 3, game.LivesOptions{})
game.Lives(c, "Stars", 2, game.LivesOptions{Sprite: icon.Star})
})
lives, stars := find(t, tt, "Lives"), find(t, tt, "Stars")
lx, ly := dots(lives)
sx, sy := dots(stars)
// The hero wears a Bad cap; the star is ink.
if count(t, tt.Image(), lx, ly, 12, 16, pixel.Bad) == 0 || count(t, tt.Image(), sx, sy, 12, 16, pixel.Bad) != 0 {
t.Fatal("life icons are wrong")
}
if int(lives.W)/2 != 12+2+pixel.Measure("×3") {
t.Fatalf("lives are %v px wide", lives.W)
}
}
func TestCoinSpinsUnlessReduced(t *testing.T) {
for _, reduce := range []bool{false, true} {
wd := &world{clk: newClock(), reduce: reduce}
tt := view(300, 60, wd, func(c *ui.Context) { game.CoinCounter(c, "Gold", 1250, game.CoinCounterOptions{}) })
find(t, tt, "Gold")
before := count(t, tt.Image(), 0, 0, 12, 16, pixel.Warn)
wd.clk.t = wd.clk.t.Add(250 * time.Millisecond)
tt.Frame()
if spun := count(t, tt.Image(), 0, 0, 12, 16, pixel.Warn) != before; spun == reduce {
t.Fatalf("reduced %v: the coin spun %v", reduce, spun)
}
}
}
func TestScoreBoardLightsANewBest(t *testing.T) {
score := 500
tt := view(400, 100, &world{clk: newClock()}, func(c *ui.Context) { game.ScoreBoard(c, score, 9900, game.ScoreBoardOptions{}) })
if !tt.HasText("SCORE 500, HI-SCORE 9900") || count(t, tt.Image(), 0, 0, 200, 8, pixel.Warn) != 0 {
t.Fatalf("texts %q", tt.Texts())
}
score = 12500
tt.Frame()
if !tt.HasText("SCORE 12500, HI-SCORE 12500") || count(t, tt.Image(), 0, 0, 200, 8, pixel.Warn) == 0 {
t.Fatal("a new best is not lit")
}
}
func TestTimerHUDHurriesUnderThirty(t *testing.T) {
for _, tc := range []struct {
left time.Duration
reduce, blink bool
ink pixel.Slot
}{{87 * time.Second, false, false, pixel.Ink}, {12 * time.Second, false, true, pixel.Bad}, {12 * time.Second, true, false, pixel.Bad},
{29500 * time.Millisecond, true, false, pixel.Bad}} {
wd := &world{clk: newClock(), reduce: tc.reduce}
tt := view(300, 100, wd, func(c *ui.Context) { game.TimerHUD(c, tc.left, game.TimerHUDOptions{}) })
var shown []int
for range 2 {
shown = append(shown, count(t, tt.Image(), 0, 8, 60, 34, tc.ink))
wd.clk.t = wd.clk.t.Add(250 * time.Millisecond)
tt.Frame()
}
if blinked := (shown[0] == 0) != (shown[1] == 0); blinked != tc.blink || shown[0]+shown[1] == 0 {
t.Fatalf("%v left, reduced %v: digit dots %v", tc.left, tc.reduce, shown)
}
}
}