// Frame animation via createAnimation: a row of sprites sharing one looping
// clip (one clock, one setSprite per sprite per STEP - an app's whole crowd
// animates off a handful of no-demand frame callbacks, presenting only on
// a step), plus a one-shot clip
// that holds its last frame and fires onFinish. Self-asserting: samples the
// sprites' frames against the wall clock and logs ANIM-OK / ANIM-FAIL.
//
// The atlas is the core logo sliced 2x2 by grid(): a 4-frame "clip". Real
// art would slice a sheet the same way and pass a consecutive slice of the
// frames array per animation.
import { decodeImage, render } from "@solidrt/core"
import { addSprite, createAnimation, createAtlas, createSpriteLayer, getSprite, grid } from "@solidrt/2d"
import type { Frame, SpriteHandle } from "@solidrt/2d"
import logoBytes from "./logo.png" with { type: "binary" }
const W = 520
const H = 200
const SPRITE = 96
// Slow enough that a late timer cannot blur a sampled frame boundary: the
// checks sample mid-frame, half a period from either edge.
const LOOP_FPS = 2
const SHOT_FPS = 5
function App() {
let atlas = createAtlas(decodeImage(logoBytes), { label: "logo-atlas" })
let frames = grid(atlas, 2, 2)
let layer = createSpriteLayer([atlas], { label: "anim" })
let view = layer.createView({ width: W, height: H, clearColor: [0.05, 0.05, 0.09, 1] })
// Three sprites share the looping clip - one clock steps all of them.
let looping = createAnimation(frames, LOOP_FPS)
let looped: SpriteHandle[] = []
for (let i = 0; i < 3; i++) {
let sprite = addSprite(layer, { x: 80 + i * 120, y: H / 2, w: SPRITE, h: SPRITE })
looping.add(sprite)
looped.push(sprite)
}
// The one-shot: plays through once, holds the last frame, fires onFinish.
let shot = createAnimation(frames, SHOT_FPS, { loop: false })
let ended = 0
shot.onFinish = () => ended++
let shotSprite = addSprite(layer, { x: 440, y: H / 2, w: SPRITE, h: SPRITE })
shot.add(shotSprite)
let failures: string[] = []
function check(ok: boolean, what: string) {
if (!ok) failures.push(what)
}
function indexOf(frame: Frame): number {
return frames.findIndex(f => Math.abs(f.u0 - frame.u0) < 1e-6 && Math.abs(f.v0 - frame.v0) < 1e-6)
}
function shownIndex(sprite: SpriteHandle): number {
return indexOf(getSprite(sprite)!.frame)
}
// Sample mid-frame on timers. At LOOP_FPS = 2 a frame is 500ms and the
// clip steps on the display frame a boundary falls in, so mid-frame
// samples have ~250ms of margin either way.
let period = 1000 / LOOP_FPS
setTimeout(() => {
check(shownIndex(looped[0]!) === 0, "loop shows frame 0 in its first period")
}, period / 2)
setTimeout(() => {
check(shownIndex(looped[1]!) === 1, "loop advanced to frame 1")
check(looping.frame() === shownIndex(looped[1]!), "anim.frame matches the sprite's shown frame")
}, period * 1.5)
setTimeout(() => {
check(shownIndex(looped[2]!) === 0, "loop wrapped back to frame 0 after a full cycle")
// One-shot: 4 frames at 5fps end at 800ms - long since done here.
check(shownIndex(shotSprite) === frames.length - 1, "one-shot holds its last frame")
check(!shot.playing(), "one-shot stopped playing")
check(ended === 1, `onFinish fired once, got ${ended}`)
check(looping.playing(), "the looping clip is still playing")
console.log(failures.length === 0 ? "ANIM-OK" : `ANIM-FAIL: ${failures.join("; ")}`)
}, period * 4.5)
return (
)
}
render(() => )