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

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

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

No files matched your search

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