Audit keyboard, focus, veils, motion and clocks across all 500

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.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-12 16:03:04 -04:00
1 parent 81003b21ee
commit b4b4d67b92
198 files changed
+2708 -422

No files matched your search

+1 -1
View File
@@ -55,7 +55,7 @@ func Credits(c *ui.Context, text string, credits []Credit, o CreditsOptions) Cre
end, roll := h/2+full-pixel.LineHeight/2, start(k, e, o.Cue)
top, step := 0, end
if !k.Reduce() {
step = k.Since(c, roll.at, 16, end)
step = k.Since(c, roll.at, 15, end)
top = h - step
} else {
h = full
+5 -3
View File
@@ -31,13 +31,15 @@ const life = 800 * time.Millisecond
func DamageNumbers(c *ui.Context, text string, hits *[]Hit, o DamageNumbersOptions) *ui.Element {
k := pixel.K(c)
w, h := pixel.Or("DamageNumbers Width", o.Width, 160), pixel.Or("DamageNumbers Height", o.Height, 80)
now := k.Now()
// S5: hits rise in whole frames, fifteen a second.
now := k.Now().Truncate(time.Second / 15)
live := (*hits)[:0]
for _, hit := range *hits {
if hit.X < 0 || hit.X >= w || hit.Y < 0 || hit.Y >= h {
panic(fmt.Sprintf("game: hit at %d,%d is off the %d×%d box", hit.X, hit.Y, w, h))
}
if hit.at.IsZero() {
// A clock stepping back starts a hit again.
if hit.at.IsZero() || now.Before(hit.at) {
hit.at = now
}
if now.Sub(hit.at) < life {
@@ -54,7 +56,7 @@ func DamageNumbers(c *ui.Context, text string, hits *[]Hit, o DamageNumbersOptio
}
}
if len(shown) > 0 {
k.Tick(c, 20)
k.Tick(c, 15)
}
return ui.Box(c).Role(ui.RoleImage).Label(text).Description(strings.Join(parts, ", ")).Size(k.D(w), k.D(h)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
+1 -1
View File
@@ -66,7 +66,7 @@ func Equipment(c *ui.Context, text string, gear []data.Item, selected *int, o Eq
})
arrows(base, spots, selected)
res := EquipmentResult{Element: shell, Picked: -1}
if base.Shortcut(0, ui.KeyEnter) || base.Shortcut(0, ui.KeySpace) {
if pixel.Shortcut(base, 0, ui.KeyEnter) || pixel.Shortcut(base, 0, ui.KeySpace) {
res.Picked = *selected
}
left := (w - (3*pitch - 4)) / 2
+2 -1
View File
@@ -93,7 +93,7 @@ func arrows(base *ui.Element, spots []Spot, cur *int) {
key ui.Key
dx, dy int
}{{ui.KeyLeft, -1, 0}, {ui.KeyRight, 1, 0}, {ui.KeyUp, 0, -1}, {ui.KeyDown, 0, 1}} {
if base.Shortcut(0, m.key) {
if pixel.Shortcut(base, 0, m.key) {
*cur = nearest(spots, *cur, m.dx, m.dy)
}
}
@@ -120,5 +120,6 @@ func start(k pixel.Kit, e *ui.Element, cue int) begun {
if s.cue != cue {
s.cue, s.at, s.run = cue, k.Now(), s.run+1
}
k.Elapsed(&s.at)
return *s
}
+4 -2
View File
@@ -40,7 +40,9 @@ func HealthBar(c *ui.Context, text string, hp, full int, o HealthBarOptions) *ui
now := k.Now()
// end is the trail's end: held, then draining fast.
end := func() float64 {
return max(float64(tr.seen), tr.from-max(0, now.Sub(tr.at)-hold).Seconds()*float64(full))
// S5: the drain moves in whole frames, fifteen a second.
drained := max(0, now.Sub(tr.at)-hold).Truncate(time.Second / 15)
return max(float64(tr.seen), tr.from-drained.Seconds()*float64(full))
}
if hp < tr.seen {
tr.from, tr.at = end(), now
@@ -54,7 +56,7 @@ func HealthBar(c *ui.Context, text string, hp, full int, o HealthBarOptions) *ui
shown = float64(hp)
}
if shown > float64(hp) {
k.Tick(c, 30)
k.Tick(c, 15)
}
// Divide, not multiply: huge HP must not overflow.
ink := pixel.Good
+15 -10
View File
@@ -40,27 +40,32 @@ func Hotbar(c *ui.Context, text string, items []data.Item, selected *int, o Hotb
old := *selected
shell, base, st, used := pixel.Pad(c, text, n, n, data.SlotSize, data.SlotSize, selected, o.Disabled)
for i := range n {
if base.Shortcut(0, ui.Key1+ui.Key(i)) {
if pixel.Shortcut(base, 0, ui.Key1+ui.Key(i)) {
*selected = i
}
}
wh := ui.Local(base, wheelKey{}, func() wheel { return wheel{} })
notch := k.D(pixel.LineHeight)
// The handler runs before this frame knows it is off.
base.HandleInput(func(ev ui.InputEvent) bool {
if ev.Kind != ui.InputScroll {
return false
}
// A notch moves one slot; the bar wraps around.
for wh.left += ev.DY; wh.left >= notch || wh.left <= -notch; {
d := 1
if wh.left < 0 {
d = -1
}
wh.left -= float32(d) * notch
*selected, wh.moved = ((*selected+d)%n+n)%n, true
}
wh.left += ev.DY
return true
})
if base.IsDisabled() {
wh.left = 0
}
// A notch moves one slot; the bar wraps around.
for wh.left >= notch || wh.left <= -notch {
d := 1
if wh.left < 0 {
d = -1
}
wh.left -= float32(d) * notch
*selected, wh.moved = ((*selected+d)%n+n)%n, true
}
res := HotbarResult{Element: shell, Changed: *selected != old || wh.moved, Used: used}
wh.moved = false
base.Description(items[*selected].String())
+1 -1
View File
@@ -31,7 +31,7 @@ func InteractPrompt(c *ui.Context, target *ui.Element, action string, o Interact
at := max(3, int((b.X+b.W/2-x)/k.Dot()))
bob := 0
if !k.Reduce() {
bob = k.Tick(c, 2) % 2
bob = k.Tick(c, 4) / 2 % 2
}
var e *ui.Element
ui.Overlay(c, func() {
+70
View File
@@ -0,0 +1,70 @@
package game_test
import (
"testing"
"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"
)
func TestSpaceAfterEnterPicksTheCursor(t *testing.T) {
gear := []data.Item{{Sprite: icon.Crown, Name: "Crown"}, {}, {Sprite: icon.Sword, Name: "Sword"}, {}, {}}
sel, picks := game.Head, []int{}
tt := view(400, 200, &world{clk: newClock()}, func(c *ui.Context) {
if r := game.Equipment(c, "Gear", gear, &sel, game.EquipmentOptions{}); r.Picked >= 0 {
picks = append(picks, r.Picked)
}
})
r := find(t, tt, "Gear")
// The pointer rests over the doll's lower row.
tt.Move(r.X+r.W/2, r.Y+2*(26+11))
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeyEnter)
tt.Key(0, ui.KeySpace)
if len(picks) != 2 || picks[0] != game.Head || picks[1] != game.Head || sel != game.Head {
t.Fatalf("Enter then Space picked %v, cursor %d", picks, sel)
}
}
func TestSpaceLearnsTheCursorNotThePointer(t *testing.T) {
skills := []game.Skill{
{Name: "Root", Sprite: icon.Star},
{Name: "Slash", Sprite: icon.Sword, At: game.Spot{X: 1}, Needs: []int{0}},
}
cur, points, learned := 0, 2, -1
tt := view(400, 200, &world{clk: newClock()}, func(c *ui.Context) {
if r := game.SkillTree(c, "Skills", skills, &cur, &points, game.SkillTreeOptions{}); r.Learned >= 0 {
learned = r.Learned
}
})
r := find(t, tt, "Skills")
// The pointer rests on Slash; the cursor is on Root.
tt.Move(r.X+2*(28+10), r.Y+2*(20+10))
tt.Key(0, ui.KeyTab)
tt.Key(0, ui.KeySpace)
if learned != 0 || cur != 0 {
t.Fatalf("Space learned %d, cursor %d", learned, cur)
}
}
func TestHotbarWheelAsAVeilFallsMovesNothing(t *testing.T) {
wd, slot := &world{clk: newClock()}, 0
tt := view(400, 200, wd, func(c *ui.Context) {
game.Hotbar(c, "Slots", []data.Item{{Name: "A"}, {Name: "B"}}, &slot, game.HotbarOptions{})
})
r := find(t, tt, "Slots")
wd.inert = true
tt.Scroll(r.X+r.W/2, r.Y+r.H/2, 0, 40)
if slot != 0 {
t.Fatalf("the wheel moved a veiled bar to %d", slot)
}
wd.inert = false
tt.Frame()
tt.Scroll(r.X+r.W/2, r.Y+r.H/2, 0, 40)
if slot != 1 {
t.Fatalf("the wheel did not move the bar: %d", slot)
}
}
+1 -1
View File
@@ -17,7 +17,7 @@ func Letterbox(c *ui.Context, on bool, o LetterboxOptions, content func()) *ui.E
k := pixel.K(c)
bar := pixel.Or("Letterbox Bar", o.Bar, 16)
var cover *ui.Element
e := layout.Layers(c, content, func() { cover = ui.Box(c).Fill().PassThrough() })
e := layout.Layers(c, layout.LayersOptions{}, content, func() { cover = ui.Box(c).Fill().PassThrough() })
h := bar * k.Toward(c, e, on, 8, 4) / 4
cover.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
+1 -1
View File
@@ -34,7 +34,7 @@ func LootPopup(c *ui.Context, it data.Item, open *bool, o LootPopupOptions) Loot
// The item rises 8 dots; reduced, it starts up.
rise := 8
if !k.Reduce() {
rise = k.Since(c, start(k, e, 0).at, 16, 8)
rise = k.Since(c, start(k, e, 0).at, 15, 8)
}
e.Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
+3 -2
View File
@@ -166,8 +166,9 @@ func TestDamageNumbersRiseAndExpire(t *testing.T) {
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 {
// Halfway through its life: 6 dots, or 5 stepped.
rose := t0 - top(pixel.Ink)
if moved := rose == 5 || rose == 6; t0 < 0 || crit < 0 || moved == reduce || !moved && 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)
+89
View File
@@ -6,7 +6,9 @@ import (
"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"
)
@@ -256,3 +258,90 @@ func TestLoadingTipsTurnEveryFourSeconds(t *testing.T) {
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")
}
}
+6 -6
View File
@@ -73,21 +73,21 @@ func OptionsMenu(c *ui.Context, text string, settings []Setting, row *int, o Opt
*s.Value, res.Changed = v, true
}
}
if base.Shortcut(0, ui.KeyUp) && *row > 0 {
if pixel.Shortcut(base, 0, ui.KeyUp) && *row > 0 {
*row--
}
if base.Shortcut(0, ui.KeyDown) && *row < len(settings)-1 {
if pixel.Shortcut(base, 0, ui.KeyDown) && *row < len(settings)-1 {
*row++
}
if base.Shortcut(0, ui.KeyLeft) {
if pixel.Shortcut(base, 0, ui.KeyLeft) {
step(-1)
}
if base.Shortcut(0, ui.KeyRight) {
if pixel.Shortcut(base, 0, ui.KeyRight) {
step(1)
}
// Enter and Space are taken, so clicks come from pointers.
base.Shortcut(0, ui.KeyEnter)
base.Shortcut(0, ui.KeySpace)
pixel.Shortcut(base, 0, ui.KeyEnter)
pixel.Shortcut(base, 0, ui.KeySpace)
aw := pixel.Measure("◀")
ax := pixel.Hand.W + 4 + name + 10
vx := ax + aw + 3
+1 -1
View File
@@ -29,7 +29,7 @@ func PressStart(c *ui.Context, o PressStartOptions) PressStartResult {
shell.Size(k.D(pixel.Measure(s)+8), k.D(pixel.LineHeight+4)).Draw(func(p *ui.Painter, r ui.Rect) {
g := k.Canvas(p, r)
// It blinks at 1 Hz; reduced, it holds still.
if g.Step(2)%2 == 0 {
if g.Step(4)%4 < 2 {
g.Text(4, 2, s, pixel.Ink, 1)
}
g.Finish(st)
+1 -1
View File
@@ -73,7 +73,7 @@ func SkillTree(c *ui.Context, text string, skills []Skill, cursor, points *int,
return ui.Box(c).Focusable().Role(ui.RoleGroup).Label(text).Disabled(o.Disabled)
})
arrows(base, spots, cursor)
learn := base.Shortcut(0, ui.KeyEnter) || base.Shortcut(0, ui.KeySpace)
learn := pixel.Shortcut(base, 0, ui.KeyEnter) || pixel.Shortcut(base, 0, ui.KeySpace)
top := pixel.LineHeight + 4
// A click moves the cursor; clicking it again learns.
if base.Clicked() {
+1 -1
View File
@@ -50,7 +50,7 @@ func TreasureChest(c *ui.Context, text string, it data.Item, open *bool, o Treas
if *open {
frame, rise = 2, 10
if !k.Reduce() {
frame, rise = k.Since(c, s.at, 8, 2), k.Since(c, s.at, 16, 10)
frame, rise = k.Since(c, s.at, 8, 2), k.Since(c, s.at, 15, 10)
}
}
if *open {