diff --git a/src/sprite.test.tsx b/src/sprite.test.tsx new file mode 100644 index 0000000..e5377a9 --- /dev/null +++ b/src/sprite.test.tsx @@ -0,0 +1,59 @@ +import { test, expect } from "bun:test" +import { Sprite } from "./sprite" + +test("Sprite renders div with correct dimensions", () => { + const html = ().toString() + expect(html).toContain('width:32px') + expect(html).toContain('height:32px') +}) + +test("Sprite sets background-image", () => { + const html = ().toString() + expect(html).toContain("background-image:url('/warrior.png')") +}) + +test("Sprite calculates background-size for horizontal strip", () => { + const html = ().toString() + expect(html).toContain('background-size:128px 32px') +}) + +test("Sprite generates keyframes animation", () => { + const html = ().toString() + expect(html).toContain('@keyframes sprite-') + expect(html).toContain('steps(4)') + expect(html).toContain('400ms') +}) + +test("Sprite keyframes animate background-position", () => { + const html = ().toString() + expect(html).toContain('from{background-position:0 0}') + expect(html).toContain('to{background-position:-128px 0}') +}) + +test("Sprite with columns calculates grid background-size", () => { + const html = ().toString() + expect(html).toContain('background-size:128px 64px') +}) + +test("Sprite with columns generates grid keyframes", () => { + const html = ().toString() + expect(html).toContain('0%{background-position:0px 0px}') + expect(html).toContain('25%{background-position:-32px 0px}') + expect(html).toContain('50%{background-position:0px -32px}') + expect(html).toContain('75%{background-position:-32px -32px}') +}) + +test("Sprite passes through class", () => { + const html = ().toString() + expect(html).toContain('class="my-sprite"') +}) + +test("Sprite passes through style", () => { + const html = ().toString() + expect(html).toContain('opacity:0.5') +}) + +test("Sprite pauses when playing is false", () => { + const html = ().toString() + expect(html).toContain('animation-play-state:paused') +}) diff --git a/src/sprite.tsx b/src/sprite.tsx new file mode 100644 index 0000000..0ab371c --- /dev/null +++ b/src/sprite.tsx @@ -0,0 +1,70 @@ +type SpriteProps = { + src: string + width: number + height: number + frames: number + frameDuration: number + columns?: number + class?: string + style?: string + playing?: boolean +} + +export const Sprite = ({ + src, + width, + height, + frames, + frameDuration, + columns, + class: className, + style, + playing = true, +}: SpriteProps) => { + const isGrid = columns !== undefined + const rows = isGrid ? Math.ceil(frames / columns!) : 1 + const cols = isGrid ? columns! : frames + + const sheetWidth = cols * width + const sheetHeight = rows * height + const totalDuration = frames * frameDuration + + const keyframeId = `sprite-${Bun.hash(`${src}-${width}-${height}-${frames}-${frameDuration}-${columns}`).toString(36)}` + + const keyframes = isGrid + ? generateGridKeyframes(keyframeId, width, height, frames, columns!) + : generateStripKeyframes(keyframeId, sheetWidth) + + const divStyle = [ + `width:${width}px`, + `height:${height}px`, + `background-image:url('${src}')`, + `background-size:${sheetWidth}px ${sheetHeight}px`, + `animation:${keyframeId} ${totalDuration}ms steps(${frames}) infinite`, + playing ? '' : 'animation-play-state:paused', + style, + ].filter(Boolean).join(';') + + return ( + <> + +
+ + ) +} + +const generateStripKeyframes = (id: string, sheetWidth: number): string => + `@keyframes ${id}{from{background-position:0 0}to{background-position:-${sheetWidth}px 0}}` + +const generateGridKeyframes = (id: string, width: number, height: number, frames: number, columns: number): string => { + const steps: string[] = [] + for (let i = 0; i < frames; i++) { + const col = i % columns + const row = Math.floor(i / columns) + const x = -col * width + const y = -row * height + const percent = (i / frames) * 100 + steps.push(`${percent}%{background-position:${x}px ${y}px}`) + } + return `@keyframes ${id}{${steps.join('')}}` +}