Add fx components 291-320 and the home scene; Kit.Shot, Kit.Toward

Palette cycling, sky, sun and moon, clouds, starfield, rain, snow,
petals, leaves, fireflies, waterfall, ocean, campfire, lava, aurora,
lightning, mountains and a season scene; scanlines, vignette, screen
shake, flash, dither fades, iris wipes, dissolves, mosaics, glitches,
sparkles, explosions and confetti. Home opens on a live scene in the
palette's season and a strip of all eight palettes.

pixel adds Canvas.Cycled, Sprite.At, SpriteOf, Kit.Toward and
Kit.Shot, which spends a cue under Reduce Motion so it never replays;
display exports Reduce. Gallery grids and the sidebar are one Tab
stop each.
This commit is contained in:
ZacharyZhang-NY committed 2026-10-12 04:05:25 -04:00
1 parent 86a6901333
commit 8be66c09f2
56 files changed
+3324 -101

No files matched your search

+27
View File
@@ -15,6 +15,8 @@ type Canvas struct {
k Kit
gx, gy int
W, H int
// remap swaps slots as they draw; nil keeps them.
remap *[Slots]Slot
}
// painter is what Canvas needs of *ui.Painter.
@@ -42,10 +44,35 @@ func (g Canvas) Rect(x, y, w, h int, s Slot) {
if w <= 0 || h <= 0 {
return
}
if g.remap != nil {
s = g.remap[s]
}
d := g.k.dot
g.p.Fill(ui.Rect{X: float32(g.gx+x) * d, Y: float32(g.gy+y) * d, W: float32(w) * d, H: float32(h) * d}, g.k.C(s), 0)
}
// Cycled draws band's slots where Cycle(band, step) moves them.
func (g Canvas) Cycled(band []Slot, step int) Canvas {
var m [Slots]Slot
for i := range m {
m[i] = Slot(i)
}
to := Cycle(band, step)
for i, s := range band {
if s >= Slots || slices.Index(band, s) != i {
panic(fmt.Sprintf("pixel: Cycled band %v repeats or overflows", band))
}
m[s] = to[i]
}
if g.remap != nil {
for i := range m {
m[i] = g.remap[m[i]]
}
}
g.remap = &m
return g
}
// Clip limits fn's drawing to a dot rectangle.
func (g Canvas) Clip(x, y, w, h int, fn func()) {
d := g.k.dot
+65
View File
@@ -74,6 +74,71 @@ func (k Kit) Since(c *ui.Context, t time.Time, fps, done int) int {
return min(n, done)
}
type towardKey struct{}
// heading is a Toward's target, last turn and step then.
type heading struct {
on bool
at time.Time
from int
}
// Toward steps 0–n toward on, turning from where it is.
func (k Kit) Toward(c *ui.Context, e *ui.Element, on bool, fps, n int) int {
checkFPS(fps)
if n < 1 {
panic(fmt.Sprintf("pixel: Toward needs steps, got %d", n))
}
end := func(on bool) int {
if on {
return n
}
return 0
}
// First shown, it already sits where on puts it.
s := ui.Local(e, towardKey{}, func() heading { return heading{on: on, from: end(on)} })
// Reduce Motion settles at the end, so nothing resumes later.
if k.reduce {
*s = heading{on: on, from: end(on)}
return s.from
}
shown := func() int {
if s.on {
return s.from + k.Since(c, s.at, fps, n-s.from)
}
return s.from - k.Since(c, s.at, fps, s.from)
}
if on != s.on {
s.from, s.on, s.at = shown(), on, k.now
}
return shown()
}
type shotKey struct{}
// shot is a one-shot's cue, start, and spent mark.
type shot struct {
cue int
at time.Time
spent bool
}
// Shot steps a cued one-shot; Reduce Motion spends it whole.
func (k Kit) Shot(c *ui.Context, e *ui.Element, cue, fps, done int) int {
// Before any change the zero start reads as done.
s := ui.Local(e, shotKey{}, func() shot { return shot{cue: cue} })
if cue != s.cue {
s.cue, s.at, s.spent = cue, k.now, false
}
if k.reduce {
s.spent = true
}
if s.spent {
return done
}
return k.Since(c, s.at, fps, done)
}
type cueKey struct{}
// cued is a cue's last value and when it changed.
+155
View File
@@ -0,0 +1,155 @@
package pixel_test
import (
"testing"
"time"
"github.com/egoist/mygo/ui"
"git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel"
)
func TestCycledRemapsTheBand(t *testing.T) {
sp := pixel.MustSprite("agcr")
band := []pixel.Slot{pixel.Accent, pixel.Good, pixel.Info}
for _, tc := range []struct {
draw func(g pixel.Canvas) pixel.Canvas
want []pixel.Slot
}{
{func(g pixel.Canvas) pixel.Canvas { return g.Cycled(band, 1) }, []pixel.Slot{pixel.Good, pixel.Info, pixel.Accent, pixel.Bad}},
{func(g pixel.Canvas) pixel.Canvas { return g.Cycled(band, -1) }, []pixel.Slot{pixel.Info, pixel.Accent, pixel.Good, pixel.Bad}},
// The later band maps first, the earlier one after it.
{func(g pixel.Canvas) pixel.Canvas {
return g.Cycled(band[:2], 1).Cycled(band[1:], 1)
}, []pixel.Slot{pixel.Good, pixel.Info, pixel.Accent, pixel.Bad}},
} {
d := canvas(t, 4, 1, func(g pixel.Canvas) { tc.draw(g).Sprite(0, 0, sp, 0, pixel.Ink, 1) })
for i, want := range tc.want {
if got := d.at(i, 0); got != want {
t.Fatalf("dot %d drew %v, want %v", i, got, want)
}
}
}
// The original canvas keeps its slots.
d := canvas(t, 2, 1, func(g pixel.Canvas) {
g.Cycled(band, 1)
g.Sprite(0, 0, sp, 0, pixel.Ink, 1)
})
if d.at(0, 0) != pixel.Accent {
t.Fatal("Cycled changed the canvas it came from")
}
for name, band := range map[string][]pixel.Slot{"repeat": {pixel.Good, pixel.Good}, "overflow": {pixel.Good, pixel.Slots}, "empty": nil} {
mustPanic(t, name, func() { canvas(t, 1, 1, func(g pixel.Canvas) { g.Cycled(band, 1) }) })
}
}
func TestSpriteAtAndSpriteOf(t *testing.T) {
sp := pixel.MustSprite("a#.\ng.y\n\nyyy\nyyy")
for _, tc := range []struct {
frame, x, y int
want pixel.Slot
ok bool
}{{0, 0, 0, pixel.Accent, true}, {0, 1, 0, pixel.Warn, true}, {0, 2, 0, 0, false}, {0, 2, 1, pixel.Warn, true}, {1, 1, 1, pixel.Warn, true}, {2, 0, 1, pixel.Good, true}} {
if got, ok := sp.At(tc.frame, tc.x, tc.y, pixel.Warn); got != tc.want || ok != tc.ok {
t.Fatalf("At(%d, %d, %d) = %v %v, want %v %v", tc.frame, tc.x, tc.y, got, ok, tc.want, tc.ok)
}
}
mustPanic(t, "At outside", func() { sp.At(0, 3, 0, pixel.Ink) })
of := pixel.SpriteOf(3, 1, func(x, y int) (pixel.Slot, bool) { return pixel.Slot(x + 11), x != 1 })
d := canvas(t, 3, 1, func(g pixel.Canvas) { g.Sprite(0, 0, of, 0, pixel.Ink, 1) })
if d.at(0, 0) != pixel.Good || d.at(1, 0) != pixel.Back || d.at(2, 0) != pixel.Bad {
t.Fatal("SpriteOf drew the wrong dots")
}
mustPanic(t, "SpriteOf empty", func() { pixel.SpriteOf(0, 1, func(x, y int) (pixel.Slot, bool) { return 0, true }) })
mustPanic(t, "SpriteOf slot", func() { pixel.SpriteOf(1, 1, func(x, y int) (pixel.Slot, bool) { return pixel.Slots, true }) })
}
func TestShotSpendsCuesUnderReduceMotion(t *testing.T) {
at := time.Unix(1_700_000_000, 0)
cue, step := 0, -1
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: func() time.Time { return at }})
step = pixel.K(c).Shot(c, ui.Box(c), cue, 10, 5)
}, 10, 10)
frame := func(want int) {
t.Helper()
tt.Frame()
if step != want {
t.Fatalf("cue %d: step %d, want %d", cue, step, want)
}
}
// No cue yet: the shot is over.
frame(5)
cue = 1
frame(0)
at = at.Add(200 * time.Millisecond)
frame(2)
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
cue = 2
frame(5)
tt.SetPreferences(ui.Preferences{ReduceMotion: false, TextScale: 1})
frame(5)
cue = 3
frame(0)
}
func TestTowardSettlesUnderReduceMotion(t *testing.T) {
at := time.Unix(1_700_000_000, 0)
on, step := false, -1
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: func() time.Time { return at }})
step = pixel.K(c).Toward(c, ui.Box(c), on, 8, 4)
}, 10, 10)
on = true
tt.Frame()
at = at.Add(125 * time.Millisecond)
tt.Frame()
// Reduce Motion jumps to the end, and the end stays.
for _, reduce := range []bool{true, false} {
tt.SetPreferences(ui.Preferences{ReduceMotion: reduce, TextScale: 1})
tt.Frame()
if step != 4 {
t.Fatalf("reduce %v: step %d, want 4", reduce, step)
}
}
}
func TestTowardStepsAndTurnsWhereItIs(t *testing.T) {
for _, reduce := range []bool{false, true} {
at := time.Unix(1_700_000_000, 0)
on, step := true, -1
tt := ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay, Now: func() time.Time { return at }})
e := ui.Box(c)
step = pixel.K(c).Toward(c, e, on, 8, 4)
}, 10, 10)
if reduce {
tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1})
}
frame := func(d time.Duration, want int) {
t.Helper()
at = at.Add(d)
tt.Frame()
if !reduce && step != want || reduce && step != 4*boolInt(on) {
t.Fatalf("reduce %v, on %v: step %d, want %d", reduce, on, step, want)
}
}
// Shown first, it is already at its end.
frame(0, 4)
on = false
frame(0, 4)
frame(125*time.Millisecond, 3)
frame(125*time.Millisecond, 2)
// Turned at 2, it climbs back from 2.
on = true
frame(0, 2)
frame(125*time.Millisecond, 3)
frame(time.Second, 4)
}
mustPanic(t, "no steps", func() {
ui.NewTester(func(c *ui.Context) {
pixel.Use(c, pixel.Settings{Palette: pixel.SpringDay})
pixel.K(c).Toward(c, ui.Box(c), true, 8, 0)
}, 10, 10)
})
}
+36
View File
@@ -71,6 +71,42 @@ func cell(c byte) int8 {
func (s *Sprite) Frames() int { return len(s.frames) }
// At is frame's slot at x, y; false where clear.
func (s *Sprite) At(frame, x, y int, ink Slot) (Slot, bool) {
if x < 0 || y < 0 || x >= s.W || y >= s.H {
panic(fmt.Sprintf("pixel: sprite dot %d,%d outside %d×%d", x, y, s.W, s.H))
}
n := len(s.frames)
switch v := s.frames[(frame%n+n)%n][y*s.W+x]; v {
case cellClear:
return 0, false
case cellInk:
return ink, true
default:
return Slot(v), true
}
}
// SpriteOf builds a one-frame sprite; at reports each dot.
func SpriteOf(w, h int, at func(x, y int) (Slot, bool)) *Sprite {
if w < 1 || h < 1 {
panic(fmt.Sprintf("pixel: sprite of %d×%d", w, h))
}
px := make([]int8, w*h)
for y := range h {
for x := range w {
px[y*w+x] = cellClear
if s, ok := at(x, y); ok {
if s >= Slots {
panic(fmt.Sprintf("pixel: sprite slot %d at %d,%d", s, x, y))
}
px[y*w+x] = int8(s)
}
}
}
return &Sprite{W: w, H: h, frames: [][]int8{px}}
}
// Mirror returns s flipped left to right.
func (s *Sprite) Mirror() *Sprite {
m := &Sprite{W: s.W, H: s.H}