From 8be66c09f246fcda0390c7ad78967ba1aa44ecf9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Mon, 12 Oct 2026 04:05:25 -0400 Subject: [PATCH] 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. --- AGENTS.md | 6 +- display/internal_test.go | 26 ++- display/picture.go | 65 +++---- feedback/effects_test.go | 18 ++ feedback/shake.go | 5 +- fx/aurora.go | 35 ++++ fx/clouds.go | 36 ++++ fx/confetti.go | 44 +++++ fx/dissolve.go | 39 ++++ fx/explosion.go | 77 ++++++++ fx/fadedither.go | 21 +++ fx/fire.go | 103 +++++++++++ fx/fireflies.go | 38 ++++ fx/flash.go | 31 ++++ fx/fx.go | 95 ++++++++++ fx/fx_test.go | 148 +++++++++++++++ fx/glitch.go | 64 +++++++ fx/iriswipe.go | 42 +++++ fx/lava.go | 61 ++++++ fx/leaves.go | 34 ++++ fx/lightning.go | 65 +++++++ fx/mosaic.go | 62 +++++++ fx/mountains.go | 50 +++++ fx/ocean.go | 39 ++++ fx/palettecycle.go | 31 ++++ fx/petals.go | 32 ++++ fx/rain.go | 44 +++++ fx/scanlines.go | 23 +++ fx/scenes_test.go | 252 +++++++++++++++++++++++++ fx/screens_test.go | 367 +++++++++++++++++++++++++++++++++++++ fx/screenshake.go | 27 +++ fx/seasonscene.go | 80 ++++++++ fx/sky.go | 63 +++++++ fx/snow.go | 41 +++++ fx/sparkle.go | 48 +++++ fx/sprites.go | 179 ++++++++++++++++++ fx/starfield.go | 41 +++++ fx/sunmoon.go | 54 ++++++ fx/vignette.go | 40 ++++ fx/waterfall.go | 57 ++++++ gallery/app_test.go | 2 +- gallery/chrome.go | 2 +- gallery/demos_fx.go | 194 ++++++++++++++++++++ gallery/demos_screenfx.go | 156 ++++++++++++++++ gallery/demos_screens.go | 2 +- gallery/fx_demo_test.go | 86 +++++++++ gallery/invariants_test.go | 3 +- gallery/pages.go | 52 +++++- gallery/registry.go | 2 +- game/letterbox.go | 36 +--- overlay/overlay.go | 5 +- overlay/panels_test.go | 19 ++ pixel/canvas.go | 27 +++ pixel/clock.go | 65 +++++++ pixel/motion_test.go | 155 ++++++++++++++++ pixel/sprite.go | 36 ++++ 56 files changed, 3324 insertions(+), 101 deletions(-) create mode 100644 fx/aurora.go create mode 100644 fx/clouds.go create mode 100644 fx/confetti.go create mode 100644 fx/dissolve.go create mode 100644 fx/explosion.go create mode 100644 fx/fadedither.go create mode 100644 fx/fire.go create mode 100644 fx/fireflies.go create mode 100644 fx/flash.go create mode 100644 fx/fx.go create mode 100644 fx/fx_test.go create mode 100644 fx/glitch.go create mode 100644 fx/iriswipe.go create mode 100644 fx/lava.go create mode 100644 fx/leaves.go create mode 100644 fx/lightning.go create mode 100644 fx/mosaic.go create mode 100644 fx/mountains.go create mode 100644 fx/ocean.go create mode 100644 fx/palettecycle.go create mode 100644 fx/petals.go create mode 100644 fx/rain.go create mode 100644 fx/scanlines.go create mode 100644 fx/scenes_test.go create mode 100644 fx/screens_test.go create mode 100644 fx/screenshake.go create mode 100644 fx/seasonscene.go create mode 100644 fx/sky.go create mode 100644 fx/snow.go create mode 100644 fx/sparkle.go create mode 100644 fx/sprites.go create mode 100644 fx/starfield.go create mode 100644 fx/sunmoon.go create mode 100644 fx/vignette.go create mode 100644 fx/waterfall.go create mode 100644 gallery/demos_fx.go create mode 100644 gallery/demos_screenfx.go create mode 100644 gallery/fx_demo_test.go create mode 100644 pixel/motion_test.go diff --git a/AGENTS.md b/AGENTS.md index 4d24a0b..42bd406 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,7 @@ Imports point down only. Same-tier imports are forbidden. `internal/archtest` en - Name palette slots, never colors. Every pixel must be a palette color. - Only axis-aligned rectangles reach the screen: no radius, path, shadow, gradient, opacity, scaled bitmap. - Tone comes from Bayer dither between two slots. -- Animation steps through `Canvas.Step`, `Canvas.Busy` or `Kit.Tick`. No easing. +- Animation steps through `Canvas.Step`, `Canvas.Busy` or `Kit.Tick`. No easing. Color cycling turns a band of slots a place each step: `Canvas.Cycled` remaps a drawn picture's slots; procedural art indexes `pixel.Cycle(band, step)`, which may repeat a slot. - Text through the pixel font only. ## MyGo rules @@ -55,7 +55,7 @@ Imports point down only. Same-tier imports are forbidden. `internal/archtest` en - Signatures and results: PRD §5.1. Zero options give the default look. Sizes in options are dots. - `k := pixel.K(c)` once per component; layout sizes through `k.D(n)`. Integer options default through `pixel.Or`. -- Custom-drawn controls end their Draw with `g.Finish(st)`. Grids of cells moved by arrows use `pixel.Pad`; one-shot timing steps with `k.Since`; a view that changes at a known moment asks `k.At`. +- Custom-drawn controls end their Draw with `g.Finish(st)`. Grids of cells moved by arrows use `pixel.Pad`; one-shot timing steps with `k.Since`; a decorative one-shot on a cue steps with `k.Shot`, which Reduce Motion spends whole; a view that changes at a known moment asks `k.At`; a two-way slide (bars, fades, wipes) steps with `k.Toward`, which turns from where it is. - A one-shot effect inside a lasting view starts when its cue changes (a `Cue` option read through `k.Cue`, or a value that rises), never on its first frame. A state screen (`ErrorState`, `SuccessState`) starts when it appears. Decorative motion jumps to its end under Reduce Motion; countdowns and loaders keep time. - Containers take content as a final `func()`; interactive results are `NameResult`. - A Clicked, Shortcut, Changed or drag during the build makes MyGo build the frame again, so what they change shows at once. State changed any other way after its element was built (`Pressed`, `Edit.Changed`, a drag's release) is read at paint time, or set on the element after its children. @@ -84,7 +84,7 @@ Each component adds one `Entry` in `gallery/demos_.go`: ID, Name, Zh, - `go vet ./... && go test ./...` before reporting done. - Behavior tests drive `ui.Tester` and assert values. Timed components take `pixel.Settings.Now` from a test clock. `Tester.Focused(label)` is also true when an ancestor holds the focus. -- `gallery` checks every entry under every palette: only palette colors, whole dots. `PIXEL_SHOTS= go test ./gallery -run TestInvariants` writes PNGs for visual review. +- `gallery` checks every entry under every palette: only palette colors, whole dots. Demos that call `pixel.Scope`, and the home and entry pages, may show other palettes. `PIXEL_SHOTS= go test ./gallery -run TestInvariants` writes PNGs for visual review. - Web: `npm install && npm test` in `web/` builds `web/dist` and runs Playwright on a local server; `BASE_URL= npx playwright test` checks a deployed site. Tests read `window.__8bit` (`ready`, `stats()`, `texts()`, `page()`, `find(label)`, `log`). ## Deploy diff --git a/display/internal_test.go b/display/internal_test.go index fe6324b..945c690 100644 --- a/display/internal_test.go +++ b/display/internal_test.go @@ -18,23 +18,39 @@ func fill(w, h int, c color.Color) *image.RGBA { return img } +// dots are a sprite's slots, -1 where clear. +func dots(sp *pixel.Sprite) []int { + var out []int + for y := range sp.H { + for x := range sp.W { + s, ok := sp.At(0, x, y, pixel.Ink) + if !ok { + out = append(out, -1) + continue + } + out = append(out, int(s)) + } + } + return out +} + func TestReduce(t *testing.T) { pal := pixel.SpringDay a := pal.Colors[pixel.Accent] - for _, s := range reduce(fill(8, 8, color.RGBA{a.R, a.G, a.B, 255}), 4, 4, pal) { - if s != int8(pixel.Accent) { + for _, s := range dots(Reduce(fill(8, 8, color.RGBA{a.R, a.G, a.B, 255}), 4, 4, pal)) { + if s != int(pixel.Accent) { t.Fatalf("a flat Accent image gave slot %d", s) } } - for _, s := range reduce(fill(4, 4, color.RGBA{}), 4, 4, pal) { + for _, s := range dots(Reduce(fill(4, 4, color.RGBA{}), 4, 4, pal)) { if s != -1 { t.Fatal("a clear image drew") } } i, b := pal.Colors[pixel.Info], pal.Colors[pixel.Back] mid := color.RGBA{uint8((int(i.R) + int(b.R)) / 2), uint8((int(i.G) + int(b.G)) / 2), uint8((int(i.B) + int(b.B)) / 2), 255} - seen := map[int8]int{} - for _, s := range reduce(fill(16, 16, mid), 16, 16, pal) { + seen := map[int]int{} + for _, s := range dots(Reduce(fill(16, 16, mid), 16, 16, pal)) { seen[s]++ } if len(seen) < 2 { diff --git a/display/picture.go b/display/picture.go index f415697..c913e3e 100644 --- a/display/picture.go +++ b/display/picture.go @@ -20,7 +20,7 @@ type pictureCache struct { sum uint64 pal *pixel.Palette w, h int - dots []int8 + sp *pixel.Sprite } // Picture reduces img to the palette with Bayer dither. @@ -30,55 +30,38 @@ func Picture(c *ui.Context, img image.Image, o PictureOptions) *ui.Element { w, h := pixel.Or("Picture W", o.W, b.Dx()), pixel.Or("Picture H", o.H, b.Dy()) e := art(c, k, o.Label, w, h, nil) pc := ui.Local(e, pictureKey{}, func() pictureCache { return pictureCache{} }) - if sum := fingerprint(img); pc.dots == nil || pc.sum != sum || pc.pal != k.Palette() || pc.w != w || pc.h != h { - *pc = pictureCache{sum, k.Palette(), w, h, reduce(img, w, h, k.Palette())} + if sum := fingerprint(img); pc.sp == nil || pc.sum != sum || pc.pal != k.Palette() || pc.w != w || pc.h != h { + *pc = pictureCache{sum, k.Palette(), w, h, Reduce(img, w, h, k.Palette())} } - dots := pc.dots + sp := pc.sp return e.Draw(func(p *ui.Painter, r ui.Rect) { - g := k.Canvas(p, r) - for y := range h { - for x := 0; x < w; { - s, end := dots[y*w+x], x+1 - for end < w && dots[y*w+end] == s { - end++ - } - if s >= 0 { - g.Rect(x, y, end-x, 1, pixel.Slot(s)) - } - x = end - } - } + k.Canvas(p, r).Sprite(0, 0, sp, 0, pixel.Ink, 1) }) } -// reduce maps img to w×h slots; -1 marks clear dots. -func reduce(img image.Image, w, h int, pal *pixel.Palette) []int8 { +// Reduce maps img onto pal as a dithered w×h sprite. +func Reduce(img image.Image, w, h int, pal *pixel.Palette) *pixel.Sprite { b := img.Bounds() - out := make([]int8, w*h) - for y := range h { + return pixel.SpriteOf(w, h, func(x, y int) (pixel.Slot, bool) { y0, y1 := span(b.Min.Y, b.Dy(), y, h) - for x := range w { - x0, x1 := span(b.Min.X, b.Dx(), x, w) - var sum [4]uint32 - n := uint32(0) - for sy := y0; sy < y1; sy++ { - for sx := x0; sx < x1; sx++ { - r, g, bl, a := img.At(sx, sy).RGBA() - sum[0], sum[1], sum[2], sum[3] = sum[0]+r>>8, sum[1]+g>>8, sum[2]+bl>>8, sum[3]+a>>8 - n++ - } + x0, x1 := span(b.Min.X, b.Dx(), x, w) + var sum [4]uint32 + n := uint32(0) + for sy := y0; sy < y1; sy++ { + for sx := x0; sx < x1; sx++ { + r, g, bl, a := img.At(sx, sy).RGBA() + sum[0], sum[1], sum[2], sum[3] = sum[0]+r>>8, sum[1]+g>>8, sum[2]+bl>>8, sum[3]+a>>8 + n++ } - if sum[3]/n < 128 { - out[y*w+x] = -1 - continue - } - nudge := (float64(pixel.Bayer(x, y))+0.5)/16 - 0.5 - out[y*w+x] = int8(nearest(pal, [3]float64{ - float64(sum[0]/n) + nudge*48, float64(sum[1]/n) + nudge*48, float64(sum[2]/n) + nudge*48, - })) } - } - return out + if sum[3]/n < 128 { + return 0, false + } + nudge := (float64(pixel.Bayer(x, y))+0.5)/16 - 0.5 + return nearest(pal, [3]float64{ + float64(sum[0]/n) + nudge*48, float64(sum[1]/n) + nudge*48, float64(sum[2]/n) + nudge*48, + }), true + }) } // fingerprint mixes img's bounds and pixels, to notice edits. diff --git a/feedback/effects_test.go b/feedback/effects_test.go index a483b45..fa3d083 100644 --- a/feedback/effects_test.go +++ b/feedback/effects_test.go @@ -278,6 +278,24 @@ func TestReadyGoReportsDoneAfterAJump(t *testing.T) { } } +func TestShakeSpendsCuesUnderReduceMotion(t *testing.T) { + clk, cue := newClock(), 0 + tt := view(300, 100, clk, false, func(c *ui.Context) { + feedback.Shake(c, feedback.ShakeOptions{Cue: cue}, func() { + ui.Box(c).Size(20, 20).Label("box") + }) + }) + x := find(t, tt, "box").X + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + cue++ + tt.Frame() + tt.SetPreferences(ui.Preferences{ReduceMotion: false, TextScale: 1}) + tt.Frame() + if find(t, tt, "box").X != x { + t.Fatal("a cue spent under Reduce Motion shook later") + } +} + func TestShakeJoltsUnlessReduced(t *testing.T) { for _, reduce := range []bool{false, true} { clk := newClock() diff --git a/feedback/shake.go b/feedback/shake.go index 2600cd3..6c4588f 100644 --- a/feedback/shake.go +++ b/feedback/shake.go @@ -18,9 +18,6 @@ var shakes = [...]int{2, -2, 2, -2, 1, -1, 0} func Shake(c *ui.Context, o ShakeOptions, content func()) *ui.Element { k := pixel.K(c) e := ui.Box(c) - dx := 0 - if step := k.Since(c, k.Cue(e, o.Cue), 20, len(shakes)-1); !k.Reduce() { - dx = shakes[step] - } + dx := shakes[k.Shot(c, e, o.Cue, 20, len(shakes)-1)] return e.Padding(0, k.D(2-dx), 0, k.D(2+dx)).Children(content) } diff --git a/fx/aurora.go b/fx/aurora.go new file mode 100644 index 0000000..e6103cd --- /dev/null +++ b/fx/aurora.go @@ -0,0 +1,35 @@ +package fx + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type AuroraOptions struct { + // Width and Height are dots; zeros mean 160 by 96. + Width, Height int + Label string +} + +// Aurora hangs curtains whose slots slide sideways. +func Aurora(c *ui.Context, o AuroraOptions) *ui.Element { + k := pixel.K(c) + w, h := size("Aurora", o.Width, o.Height, 160, 96) + return box(c, k, o.Label, w, h).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Clip(0, 0, g.W, g.H, func() { aurora(g, g.Step(8)) }) + }) +} + +// aurora's columns ripple on top and fade at the hem. +func aurora(g pixel.Canvas, step int) { + band := pixel.Cycle([]pixel.Slot{pixel.Good, pixel.Info, pixel.Magic, pixel.Good, pixel.AccentLit}, step) + for x := range g.W { + top := g.H/8 + tri(x/2+step, 64, g.H/6) + tri(x+2*step, 36, 3) + long := g.H/4 + tri(3*x/2-step, 50, g.H/6) + s := band[x/4%len(band)] + g.VLine(x, top, long*2/3, s) + g.Veil(x, top+long*2/3, 1, long/3, s, 6) + } +} diff --git a/fx/clouds.go b/fx/clouds.go new file mode 100644 index 0000000..4f76a37 --- /dev/null +++ b/fx/clouds.go @@ -0,0 +1,36 @@ +package fx + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type CloudsOptions struct { + // Width and Height are dots; zeros mean 160 by 96. + Width, Height int + Label string +} + +// Clouds drift by: far ones slowly, near ones faster. +func Clouds(c *ui.Context, o CloudsOptions) *ui.Element { + k := pixel.K(c) + w, h := size("Clouds", o.Width, o.Height, 160, 96) + return box(c, k, o.Label, w, h).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Clip(0, 0, g.W, g.H, func() { clouds(g, g.Step(8)) }) + }) +} + +// clouds drifts two layers across the top third of g. +func clouds(g pixel.Canvas, step int) { + for layer, sp := range []*pixel.Sprite{smallCloud, bigCloud} { + // Far clouds shift a dot each four steps; near, two. + shift, span := step/(4>>layer), g.W+sp.W + for i := range max(1, g.W/(120-20*layer)) { + x := wrap(rnd(i, 10+layer)-shift, span) - sp.W + y := rnd(i, 12+layer)%max(1, g.H/3) + 4*layer + g.Sprite(x, y, sp, 0, pixel.Ink, 1) + } + } +} diff --git a/fx/confetti.go b/fx/confetti.go new file mode 100644 index 0000000..6213689 --- /dev/null +++ b/fx/confetti.go @@ -0,0 +1,44 @@ +package fx + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ConfettiOptions struct { + // Width and Height are dots; zeros mean 160 by 96. + Width, Height int + Label string +} + +// confettiSlots color the bits in turn. +var confettiSlots = []pixel.Slot{pixel.Accent, pixel.Good, pixel.Warn, pixel.Bad, pixel.Info, pixel.Magic} + +// Confetti tumbles bits of every color down, turning. +func Confetti(c *ui.Context, o ConfettiOptions) *ui.Element { + k := pixel.K(c) + w, h := size("Confetti", o.Width, o.Height, 160, 96) + return box(c, k, o.Label, w, h).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Clip(0, 0, g.W, g.H, func() { confetti(g, g.Step(10)) }) + }) +} + +// confetti turns each bit flat, edge-on, upright, edge-on. +func confetti(g pixel.Canvas, step int) { + for i := range g.W * g.H / 90 { + speed := 1 + rnd(i, 90)%2 + y := wrap(rnd(i, 91)+step*speed, g.H+4) - 2 + x := wrap(rnd(i, 92)+sway(step+rnd(i, 93), 12, 1), g.W) + s := confettiSlots[i%len(confettiSlots)] + switch wrap(step+i, 4) { + case 0: + g.Rect(x, y, 2, 1, s) + case 2: + g.Rect(x, y, 1, 2, s) + default: + g.Dot(x, y, s) + } + } +} diff --git a/fx/dissolve.go b/fx/dissolve.go new file mode 100644 index 0000000..079a9df --- /dev/null +++ b/fx/dissolve.go @@ -0,0 +1,39 @@ +package fx + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type DissolveOptions struct { + // Slot replaces the dots; zero means the darkest slot. + Slot pixel.Slot +} + +// Dissolve switches content's dots to a slot in shuffled order. +func Dissolve(c *ui.Context, covered bool, o DissolveOptions, content func()) *ui.Element { + k := pixel.K(c) + s := slotOr(o.Slot, dark(k)) + e := ui.Column(c) + step := k.Toward(c, e, covered, 12, 8) + return cover(k, e, func(g pixel.Canvas) { dissolve(g, s, step) }, content) +} + +// dissolve covers dots whose fixed rank, 0–7, is below step. +func dissolve(g pixel.Canvas, s pixel.Slot, step int) { + on := func(x, y int) bool { return rnd(x+y<<16, 150)%8 < step } + for y := range g.H { + for x := 0; x < g.W; x++ { + if !on(x, y) { + continue + } + end := x + 1 + for end < g.W && on(end, y) { + end++ + } + g.HLine(x, y, end-x, s) + x = end + } + } +} diff --git a/fx/explosion.go b/fx/explosion.go new file mode 100644 index 0000000..bdd87ab --- /dev/null +++ b/fx/explosion.go @@ -0,0 +1,77 @@ +package fx + +import ( + "math" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ExplosionOptions struct { + // Cue sets off a blast each time it changes. + Cue int + // Size is the square's side in dots; zero means 48. + Size int + Label string +} + +const blastSteps = 8 + +// debrisSlots color the bits thrown out, in turn. +var debrisSlots = []pixel.Slot{pixel.Warn, pixel.Bad, pixel.Faint} + +// Explosion bursts, breaks into smoke and throws debris. +func Explosion(c *ui.Context, o ExplosionOptions) *ui.Element { + k := pixel.K(c) + n := pixel.Or("Explosion Size", o.Size, 48) + e := box(c, k, o.Label, n, n) + step := k.Shot(c, e, o.Cue, 12, blastSteps) + return e.Draw(func(p *ui.Painter, r ui.Rect) { + if step == blastSteps { + return + } + g := k.Canvas(p, r) + g.Clip(0, 0, g.W, g.H, func() { blast(g, k, step, o.Cue) }) + }) +} + +// blast sizes rings and puffs in sixteenths of the box. +func blast(g pixel.Canvas, k pixel.Kit, step, seed int) { + cx, cy, u := g.W/2, g.H/2, max(1, g.W/16) + hot := light(k) + switch step { + case 0: + g.Disc(cx, cy, 2*u, pixel.Warn) + g.Disc(cx, cy, u, hot) + case 1, 2: + g.Disc(cx, cy, (3+step)*u, pixel.Bad) + g.Disc(cx, cy, 3*u, pixel.Warn) + g.Disc(cx, cy, (3-step)*u, hot) + default: + // The fireball breaks into puffs that rise and thin out. + for i := range 6 { + a := float64(i)*math.Pi/3 + float64(rnd(seed, 180+i)%40-20)*math.Pi/180 + d := float64((step + 1) * u) + x, y := cx+int(d*math.Cos(a)), cy+int(d*math.Sin(a))-(step-3)*u + if r := max(1, (8-step)*u/2); step < 6 { + g.Disc(x, y, r, pixel.Faint) + } else { + g.Circle(x, y, r, pixel.Faint) + } + } + if step < 5 { + g.Disc(cx, cy, (5-step)*u, pixel.Bad) + g.Disc(cx, cy, (4-step)*u, pixel.Warn) + } + } + // Debris flies out and falls. + t := float64(step) + for i := range 10 { + a := float64(rnd(seed*16+i, 181)%360) * math.Pi / 180 + v := float64(u) * (1.5 + float64(rnd(seed*16+i, 182)%3)/2) + x := cx + int(v*t*math.Cos(a)) + y := cy + int(v*t*math.Sin(a)+0.4*float64(u)*t*t) + g.Rect(x, y, max(1, u/2), max(1, u/2), debrisSlots[i%len(debrisSlots)]) + } +} diff --git a/fx/fadedither.go b/fx/fadedither.go new file mode 100644 index 0000000..27b4071 --- /dev/null +++ b/fx/fadedither.go @@ -0,0 +1,21 @@ +package fx + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type FadeDitherOptions struct { + // Slot covers the content; zero means the darkest slot. + Slot pixel.Slot +} + +// FadeDither covers content through Bayer levels, or uncovers it. +func FadeDither(c *ui.Context, covered bool, o FadeDitherOptions, content func()) *ui.Element { + k := pixel.K(c) + s := slotOr(o.Slot, dark(k)) + e := ui.Column(c) + level := 2 * k.Toward(c, e, covered, 12, 8) + return cover(k, e, func(g pixel.Canvas) { g.Veil(0, 0, g.W, g.H, s, level) }, content) +} diff --git a/fx/fire.go b/fx/fire.go new file mode 100644 index 0000000..1ee4c88 --- /dev/null +++ b/fx/fire.go @@ -0,0 +1,103 @@ +package fx + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type FireOptions struct { + // Scale multiplies the 16×20 campfire; zero means 2. + Scale int + Label string +} + +// tongues are each flicker frame's three tongue heights. +var tongues = [3][3]int{{3, 6, 2}, {4, 5, 3}, {2, 7, 4}} + +// bases are the flame's half widths from row 6 down. +var bases = [8]int{3, 4, 5, 5, 6, 6, 5, 4} + +// flames are flicker frames: a Bad rim, a Warn body. +var flames = func() (out [3]*pixel.Sprite) { + for f := range out { + in := func(x, y int) bool { + if y < 0 || y > 13 || x < 0 || x > 12 { + return false + } + if y >= 6 && abs(x-6) <= bases[y-6] { + return true + } + for i, h := range tongues[f] { + if tip := 6 - h; y >= tip && y <= 6 && abs(x-3-3*i) <= (y-tip)/2 { + return true + } + } + return false + } + out[f] = pixel.SpriteOf(13, 14, func(x, y int) (pixel.Slot, bool) { + switch { + case !in(x, y): + return 0, false + case !in(x-1, y) || !in(x+1, y) || !in(x, y-1) || !in(x, y+1): + return pixel.Bad, true + } + return pixel.Warn, true + }) + } + return out +}() + +// logs cross under the fire; s marks the cut ends. +var logs = pixel.MustSprite(` +ss............ss +s###........###s +..####....####.. +....########.... +..####....####.. +s###........###s +`) + +// Fire burns a campfire: flicker frames, heat rising through it. +func Fire(c *ui.Context, o FireOptions) *ui.Element { + k := pixel.K(c) + sc := scale("Fire", o.Scale, 2) + return box(c, k, o.Label, 16*sc, 20*sc).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + fire(g, k, sc, g.Step(10)) + }) +} + +// fire lights a third of every fourth body row, rising. +func fire(g pixel.Canvas, k pixel.Kit, sc, step int) { + px := func(x, y, w, h int, s pixel.Slot) { g.Rect(x*sc, y*sc, w*sc, h*sc, s) } + wood := g + if k.Palette().Night { + wood = g.Cycled([]pixel.Slot{pixel.Shade, pixel.Lit}, 1) + } + wood.Sprite(0, 14*sc, logs, 0, pick(k, pixel.Faint, pixel.Face), sc) + sp, hot := flames[wrap(step, 3)], nightLit(g, k) + hot.Sprite(sc, 2*sc, sp, 0, pixel.Ink, sc) + // Body rows index the band; turning it lifts the heat. + heat := pixel.Cycle([]pixel.Slot{pixel.Lit, pixel.Warn, pixel.Warn, pixel.Warn}, step) + for y := range sp.H { + for x := range sp.W { + if s, ok := sp.At(0, x, y, pixel.Ink); ok && s == pixel.Warn && rnd(16*y+x, 200)%3 == 0 && heat[y%4] == pixel.Lit { + hot.Rect((1+x)*sc, (2+y)*sc, sc, sc, pixel.Lit) + } + } + } + // Sparks rise beside the tips. + for i := range 2 { + if t := wrap(step+5*i, 8); t < 5 { + px(4+8*i+sway(step+3*i, 8, 1), 5-t, 1, 1, pixel.Warn) + } + } +} + +func abs(v int) int { + if v < 0 { + return -v + } + return v +} diff --git a/fx/fireflies.go b/fx/fireflies.go new file mode 100644 index 0000000..24f4da2 --- /dev/null +++ b/fx/fireflies.go @@ -0,0 +1,38 @@ +package fx + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type FirefliesOptions struct { + // Width and Height are dots; zeros mean 160 by 96. + Width, Height int + Label string +} + +// Fireflies wander and glow: bright, dim, then dark a while. +func Fireflies(c *ui.Context, o FirefliesOptions) *ui.Element { + k := pixel.K(c) + w, h := size("Fireflies", o.Width, o.Height, 160, 96) + return box(c, k, o.Label, w, h).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Clip(0, 0, g.W, g.H, func() { fireflies(g, g.Step(8)) }) + }) +} + +func fireflies(g pixel.Canvas, step int) { + for i := range max(3, g.W*g.H/600) { + x := rnd(i, 70)%g.W + sway(step+rnd(i, 71), 40+rnd(i, 72)%24, 8) + y := rnd(i, 73)%g.H + sway(step+rnd(i, 74), 28+rnd(i, 75)%20, 5) + switch t := wrap(step+rnd(i, 76), 16); { + case t < 6: + g.Rect(x-1, y, 3, 1, pixel.Good) + g.Rect(x, y-1, 1, 3, pixel.Good) + g.Dot(x, y, pixel.Warn) + case t < 9: + g.Dot(x, y, pixel.Good) + } + } +} diff --git a/fx/flash.go b/fx/flash.go new file mode 100644 index 0000000..1ad869f --- /dev/null +++ b/fx/flash.go @@ -0,0 +1,31 @@ +package fx + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type FlashOptions struct { + // Cue flashes each time it changes. + Cue int + // Slot fills the flash; zero means Lit. + Slot pixel.Slot +} + +// Flash fills content with a slot one step, then half. +func Flash(c *ui.Context, o FlashOptions, content func()) *ui.Element { + k := pixel.K(c) + s := slotOr(o.Slot, pixel.Lit) + e := ui.Column(c) + // Reduce Motion never flashes. + step := k.Shot(c, e, o.Cue, 10, 2) + return cover(k, e, func(g pixel.Canvas) { + switch step { + case 0: + g.Rect(0, 0, g.W, g.H, s) + case 1: + g.Veil(0, 0, g.W, g.H, s, 8) + } + }, content) +} diff --git a/fx/fx.go b/fx/fx.go new file mode 100644 index 0000000..7199427 --- /dev/null +++ b/fx/fx.go @@ -0,0 +1,95 @@ +// Package fx draws weather, scenes and transitions in stepped frames. +package fx + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +// box sizes an effect; a label makes it an image. +func box(c *ui.Context, k pixel.Kit, label string, w, h int) *ui.Element { + e := ui.Box(c).Size(k.D(w), k.D(h)) + if label != "" { + return e.Label(label).Role(ui.RoleImage) + } + return e.Role(ui.RoleNone) +} + +// size is w×h; zeros take dw×dh. +func size(name string, w, h, dw, dh int) (int, int) { + return pixel.Or(name+" Width", w, dw), pixel.Or(name+" Height", h, dh) +} + +// scale is s, or def for zero; above 4 panics. +func scale(name string, s, def int) int { + s = pixel.Or(name+" Scale", s, def) + if s > 4 { + panic(fmt.Sprintf("fx: %s Scale %d outside 1–4", name, s)) + } + return s +} + +// rnd is a fixed pseudo-random number for i and salt. +func rnd(i, salt int) int { + x := uint64(i)*0x9E3779B97F4A7C15 + uint64(salt)*0xD1B54A32D192ED03 + x = (x ^ x>>30) * 0xBF58476D1CE4E5B9 + x = (x ^ x>>27) * 0x94D049BB133111EB + return int((x ^ x>>31) >> 1) +} + +// wrap is v modulo n, never negative. +func wrap(v, n int) int { return (v%n + n) % n } + +// sway swings 0 to a, back through -a, over p. +func sway(t, p, a int) int { + t = wrap(t, p) + q := p / 4 + switch { + case t < q: + return a * t / q + case t < 3*q: + return a - 2*a*(t-q)/(2*q) + default: + return -a + a*(t-3*q)/q + } +} + +// pick is day under day palettes, night under night ones. +func pick(k pixel.Kit, day, night pixel.Slot) pixel.Slot { + if k.Palette().Night { + return night + } + return day +} + +// light is the palette's brightest slot. +func light(k pixel.Kit) pixel.Slot { return pick(k, pixel.Lit, pixel.Ink) } + +// dark is the palette's darkest slot. +func dark(k pixel.Kit) pixel.Slot { return pick(k, pixel.Ink, pixel.Well) } + +// slotOr is s, or def for the zero slot. +func slotOr(s, def pixel.Slot) pixel.Slot { + if s == pixel.Ink { + return def + } + return s +} + +// checkHour panics outside 0–23. +func checkHour(name string, hour int) { + if hour < 0 || hour > 23 { + panic(fmt.Sprintf("fx: %s hour %d outside 0–23", name, hour)) + } +} + +// cover draws over e's content, clipped to e. +func cover(k pixel.Kit, e *ui.Element, draw func(g pixel.Canvas), content func()) *ui.Element { + return e.DrawOver(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Clip(0, 0, g.W, g.H, func() { draw(g) }) + }).Children(content) +} diff --git a/fx/fx_test.go b/fx/fx_test.go new file mode 100644 index 0000000..6b159d4 --- /dev/null +++ b/fx/fx_test.go @@ -0,0 +1,148 @@ +package fx_test + +import ( + "image" + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/fx" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type clock struct{ t time.Time } + +func (c *clock) now() time.Time { return c.t } + +func newClock() *clock { return &clock{time.Unix(1_800_000_000, 0)} } + +// step is the clock's frame at fps, as pixel counts. +func (c *clock) step(fps int64) int { return int(c.t.UnixNano() / (int64(time.Second) / fps)) } + +// tick is one step at fps, just past the edge. +func tick(fps int) time.Duration { return time.Second/time.Duration(fps) + time.Microsecond } + +// view builds w×h dots on a fake clock, 2px each. +func view(w, h int, pal *pixel.Palette, clk *clock, reduce bool, build func(c *ui.Context)) *ui.Tester { + tt := ui.NewTester(func(c *ui.Context) { + pixel.Use(c, pixel.Settings{Palette: pal, Now: clk.now}) + build(c) + }, 2*w, 2*h) + if reduce { + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + tt.Frame() + } + return tt +} + +// dots reads w×h dots as slots; -1 is off-palette. +func dots(img *image.RGBA, pal *pixel.Palette, w, h int) [][]int { + out := make([][]int, h) + for y := range h { + out[y] = make([]int, w) + for x := range w { + c, s := img.RGBAAt(2*x, 2*y), -1 + for i, pc := range pal.Colors { + if pc.R == c.R && pc.G == c.G && pc.B == c.B { + s = i + break + } + } + out[y][x] = s + } + } + return out +} + +// count is how many dots of a box hold s. +func count(d [][]int, x, y, w, h int, s pixel.Slot) int { + n := 0 + for j := y; j < y+h; j++ { + for i := x; i < x+w; i++ { + if d[j][i] == int(s) { + n++ + } + } + } + return n +} + +func same(a, b [][]int) bool { + for y := range a { + for x := range a[y] { + if a[y][x] != b[y][x] { + return false + } + } + } + return true +} + +func mustPanic(t *testing.T, name string, fn func()) { + t.Helper() + defer func() { + if recover() == nil { + t.Errorf("%s did not panic", name) + } + }() + fn() +} + +// solid is a one-slot w×h box under effects. +func solid(c *ui.Context, w, h int, s pixel.Slot) { + k := pixel.K(c) + ui.Box(c).Size(k.D(w), k.D(h)).Label("content").Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Rect(0, 0, g.W, g.H, s) + }) +} + +var portal = pixel.MustSprite("agcagc\ncagcag") + +func TestAmbientEffectsMoveButHoldStillUnderReduceMotion(t *testing.T) { + for name, build := range map[string]func(c *ui.Context){ + "PaletteCycle": func(c *ui.Context) { + fx.PaletteCycle(c, portal, []pixel.Slot{pixel.Accent, pixel.Good, pixel.Info}, fx.PaletteCycleOptions{}) + }, + "SunMoon": func(c *ui.Context) { fx.SunMoon(c, 12, fx.SunMoonOptions{Width: 80, Height: 48}) }, + "Clouds": func(c *ui.Context) { fx.Clouds(c, fx.CloudsOptions{Width: 80, Height: 48}) }, + "Starfield": func(c *ui.Context) { fx.Starfield(c, fx.StarfieldOptions{Width: 80, Height: 48}) }, + "Rain": func(c *ui.Context) { fx.Rain(c, fx.RainOptions{Width: 80, Height: 48}) }, + "Snow": func(c *ui.Context) { fx.Snow(c, fx.SnowOptions{Width: 80, Height: 48}) }, + "Petals": func(c *ui.Context) { fx.Petals(c, fx.PetalsOptions{Width: 80, Height: 48}) }, + "Leaves": func(c *ui.Context) { fx.Leaves(c, fx.LeavesOptions{Width: 80, Height: 48}) }, + "Fireflies": func(c *ui.Context) { fx.Fireflies(c, fx.FirefliesOptions{Width: 80, Height: 48}) }, + "Waterfall": func(c *ui.Context) { fx.Waterfall(c, fx.WaterfallOptions{Width: 40, Height: 48}) }, + "Ocean": func(c *ui.Context) { fx.Ocean(c, fx.OceanOptions{Width: 80, Height: 48}) }, + "Fire": func(c *ui.Context) { fx.Fire(c, fx.FireOptions{}) }, + "Lava": func(c *ui.Context) { fx.Lava(c, fx.LavaOptions{Width: 80, Height: 48}) }, + "Aurora": func(c *ui.Context) { fx.Aurora(c, fx.AuroraOptions{Width: 80, Height: 48}) }, + "Mountains": func(c *ui.Context) { fx.Mountains(c, fx.MountainsOptions{Width: 80, Height: 48}) }, + "Season": func(c *ui.Context) { fx.SeasonScene(c, pixel.Winter, 22, fx.SeasonSceneOptions{Width: 80, Height: 48}) }, + "Sparkle": func(c *ui.Context) { fx.Sparkle(c, fx.SparkleOptions{}) }, + "Confetti": func(c *ui.Context) { fx.Confetti(c, fx.ConfettiOptions{Width: 80, Height: 48}) }, + "NightSky": func(c *ui.Context) { fx.Sky(c, fx.SkyOptions{Width: 80, Height: 48}) }, + "NightSnows": func(c *ui.Context) { fx.Snow(c, fx.SnowOptions{Width: 80, Height: 48}) }, + } { + // A day sky holds still: only stars move. + pal := pixel.SpringDay + if name == "NightSky" || name == "NightSnows" || name == "Season" { + pal = pixel.WinterNight + } + for _, reduce := range []bool{false, true} { + clk := newClock() + tt := view(80, 48, pal, clk, reduce, build) + first := dots(tt.Image(), pal, 80, 48) + moved := false + for _, d := range []time.Duration{300, 700, 1300} { + clk.t = clk.t.Add(d * time.Millisecond) + tt.Frame() + moved = moved || !same(first, dots(tt.Image(), pal, 80, 48)) + } + if moved == reduce { + t.Errorf("%s, reduce %v: moved %v", name, reduce, moved) + } + } + } +} diff --git a/fx/glitch.go b/fx/glitch.go new file mode 100644 index 0000000..232a75e --- /dev/null +++ b/fx/glitch.go @@ -0,0 +1,64 @@ +package fx + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type GlitchOptions struct { + // Cue glitches the picture each time it changes. + Cue int + // Slot inks the sprite's # dots. + Slot pixel.Slot + Scale int + Label string +} + +const glitchSteps = 6 + +// Glitch shifts row bands and swaps two slots, briefly. +func Glitch(c *ui.Context, sp *pixel.Sprite, o GlitchOptions) *ui.Element { + k := pixel.K(c) + sc := scale("Glitch", o.Scale, 1) + // Three dots either side hold the rows shifted out. + e := box(c, k, o.Label, (sp.W+6)*sc, sp.H*sc) + step := k.Shot(c, e, o.Cue, 15, glitchSteps) + used := slotsOf(sp, o.Slot) + return e.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + if step == glitchSteps { + g.Sprite(3*sc, 0, sp, 0, o.Slot, sc) + return + } + seed := o.Cue*glitchSteps + step + if len(used) > 1 { + a := rnd(seed, 160) % len(used) + b := (a + 1 + rnd(seed, 161)%(len(used)-1)) % len(used) + g = g.Cycled([]pixel.Slot{used[a], used[b]}, 1) + } + for y := 0; y < sp.H; { + h, dx := 1+rnd(seed+y, 162)%4, 0 + if rnd(seed+y, 163)%3 == 0 { + dx = rnd(seed+y, 164)%7 - 3 + } + g.Clip(0, y*sc, g.W, h*sc, func() { g.Sprite((3+dx)*sc, 0, sp, 0, o.Slot, sc) }) + y += h + } + }) +} + +// slotsOf lists the slots sp's first frame uses. +func slotsOf(sp *pixel.Sprite, ink pixel.Slot) []pixel.Slot { + var seen [pixel.Slots]bool + var out []pixel.Slot + for y := range sp.H { + for x := range sp.W { + if s, ok := sp.At(0, x, y, ink); ok && !seen[s] { + seen[s] = true + out = append(out, s) + } + } + } + return out +} diff --git a/fx/iriswipe.go b/fx/iriswipe.go new file mode 100644 index 0000000..8d67086 --- /dev/null +++ b/fx/iriswipe.go @@ -0,0 +1,42 @@ +package fx + +import ( + "math" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type IrisWipeOptions struct { + // Slot fills outside the iris; zero means the darkest slot. + Slot pixel.Slot +} + +// IrisWipe closes a circle on content's middle, or opens it. +func IrisWipe(c *ui.Context, closed bool, o IrisWipeOptions, content func()) *ui.Element { + k := pixel.K(c) + s := slotOr(o.Slot, dark(k)) + e := ui.Column(c) + step := k.Toward(c, e, closed, 12, 8) + return cover(k, e, func(g pixel.Canvas) { iris(g, s, step) }, content) +} + +// iris fills outside a circle, gone by step 8. +func iris(g pixel.Canvas, s pixel.Slot, step int) { + if step == 0 { + return + } + cx, cy := g.W/2, g.H/2 + r := int(math.Ceil(math.Hypot(float64(g.W)/2, float64(g.H)/2))) * (8 - step) / 8 + for y := range g.H { + dy := y - cy + if dy*dy >= r*r { + g.HLine(0, y, g.W, s) + continue + } + dx := int(math.Sqrt(float64(r*r - dy*dy))) + g.HLine(0, y, cx-dx, s) + g.HLine(cx+dx, y, g.W-cx-dx, s) + } +} diff --git a/fx/lava.go b/fx/lava.go new file mode 100644 index 0000000..d40e38b --- /dev/null +++ b/fx/lava.go @@ -0,0 +1,61 @@ +package fx + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type LavaOptions struct { + // Width and Height are dots; zeros mean 160 by 48. + Width, Height int + Label string +} + +// Lava flows in cycling veins while bubbles swell and pop. +func Lava(c *ui.Context, o LavaOptions) *ui.Element { + k := pixel.K(c) + w, h := size("Lava", o.Width, o.Height, 160, 48) + return box(c, k, o.Label, w, h).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Clip(0, 0, g.W, g.H, func() { lava(g, k, g.Step(6)) }) + }) +} + +// lava's veins flow and cycle row by row; crust drifts. +func lava(g pixel.Canvas, k pixel.Kit, step int) { + g.Rect(0, 0, g.W, g.H, pixel.Bad) + band := pixel.Cycle([]pixel.Slot{pixel.Warn, pixel.Warn, light(k), pixel.Warn, pixel.Bad, pixel.Bad}, -step) + for j := 0; 4*j < g.H; j++ { + s, dir := band[j%len(band)], 1-2*(j%2) + for i := range g.W/14 + 1 { + x := wrap(rnd(i, 130+j)+dir*step, g.W+16) - 8 + g.Rect(x, 4*j+1+rnd(i, 140+j)%2, 4+rnd(i, 150+j)%8, 1, s) + } + } + crust := pick(k, pixel.Edge, pixel.Shade) + for i := range g.W * g.H / 300 { + g.Rect(wrap(rnd(i, 160)+step/2, g.W), rnd(i, 161)%g.H, 2+rnd(i, 162)%3, 1, crust) + } + for i := range max(2, g.W/24) { + t := step + rnd(i, 130) + // Each life of a bubble rises somewhere new. + x, y := rnd(t/12*64+i, 131)%g.W, 4+rnd(t/12*64+i, 132)%max(1, g.H-8) + switch wrap(t, 12) { + case 4: + g.Dot(x, y, pixel.Warn) + case 5: + g.Disc(x, y, 1, pixel.Warn) + case 6: + g.Disc(x, y, 2, pixel.Warn) + g.Dot(x-1, y-1, light(k)) + case 7: + g.Circle(x, y, 3, pixel.Warn) + g.Dot(x-1, y-1, light(k)) + case 8: + for _, d := range [][2]int{{-4, 0}, {4, 0}, {0, -4}, {0, 4}} { + g.Dot(x+d[0], y+d[1], pixel.Warn) + } + } + } +} diff --git a/fx/leaves.go b/fx/leaves.go new file mode 100644 index 0000000..17463d9 --- /dev/null +++ b/fx/leaves.go @@ -0,0 +1,34 @@ +package fx + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type LeavesOptions struct { + // Width and Height are dots; zeros mean 160 by 96. + Width, Height int + Label string +} + +// leafSlots color the leaves in turn. +var leafSlots = []pixel.Slot{pixel.Warn, pixel.Bad, pixel.Accent} + +// Leaves swing down side to side, turning on their stems. +func Leaves(c *ui.Context, o LeavesOptions) *ui.Element { + k := pixel.K(c) + w, h := size("Leaves", o.Width, o.Height, 160, 96) + return box(c, k, o.Label, w, h).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Clip(0, 0, g.W, g.H, func() { leaves(g, g.Step(8)) }) + }) +} + +func leaves(g pixel.Canvas, step int) { + for i := range max(2, g.W*g.H/500) { + y := wrap(rnd(i, 60)+step, g.H+10) - 5 + x := wrap(rnd(i, 61)+step/4+sway(step+rnd(i, 62), 20, 5), g.W+10) - 5 + g.Sprite(x, y, leaf, (step+i)/3, leafSlots[i%len(leafSlots)], 1) + } +} diff --git a/fx/lightning.go b/fx/lightning.go new file mode 100644 index 0000000..c89328b --- /dev/null +++ b/fx/lightning.go @@ -0,0 +1,65 @@ +package fx + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type LightningOptions struct { + // Cue strikes each time it changes. + Cue int + // Width and Height are dots; zeros mean 96 by 96. + Width, Height int + Label string +} + +// strikeSteps run flash, half flash, bolt, bolt, dim bolt. +const strikeSteps = 5 + +// Lightning flashes, then forks a bolt to the ground. +func Lightning(c *ui.Context, o LightningOptions) *ui.Element { + k := pixel.K(c) + w, h := size("Lightning", o.Width, o.Height, 96, 96) + e := box(c, k, o.Label, w, h) + step := k.Shot(c, e, o.Cue, 15, strikeSteps) + return e.Draw(func(p *ui.Painter, r ui.Rect) { + if step == strikeSteps { + return + } + g := k.Canvas(p, r) + g.Clip(0, 0, g.W, g.H, func() { strike(g, k, step, o.Cue) }) + }) +} + +// strike draws step of a strike; seed shapes the bolt. +func strike(g pixel.Canvas, k pixel.Kit, step, seed int) { + switch step { + case 0: + g.Rect(0, 0, g.W, g.H, light(k)) + return + case 1: + g.Veil(0, 0, g.W, g.H, light(k), 6) + } + core, glow := pick(k, pixel.Warn, pixel.Ink), pixel.Info + if step == 4 { + core = pixel.AccentLit + } + x, y := g.W/3+rnd(seed, 140)%max(1, g.W/3), 0 + fork := 1 + rnd(seed, 141)%3 + for i := 0; y < g.H; i++ { + nx, ny := x+rnd(seed+i, 142)%7-3, y+4+rnd(seed+i, 143)%6 + g.Line(x+1, y, nx+1, ny, glow) + g.Line(x, y, nx, ny, core) + // One branch forks off and dies out. + if i == fork { + bx, by, dir := nx, ny, 1-2*(rnd(seed, 144)%2) + for j := range 3 { + ex, ey := bx+dir*(3+rnd(seed+j, 145)%3), by+3+rnd(seed+j, 146)%4 + g.Line(bx, by, ex, ey, core) + bx, by = ex, ey + } + } + x, y = nx, ny + } +} diff --git a/fx/mosaic.go b/fx/mosaic.go new file mode 100644 index 0000000..ffc0d7f --- /dev/null +++ b/fx/mosaic.go @@ -0,0 +1,62 @@ +package fx + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type MosaicOptions struct { + // Cue pixelates the picture and back each time it changes. + Cue int + // Slot inks the sprite's # dots. + Slot pixel.Slot + Scale int + Label string +} + +// blocks are block sides a step each: growing, then back. +var blocks = [...]int{1, 2, 3, 4, 6, 8, 6, 4, 3, 2, 1} + +// Mosaic pixelates sp in growing blocks, then sharpens it again. +func Mosaic(c *ui.Context, sp *pixel.Sprite, o MosaicOptions) *ui.Element { + k := pixel.K(c) + sc := scale("Mosaic", o.Scale, 1) + e := box(c, k, o.Label, sp.W*sc, sp.H*sc) + b := blocks[k.Shot(c, e, o.Cue, 10, len(blocks)-1)] + return e.Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + if b == 1 { + g.Sprite(0, 0, sp, 0, o.Slot, sc) + return + } + for y := 0; y < sp.H; y += b { + for x := 0; x < sp.W; x += b { + if s, ok := majority(sp, o.Slot, x, y, b); ok { + g.Rect(x*sc, y*sc, min(b, sp.W-x)*sc, min(b, sp.H-y)*sc, s) + } + } + } + }) +} + +// majority is a block's commonest slot; clear wins ties. +func majority(sp *pixel.Sprite, ink pixel.Slot, x0, y0, b int) (pixel.Slot, bool) { + var n [pixel.Slots + 1]int + for y := y0; y < min(y0+b, sp.H); y++ { + for x := x0; x < min(x0+b, sp.W); x++ { + if s, ok := sp.At(0, x, y, ink); ok { + n[s]++ + } else { + n[pixel.Slots]++ + } + } + } + best := pixel.Slots + for s := range pixel.Slots { + if n[s] > n[best] { + best = s + } + } + return pixel.Slot(best), best != pixel.Slots +} diff --git a/fx/mountains.go b/fx/mountains.go new file mode 100644 index 0000000..0d601a6 --- /dev/null +++ b/fx/mountains.go @@ -0,0 +1,50 @@ +package fx + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type MountainsOptions struct { + // Width and Height are dots; zeros mean 160 by 96. + Width, Height int + Label string +} + +// Mountains scroll three ridges: far ones slow, near ones quick. +func Mountains(c *ui.Context, o MountainsOptions) *ui.Element { + k := pixel.K(c) + w, h := size("Mountains", o.Width, o.Height, 160, 96) + return box(c, k, o.Label, w, h).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Clip(0, 0, g.W, g.H, func() { mountains(g, k, g.Step(8), 3) }) + }) +} + +// tri rises from 0 to a, then falls, over p. +func tri(t, p, a int) int { + t, h := wrap(t, p), p/2 + if t < h { + return a * t / h + } + return a * (p - t) / h +} + +// mountains draws n ridges; ridge l moves every 8>>l steps. +func mountains(g pixel.Canvas, k pixel.Kit, step, n int) { + slots := [3]pixel.Slot{pick(k, pixel.Shade, pixel.Face), pick(k, pixel.Faint, pixel.Back), pick(k, pixel.Edge, pixel.Well)} + snowy := k.Palette().Season == pixel.Winter + for l := range n { + shift := step / (8 >> l) + for x := range g.W { + u := x + shift + h := g.H*(7-2*l)/16 + tri(u+rnd(l, 100), 48+16*l, g.H/5) + tri(2*u+rnd(l, 101), 37, g.H/12) + rnd(u, 102+l)%2 + g.VLine(x, g.H-h, h, slots[l]) + // Winter caps the far peaks with snow. + if snowy && l == 0 && h > g.H*9/16 { + g.VLine(x, g.H-h, 2, light(k)) + } + } + } +} diff --git a/fx/ocean.go b/fx/ocean.go new file mode 100644 index 0000000..efb5cfb --- /dev/null +++ b/fx/ocean.go @@ -0,0 +1,39 @@ +package fx + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type OceanOptions struct { + // Width and Height are dots; zeros mean 160 by 64. + Width, Height int + Label string +} + +// Ocean rolls wave crests toward you by cycling their slots. +func Ocean(c *ui.Context, o OceanOptions) *ui.Element { + k := pixel.K(c) + w, h := size("Ocean", o.Width, o.Height, 160, 64) + return box(c, k, o.Label, w, h).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Clip(0, 0, g.W, g.H, func() { ocean(g, k, g.Step(6)) }) + }) +} + +// ocean's crest rows grow sparser and longer downward. +func ocean(g pixel.Canvas, k pixel.Kit, step int) { + g.Rect(0, 0, g.W, g.H, pixel.Info) + g.Dither(0, 0, g.W, 2, pixel.Info, light(k), 6) + // Crest row j shows the band turned back each step. + band := pixel.Cycle([]pixel.Slot{pixel.Info, pixel.Info, pixel.AccentLit, light(k)}, -step) + for y, j := 3, 0; y < g.H; j++ { + if s := band[j%len(band)]; s != pixel.Info { + for i := range max(1, g.W/12) { + g.HLine(rnd(i, 120+j)%g.W, y, 2+j/2, s) + } + } + y += 2 + j/2 + } +} diff --git a/fx/palettecycle.go b/fx/palettecycle.go new file mode 100644 index 0000000..a5034cd --- /dev/null +++ b/fx/palettecycle.go @@ -0,0 +1,31 @@ +package fx + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type PaletteCycleOptions struct { + // FPS is steps a second; zero means 8. + FPS int + // Slot inks the sprite's # dots. + Slot pixel.Slot + Scale int + Label string +} + +// PaletteCycle draws sp, rotating band's slots one place a step. +func PaletteCycle(c *ui.Context, sp *pixel.Sprite, band []pixel.Slot, o PaletteCycleOptions) *ui.Element { + k := pixel.K(c) + fps, sc := pixel.Or("PaletteCycle FPS", o.FPS, 8), scale("PaletteCycle", o.Scale, 1) + if len(band) < 2 { + panic(fmt.Sprintf("fx: PaletteCycle band %v needs two slots", band)) + } + return box(c, k, o.Label, sp.W*sc, sp.H*sc).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Cycled(band, g.Step(fps)).Sprite(0, 0, sp, 0, o.Slot, sc) + }) +} diff --git a/fx/petals.go b/fx/petals.go new file mode 100644 index 0000000..a65abcc --- /dev/null +++ b/fx/petals.go @@ -0,0 +1,32 @@ +package fx + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type PetalsOptions struct { + // Width and Height are dots; zeros mean 160 by 96. + Width, Height int + Label string +} + +// Petals fall on a breeze, turning as they go. +func Petals(c *ui.Context, o PetalsOptions) *ui.Element { + k := pixel.K(c) + w, h := size("Petals", o.Width, o.Height, 160, 96) + return box(c, k, o.Label, w, h).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Clip(0, 0, g.W, g.H, func() { petals(g, g.Step(8)) }) + }) +} + +// petals fall a row a step, drifting left every three. +func petals(g pixel.Canvas, step int) { + for i := range g.W * g.H / 260 { + y := wrap(rnd(i, 50)+step, g.H+6) - 3 + x := wrap(rnd(i, 51)-step/3+sway(step+rnd(i, 52), 24, 3), g.W+4) - 2 + g.Sprite(x, y, petal, step/2+i, pixel.Ink, 1) + } +} diff --git a/fx/rain.go b/fx/rain.go new file mode 100644 index 0000000..3639c41 --- /dev/null +++ b/fx/rain.go @@ -0,0 +1,44 @@ +package fx + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type RainOptions struct { + // Width and Height are dots; zeros mean 160 by 96. + Width, Height int + Label string +} + +// Rain slants streaks down; each splashes as it lands. +func Rain(c *ui.Context, o RainOptions) *ui.Element { + k := pixel.K(c) + w, h := size("Rain", o.Width, o.Height, 160, 96) + return box(c, k, o.Label, w, h).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Clip(0, 0, g.W, g.H, func() { rain(g, k, g.Step(12)) }) + }) +} + +// rain drops fall three rows, a dot left, each step. +func rain(g pixel.Canvas, k pixel.Kit, step int) { + s := pick(k, pixel.Faint, pixel.Info) + fall := (g.H+4)/3 + 1 + for i := range g.W * g.H / 160 { + p := wrap(step+rnd(i, 30), fall+3) + x := wrap(rnd(i, 31)-p, g.W) + switch { + case p < fall: + y := 3*p - 4 + g.Line(x+1, y, x, y+3, s) + case p == fall: + g.Dot(x-1, g.H-2, s) + g.Dot(x+1, g.H-2, s) + case p == fall+1: + g.Dot(x-2, g.H-3, s) + g.Dot(x+2, g.H-3, s) + } + } +} diff --git a/fx/scanlines.go b/fx/scanlines.go new file mode 100644 index 0000000..39327c6 --- /dev/null +++ b/fx/scanlines.go @@ -0,0 +1,23 @@ +package fx + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ScanlinesOptions struct { + // Slot shades the rows; zero means Shade. + Slot pixel.Slot +} + +// Scanlines shades every other row of content at half strength. +func Scanlines(c *ui.Context, o ScanlinesOptions, content func()) *ui.Element { + k := pixel.K(c) + s := slotOr(o.Slot, pixel.Shade) + return cover(k, ui.Column(c), func(g pixel.Canvas) { + for y := 1; y < g.H; y += 2 { + g.Checker(0, y, g.W, 1, s) + } + }, content) +} diff --git a/fx/scenes_test.go b/fx/scenes_test.go new file mode 100644 index 0000000..a24f913 --- /dev/null +++ b/fx/scenes_test.go @@ -0,0 +1,252 @@ +package fx_test + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/fx" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestPaletteCycleRotatesTheBand(t *testing.T) { + band := []pixel.Slot{pixel.Accent, pixel.Good, pixel.Info} + sp := pixel.MustSprite("agcr") + clk := newClock() + tt := view(8, 2, pixel.SpringDay, clk, false, func(c *ui.Context) { + fx.PaletteCycle(c, sp, band, fx.PaletteCycleOptions{}) + }) + for range 4 { + n, d := clk.step(8), dots(tt.Image(), pixel.SpringDay, 4, 1) + for i := range band { + if want := band[(i+n)%3]; d[0][i] != int(want) { + t.Fatalf("step %d: dot %d is %d, want %v", n, i, d[0][i], want) + } + } + if d[0][3] != int(pixel.Bad) { + t.Fatal("a slot outside the band changed") + } + clk.t = clk.t.Add(125 * time.Millisecond) + tt.Frame() + } + mustPanic(t, "one slot", func() { + view(8, 2, pixel.SpringDay, clk, false, func(c *ui.Context) { fx.PaletteCycle(c, sp, band[:1], fx.PaletteCycleOptions{}) }) + }) +} + +func TestSkyBandsByDayStarsByNight(t *testing.T) { + d := dots(view(80, 48, pixel.SpringDay, newClock(), false, func(c *ui.Context) { + fx.Sky(c, fx.SkyOptions{Width: 80, Height: 48}) + }).Image(), pixel.SpringDay, 80, 48) + // By day the top band is plain Info: no stars. + if count(d, 0, 0, 80, 24, pixel.Info) != 80*24 || count(d, 0, 33, 80, 1, pixel.AccentLit) != 80 || count(d, 0, 47, 80, 1, pixel.Lit) != 80 { + t.Fatal("the day sky must run Info, AccentLit, Lit") + } + n := dots(view(80, 48, pixel.WinterNight, newClock(), false, func(c *ui.Context) { + fx.Sky(c, fx.SkyOptions{Width: 80, Height: 48}) + }).Image(), pixel.WinterNight, 80, 48) + if count(n, 0, 0, 80, 32, pixel.Ink) == 0 || count(n, 0, 32, 80, 16, pixel.Ink) != 0 { + t.Fatal("stars belong to the top two thirds of the night") + } +} + +func TestSunMoonRidesItsArc(t *testing.T) { + at := func(hour, x, y int) int { + d := dots(view(160, 96, pixel.SpringDay, newClock(), false, func(c *ui.Context) { + fx.SunMoon(c, hour, fx.SunMoonOptions{}) + }).Image(), pixel.SpringDay, 160, 96) + if hour >= 6 && hour < 18 == (count(d, 0, 0, 160, 96, pixel.Warn) == 0) { + t.Fatalf("hour %d: the sun is out by day only", hour) + } + return d[y][x] + } + // Noon and midnight top the arc; six sits low left. + for _, tc := range []struct { + hour, x, y int + want pixel.Slot + }{{12, 85, 10, pixel.Warn}, {6, 15, 63, pixel.Warn}, {0, 85, 10, pixel.Lit}, {18, 15, 63, pixel.Lit}} { + if got := at(tc.hour, tc.x, tc.y); got != int(tc.want) { + t.Errorf("hour %d: dot %d,%d is %d, want %v", tc.hour, tc.x, tc.y, got, tc.want) + } + } + for _, hour := range []int{-1, 24} { + mustPanic(t, "hour", func() { + view(16, 16, pixel.SpringDay, newClock(), false, func(c *ui.Context) { fx.SunMoon(c, hour, fx.SunMoonOptions{}) }) + }) + } +} + +// ridge is each column's top s dot, or the height. +func ridge(d [][]int, s pixel.Slot) []int { + out := make([]int, len(d[0])) + for x := range out { + out[x] = len(d) + for y := range d { + if d[y][x] == int(s) { + out[x] = y + break + } + } + } + return out +} + +func TestMountainRidgesScrollAtTheirSpeeds(t *testing.T) { + clk := newClock() + tt := view(80, 48, pixel.SpringDay, clk, false, func(c *ui.Context) { + fx.Mountains(c, fx.MountainsOptions{Width: 80, Height: 48}) + }) + near, far := ridge(dots(tt.Image(), pixel.SpringDay, 80, 48), pixel.Edge), ridge(dots(tt.Image(), pixel.SpringDay, 80, 48), pixel.Shade) + // The near ridge moves every two steps, the far eight. + clk.t = clk.t.Add(250 * time.Millisecond) + tt.Frame() + if now := ridge(dots(tt.Image(), pixel.SpringDay, 80, 48), pixel.Edge); !equalShift(near, now, -1) { + t.Fatalf("near ridge %v became %v", near, now) + } + clk.t = clk.t.Add(750 * time.Millisecond) + tt.Frame() + if now := ridge(dots(tt.Image(), pixel.SpringDay, 80, 48), pixel.Shade); !equalShift(far, now, 48) { + t.Fatalf("far ridge %v became %v", far, now) + } +} + +// equalShift is whether b is a, one column left. +func equalShift(a, b []int, hidden int) bool { + shown := 0 + for x := range len(a) - 1 { + if b[x] == hidden || a[x+1] == hidden { + continue + } + if b[x] != a[x+1] { + return false + } + shown++ + } + return shown > len(a)/2 +} + +func TestOceanCrestsLightUpInTurn(t *testing.T) { + clk := newClock() + tt := view(80, 48, pixel.SpringDay, clk, false, func(c *ui.Context) { fx.Ocean(c, fx.OceanOptions{Width: 80, Height: 48}) }) + // The first crest row runs Info, Lit, AccentLit, Info. + for range 4 { + d, n := dots(tt.Image(), pixel.SpringDay, 80, 48), clk.step(6) + lit, pink := count(d, 0, 3, 80, 1, pixel.Lit), count(d, 0, 3, 80, 1, pixel.AccentLit) + if want := [4][2]bool{{false, false}, {true, false}, {false, true}, {false, false}}[n%4]; (lit > 0) != want[0] || (pink > 0) != want[1] { + t.Fatalf("step %d: %d Lit and %d AccentLit dots on the first crest", n, lit, pink) + } + clk.t = clk.t.Add(tick(6)) + tt.Frame() + } +} + +func TestWaterfallSegmentsFallThreeRowsAStep(t *testing.T) { + clk := newClock() + tt := view(40, 48, pixel.SpringDay, clk, false, func(c *ui.Context) { + fx.Waterfall(c, fx.WaterfallOptions{Width: 40, Height: 48}) + }) + before := dots(tt.Image(), pixel.SpringDay, 40, 48) + clk.t = clk.t.Add(100 * time.Millisecond) + tt.Frame() + after := dots(tt.Image(), pixel.SpringDay, 40, 48) + lit := 0 + for x := 10; x < 30; x++ { + for y := 3; y < 39; y++ { + if before[y-3][x] == int(pixel.Lit) { + lit++ + } + if after[y][x] != before[y-3][x] { + t.Fatalf("water at %d,%d did not fall three rows", x, y) + } + } + } + // One segment in six is lit. + if lit < 20*36/6-40 || lit > 20*36/6+40 { + t.Fatalf("%d lit water dots", lit) + } +} + +func TestFireHeatRisesARowAStep(t *testing.T) { + clk := newClock() + tt := view(32, 40, pixel.SpringDay, clk, false, func(c *ui.Context) { fx.Fire(c, fx.FireOptions{}) }) + for range 4 { + d, n, glows := dots(tt.Image(), pixel.SpringDay, 32, 40), clk.step(10), 0 + for y := range d { + for x := range d[y] { + if d[y][x] == int(pixel.Lit) { + glows++ + if row := y/2 - 2; (row+n)%4 != 0 { + t.Fatalf("step %d: heat glows on flame row %d", n, row) + } + } + } + } + if glows == 0 { + t.Fatalf("step %d: no heat", n) + } + clk.t = clk.t.Add(100 * time.Millisecond) + tt.Frame() + } +} + +func TestSeasonScenePaintsInItsSeasonsPalette(t *testing.T) { + for _, tc := range []struct { + season pixel.Season + hour int + pal *pixel.Palette + }{{pixel.Spring, 10, pixel.SpringDay}, {pixel.Summer, 21, pixel.SummerNight}, {pixel.Autumn, 5, pixel.AutumnNight}, {pixel.Winter, 6, pixel.WinterDay}} { + d := dots(view(80, 48, pixel.SpringDay, newClock(), false, func(c *ui.Context) { + fx.SeasonScene(c, tc.season, tc.hour, fx.SeasonSceneOptions{Width: 80, Height: 48}) + }).Image(), tc.pal, 80, 48) + for y := range d { + for x := range d[y] { + if d[y][x] < 0 { + t.Fatalf("%s at %d: dot %d,%d is off its palette", tc.pal.Name, tc.hour, x, y) + } + } + } + } + mustPanic(t, "season", func() { + view(16, 16, pixel.SpringDay, newClock(), false, func(c *ui.Context) { fx.SeasonScene(c, 4, 10, fx.SeasonSceneOptions{}) }) + }) + mustPanic(t, "hour", func() { + view(16, 16, pixel.SpringDay, newClock(), false, func(c *ui.Context) { fx.SeasonScene(c, pixel.Spring, 24, fx.SeasonSceneOptions{}) }) + }) +} + +func TestWeatherKeepsToItsSlots(t *testing.T) { + for name, tc := range map[string]struct { + build func(c *ui.Context) + slots []pixel.Slot + }{ + "Rain": {func(c *ui.Context) { fx.Rain(c, fx.RainOptions{Width: 80, Height: 48}) }, []pixel.Slot{pixel.Faint}}, + "Snow": {func(c *ui.Context) { fx.Snow(c, fx.SnowOptions{Width: 80, Height: 48}) }, []pixel.Slot{pixel.Lit}}, + "Petals": {func(c *ui.Context) { fx.Petals(c, fx.PetalsOptions{Width: 80, Height: 48}) }, []pixel.Slot{pixel.Accent, pixel.AccentLit}}, + "Leaves": {func(c *ui.Context) { fx.Leaves(c, fx.LeavesOptions{Width: 80, Height: 48}) }, []pixel.Slot{pixel.Warn, pixel.Bad, pixel.Accent}}, + "Fireflies": {func(c *ui.Context) { fx.Fireflies(c, fx.FirefliesOptions{Width: 80, Height: 48}) }, []pixel.Slot{pixel.Good, pixel.Warn}}, + "Confetti": {func(c *ui.Context) { fx.Confetti(c, fx.ConfettiOptions{Width: 80, Height: 48}) }, + []pixel.Slot{pixel.Accent, pixel.Good, pixel.Warn, pixel.Bad, pixel.Info, pixel.Magic}}, + } { + d := dots(view(80, 48, pixel.SpringDay, newClock(), false, tc.build).Image(), pixel.SpringDay, 80, 48) + seen := 0 + for y := range d { + for x, s := range d[y] { + if s == int(pixel.Back) { + continue + } + seen++ + ok := false + for _, want := range tc.slots { + ok = ok || s == int(want) + } + if !ok { + t.Fatalf("%s drew slot %d at %d,%d", name, s, x, y) + } + } + } + if seen == 0 { + t.Fatalf("%s drew nothing", name) + } + } +} diff --git a/fx/screens_test.go b/fx/screens_test.go new file mode 100644 index 0000000..ee7d462 --- /dev/null +++ b/fx/screens_test.go @@ -0,0 +1,367 @@ +package fx_test + +import ( + "testing" + "time" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/fx" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestScanlinesShadeHalfOfEveryOtherRow(t *testing.T) { + d := dots(view(40, 20, pixel.SpringDay, newClock(), false, func(c *ui.Context) { + fx.Scanlines(c, fx.ScanlinesOptions{}, func() { solid(c, 40, 20, pixel.Info) }) + }).Image(), pixel.SpringDay, 40, 20) + for y := range 20 { + if want := 20 * (y % 2); count(d, 0, y, 40, 1, pixel.Shade) != want { + t.Fatalf("row %d holds %d Shade dots, want %d", y, count(d, 0, y, 40, 1, pixel.Shade), want) + } + } +} + +func TestVignetteDarkensTowardTheEdges(t *testing.T) { + d := dots(view(80, 48, pixel.SpringDay, newClock(), false, func(c *ui.Context) { + fx.Vignette(c, fx.VignetteOptions{}, func() { solid(c, 80, 48, pixel.Lit) }) + }).Image(), pixel.SpringDay, 80, 48) + if d[0][0] != int(pixel.Ink) || d[47][79] != int(pixel.Ink) || count(d, 24, 14, 32, 20, pixel.Ink) != 0 { + t.Fatal("corners must be solid and the middle clear") + } + if edge, in := count(d, 0, 0, 1, 48, pixel.Ink), count(d, 12, 0, 1, 48, pixel.Ink); edge <= in || in == 0 { + t.Fatalf("the edge column holds %d dark dots, one further in %d", edge, in) + } +} + +// cued builds an effect whose cue the test changes. +func cued(clk *clock, reduce bool, w, h int, build func(c *ui.Context, cue int)) (*ui.Tester, *int) { + cue := new(int) + return view(w, h, pixel.SpringDay, clk, reduce, func(c *ui.Context) { build(c, *cue) }), cue +} + +func TestScreenShakeJoltsThenSettles(t *testing.T) { + for _, reduce := range []bool{false, true} { + clk := newClock() + tt, cue := cued(clk, reduce, 40, 30, func(c *ui.Context, cue int) { + fx.ScreenShake(c, fx.ScreenShakeOptions{Cue: cue}, func() { solid(c, 20, 10, pixel.Info) }) + }) + at := func() (float32, float32) { + r, ok := tt.Find("content") + if !ok { + t.Fatal("no content") + } + return r.X, r.Y + } + x0, y0 := at() + *cue++ + tt.Frame() + // The first jolt is three dots right and two up. + if x, y := at(); !reduce && (x != x0+6 || y != y0-4) || reduce && (x != x0 || y != y0) { + t.Fatalf("reduce %v: content moved from %v,%v to %v,%v", reduce, x0, y0, x, y) + } + clk.t = clk.t.Add(time.Second) + tt.Frame() + if x, y := at(); x != x0 || y != y0 { + t.Fatalf("reduce %v: the shake did not settle", reduce) + } + } +} + +func TestFlashFillsAStepThenHalfAStep(t *testing.T) { + for _, reduce := range []bool{false, true} { + clk := newClock() + tt, cue := cued(clk, reduce, 40, 20, func(c *ui.Context, cue int) { + fx.Flash(c, fx.FlashOptions{Cue: cue}, func() { solid(c, 40, 20, pixel.Info) }) + }) + lit := func() int { return count(dots(tt.Image(), pixel.SpringDay, 40, 20), 0, 0, 40, 20, pixel.Lit) } + if lit() != 0 { + t.Fatal("flashed before its cue") + } + *cue++ + want := []int{800, 400, 0} + if reduce { + want = []int{0, 0, 0} + } + for _, w := range want { + tt.Frame() + if lit() != w { + t.Fatalf("reduce %v: %d Lit dots, want %d", reduce, lit(), w) + } + clk.t = clk.t.Add(100 * time.Millisecond) + } + } +} + +func TestFadeDitherStepsThroughBayerLevels(t *testing.T) { + clk, covered := newClock(), true + tt := view(32, 16, pixel.SpringDay, clk, false, func(c *ui.Context) { + fx.FadeDither(c, covered, fx.FadeDitherOptions{}, func() { solid(c, 32, 16, pixel.Lit) }) + }) + dark := func() int { return count(dots(tt.Image(), pixel.SpringDay, 32, 16), 0, 0, 32, 16, pixel.Ink) } + // It starts covered; uncovering drops two levels a step. + covered = false + for level := 16; level >= 0; level -= 2 { + tt.Frame() + if dark() != 32*level { + t.Fatalf("level %d: %d dark dots", level, dark()) + } + clk.t = clk.t.Add(tick(12)) + } +} + +func TestIrisWipeClosesOnTheMiddle(t *testing.T) { + clk, closed := newClock(), false + tt := view(64, 32, pixel.SpringDay, clk, false, func(c *ui.Context) { + fx.IrisWipe(c, closed, fx.IrisWipeOptions{}, func() { solid(c, 64, 32, pixel.Lit) }) + }) + closed = true + last := -1 + for step := range 9 { + tt.Frame() + d := dots(tt.Image(), pixel.SpringDay, 64, 32) + n := count(d, 0, 0, 64, 32, pixel.Ink) + switch { + case n <= last && step < 8: + t.Fatalf("step %d: the iris did not close further", step) + case count(d, 0, 0, 32, 32, pixel.Ink) != count(d, 32, 0, 32, 32, pixel.Ink): + t.Fatalf("step %d: the iris is off the middle", step) + case step < 8 && d[16][32] != int(pixel.Lit), step == 8 && n != 64*32: + t.Fatalf("step %d: the middle shows %d", step, d[16][32]) + } + last = n + clk.t = clk.t.Add(tick(12)) + } +} + +func TestDissolveCoversDotsInShuffledOrder(t *testing.T) { + clk, covered := newClock(), false + tt := view(64, 32, pixel.SpringDay, clk, false, func(c *ui.Context) { + fx.Dissolve(c, covered, fx.DissolveOptions{}, func() { solid(c, 64, 32, pixel.Lit) }) + }) + covered = true + var prev [][]int + for step := range 9 { + tt.Frame() + d := dots(tt.Image(), pixel.SpringDay, 64, 32) + if n := count(d, 0, 0, 64, 32, pixel.Ink); n < 2048*step/8-120 || n > 2048*step/8+120 { + t.Fatalf("step %d covered %d of 2048", step, n) + } + for y := range d { + for x := range d[y] { + if prev != nil && prev[y][x] == int(pixel.Ink) && d[y][x] != int(pixel.Ink) { + t.Fatalf("step %d uncovered %d,%d", step, x, y) + } + } + } + // Halfway, every row and column is part covered. + if step == 4 { + for y := range 32 { + if n := count(d, 0, y, 64, 1, pixel.Ink); n == 0 || n == 64 { + t.Fatalf("row %d has %d of 64 covered", y, n) + } + } + for x := range 64 { + if n := count(d, x, 0, 1, 32, pixel.Ink); n == 0 || n == 32 { + t.Fatalf("column %d has %d of 32 covered", x, n) + } + } + } + prev = d + clk.t = clk.t.Add(tick(12)) + } +} + +// quilt is 16×16 dots of four slots; neighbors differ. +var quilt = pixel.SpriteOf(16, 16, func(x, y int) (pixel.Slot, bool) { + return []pixel.Slot{pixel.Good, pixel.Info, pixel.Bad, pixel.Warn}[(x+y*3)%4], true +}) + +func TestMosaicGrowsBlocksThenSharpens(t *testing.T) { + clk := newClock() + tt, cue := cued(clk, false, 16, 16, func(c *ui.Context, cue int) { fx.Mosaic(c, quilt, fx.MosaicOptions{Cue: cue}) }) + plain := dots(tt.Image(), pixel.SpringDay, 16, 16) + *cue++ + for step, b := range []int{1, 2, 3, 4, 6, 8, 6, 4, 3, 2, 1} { + tt.Frame() + d := dots(tt.Image(), pixel.SpringDay, 16, 16) + for y := range 16 { + for x := range 16 { + if d[y][x] != d[y/b*b][x/b*b] { + t.Fatalf("step %d: block %d is not one slot at %d,%d", step, b, x, y) + } + } + } + if b == 1 != same(d, plain) { + t.Fatalf("step %d: blocks of %d, picture changed %v", step, b, !same(d, plain)) + } + clk.t = clk.t.Add(100 * time.Millisecond) + } +} + +func TestMosaicBlocksTakeTheirCommonestSlot(t *testing.T) { + // One Good dot in a field of Bad. + sp := pixel.SpriteOf(8, 8, func(x, y int) (pixel.Slot, bool) { + if x == 0 && y == 0 { + return pixel.Good, true + } + return pixel.Bad, true + }) + clk := newClock() + tt, cue := cued(clk, false, 8, 8, func(c *ui.Context, cue int) { fx.Mosaic(c, sp, fx.MosaicOptions{Cue: cue}) }) + *cue++ + tt.Frame() + clk.t = clk.t.Add(500*time.Millisecond + time.Microsecond) + tt.Frame() + if d := dots(tt.Image(), pixel.SpringDay, 8, 8); count(d, 0, 0, 8, 8, pixel.Bad) != 64 { + t.Fatal("an 8-dot block must take its commonest slot") + } +} + +func TestGlitchScramblesAFewFramesThenSettles(t *testing.T) { + for _, reduce := range []bool{false, true} { + clk := newClock() + tt, cue := cued(clk, reduce, 22, 16, func(c *ui.Context, cue int) { fx.Glitch(c, quilt, fx.GlitchOptions{Cue: cue}) }) + plain := dots(tt.Image(), pixel.SpringDay, 22, 16) + *cue++ + changed := false + for range 6 { + tt.Frame() + changed = changed || !same(plain, dots(tt.Image(), pixel.SpringDay, 22, 16)) + clk.t = clk.t.Add(tick(15)) + } + tt.Frame() + if changed == reduce || !same(plain, dots(tt.Image(), pixel.SpringDay, 22, 16)) { + t.Fatalf("reduce %v: glitched %v, then not back to the sprite", reduce, changed) + } + } +} + +func TestCuesSpentUnderReduceMotionNeverReplay(t *testing.T) { + for name, build := range map[string]func(c *ui.Context, cue int){ + "Lightning": func(c *ui.Context, cue int) { fx.Lightning(c, fx.LightningOptions{Cue: cue, Width: 32, Height: 32}) }, + "Explosion": func(c *ui.Context, cue int) { fx.Explosion(c, fx.ExplosionOptions{Cue: cue, Size: 32}) }, + "Mosaic": func(c *ui.Context, cue int) { fx.Mosaic(c, quilt, fx.MosaicOptions{Cue: cue}) }, + "Glitch": func(c *ui.Context, cue int) { fx.Glitch(c, quilt, fx.GlitchOptions{Cue: cue}) }, + "Flash": func(c *ui.Context, cue int) { + fx.Flash(c, fx.FlashOptions{Cue: cue}, func() { solid(c, 32, 16, pixel.Info) }) + }, + "ScreenShake": func(c *ui.Context, cue int) { + fx.ScreenShake(c, fx.ScreenShakeOptions{Cue: cue}, func() { solid(c, 20, 10, pixel.Info) }) + }, + } { + clk := newClock() + tt, cue := cued(clk, false, 40, 40, build) + before := dots(tt.Image(), pixel.SpringDay, 40, 40) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + *cue++ + tt.Frame() + tt.SetPreferences(ui.Preferences{ReduceMotion: false, TextScale: 1}) + for _, d := range []time.Duration{0, 300 * time.Millisecond} { + clk.t = clk.t.Add(d) + tt.Frame() + if !same(before, dots(tt.Image(), pixel.SpringDay, 40, 40)) { + t.Errorf("%s replayed a cue spent under Reduce Motion", name) + } + } + } +} + +func TestTransitionsKeepTheirEndAfterReduceMotion(t *testing.T) { + clk, covered := newClock(), false + tt := view(32, 16, pixel.SpringDay, clk, false, func(c *ui.Context) { + fx.FadeDither(c, covered, fx.FadeDitherOptions{}, func() { solid(c, 32, 16, pixel.Lit) }) + }) + covered = true + tt.Frame() + clk.t = clk.t.Add(tick(12)) + tt.Frame() + for _, reduce := range []bool{true, false} { + tt.SetPreferences(ui.Preferences{ReduceMotion: reduce, TextScale: 1}) + tt.Frame() + if n := count(dots(tt.Image(), pixel.SpringDay, 32, 16), 0, 0, 32, 16, pixel.Ink); n != 512 { + t.Fatalf("reduce %v: %d of 512 covered", reduce, n) + } + } +} + +// blank is whether a box shows only the page. +func blank(tt *ui.Tester, w, h int) bool { + return count(dots(tt.Image(), pixel.SpringDay, w, h), 0, 0, w, h, pixel.Back) == w*h +} + +func TestOneShotsRunOnlyOnTheirCue(t *testing.T) { + for name, tc := range map[string]struct { + fps int + steps int + build func(c *ui.Context, cue int) + }{ + "Explosion": {12, 8, func(c *ui.Context, cue int) { fx.Explosion(c, fx.ExplosionOptions{Cue: cue}) }}, + "Lightning": {15, 5, func(c *ui.Context, cue int) { fx.Lightning(c, fx.LightningOptions{Cue: cue, Width: 48, Height: 48}) }}, + } { + for _, reduce := range []bool{false, true} { + clk := newClock() + tt, cue := cued(clk, reduce, 48, 48, tc.build) + if !blank(tt, 48, 48) { + t.Fatalf("%s showed before its cue", name) + } + *cue++ + for step := range tc.steps { + tt.Frame() + if blank(tt, 48, 48) != reduce { + t.Fatalf("%s, reduce %v: step %d blank %v", name, reduce, step, !reduce) + } + clk.t = clk.t.Add(tick(tc.fps)) + } + tt.Frame() + if !blank(tt, 48, 48) { + t.Fatalf("%s outlived its steps", name) + } + } + } +} + +func TestLightningFlashesThenForks(t *testing.T) { + bolt := func(cue int) [][]int { + clk := newClock() + tt, c := cued(clk, false, 48, 48, func(c *ui.Context, cue int) { + fx.Lightning(c, fx.LightningOptions{Cue: cue, Width: 48, Height: 48}) + }) + *c = cue + tt.Frame() + if d := dots(tt.Image(), pixel.SpringDay, 48, 48); count(d, 0, 0, 48, 48, pixel.Lit) != 48*48 { + t.Fatal("a strike opens with a full flash") + } + clk.t = clk.t.Add(2 * tick(15)) + tt.Frame() + return dots(tt.Image(), pixel.SpringDay, 48, 48) + } + a, b := bolt(1), bolt(2) + if count(a, 0, 0, 48, 48, pixel.Warn) == 0 || count(a, 0, 47, 48, 1, pixel.Warn) == 0 { + t.Fatal("the bolt must reach the ground") + } + if same(a, b) { + t.Fatal("two strikes drew the same bolt") + } +} + +func TestSparkleCirclesItsMiddle(t *testing.T) { + clk := newClock() + tt := view(40, 40, pixel.SpringDay, clk, false, func(c *ui.Context) { fx.Sparkle(c, fx.SparkleOptions{}) }) + var quarters [4]int + for range 8 { + d := dots(tt.Image(), pixel.SpringDay, 40, 40) + if count(d, 0, 0, 24, 24, pixel.Warn) != count(d, 0, 0, 40, 40, pixel.Warn) { + t.Fatal("a star left the square") + } + for i, q := range [][2]int{{0, 0}, {12, 0}, {0, 12}, {12, 12}} { + quarters[i] += count(d, q[0], q[1], 12, 12, pixel.Warn) + } + clk.t = clk.t.Add(tick(10)) + tt.Frame() + } + for i, n := range quarters { + if n == 0 { + t.Fatalf("no star twinkled in quarter %d", i) + } + } +} diff --git a/fx/screenshake.go b/fx/screenshake.go new file mode 100644 index 0000000..c6d82f3 --- /dev/null +++ b/fx/screenshake.go @@ -0,0 +1,27 @@ +package fx + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type ScreenShakeOptions struct { + // Cue shakes the content each time it changes. + Cue int + // Amount is the widest jolt in dots; zero means 3. + Amount int +} + +// quakes are the jolts, a step each, in Amount thirds. +var quakes = [...][2]int{{3, -2}, {-3, 2}, {2, 3}, {-3, -2}, {2, 1}, {-1, -2}, {1, 1}, {-1, 0}, {0, 0}} + +// ScreenShake jolts its content both ways, settling as it goes. +func ScreenShake(c *ui.Context, o ScreenShakeOptions, content func()) *ui.Element { + k := pixel.K(c) + a := pixel.Or("ScreenShake Amount", o.Amount, 3) + e := ui.Box(c) + q := quakes[k.Shot(c, e, o.Cue, 15, len(quakes)-1)] + dx, dy := q[0]*a/3, q[1]*a/3 + return e.Padding(k.D(a+dy), k.D(a-dx), k.D(a-dy), k.D(a+dx)).Children(content) +} diff --git a/fx/seasonscene.go b/fx/seasonscene.go new file mode 100644 index 0000000..3df6e71 --- /dev/null +++ b/fx/seasonscene.go @@ -0,0 +1,80 @@ +package fx + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type SeasonSceneOptions struct { + // Width and Height are dots; zeros mean 160 by 96. + Width, Height int + Label string +} + +// SeasonScene paints a season at an hour, in its palette. +func SeasonScene(c *ui.Context, season pixel.Season, hour int, o SeasonSceneOptions) *ui.Element { + checkHour("SeasonScene", hour) + if season > pixel.Winter { + panic(fmt.Sprintf("fx: SeasonScene season %d", season)) + } + s := pixel.Current(c) + s.Palette = pixel.Find(season, hour < 6 || hour >= 18) + var e *ui.Element + pixel.Scope(c, s, func() { + k := pixel.K(c) + w, h := size("SeasonScene", o.Width, o.Height, 160, 96) + e = box(c, k, o.Label, w, h).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Clip(0, 0, g.W, g.H, func() { scene(g, k, season, hour, g.Step(8)) }) + }) + }) + return e +} + +// scene layers sky, sun or moon, ridges, trees and weather. +func scene(g pixel.Canvas, k pixel.Kit, season pixel.Season, hour, step int) { + night := k.Palette().Night + sky(g, k, step/2) + sunMoon(g, k, hour, step/2) + if !night { + clouds(g, step) + } + mountains(g, k, step, 2) + top := g.H - g.H/6 + g.Rect(0, top, g.W, g.H-top, [4]pixel.Slot{pixel.Good, pixel.Good, pixel.Warn, light(k)}[season]) + if night { + g.Veil(0, top, g.W, g.H-top, dark(k), 8) + } + tree := trees[season] + for x := 4 + rnd(int(season), 190)%12; x < g.W-tree.W; x += 30 + rnd(x, 191)%30 { + y := top - tree.H + 3 + if !night { + g.Sprite(x, y, tree, 0, pixel.Ink, 1) + continue + } + // By night trees stand dark; only snow catches the light. + g.Silhouette(x, y, tree, 0, dark(k), 1) + for ty := range tree.H { + for tx := range tree.W { + if s, ok := tree.At(0, tx, ty, pixel.Ink); ok && s == pixel.Lit { + g.Dot(x+tx, y+ty, light(k)) + } + } + } + } + switch season { + case pixel.Spring: + petals(g, step) + case pixel.Summer: + if night { + fireflies(g, step) + } + case pixel.Autumn: + leaves(g, step) + case pixel.Winter: + snow(g, k, step) + } +} diff --git a/fx/sky.go b/fx/sky.go new file mode 100644 index 0000000..494bbfa --- /dev/null +++ b/fx/sky.go @@ -0,0 +1,63 @@ +package fx + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type SkyOptions struct { + // Width and Height are dots; zeros mean 160 by 96. + Width, Height int + Label string +} + +// Sky dithers down the palette's sky slots; night shows stars. +func Sky(c *ui.Context, o SkyOptions) *ui.Element { + k := pixel.K(c) + w, h := size("Sky", o.Width, o.Height, 160, 96) + return box(c, k, o.Label, w, h).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Clip(0, 0, g.W, g.H, func() { sky(g, k, g.Step(4)) }) + }) +} + +// sky fills g in bands cut at height percents. +func sky(g pixel.Canvas, k pixel.Kit, step int) { + bands, cuts := []pixel.Slot{pixel.Info, pixel.AccentLit, pixel.Lit}, []int{0, 55, 85, 100} + if k.Palette().Night { + bands, cuts = []pixel.Slot{pixel.Well, pixel.Back, pixel.Face, pixel.Lit}, []int{0, 35, 60, 82, 100} + } + for i, s := range bands { + y0, y1 := g.H*cuts[i]/100, g.H*cuts[i+1]/100 + g.Rect(0, y0, g.W, y1-y0, s) + } + // Seams dither five rows from one band into the next. + for i := 1; i < len(bands); i++ { + y := g.H * cuts[i] / 100 + for j := range 5 { + g.Dither(0, y-2+j, g.W, 1, bands[i-1], bands[i], 2+3*j) + } + } + if k.Palette().Night { + stars(g, k, step) + } +} + +// stars twinkle over the top two thirds. +func stars(g pixel.Canvas, k pixel.Kit, step int) { + tones := []pixel.Slot{light(k), light(k), pixel.AccentLit, light(k), pixel.Warn} + for i := range g.W * g.H / 90 { + x, y := rnd(i, 1)%g.W, rnd(i, 2)%max(1, g.H*2/3) + s, t := tones[i%len(tones)], wrap(step+rnd(i, 3), 8) + switch { + case t == 0: + g.Dot(x, y, pixel.Faint) + case i%9 == 0 && t < 3: + g.Rect(x-1, y, 3, 1, s) + g.Rect(x, y-1, 1, 3, s) + default: + g.Dot(x, y, s) + } + } +} diff --git a/fx/snow.go b/fx/snow.go new file mode 100644 index 0000000..c524d34 --- /dev/null +++ b/fx/snow.go @@ -0,0 +1,41 @@ +package fx + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type SnowOptions struct { + // Width and Height are dots; zeros mean 160 by 96. + Width, Height int + Label string +} + +// Snow drifts flakes down, swaying; near ones are big, quick. +func Snow(c *ui.Context, o SnowOptions) *ui.Element { + k := pixel.K(c) + w, h := size("Snow", o.Width, o.Height, 160, 96) + return box(c, k, o.Label, w, h).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Clip(0, 0, g.W, g.H, func() { snow(g, k, g.Step(8)) }) + }) +} + +func snow(g pixel.Canvas, k pixel.Kit, step int) { + s := light(k) + for i := range g.W * g.H / 110 { + near := rnd(i, 40)%3 == 0 + speed := 1 + if near { + speed = 2 + } + y := wrap(rnd(i, 41)+step*speed, g.H+4) - 2 + x := wrap(rnd(i, 42)+sway(step+rnd(i, 43), 16, 2), g.W) + if near { + g.Rect(x, y, 2, 2, s) + } else { + g.Dot(x, y, s) + } + } +} diff --git a/fx/sparkle.go b/fx/sparkle.go new file mode 100644 index 0000000..05a3276 --- /dev/null +++ b/fx/sparkle.go @@ -0,0 +1,48 @@ +package fx + +import ( + "math" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type SparkleOptions struct { + // Size is the square's side in dots; zero means 24. + Size int + // Slot inks the stars; zero means Warn. + Slot pixel.Slot + Label string +} + +// glints are a star's frames by step; -1 is dark. +var glints = [...]int{0, 1, 2, 1, 0, -1, -1, -1} + +// Sparkle twinkles four stars around its middle, in turn. +func Sparkle(c *ui.Context, o SparkleOptions) *ui.Element { + k := pixel.K(c) + n := pixel.Or("Sparkle Size", o.Size, 24) + s := slotOr(o.Slot, pixel.Warn) + return box(c, k, o.Label, n, n).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Clip(0, 0, g.W, g.H, func() { sparkle(g, s, g.Step(10)) }) + }) +} + +// sparkle moves each star to a new spot every cycle. +func sparkle(g pixel.Canvas, s pixel.Slot, step int) { + for i := range 4 { + t := step + 2*i + f := glints[wrap(t, len(glints))] + if f < 0 { + continue + } + seed := t/len(glints)*4 + i + a := float64(i)*math.Pi/2 + float64(rnd(seed, 170)%60-30)*math.Pi/180 + d := float64(g.W) * float64(20+rnd(seed, 171)%20) / 100 + x := g.W/2 + int(math.Round(d*math.Cos(a))) - 2 + y := g.H/2 + int(math.Round(d*math.Sin(a))) - 2 + g.Sprite(x, y, twinkle, f, s, 1) + } +} diff --git a/fx/sprites.go b/fx/sprites.go new file mode 100644 index 0000000..d5fec71 --- /dev/null +++ b/fx/sprites.go @@ -0,0 +1,179 @@ +package fx + +import "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" + +var smallCloud = pixel.MustSprite(` +.....lll...... +..llllllll.l.. +.llllllllllll. +llllllllllllll +fllllllllllllf +.ffffffffffff. +`) + +var bigCloud = pixel.MustSprite(` +.........llll........... +....llll.llllll......... +..lllllllllllllll.llll.. +.lllllllllllllllllllllll +llllllllllllllllllllllll +lllllllllllllllllllllllf +fllllllllllllllllllllllf +.fffllllllllllllllllfff. +...ffffffffffffffffff... +`) + +// trees stand in the scene, one per season. +var trees = [4]*pixel.Sprite{ + pixel.MustSprite(` +.....AAAAA..... +...AAAaAAAAA... +..AAAAAAAAaAA.. +.AAaAAAAAAAAAA. +.AAAAAAaAAAAAA. +AAAAAAAAAAAaAAA +AAaAAAAAAAAAAAA +AAAAAAaAAAAAAAA +.AAAAAAAAAaAAA. +..AAAaAAAAAAA.. +...AAAAnAAAA... +.....AnnnA..... +......nnn...... +......nnn...... +......nnn...... +......nnn...... +.....nnnnn..... +`), + pixel.MustSprite(` +.....ggggg..... +...ggglgggggg.. +..ggllggggggg.. +.gggllgggggggg. +.ggggggggggggg. +ggggggggggggggg +ggggggggggggggg +.ggggggggggggg. +.ggggggggggggg. +..ggggggggggg.. +...gggnggggg... +.....ggnngg.... +......nnn...... +......nnn...... +......nnn...... +......nnn...... +.....nnnnn..... +`), + pixel.MustSprite(` +.....yyyyy..... +...yyryyyyay... +..yyyyyyryyyy.. +.yyayyyyyyyyry. +.yyyyyryyyyyyy. +yyyryyyyyyayyyy +yyyyyyyyryyyyyy +.yyyayyyyyyyry. +.yyyyyyyyyyyyy. +..yyyryyyyyyy.. +...yyyynyyyy... +.....yynnyy.... +......nnn...... +......nnn...... +......nnn...... +......nnn...... +.....nnnnn..... +`), + pixel.MustSprite(` +.......l....... +......lgl...... +.....lgggl..... +......ggg...... +....llgggll.... +...lgggggggl... +....ggggggg.... +...llgggggll... +..lgggggggggl.. +...ggggggggg... +..llgggggggll.. +.lgggggggggggl. +lgggggggggggggl +......nnn...... +......nnn...... +......nnn...... +.....nnnnn..... +`), +} + +// petal turns: face, tilt, edge, tilt back. +var petal = pixel.MustSprite(` +.A. +AAA +.a. + +..A +.Aa +a.. + +... +AAa +... + +A.. +aA. +..a +`) + +// leaf turns about its stem; # takes the leaf's slot. +var leaf = pixel.MustSprite(` +.###. +##### +##### +.###. +..#.. + +..##. +.###. +####. +.##.. +..#.. + +..#.. +..#.. +..#.. +..#.. +..#.. + +.##.. +.###. +.#### +..##. +..#.. +`) + +// twinkle grows from a dot to a star. +var twinkle = pixel.MustSprite(` +..... +..... +..#.. +..... +..... + +..... +..#.. +.###. +..#.. +..... + +..#.. +..#.. +##### +..#.. +..#.. +`) + +// nightLit draws Lit art in Ink, bright at night. +func nightLit(g pixel.Canvas, k pixel.Kit) pixel.Canvas { + if k.Palette().Night { + return g.Cycled([]pixel.Slot{pixel.Lit, pixel.Ink}, 1) + } + return g +} diff --git a/fx/starfield.go b/fx/starfield.go new file mode 100644 index 0000000..4e17589 --- /dev/null +++ b/fx/starfield.go @@ -0,0 +1,41 @@ +package fx + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type StarfieldOptions struct { + // Width and Height are dots; zeros mean 160 by 96. + Width, Height int + Label string +} + +// Starfield scrolls three layers of stars, near ones fastest. +func Starfield(c *ui.Context, o StarfieldOptions) *ui.Element { + k := pixel.K(c) + w, h := size("Starfield", o.Width, o.Height, 160, 96) + return box(c, k, o.Label, w, h).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Clip(0, 0, g.W, g.H, func() { starfield(g, k, g.Step(12)) }) + }) +} + +// starfield moves layer l one dot every 4>>l steps. +func starfield(g pixel.Canvas, k pixel.Kit, step int) { + g.Rect(0, 0, g.W, g.H, dark(k)) + for l, s := range []pixel.Slot{pixel.Faint, pixel.AccentLit, light(k)} { + shift := step / (4 >> l) + for i := range g.W * g.H / (120 + 80*l) { + x, y := wrap(rnd(i, 20+l)-shift, g.W), rnd(i, 23+l)%g.H + switch t := wrap(step+rnd(i, 26), 12); { + case l == 2 && t < 2: + g.Rect(x-1, y, 3, 1, s) + g.Rect(x, y-1, 1, 3, s) + case t != 6: + g.Dot(x, y, s) + } + } + } +} diff --git a/fx/sunmoon.go b/fx/sunmoon.go new file mode 100644 index 0000000..762787f --- /dev/null +++ b/fx/sunmoon.go @@ -0,0 +1,54 @@ +package fx + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type SunMoonOptions struct { + // Width and Height are dots; zeros mean 160 by 96. + Width, Height int + Label string +} + +// SunMoon arcs the sun by day, the moon by night. +func SunMoon(c *ui.Context, hour int, o SunMoonOptions) *ui.Element { + checkHour("SunMoon", hour) + k := pixel.K(c) + w, h := size("SunMoon", o.Width, o.Height, 160, 96) + return box(c, k, o.Label, w, h).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Clip(0, 0, g.W, g.H, func() { sunMoon(g, k, hour, g.Step(4)) }) + }) +} + +// arc centers the body: low at the ends, high midway. +func arc(w, h, hour int) (int, int) { + t := wrap(hour-6, 12) + horizon, top := h*3/4, 10 + x := 10 + (w-20)*(2*t+1)/24 + return x, horizon - (horizon-top)*(2*t+1)*(23-2*t)/143 +} + +// sunMoon draws a sun with turning rays, or cratered moon. +func sunMoon(g pixel.Canvas, k pixel.Kit, hour, step int) { + x, y := arc(g.W, g.H, hour) + if hour < 6 || hour >= 18 { + g.Disc(x, y, 5, light(k)) + g.Dot(x-2, y-1, pixel.Faint) + g.Rect(x+1, y+1, 2, 1, pixel.Faint) + g.Dot(x+2, y-3, pixel.Faint) + return + } + // Rays turn an eighth each step: straight, then slanted. + for i := range 4 { + dx, dy := [4]int{1, 0, -1, 0}[i], [4]int{0, 1, 0, -1}[i] + if step%2 == 1 { + dx, dy = [4]int{1, 1, -1, -1}[i], [4]int{1, -1, 1, -1}[i] + } + g.Line(x+7*dx, y+7*dy, x+8*dx, y+8*dy, pixel.Warn) + } + g.Disc(x, y, 5, pixel.Warn) + g.Rect(x-2, y-2, 2, 2, light(k)) +} diff --git a/fx/vignette.go b/fx/vignette.go new file mode 100644 index 0000000..ff3fc82 --- /dev/null +++ b/fx/vignette.go @@ -0,0 +1,40 @@ +package fx + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type VignetteOptions struct { + // Slot darkens the edges; zero means the darkest slot. + Slot pixel.Slot +} + +// Vignette dithers a slot inward from edges; corners go solid. +func Vignette(c *ui.Context, o VignetteOptions, content func()) *ui.Element { + k := pixel.K(c) + s := slotOr(o.Slot, dark(k)) + return cover(k, ui.Column(c), func(g pixel.Canvas) { vignette(g, s) }, content) +} + +// vignette's level grows with the elliptic distance out. +func vignette(g pixel.Canvas, s pixel.Slot) { + level := func(x, y int) int { + ex := float64(2*x+1-g.W) / float64(g.W) + ey := float64(2*y+1-g.H) / float64(g.H) + return min(16, max(0, int((ex*ex+ey*ey-0.6)*16/1.2))) + } + for y := range g.H { + for x := 0; x < g.W; { + l, end := level(x, y), x+1 + for end < g.W && level(end, y) == l { + end++ + } + if l > 0 { + g.Veil(x, y, end-x, 1, s, l) + } + x = end + } + } +} diff --git a/fx/waterfall.go b/fx/waterfall.go new file mode 100644 index 0000000..9f7e6fd --- /dev/null +++ b/fx/waterfall.go @@ -0,0 +1,57 @@ +package fx + +import ( + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +type WaterfallOptions struct { + // Width and Height are dots; zeros mean 64 by 96. + Width, Height int + Label string +} + +// Waterfall pours between cliffs by cycling its water slots. +func Waterfall(c *ui.Context, o WaterfallOptions) *ui.Element { + k := pixel.K(c) + w, h := size("Waterfall", o.Width, o.Height, 64, 96) + return box(c, k, o.Label, w, h).Draw(func(p *ui.Painter, r ui.Rect) { + g := k.Canvas(p, r) + g.Clip(0, 0, g.W, g.H, func() { waterfall(g, k, g.Step(10)) }) + }) +} + +// waterfall lights one three-row segment in six per column. +func waterfall(g pixel.Canvas, k pixel.Kit, step int) { + l, r, pool := g.W/4, g.W-g.W/4, g.H-g.H/6 + rock, lit := pick(k, pixel.Faint, pixel.Face), pick(k, pixel.Shade, pixel.Lit) + g.Rect(0, 0, l, g.H, rock) + g.Rect(r, 0, g.W-r, g.H, rock) + for i := range g.H / 3 { + x := rnd(i, 110) % max(1, l-2) + if i%2 == 1 { + x += r + 1 + } + g.HLine(x, rnd(i, 111)%pool, 2, lit) + } + g.Rect(l, 0, r-l, pool, pixel.Info) + // Segments take the band turned back each step: falling. + band := pixel.Cycle([]pixel.Slot{light(k), pixel.Info, pixel.Info, pixel.Info, pixel.Info, pixel.Info}, -step) + for x := l; x < r; x++ { + off := rnd(x, 112) % 6 + for seg := 0; 3*seg < pool; seg++ { + if s := band[(seg+off)%6]; s != pixel.Info { + g.VLine(x, 3*seg, min(3, pool-3*seg), s) + } + } + } + // Foam churns where it lands; ripples run outward. + g.Rect(0, pool, g.W, g.H-pool, pixel.Info) + g.Veil(l-3, pool, r-l+6, 3, light(k), 6+4*wrap(step, 2)) + for i := range 2 { + d := wrap(step/2+4*i, 8) + g.HLine(l-6-2*d, pool+4+2*i, 3, light(k)) + g.HLine(r+3+2*d, pool+4+2*i, 3, light(k)) + } +} diff --git a/gallery/app_test.go b/gallery/app_test.go index 44530c6..2a68c86 100644 --- a/gallery/app_test.go +++ b/gallery/app_test.go @@ -85,7 +85,7 @@ func TestSearch(t *testing.T) { t.Fatal("/ did not focus search") } tt.Type("dith") - if !tt.HasText("1 results") || !tt.HasText("Dither") { + if !tt.HasText("2 results") || !tt.HasText("Dither") || !tt.HasText("FadeDither") { t.Fatalf("results: %q", tt.Texts()) } tt.Key(0, ui.KeyP) diff --git a/gallery/chrome.go b/gallery/chrome.go index 5f82873..40da1fd 100644 --- a/gallery/chrome.go +++ b/gallery/chrome.go @@ -75,7 +75,7 @@ func (a *App) sidebar(c *ui.Context) { g.VLine(g.W-2, 0, g.H, pixel.Shade) g.VLine(g.W-1, 0, g.H, pixel.Edge) }).Children(func() { - ui.Column(c).Padding(k.D(4)).Gap(k.D(1)).Children(func() { + ui.Column(c).Padding(k.D(4)).Gap(k.D(1)).FocusGroup(ui.Vertical).Children(func() { for _, cat := range Categories { n := count(cat.Key) path := "/cat/" + cat.Key diff --git a/gallery/demos_fx.go b/gallery/demos_fx.go new file mode 100644 index 0000000..a485675 --- /dev/null +++ b/gallery/demos_fx.go @@ -0,0 +1,194 @@ +package gallery + +import ( + "fmt" + "math" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/fx" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var fxEntries = []Entry{ + {291, "PaletteCycle", "调色板循环", "fx", "Four slots take turns, so the rings seem to flow.", demoPaletteCycle}, + {292, "Sky", "天空", "fx", "Blue by day; by night, stars that blink.", demoSky}, + {293, "SunMoon", "日月", "fx", "Up at six, down at six. The moon takes the night shift.", demoSunMoon}, + {294, "Clouds", "云", "fx", "Far clouds dawdle, near clouds hurry.", demoClouds}, + {295, "Starfield", "星空", "fx", "Three layers of stars rush past. Hyperspace at 12 fps.", demoStarfield}, + {296, "Rain", "雨", "fx", "Slanted streaks, each landing with a tiny splash.", demoRain}, + {297, "Snow", "雪", "fx", "Flakes sway down; near ones are big and quick.", demoSnow}, + {298, "Petals", "樱花瓣", "fx", "Petals ride the breeze, turning as they fall.", demoPetals}, + {299, "Leaves", "落叶", "fx", "Leaves swing side to side on the way down.", demoLeaves}, + {300, "Fireflies", "萤火虫", "fx", "Little lights wander, glow, and rest.", demoFireflies}, + {301, "Waterfall", "瀑布", "fx", "Four slots cycling down make the water fall.", demoWaterfallFx}, + {302, "Ocean", "海浪", "fx", "Wave bands roll in by cycling, with glints on top.", demoOcean}, + {303, "Fire", "火焰", "fx", "Three flicker frames, heat rising through them.", demoFire}, + {304, "Lava", "熔岩", "fx", "Hot bands flow; bubbles swell and pop. Do not touch.", demoLava}, + {305, "Aurora", "极光", "fx", "Curtains of light, their colors sliding sideways.", demoAurora}, + {306, "Lightning", "闪电", "fx", "A flash, then a forked bolt. No two strikes alike.", demoLightning}, + {307, "Mountains", "山脉", "fx", "Three ridges scroll at three speeds. Parallax!", demoMountains}, + {308, "SeasonScene", "四季场景", "fx", "Pick a season and an hour; the world repaints itself.", demoSeasonScene}, +} + +type fxDemo struct { + season, hour int + strike, shake, flash, mosaic, glitch, boom int + fade, iris, dissolve bool +} + +func fxState(c *ui.Context) *fxDemo { + p := pixel.K(c).Palette() + return ui.Local(c.Root(), demoKey("fx"), func() fxDemo { return fxDemo{season: int(p.Season), hour: hourOf(p)} }) +} + +// hourOf suits p: morning by day, late evening by night. +func hourOf(p *pixel.Palette) int { + if p.Night { + return 21 + } + return 10 +} + +// paletteOf is the settings in force with season's palette. +func paletteOf(c *ui.Context, s pixel.Season, night bool) pixel.Settings { + set := pixel.Current(c) + set.Palette = pixel.Find(s, night) + return set +} + +// over stacks an effect on a same-sized sky. +func over(c *ui.Context, w, h int, effect func()) { + layout.Layers(c, func() { fx.Sky(c, fx.SkyOptions{Width: w, Height: h}) }, effect) +} + +// portalBand rings the portal, outside in. +var portalBand = []pixel.Slot{pixel.Accent, pixel.AccentLit, pixel.Magic, pixel.Info} + +var portal = pixel.SpriteOf(15, 15, func(x, y int) (pixel.Slot, bool) { + d := math.Hypot(float64(x-7), float64(y-7)) + return portalBand[int(d)%len(portalBand)], d < 7.5 +}) + +func demoPaletteCycle(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() { + fx.PaletteCycle(c, portal, portalBand, fx.PaletteCycleOptions{Scale: 3, Label: "portal"}) + fx.PaletteCycle(c, portal, portalBand, fx.PaletteCycleOptions{Scale: 2, FPS: 4, Label: "slow portal"}) + }) +} + +func demoSky(c *ui.Context, log func(string)) { + k := pixel.K(c) + ui.Row(c).Gap(k.D(4)).Children(func() { + for _, night := range []bool{false, true} { + pixel.Scope(c, paletteOf(c, k.Palette().Season, night), func() { + fx.Sky(c, fx.SkyOptions{Width: 68, Height: 64, Label: map[bool]string{false: "day sky", true: "night sky"}[night]}) + }) + } + }) +} + +func demoSunMoon(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := fxState(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + pixel.Scope(c, paletteOf(c, k.Palette().Season, s.hour < 6 || s.hour >= 18), func() { + over(c, 140, 72, func() { + fx.SunMoon(c, s.hour, fx.SunMoonOptions{Width: 140, Height: 72, Label: fmt.Sprintf("%02d:00", s.hour)}) + }) + }) + if input.Stepper(c, "Hour", &s.hour, input.StepperOptions{Max: 23}).Changed { + log(fmt.Sprintf("hour %02d", s.hour)) + } + }) +} + +func demoClouds(c *ui.Context, log func(string)) { + over(c, 140, 80, func() { fx.Clouds(c, fx.CloudsOptions{Width: 140, Height: 80, Label: "clouds"}) }) +} + +func demoStarfield(c *ui.Context, log func(string)) { + fx.Starfield(c, fx.StarfieldOptions{Width: 140, Height: 80, Label: "stars"}) +} + +func demoRain(c *ui.Context, log func(string)) { + over(c, 140, 80, func() { fx.Rain(c, fx.RainOptions{Width: 140, Height: 80, Label: "rain"}) }) +} + +func demoSnow(c *ui.Context, log func(string)) { + over(c, 140, 80, func() { fx.Snow(c, fx.SnowOptions{Width: 140, Height: 80, Label: "snow"}) }) +} + +func demoPetals(c *ui.Context, log func(string)) { + over(c, 140, 80, func() { fx.Petals(c, fx.PetalsOptions{Width: 140, Height: 80, Label: "petals"}) }) +} + +func demoLeaves(c *ui.Context, log func(string)) { + over(c, 140, 80, func() { fx.Leaves(c, fx.LeavesOptions{Width: 140, Height: 80, Label: "leaves"}) }) +} + +func demoFireflies(c *ui.Context, log func(string)) { + pixel.Scope(c, paletteOf(c, pixel.K(c).Palette().Season, true), func() { + over(c, 140, 80, func() { fx.Fireflies(c, fx.FirefliesOptions{Width: 140, Height: 80, Label: "fireflies"}) }) + }) +} + +func demoWaterfallFx(c *ui.Context, log func(string)) { + fx.Waterfall(c, fx.WaterfallOptions{Label: "waterfall"}) +} + +func demoOcean(c *ui.Context, log func(string)) { + fx.Ocean(c, fx.OceanOptions{Width: 140, Label: "ocean"}) +} + +func demoFire(c *ui.Context, log func(string)) { + fx.Fire(c, fx.FireOptions{Scale: 3, Label: "campfire"}) +} + +func demoLava(c *ui.Context, log func(string)) { + fx.Lava(c, fx.LavaOptions{Width: 140, Label: "lava"}) +} + +func demoAurora(c *ui.Context, log func(string)) { + pixel.Scope(c, paletteOf(c, pixel.K(c).Palette().Season, true), func() { + layout.Layers(c, func() { fx.Sky(c, fx.SkyOptions{Width: 140, Height: 80}) }, + func() { fx.Aurora(c, fx.AuroraOptions{Width: 140, Height: 80, Label: "aurora"}) }, + func() { fx.Mountains(c, fx.MountainsOptions{Width: 140, Height: 80}) }) + }) +} + +func demoLightning(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := fxState(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + pixel.Scope(c, paletteOf(c, k.Palette().Season, true), func() { + over(c, 120, 80, func() { fx.Lightning(c, fx.LightningOptions{Cue: s.strike, Width: 120, Height: 80, Label: "storm"}) }) + }) + if small(c, "STRIKE", false) { + s.strike++ + log(fmt.Sprintf("strike %d", s.strike)) + } + }) +} + +func demoMountains(c *ui.Context, log func(string)) { + over(c, 140, 80, func() { fx.Mountains(c, fx.MountainsOptions{Width: 140, Height: 80, Label: "mountains"}) }) +} + +func demoSeasonScene(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := fxState(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + fx.SeasonScene(c, pixel.Season(s.season), s.hour, fx.SeasonSceneOptions{Width: 144, Height: 80, Label: "the world"}) + seasons := []string{"Spring", "Summer", "Autumn", "Winter"} + if input.Segmented(c, "Season", seasons, &s.season, input.SegmentedOptions{}).Changed { + log("season " + seasons[s.season]) + } + if input.Stepper(c, "Hour", &s.hour, input.StepperOptions{Max: 23}).Changed { + log(fmt.Sprintf("hour %02d", s.hour)) + } + }) +} diff --git a/gallery/demos_screenfx.go b/gallery/demos_screenfx.go new file mode 100644 index 0000000..52d352e --- /dev/null +++ b/gallery/demos_screenfx.go @@ -0,0 +1,156 @@ +package gallery + +import ( + "fmt" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/fx" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +var screenFxEntries = []Entry{ + {309, "Scanlines", "扫描线", "fx", "Every other row, half shaded. Instant CRT.", demoScanlines}, + {310, "Vignette", "暗角", "fx", "The corners dither into the dark.", demoVignette}, + {311, "ScreenShake", "震屏", "fx", "Something big just landed.", demoScreenShake}, + {312, "Flash", "闪白", "fx", "A step of light, then half a step. Never under Reduce Motion.", demoFlash}, + {313, "FadeDither", "抖动淡出", "fx", "Fades through Bayer levels, never through opacity.", demoFadeDither}, + {314, "IrisWipe", "光圈转场", "fx", "The circle closes on the middle. That's all, folks.", demoIrisWipe}, + {315, "Dissolve", "溶解", "fx", "Dots give up one batch at a time, in shuffled order.", demoDissolve}, + {316, "Mosaic", "马赛克", "fx", "Blocks grow to eight dots, then the picture comes back.", demoMosaic}, + {317, "Glitch", "故障", "fx", "Rows slip and two colors trade places. Blow on the cartridge.", demoGlitch}, + {318, "Sparkle", "闪光", "fx", "Four stars take turns around a shiny thing.", demoSparkle}, + {319, "Explosion", "爆炸", "fx", "Fireball, smoke, debris. Stand back.", demoExplosion}, + {320, "Confetti", "彩纸", "fx", "Bits of every color tumble down. You won!", demoConfetti}, +} + +// stage is a little world in the palette's season. +func stage(c *ui.Context) { + p := pixel.K(c).Palette() + fx.SeasonScene(c, p.Season, hourOf(p), fx.SeasonSceneOptions{Width: 128, Height: 72, Label: "stage"}) +} + +func demoScanlines(c *ui.Context, log func(string)) { + ui.Column(c).AlignItems(ui.Start).Children(func() { + fx.Scanlines(c, fx.ScanlinesOptions{}, func() { stage(c) }) + }) +} + +func demoVignette(c *ui.Context, log func(string)) { + ui.Column(c).AlignItems(ui.Start).Children(func() { + fx.Vignette(c, fx.VignetteOptions{}, func() { stage(c) }) + }) +} + +func demoScreenShake(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := fxState(c) + ui.Column(c).Gap(k.D(6)).AlignItems(ui.Start).Children(func() { + fx.ScreenShake(c, fx.ScreenShakeOptions{Cue: s.shake}, func() { stage(c) }) + if small(c, "SHAKE", false) { + s.shake++ + log("rumble!") + } + }) +} + +func demoFlash(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := fxState(c) + ui.Column(c).Gap(k.D(6)).AlignItems(ui.Start).Children(func() { + fx.Flash(c, fx.FlashOptions{Cue: s.flash}, func() { stage(c) }) + if small(c, "FLASH", false) { + s.flash++ + log("flash") + } + }) +} + +// toggled shows content under a transition, with its toggle. +func toggled(c *ui.Context, text string, on *bool, log func(string), effect func(content func())) { + k := pixel.K(c) + ui.Column(c).Gap(k.D(6)).AlignItems(ui.Start).Children(func() { + effect(func() { stage(c) }) + if input.Toggle(c, text, on, input.ToggleOptions{Size: input.Small}).Changed { + log(fmt.Sprintf("%s %v", text, *on)) + } + }) +} + +func demoFadeDither(c *ui.Context, log func(string)) { + s := fxState(c) + toggled(c, "FADE OUT", &s.fade, log, func(content func()) { fx.FadeDither(c, s.fade, fx.FadeDitherOptions{}, content) }) +} + +func demoIrisWipe(c *ui.Context, log func(string)) { + s := fxState(c) + toggled(c, "CLOSE", &s.iris, log, func(content func()) { fx.IrisWipe(c, s.iris, fx.IrisWipeOptions{}, content) }) +} + +func demoDissolve(c *ui.Context, log func(string)) { + s := fxState(c) + toggled(c, "DISSOLVE", &s.dissolve, log, func(content func()) { fx.Dissolve(c, s.dissolve, fx.DissolveOptions{}, content) }) +} + +func demoMosaic(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := fxState(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() { + fx.Mosaic(c, display.Reduce(sunset, 48, 32, k.Palette()), fx.MosaicOptions{Cue: s.mosaic, Scale: 2, Label: "sunset"}) + fx.Mosaic(c, slime, fx.MosaicOptions{Cue: s.mosaic, Scale: 3, Label: "slime"}) + }) + if small(c, "PIXELATE", false) { + s.mosaic++ + log("pixelate") + } + }) +} + +func demoGlitch(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := fxState(c) + ui.Column(c).Gap(k.D(6)).Children(func() { + ui.Row(c).Gap(k.D(8)).AlignItems(ui.Center).Children(func() { + fx.Glitch(c, display.Reduce(sunset, 48, 32, k.Palette()), fx.GlitchOptions{Cue: s.glitch, Scale: 2, Label: "sunset"}) + fx.Glitch(c, slime, fx.GlitchOptions{Cue: s.glitch, Scale: 3, Label: "slime"}) + }) + if small(c, "GLITCH", false) { + s.glitch++ + log("glitch") + } + }) +} + +func demoSparkle(c *ui.Context, log func(string)) { + k := pixel.K(c) + layout.Layers(c, func() { + ui.Box(c).Size(k.D(48), k.D(48)).AlignItems(ui.Center).Justify(ui.Center).Children(func() { + display.Gem(c, display.Legendary, display.GemOptions{Scale: 2}) + }) + }, func() { fx.Sparkle(c, fx.SparkleOptions{Size: 48, Label: "sparkles"}) }) +} + +func demoExplosion(c *ui.Context, log func(string)) { + k := pixel.K(c) + s := fxState(c) + ui.Column(c).Gap(k.D(6)).AlignItems(ui.Center).Children(func() { + fx.Explosion(c, fx.ExplosionOptions{Cue: s.boom, Label: "blast"}) + if small(c, "BOOM", false) { + s.boom++ + log("boom") + } + }) +} + +func demoConfetti(c *ui.Context, log func(string)) { + k := pixel.K(c) + layout.Layers(c, func() { + ui.Box(c).Size(k.D(140), k.D(64)).AlignItems(ui.Center).Justify(ui.Center).Children(func() { + pixel.Text(c, "YOU WIN!", pixel.TextOptions{Scale: 2, Slot: pixel.Accent}) + }) + }, func() { fx.Confetti(c, fx.ConfettiOptions{Width: 140, Height: 64, Label: "confetti"}) }) +} diff --git a/gallery/demos_screens.go b/gallery/demos_screens.go index 317c2fb..71e60f0 100644 --- a/gallery/demos_screens.go +++ b/gallery/demos_screens.go @@ -136,7 +136,7 @@ func demoLoadingTips(c *ui.Context, log func(string)) { func demoLetterbox(c *ui.Context, log func(string)) { k := pixel.K(c) s := screenState(c) - ui.Column(c).Gap(k.D(6)).Children(func() { + ui.Column(c).Gap(k.D(6)).AlignItems(ui.Start).Children(func() { game.Letterbox(c, s.cutscene, game.LetterboxOptions{}, func() { ui.Box(c).Size(k.D(200), k.D(80)).Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) diff --git a/gallery/fx_demo_test.go b/gallery/fx_demo_test.go new file mode 100644 index 0000000..c9e2c55 --- /dev/null +++ b/gallery/fx_demo_test.go @@ -0,0 +1,86 @@ +package gallery + +import ( + "testing" + + "github.com/egoist/mygo/ui" + + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" +) + +func TestFxDemosLogTheirActions(t *testing.T) { + for _, tc := range []struct { + path string + steps []string + }{ + {"/c/293-sunmoon", []string{"Hour", "Right"}}, {"/c/306-lightning", []string{"STRIKE"}}, + {"/c/308-seasonscene", []string{"Winter", "Hour", "Right"}}, {"/c/311-screenshake", []string{"SHAKE"}}, + {"/c/312-flash", []string{"FLASH"}}, {"/c/313-fadedither", []string{"FADE OUT"}}, {"/c/314-iriswipe", []string{"CLOSE"}}, + {"/c/315-dissolve", []string{"DISSOLVE"}}, {"/c/316-mosaic", []string{"PIXELATE"}}, {"/c/317-glitch", []string{"GLITCH"}}, + {"/c/319-explosion", []string{"BOOM"}}, + } { + a, tt := open(tc.path) + slug := tc.path[3:] + for _, step := range tc.steps { + before := len(a.logs[slug]) + if step == "Right" { + tt.Key(0, ui.KeyRight) + } else { + click(t, tt, step) + } + // Focusing the stepper changes nothing worth a line. + if len(a.logs[slug]) <= before && step != "Hour" { + t.Errorf("%s: %s logged nothing: %q", slug, step, a.logs[slug]) + } + } + } +} + +func TestGroupsAreOneTabStopEach(t *testing.T) { + for _, page := range []struct{ path, query string }{{"/", ""}, {"/cat/fx", ""}, {"/", "e"}} { + _, tt := open(page.path) + tt.Key(0, ui.KeySlash) + tt.Type(page.query) + // Search, four top-bar controls, then one stop per group. + stops := 1 + for tt.Key(0, ui.KeyTab); !tt.Focused("Search components"); tt.Key(0, ui.KeyTab) { + if stops++; stops > 12 { + t.Fatalf("%s %q: a Tab cycle passes over 12 stops", page.path, page.query) + } + } + } + _, tt := open("/") + for range 40 { + if tt.Focused("Spring Day, in use") { + break + } + tt.Key(0, ui.KeyTab) + } + tt.Key(0, ui.KeyRight) + if !tt.Focused("Spring Night") { + t.Fatal("Right did not move along the palette strip") + } +} + +func TestHomeSceneFollowsThePaletteStrip(t *testing.T) { + a, tt := open("/") + if _, ok := tt.Find("Spring Day scene"); !ok { + t.Fatalf("no spring scene in %q", tt.Texts()) + } + click(t, tt, "Winter Night") + if a.palette != 7 { + t.Fatalf("the strip chose palette %d", a.palette) + } + r, ok := tt.Find("Winter Night scene") + if !ok { + t.Fatalf("the scene kept its season: %q", tt.Texts()) + } + // The banner's sky starts in Winter Night's darkest slot. + got, want := tt.Image().RGBAAt(int(r.X)+1, int(r.Y)+1), pixel.WinterNight.Colors[pixel.Well] + if got.R != want.R || got.G != want.G || got.B != want.B { + t.Fatalf("the banner's sky is %v, want %v", got, want) + } + if _, ok := tt.Find("Winter Night, in use"); !ok { + t.Fatal("the strip does not mark the palette in use") + } +} diff --git a/gallery/invariants_test.go b/gallery/invariants_test.go index 99a89c6..057bc7b 100644 --- a/gallery/invariants_test.go +++ b/gallery/invariants_test.go @@ -93,7 +93,8 @@ func TestInvariantsPages(t *testing.T) { a.Go(path) } allowed := colors(pal) - if name == "entry" { + // Home shows every palette in its strip. + if name == "entry" || name == "home" { allowed = colors(pixel.Palettes...) } img := render(a.View, 640, 400) diff --git a/gallery/pages.go b/gallery/pages.go index 5556fc9..a7101db 100644 --- a/gallery/pages.go +++ b/gallery/pages.go @@ -8,6 +8,7 @@ import ( "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/display" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/feedback" + "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/fx" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/input" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/pixel" @@ -49,13 +50,23 @@ func page(c *ui.Context, fn func()) { func (a *App) home(c *ui.Context) { k := pixel.K(c) - text.ShadowText(c, logo, text.ShadowTextOptions{Slot: pixel.Accent, Scale: 3}) + p := k.Palette() + // The banner lives in the palette's own season and hour. + layout.Layers(c, func() { + fx.SeasonScene(c, p.Season, hourOf(p), fx.SeasonSceneOptions{Height: 96, Label: p.Name + " scene"}).FillWidth() + }, func() { + ui.Column(c).Padding(k.D(10)).Children(func() { + text.OutlineText(c, logo, text.OutlineTextOptions{Slot: pixel.Accent, Scale: 3}) + }) + }) pixel.Text(c, "Pixel components for MyGo. Every dot sits on the grid; every color comes from sixteen.", pixel.TextOptions{Wrap: true}) pixel.Text(c, "为 MyGo 打造的像素组件。每个点落在点阵上,每种颜色来自十六格调色板。", pixel.TextOptions{Wrap: true, Slot: pixel.Faint}) comps := len(Entries()) - count("foundations") stats := fmt.Sprintf("%d COMPONENTS %d CATEGORIES %d PALETTES", comps, len(Categories)-1, len(pixel.Palettes)) text.MicroText(c, stats, text.MicroTextOptions{Slot: pixel.Accent}) - ui.Grid(c).Columns(3).Gap(k.D(4)).Children(func() { + a.palettes(c) + // Each grid is one Tab stop; arrows move inside it. + ui.Grid(c).Columns(3).Gap(k.D(4)).FocusGroup(ui.Horizontal | ui.Vertical).Children(func() { for _, cat := range Categories { n := count(cat.Key) if press(c, 0, 40, cat.Name, func(g pixel.Canvas, st pixel.State) { @@ -76,6 +87,39 @@ func (a *App) home(c *ui.Context) { }) } +// palettes shows each palette in its colors; press to pick. +func (a *App) palettes(c *ui.Context) { + k := pixel.K(c) + ui.Grid(c).Columns(4).Gap(k.D(4)).FocusGroup(ui.Horizontal | ui.Vertical).Children(func() { + for i, pal := range pixel.Palettes { + name := pal.Name + if i == a.palette { + name += ", in use" + } + set := pixel.Current(c) + set.Palette = pal + pixel.Scope(c, set, func() { + if press(c, 0, 30, name, func(g pixel.Canvas, st pixel.State) { + fill := pixel.Face + if i == a.palette || st.Hover { + fill = pixel.Lit + } + g.Frame(0, 0, g.W, g.H, pixel.Window, fill) + for s := range pixel.Slots { + g.Rect(5+3*s, 4, 3, 8, pixel.Slot(s)) + } + g.Text(5, 14, pal.Name+" "+pal.Zh, pixel.Ink, 1) + if i == a.palette { + g.Rect(g.W-9, 5, 4, 4, pixel.Accent) + } + }) { + a.setPalette(i) + } + }) + } + }) +} + func (a *App) category(c *ui.Context, key string) { var cat Category for _, x := range Categories { @@ -93,7 +137,7 @@ func (a *App) category(c *ui.Context, key string) { text.ShadowText(c, cat.Name, text.ShadowTextOptions{Scale: 2}) pixel.Text(c, cat.Zh, pixel.TextOptions{Slot: pixel.Faint}) }) - ui.Grid(c).Columns(3).Gap(k.D(4)).Children(func() { + ui.Grid(c).Columns(3).Gap(k.D(4)).FocusGroup(ui.Horizontal | ui.Vertical).Children(func() { for _, e := range Entries() { if e.Category == key { a.card(c, e) @@ -180,7 +224,7 @@ func (a *App) results(c *ui.Context) { found := matches(a.query) pixel.Text(c, fmt.Sprintf("%d results for %q", len(found), strings.TrimSpace(a.query)), pixel.TextOptions{Slot: pixel.Faint}) k := pixel.K(c) - ui.Grid(c).Columns(3).Gap(k.D(4)).Children(func() { + ui.Grid(c).Columns(3).Gap(k.D(4)).FocusGroup(ui.Horizontal | ui.Vertical).Children(func() { for _, e := range found { a.card(c, e) } diff --git a/gallery/registry.go b/gallery/registry.go index 6f84b3e..9829354 100644 --- a/gallery/registry.go +++ b/gallery/registry.go @@ -56,7 +56,7 @@ var Categories = []Category{ {"tabletop", "Tabletop", "桌游", icon.Crown}, } -var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries, gameEntries, rpgEntries, screenEntries, lootEntries) +var entries = slices.Concat(foundationEntries, layoutEntries, textEntries, displayEntries, inputEntries, controlEntries, feedbackEntries, navigationEntries, dataEntries, recordEntries, overlayEntries, popupEntries, datetimeEntries, timeEntries, chartEntries, plotEntries, gameEntries, rpgEntries, screenEntries, lootEntries, fxEntries, screenFxEntries) func Entries() []Entry { return entries } diff --git a/game/letterbox.go b/game/letterbox.go index f22f025..9296857 100644 --- a/game/letterbox.go +++ b/game/letterbox.go @@ -1,8 +1,6 @@ package game import ( - "time" - "github.com/egoist/mygo/ui" "git.zacharyzhang.com/ZacharyZhang-NY/8bitMyGoUI/layout" @@ -14,38 +12,13 @@ type LetterboxOptions struct { Bar int } -type barsKey struct{} - -// bars is the last on, its change time and step. -type bars struct { - on bool - at time.Time - from int -} - // Letterbox slides cutscene bars over content in four steps. func Letterbox(c *ui.Context, on bool, o LetterboxOptions, content func()) *ui.Element { 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() }) - // First shown, the bars are already where on puts them. - s := ui.Local(e, barsKey{}, func() bars { return bars{on: on, from: 4 * boolInt(on)} }) - // shown is the step, 0–4; turns start where it is. - shown := func() int { - if k.Reduce() { - return 4 * boolInt(s.on) - } - if n := k.Since(c, s.at, 8, 4); s.on { - return min(4, s.from+n) - } else { - return max(0, s.from-n) - } - } - if on != s.on { - s.from, s.on, s.at = shown(), on, k.Now() - } - h := bar * shown() / 4 + h := bar * k.Toward(c, e, on, 8, 4) / 4 cover.Draw(func(p *ui.Painter, r ui.Rect) { g := k.Canvas(p, r) g.Rect(0, 0, g.W, h, pixel.Ink) @@ -53,10 +26,3 @@ func Letterbox(c *ui.Context, on bool, o LetterboxOptions, content func()) *ui.E }) return e } - -func boolInt(b bool) int { - if b { - return 1 - } - return 0 -} diff --git a/overlay/overlay.go b/overlay/overlay.go index b94b235..78b39ba 100644 --- a/overlay/overlay.go +++ b/overlay/overlay.go @@ -128,8 +128,5 @@ func arriving(c *ui.Context, home *ui.Element, open bool) int { if open { on = 1 } - if k.Reduce() { - return 0 - } - return 4 - k.Since(c, k.Cue(home, on), 15, 4) + return 4 - k.Shot(c, home, on, 15, 4) } diff --git a/overlay/panels_test.go b/overlay/panels_test.go index 717e976..59c78af 100644 --- a/overlay/panels_test.go +++ b/overlay/panels_test.go @@ -54,6 +54,25 @@ func TestDrawerSlidesInInSteps(t *testing.T) { } } +func TestDrawerOpenedUnderReduceMotionNeverSlides(t *testing.T) { + clk, open := newClock(), false + tt := view(640, 400, clk, false, func(c *ui.Context) { + overlay.Drawer(c, "Menu", &open, overlay.DrawerOptions{Side: display.Right, Size: 100}, func() { + pixel.Text(c, "Items", pixel.TextOptions{}) + }) + }) + tt.SetPreferences(ui.Preferences{ReduceMotion: true, TextScale: 1}) + open = true + // At rest the panel's text sits at 640-200+16. + for _, reduce := range []bool{true, false} { + tt.SetPreferences(ui.Preferences{ReduceMotion: reduce, TextScale: 1}) + tt.Frame() + if x := find(t, tt, "Items").X; x != 456 { + t.Fatalf("reduce %v: the drawer's text is at %v", reduce, x) + } + } +} + func TestSheetClosesOnALongPull(t *testing.T) { open, closed := true, 0 tt := view(640, 400, newClock(), false, func(c *ui.Context) { diff --git a/pixel/canvas.go b/pixel/canvas.go index 328cb34..689fc29 100644 --- a/pixel/canvas.go +++ b/pixel/canvas.go @@ -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 diff --git a/pixel/clock.go b/pixel/clock.go index d568134..ee05025 100644 --- a/pixel/clock.go +++ b/pixel/clock.go @@ -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. diff --git a/pixel/motion_test.go b/pixel/motion_test.go new file mode 100644 index 0000000..3bfe1f7 --- /dev/null +++ b/pixel/motion_test.go @@ -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) + }) +} diff --git a/pixel/sprite.go b/pixel/sprite.go index 06ac035..3c74af8 100644 --- a/pixel/sprite.go +++ b/pixel/sprite.go @@ -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}