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.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-12 01:53:20 -04:00
1 parent 596c62e026
commit f48075230f
42 files changed
+3260 -26

No files matched your search

+179
View File
@@ -0,0 +1,179 @@
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 TestMinimapTilesFogPlayerAndMarks(t *testing.T) {
for _, reduce := range []bool{false, true} {
wd := &world{clk: newClock(), reduce: reduce}
seen := [][]bool{{true, true, false}, {true, true, true}}
tt := view(100, 60, wd, func(c *ui.Context) {
game.Minimap(c, "Map", []string{`#.~`, `"^ `}, seen, game.Spot{X: 1, Y: 0}, []game.Marker{{Spot: game.Spot{X: 0, Y: 1}, Slot: pixel.Warn}}, game.MinimapOptions{})
})
img := tt.Image()
// Tiles are 3 dots inside a 1-dot frame.
for _, tc := range []struct {
x, y int
s pixel.Slot
}{{0, 0, pixel.Edge}, {0, 1, pixel.Warn}, {1, 1, pixel.Faint}, {2, 1, pixel.Back}} {
if n := count(t, img, 1+tc.x*3, 1+tc.y*3, 3, 3, tc.s); n != 9 {
t.Fatalf("tile %d,%d holds %d %v dots", tc.x, tc.y, n, tc.s)
}
}
if fog := count(t, img, 7, 1, 3, 3, pixel.Edge); fog == 0 || fog == 9 {
t.Fatalf("the fogged tile holds %d Edge dots", fog)
}
var lit []int
for range 2 {
lit = append(lit, count(t, tt.Image(), 4, 1, 3, 3, pixel.Accent))
wd.clk.t = wd.clk.t.Add(250 * time.Millisecond)
tt.Frame()
}
if blinked := lit[0] != lit[1]; blinked == reduce || lit[0]+lit[1] == 0 {
t.Fatalf("reduced %v: the player lit %v", reduce, lit)
}
}
panics(t, "a ragged map", func() {
view(100, 60, &world{clk: newClock()}, func(c *ui.Context) {
game.Minimap(c, "x", []string{"##", "#"}, nil, game.Spot{}, nil, game.MinimapOptions{})
})
})
panics(t, "ragged fog", func() {
view(100, 60, &world{clk: newClock()}, func(c *ui.Context) {
game.Minimap(c, "x", []string{"##", "##"}, [][]bool{{true, true}, {true}}, game.Spot{}, nil, game.MinimapOptions{})
})
})
panics(t, "a player off the map", func() {
view(100, 60, &world{clk: newClock()}, func(c *ui.Context) {
game.Minimap(c, "x", []string{"##"}, nil, game.Spot{X: 2}, nil, game.MinimapOptions{})
})
})
}
func TestCompassTurnsWithTheHeading(t *testing.T) {
shot := func(heading float64) (*ui.Tester, int) {
tt := view(300, 60, &world{clk: newClock()}, func(c *ui.Context) {
game.Compass(c, "Heading", heading, []game.Bearing{{Degrees: 70, Slot: pixel.Warn}, {Degrees: 250, Slot: pixel.Bad}}, game.CompassOptions{})
})
return tt, count(t, tt.Image(), 0, 0, 120, 20, pixel.Ink)
}
tt, ink := shot(30)
img := tt.Image()
// N sits 20 dots left of middle, E 40 right.
if count(t, img, 38, 0, 5, 5, pixel.Ink) == 0 || count(t, img, 98, 0, 5, 5, pixel.Ink) == 0 {
t.Fatal("N or E is not where 30° puts it")
}
if count(t, img, 0, 0, 120, 20, pixel.Warn) != 9 || count(t, img, 0, 0, 120, 20, pixel.Bad) != 0 {
t.Fatal("markers in and out of view are wrong")
}
if _, again := shot(390); again != ink {
t.Fatal("390° differs from 30°")
}
// -360° is 0°, in view of a 350° heading.
tt = view(300, 60, &world{clk: newClock()}, func(c *ui.Context) {
game.Compass(c, "Heading", 350, []game.Bearing{{Degrees: -360, Slot: pixel.Magic}}, game.CompassOptions{})
})
if count(t, tt.Image(), 0, 0, 120, 20, pixel.Magic) != 9 {
t.Fatal("a marker at -360° is missing")
}
keeps(t, img)
}
func TestBossBarPhases(t *testing.T) {
tt := view(500, 80, &world{clk: newClock()}, func(c *ui.Context) {
game.BossBar(c, "SLIME KING", 500, 1000, game.BossBarOptions{Phases: []float64{0.25}})
})
img := tt.Image()
// The quarter mark stands 49 dots into the 196-dot fill.
if n := count(t, img, 0, 22, 200, 1, pixel.Bad); n != 97 || at(t, img, 51, 22) != pixel.Ink {
t.Fatalf("half a 196-dot bar is %d Bad dots", n)
}
panics(t, "a bar narrower than the name", func() {
view(500, 80, &world{clk: newClock()}, func(c *ui.Context) {
game.BossBar(c, "SLIME KING", 1, 1, game.BossBarOptions{Width: 20})
})
})
panics(t, "a phase at 1", func() {
view(500, 80, &world{clk: newClock()}, func(c *ui.Context) {
game.BossBar(c, "x", 1, 1, game.BossBarOptions{Phases: []float64{1}})
})
})
}
func TestStatusEffectsEdgeByKind(t *testing.T) {
tt := view(200, 60, &world{clk: newClock()}, func(c *ui.Context) {
game.StatusEffects(c, "Effects", []game.Effect{{Sprite: icon.Bolt, Name: "Haste", Turns: 3}, {Sprite: icon.Skull, Name: "Poison", Debuff: true}}, game.StatusEffectsOptions{})
})
img := tt.Image()
if at(t, img, 5, 1) != pixel.Good || at(t, img, 17+5, 1) != pixel.Bad {
t.Fatal("edges do not tell buffs from debuffs")
}
// Only a count above zero gets a badge.
if count(t, img, 0, 9, 16, 7, pixel.Good) == 0 || count(t, img, 17, 9, 16, 7, pixel.Bad) != 0 {
t.Fatal("turn badges are wrong")
}
}
func TestDamageNumbersStayInTheirBox(t *testing.T) {
hits := []game.Hit{{Value: 123456789, Crit: true, X: 155, Y: 2}, {Value: 7, Crit: true, X: 2, Y: 79}, {Value: 1234567890123456789, Crit: true, X: 80, Y: 40}}
tt := view(400, 300, &world{clk: newClock()}, func(c *ui.Context) {
ui.Column(c).Padding(pixel.K(c).D(20)).Children(func() {
game.DamageNumbers(c, "Hits", &hits, game.DamageNumbersOptions{})
})
})
img := tt.Image()
// The box spans dots 20–179 across and 20–99 down.
outside := count(t, img, 0, 0, 200, 20, pixel.Warn) + count(t, img, 180, 0, 20, 120, pixel.Warn) +
count(t, img, 0, 100, 200, 20, pixel.Warn) + count(t, img, 0, 0, 20, 120, pixel.Warn)
if n := outside; n != 0 || count(t, img, 20, 20, 160, 80, pixel.Warn) == 0 {
t.Fatalf("%d crit dots outside the box", n)
}
}
func TestDamageNumbersShiftInsideWhole(t *testing.T) {
whole := func(x, y int) int {
hits := []game.Hit{{Value: 12345, Crit: true, X: x, Y: y}}
tt := view(400, 200, &world{clk: newClock()}, func(c *ui.Context) { game.DamageNumbers(c, "Hits", &hits, game.DamageNumbersOptions{}) })
return count(t, tt.Image(), 0, 0, 160, 80, pixel.Warn)
}
// Near an edge a number shifts in, keeping its dots.
if mid := whole(80, 40); mid == 0 || whole(158, 40) != mid || whole(80, 79) != mid || whole(1, 1) != mid {
t.Fatal("a number near an edge lost dots")
}
}
func TestDamageNumbersRiseAndExpire(t *testing.T) {
for _, reduce := range []bool{false, true} {
wd := &world{clk: newClock(), reduce: reduce}
hits := []game.Hit{{Value: 12, X: 40, Y: 40}, {Value: 99, Crit: true, X: 110, Y: 40}}
tt := view(400, 200, wd, func(c *ui.Context) { game.DamageNumbers(c, "Hits", &hits, game.DamageNumbersOptions{}) })
top := func(s pixel.Slot) int {
for y := range 80 {
if count(t, tt.Image(), 0, y, 160, 1, s) > 0 {
return y
}
}
return -1
}
t0, crit := top(pixel.Ink), top(pixel.Warn)
wd.clk.t = wd.clk.t.Add(400 * time.Millisecond)
tt.Frame()
// Halfway through its life it has risen 6 dots.
if rose := t0 - top(pixel.Ink); t0 < 0 || crit < 0 || (rose == 6) == reduce || rose != 6 && rose != 0 {
t.Fatalf("reduced %v: the number rose %d from %d", reduce, rose, t0)
}
wd.clk.t = wd.clk.t.Add(500 * time.Millisecond)
tt.Frame()
if len(hits) != 0 || top(pixel.Ink) != -1 {
t.Fatalf("reduced %v: %d hits outlived their time", reduce, len(hits))
}
}
}