refactor: pure CSS sprite animation with CSS variables

- Remove runtime JavaScript from Sprite component entirely
- Use CSS custom properties (--x, --y, --ex) with shared @keyframes
- Add SpriteStyles component for global keyframe (include once in <head>)
- Require sheetWidth/sheetHeight props (calculated by dev tool)
- Remove grid/columns support (horizontal strips only)
- Dev tool now auto-detects frames and calculates crop bounds
- Dev server is now CLI-based with required assets directory arg
- Add image picker with filter/search

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Corey Johnson 2026-01-06 16:07:35 -08:00
parent 6dcfcdce2a
commit 311b91d0c5
7 changed files with 730 additions and 257 deletions

View File

@ -8,11 +8,11 @@
"tiny-sprites": "src/dev/server.tsx" "tiny-sprites": "src/dev/server.tsx"
}, },
"scripts": { "scripts": {
"dev": "bun --hot src/dev/server.tsx", "dev": "bun --hot src/dev/server.tsx ./assets",
"test": "tsc --noEmit && bun test" "test": "tsc --noEmit && bun test"
}, },
"dependencies": { "dependencies": {
"hono": "^4" "hono": "^4.11.3"
}, },
"devDependencies": { "devDependencies": {
"bun-types": "latest" "bun-types": "latest"

160
src/dev/analyze.ts Normal file
View File

@ -0,0 +1,160 @@
export type Crop = {
x: number
y: number
width: number
height: number
}
export type AnalyzeResult = {
frames: number
crop: Crop
}
export const analyzeSprite = (img: HTMLImageElement): AnalyzeResult => {
const { data, width, height } = getImageData(img)
// Find columns with content (non-transparent pixels)
const colHasContent = new Array(width).fill(false)
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
// pixel data is [R,G,B,A,R,G,B,A,...], so (y*width+x)*4+3 gets the alpha byte
const alpha = data[(y * width + x) * 4 + 3]
if (alpha > 0) {
colHasContent[x] = true
}
}
}
const frames = detectFrames(colHasContent, width)
const crop = calculateCrop(img, frames)
return { frames, crop }
}
export const calculateCrop = (img: HTMLImageElement, frames: number): Crop => {
const { data, width, height } = getImageData(img)
const frameWidth = Math.floor(width / frames)
// For each frame, find the bounding box, then union them all
let unionMinX = Infinity
let unionMinY = Infinity
let unionMaxX = -Infinity
let unionMaxY = -Infinity
for (let f = 0; f < frames; f++) {
const frameStartX = f * frameWidth
const frameEndX = frameStartX + frameWidth
const bounds = getFrameBounds(data, width, height, frameStartX, frameEndX)
if (bounds) {
// Convert to frame-local coordinates
const localMinX = bounds.minX - frameStartX
const localMaxX = bounds.maxX - frameStartX
unionMinX = Math.min(unionMinX, localMinX)
unionMinY = Math.min(unionMinY, bounds.minY)
unionMaxX = Math.max(unionMaxX, localMaxX)
unionMaxY = Math.max(unionMaxY, bounds.maxY)
}
}
// Handle case where no content found
if (unionMinX === Infinity) {
return { x: 0, y: 0, width: frameWidth, height }
}
return {
x: unionMinX,
y: unionMinY,
width: unionMaxX - unionMinX + 1,
height: unionMaxY - unionMinY + 1,
}
}
const getImageData = (img: HTMLImageElement) => {
const canvas = document.createElement("canvas")
canvas.width = img.width
canvas.height = img.height
const ctx = canvas.getContext("2d")!
ctx.drawImage(img, 0, 0)
return ctx.getImageData(0, 0, img.width, img.height)
}
const detectFrames = (colHasContent: boolean[], width: number): number => {
// Find regions, requiring gaps of at least 10px to separate frames
const minGapWidth = 10
const regions: { start: number; end: number }[] = []
let inRegion = false
let regionStart = 0
let gapStart = -1
for (let x = 0; x < width; x++) {
if (colHasContent[x]) {
if (!inRegion) {
inRegion = true
regionStart = x
}
gapStart = -1
} else if (inRegion) {
if (gapStart === -1) {
gapStart = x
}
if (x - gapStart >= minGapWidth) {
regions.push({ start: regionStart, end: gapStart - 1 })
inRegion = false
gapStart = -1
}
}
}
if (inRegion) {
regions.push({ start: regionStart, end: gapStart === -1 ? width - 1 : gapStart - 1 })
}
if (regions.length === 0) {
throw new Error("No content found in spritesheet")
}
// Check if regions are roughly evenly spaced
const avgWidth = width / regions.length
const tolerance = avgWidth * 0.4
for (let i = 0; i < regions.length; i++) {
const expectedCenter = avgWidth * i + avgWidth / 2
const actualCenter = (regions[i].start + regions[i].end) / 2
if (Math.abs(expectedCenter - actualCenter) > tolerance) {
throw new Error("Frames are not evenly spaced")
}
}
return regions.length
}
const getFrameBounds = (
data: Uint8ClampedArray,
width: number,
height: number,
startX: number,
endX: number
): { minX: number; minY: number; maxX: number; maxY: number } | undefined => {
let minX = Infinity
let minY = Infinity
let maxX = -Infinity
let maxY = -Infinity
for (let y = 0; y < height; y++) {
for (let x = startX; x < endX; x++) {
const alpha = data[(y * width + x) * 4 + 3]
if (alpha > 0) {
minX = Math.min(minX, x)
minY = Math.min(minY, y)
maxX = Math.max(maxX, x)
maxY = Math.max(maxY, y)
}
}
}
if (minX === Infinity) return undefined
return { minX, minY, maxX, maxY }
}

View File

@ -1,153 +1,415 @@
import { useState, useEffect } from "hono/jsx"
import { render } from "hono/jsx/dom" import { render } from "hono/jsx/dom"
import { analyzeSprite, calculateCrop, type Crop } from "./analyze"
const STORAGE_KEY = "tiny-sprites-last-file" const STORAGE_KEY = "tiny-sprites-last-file"
const loadFromStorage = () => { const state = {
try { spriteUrl: "",
fileName: "",
relativePath: "", // Path relative to assets dir for generated code
imageWidth: 0,
imageHeight: 0,
frames: 4,
frameDuration: 100,
scale: 1,
crop: undefined as Crop | undefined,
availableImages: [] as string[],
}
// Load from localStorage
try {
const stored = localStorage.getItem(STORAGE_KEY) const stored = localStorage.getItem(STORAGE_KEY)
if (stored) return JSON.parse(stored) if (stored) {
} catch {} const data = JSON.parse(stored)
} state.spriteUrl = data.spriteUrl || ""
state.fileName = data.fileName || ""
state.relativePath = data.relativePath || ""
state.imageWidth = data.imageWidth || 0
state.imageHeight = data.imageHeight || 0
state.frames = data.frames || 4
state.crop = data.crop
}
} catch {}
const saveToStorage = (data: { dataUrl: string; width: number; height: number }) => { const saveToStorage = () => {
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data)) localStorage.setItem(
STORAGE_KEY,
JSON.stringify({
spriteUrl: state.spriteUrl,
fileName: state.fileName,
relativePath: state.relativePath,
imageWidth: state.imageWidth,
imageHeight: state.imageHeight,
frames: state.frames,
crop: state.crop,
})
)
} catch {} } catch {}
} }
const App = () => { const loadImageFromPath = (path: string) => {
const stored = loadFromStorage() const url = `/assets/${encodeURIComponent(path).replace(/%2F/g, "/")}`
const [spriteUrl, setSpriteUrl] = useState(stored?.dataUrl || "")
const [imageWidth, setImageWidth] = useState(stored?.width || 0)
const [imageHeight, setImageHeight] = useState(stored?.height || 0)
const [frames, setFrames] = useState(4)
const [columns, setColumns] = useState<number | undefined>()
const [frameDuration, setFrameDuration] = useState(100)
const [scale, setScale] = useState(1)
const handleFileChange = (e: Event) => {
const file = (e.target as HTMLInputElement).files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = () => {
const dataUrl = reader.result as string
const img = new Image() const img = new Image()
img.onload = () => { img.onload = () => {
setImageWidth(img.width) state.spriteUrl = url
setImageHeight(img.height) state.fileName = path.split("/").pop() || path
setSpriteUrl(dataUrl) state.relativePath = path
saveToStorage({ dataUrl, width: img.width, height: img.height }) state.imageWidth = img.width
} state.imageHeight = img.height
img.src = dataUrl
} try {
reader.readAsDataURL(file) const result = analyzeSprite(img)
state.frames = result.frames
state.crop = result.crop
$<HTMLInputElement>("frames").value = String(result.frames)
updateCropInputs()
$("error-message").style.display = "none"
} catch (e) {
state.crop = undefined
const msg = e instanceof Error ? e.message : "Unknown error"
$("error-message").textContent = `Auto-detect failed: ${msg}. Set frame count manually.`
$("error-message").style.display = "block"
} }
// Auto-calculate frame dimensions from image size and frame count saveToStorage()
const isGrid = columns !== undefined updatePreview()
const cols = isGrid ? columns : frames renderImagePicker($<HTMLInputElement>("filter").value.toLowerCase())
const rows = isGrid ? Math.ceil(frames / columns!) : 1 }
const width = imageWidth > 0 ? Math.floor(imageWidth / cols) : 32 img.src = url
const height = imageHeight > 0 ? Math.floor(imageHeight / rows) : 32 }
const sheetWidth = cols * width
const sheetHeight = rows * height const $ = <T extends HTMLElement>(id: string) => document.getElementById(id) as T
const updateCropInputs = () => {
$<HTMLInputElement>("crop-x").value = String(state.crop?.x ?? 0)
$<HTMLInputElement>("crop-y").value = String(state.crop?.y ?? 0)
$<HTMLInputElement>("crop-width").value = String(state.crop?.width ?? 0)
$<HTMLInputElement>("crop-height").value = String(state.crop?.height ?? 0)
}
const updatePreview = () => {
const { spriteUrl, imageWidth, imageHeight, frames, frameDuration, scale, crop } = state
const frameWidth = imageWidth > 0 ? Math.floor(imageWidth / frames) : 32
const frameHeight = imageHeight > 0 ? imageHeight : 32
// Use crop dimensions if available, otherwise full frame
const displayWidth = crop ? crop.width : frameWidth
const displayHeight = crop ? crop.height : frameHeight
const offsetX = crop ? crop.x : 0
const offsetY = crop ? crop.y : 0
const totalDuration = frames * frameDuration const totalDuration = frames * frameDuration
// Update info displays
$("frame-info").textContent = spriteUrl ? `Frame: ${frameWidth} x ${frameHeight} px` : ""
$("scale-label").textContent = `Scale (${scale}x)`
// Update preview - show full frame with crop overlay
const preview = $("preview")
const previewStyle = $("preview-style")
const cropOverlay = $("crop-overlay")
if (spriteUrl) {
const keyframeId = "sprite-preview" const keyframeId = "sprite-preview"
let keyframes: string const fullKeyframes = `@keyframes ${keyframeId}{from{background-position:0 0}to{background-position:-${imageWidth * scale}px 0}}`
previewStyle.textContent = fullKeyframes
preview.style.width = `${frameWidth * scale}px`
preview.style.height = `${frameHeight * scale}px`
preview.style.backgroundImage = `url('${spriteUrl}')`
preview.style.backgroundSize = `${imageWidth * scale}px ${imageHeight * scale}px`
preview.style.animation = `${keyframeId} ${totalDuration}ms steps(${frames}) infinite`
preview.style.display = "block"
preview.style.position = "relative"
if (isGrid) { // Position crop overlay to show what's kept vs cropped
const steps: string[] = [] if (crop) {
for (let i = 0; i < frames; i++) { cropOverlay.style.position = "absolute"
const col = i % columns! cropOverlay.style.top = `${offsetY * scale}px`
const row = Math.floor(i / columns!) cropOverlay.style.left = `${offsetX * scale}px`
const x = -col * width * scale cropOverlay.style.width = `${displayWidth * scale}px`
const y = -row * height * scale cropOverlay.style.height = `${displayHeight * scale}px`
const percent = (i / frames) * 100 cropOverlay.style.boxShadow = `0 0 0 9999px rgba(0, 0, 0, 0.6)`
steps.push(`${percent}%{background-position:${x}px ${y}px}`) cropOverlay.style.display = "block"
}
keyframes = `@keyframes ${keyframeId}{${steps.join("")}}`
} else { } else {
keyframes = `@keyframes ${keyframeId}{from{background-position:0 0}to{background-position:-${sheetWidth * scale}px 0}}` cropOverlay.style.display = "none"
} }
const previewStyle = { $("preview-placeholder").style.display = "none"
width: `${width * scale}px`, } else {
height: `${height * scale}px`, preview.style.display = "none"
backgroundColor: "transparent", cropOverlay.style.display = "none"
backgroundImage: `url('${spriteUrl}')`, $("preview-placeholder").style.display = "block"
backgroundSize: `${sheetWidth * scale}px ${sheetHeight * scale}px`,
animation: `${keyframeId} ${totalDuration}ms steps(${frames}) infinite`,
} }
const columnsAttr = columns ? `\n columns={${columns}}` : "" // Update generated code
const code = `<Sprite const cropAttr = crop ? `\n crop={{ x: ${crop.x}, y: ${crop.y}, width: ${crop.width}, height: ${crop.height} }}` : ""
src="YOUR_SPRITE_URL" const scaleAttr = scale !== 1 ? `\n scale={${scale}}` : ""
width={${width}} const srcName = state.relativePath || state.fileName || "YOUR_SPRITE_URL"
height={${height}} $<HTMLTextAreaElement>("code").value = `<Sprite
src="${srcName}"
frames={${frames}} frames={${frames}}
frameDuration={${frameDuration}}${columnsAttr} frameDuration={${frameDuration}}
sheetWidth={${imageWidth}}
sheetHeight={${imageHeight}}${cropAttr}${scaleAttr}
/>` />`
}
const copyCode = () => navigator.clipboard.writeText(code) const renderImagePicker = (filter = "") => {
const container = $("image-picker")
if (state.availableImages.length === 0) {
container.style.display = "none"
return
}
return ( const filtered = filter
? state.availableImages.filter((p) => p.toLowerCase().includes(filter))
: state.availableImages
container.style.display = "block"
container.innerHTML = ""
const list = document.createElement("div")
list.style.maxHeight = "300px"
list.style.overflowY = "auto"
list.style.background = "var(--pico-card-background-color)"
list.style.borderRadius = "var(--pico-border-radius)"
for (const path of filtered) {
const item = document.createElement("div")
item.style.cursor = "pointer"
item.style.display = "flex"
item.style.flexDirection = "column"
item.style.alignItems = "center"
item.style.gap = "4px"
item.style.padding = "12px"
item.style.borderBottom = "1px solid var(--pico-muted-border-color)"
if (state.relativePath === path) {
item.style.background = "var(--pico-primary-background)"
}
item.onmouseenter = () => {
if (state.relativePath !== path) {
item.style.background = "var(--pico-secondary-background)"
}
}
item.onmouseleave = () => {
if (state.relativePath !== path) {
item.style.background = ""
}
}
const img = document.createElement("img")
img.src = `/assets/${encodeURIComponent(path).replace(/%2F/g, "/")}`
img.style.maxWidth = "100%"
img.style.height = "64px"
img.style.objectFit = "contain"
img.style.imageRendering = "pixelated"
const label = document.createElement("div")
label.style.fontSize = "11px"
label.style.wordBreak = "break-all"
label.style.textAlign = "center"
label.textContent = path
item.appendChild(img)
item.appendChild(label)
item.onclick = () => loadImageFromPath(path)
list.appendChild(item)
}
container.appendChild(list)
}
const fetchImages = async () => {
try {
const res = await fetch("/api/images")
const data = await res.json()
state.availableImages = data.images || []
renderImagePicker()
} catch {
state.availableImages = []
}
}
const App = () => (
<div class="grid"> <div class="grid">
<div> <div>
<label> <label>
Spritesheet Filter
<input type="file" accept="image/*" onChange={handleFileChange} /> <input
type="text"
id="filter"
placeholder="Search images..."
onInput={(e) => {
const filter = (e.target as HTMLInputElement).value.toLowerCase()
renderImagePicker(filter)
}}
/>
</label> </label>
{spriteUrl && ( <div id="image-picker" style={{ marginBottom: "1rem", display: "none" }}></div>
<div style={{ marginBottom: "1rem" }}> <div
<small>Spritesheet ({imageWidth} x {imageHeight})</small> id="error-message"
<img src={spriteUrl} style={{ maxWidth: "100%", imageRendering: "pixelated" }} /> style={{
</div> display: "none",
)} padding: "0.5rem",
marginBottom: "1rem",
background: "var(--pico-del-color)",
borderRadius: "var(--pico-border-radius)",
color: "white",
}}
></div>
<label> <label>
Frame Count Frame Count
<input type="number" value={frames} min={1} onInput={(e) => setFrames(+(e.target as HTMLInputElement).value)} /> <input
</label> type="number"
<label> id="frames"
Columns (empty = horizontal strip) value={state.frames}
<input type="number" value={columns} min={1} onInput={(e) => { min={1}
const val = (e.target as HTMLInputElement).value onInput={(e) => {
setColumns(val ? +val : undefined) state.frames = +(e.target as HTMLInputElement).value || 1
}} /> // Recalculate crop when frames change
if (state.spriteUrl) {
const img = new Image()
img.onload = () => {
state.crop = calculateCrop(img, state.frames)
updateCropInputs()
saveToStorage()
updatePreview()
}
img.src = state.spriteUrl
} else {
updatePreview()
}
}}
/>
</label> </label>
<label> <label>
Frame Duration (ms) Frame Duration (ms)
<input type="number" value={frameDuration} min={1} onInput={(e) => setFrameDuration(+(e.target as HTMLInputElement).value)} /> <input
type="number"
id="frameDuration"
value={state.frameDuration}
min={1}
onInput={(e) => {
state.frameDuration = +(e.target as HTMLInputElement).value || 100
updatePreview()
}}
/>
</label> </label>
<label> <label>
Scale ({scale}x) <span id="scale-label">Scale (1x)</span>
<input type="range" value={scale} min={1} max={8} step={1} onInput={(e) => setScale(+(e.target as HTMLInputElement).value)} /> <input
type="range"
id="scale"
value={state.scale}
min={1}
max={8}
step={1}
onInput={(e) => {
state.scale = +(e.target as HTMLInputElement).value || 1
updatePreview()
}}
/>
</label> </label>
{spriteUrl && ( <small id="frame-info"></small>
<small>Frame size: {width} x {height} px</small> <fieldset>
)} <legend>Crop</legend>
<div class="grid">
<label>
X
<input
type="number"
id="crop-x"
value={state.crop?.x ?? 0}
min={0}
onInput={(e) => {
const val = +(e.target as HTMLInputElement).value || 0
state.crop = { ...state.crop!, x: val }
saveToStorage()
updatePreview()
}}
/>
</label>
<label>
Y
<input
type="number"
id="crop-y"
value={state.crop?.y ?? 0}
min={0}
onInput={(e) => {
const val = +(e.target as HTMLInputElement).value || 0
state.crop = { ...state.crop!, y: val }
saveToStorage()
updatePreview()
}}
/>
</label>
</div>
<div class="grid">
<label>
Width
<input
type="number"
id="crop-width"
value={state.crop?.width ?? 0}
min={1}
onInput={(e) => {
const val = +(e.target as HTMLInputElement).value || 1
state.crop = { ...state.crop!, width: val }
saveToStorage()
updatePreview()
}}
/>
</label>
<label>
Height
<input
type="number"
id="crop-height"
value={state.crop?.height ?? 0}
min={1}
onInput={(e) => {
const val = +(e.target as HTMLInputElement).value || 1
state.crop = { ...state.crop!, height: val }
saveToStorage()
updatePreview()
}}
/>
</label>
</div>
</fieldset>
</div> </div>
<div> <div>
<div class="preview-area"> <div class="preview-area" style={{ position: "relative", overflow: "hidden" }}>
{spriteUrl ? ( <style id="preview-style"></style>
<> <div id="preview-container" style={{ position: "relative" }}>
<style>{keyframes}</style> <div id="preview" style={{ display: "none" }}></div>
<div style={previewStyle} /> <div id="crop-overlay" style={{ display: "none", pointerEvents: "none" }}></div>
</> </div>
) : ( <span id="preview-placeholder">Upload a spritesheet to preview</span>
<span>Upload a spritesheet to preview</span>
)}
</div> </div>
<label> <label>
Generated Code Generated Code
<textarea readonly rows={8} value={code} /> <textarea id="code" readonly rows={10}></textarea>
</label> </label>
<button onClick={copyCode}>Copy Code</button> <button onClick={() => navigator.clipboard.writeText($<HTMLTextAreaElement>("code").value)}>
Copy Code
</button>
</div> </div>
</div> </div>
) )
}
render(<App />, document.getElementById("app")!) render(<App />, document.getElementById("app")!)
// Initial update after render
setTimeout(() => {
$<HTMLInputElement>("frames").value = String(state.frames)
updateCropInputs()
updatePreview()
fetchImages()
}, 0)

View File

@ -1,32 +1,33 @@
<!DOCTYPE html> <!DOCTYPE html>
<html data-theme="dark"> <html data-theme="dark">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Tiny Sprites</title> <title>Tiny Sprites</title>
<link rel="stylesheet" href="/public/pico.min.css"> <link rel="stylesheet" href="/public/pico.min.css" />
<style> <style>
.preview-area { .preview-area {
min-height: 200px; min-height: 200px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background-color: #2a2a2a; background: linear-gradient(45deg, #e6e6e6 25%, transparent 25%) 0 0 / 20px 20px,
background-image: linear-gradient(45deg, transparent 75%, #e6e6e6 75%) 0 0 / 20px 20px,
linear-gradient(45deg, #3a3a3a 25%, transparent 25%, transparent 75%, #3a3a3a 75%), linear-gradient(45deg, #e6e6e6 25%, transparent 25%) 10px 10px / 20px 20px,
linear-gradient(45deg, #3a3a3a 25%, transparent 25%, transparent 75%, #3a3a3a 75%); linear-gradient(45deg, transparent 75%, #e6e6e6 75%) 10px 10px / 20px 20px, #ffffff;
background-size: 16px 16px;
background-position: 0 0, 8px 8px;
border-radius: var(--pico-border-radius); border-radius: var(--pico-border-radius);
} }
.preview-area > div { image-rendering: pixelated; } .preview-area > div {
image-rendering: pixelated;
}
</style> </style>
</head> </head>
<body> <body>
<main class="container"> <main class="container">
<h1>Tiny Sprites</h1> <h1>Tiny Sprites</h1>
<div id="app"></div> <div id="app"></div>
</main> </main>
<script type="module" src="./app.tsx"></script> <script type="module" src="./app.tsx"></script>
</body> </body>
</html> </html>

View File

@ -1,18 +1,78 @@
#!/usr/bin/env bun #!/usr/bin/env bun
import { Glob } from "bun"
import { resolve } from "path"
import index from "./index.html" import index from "./index.html"
const args = process.argv.slice(2)
const helpFlag = args.includes("--help") || args.includes("-h")
const assetsDirArg = args.find((arg) => !arg.startsWith("-"))
if (helpFlag || !assetsDirArg) {
console.log(`
tiny-sprites dev server
Usage:
tiny-sprites <assets-dir>
bun src/dev/server.tsx <assets-dir>
Arguments:
assets-dir Directory containing sprite images (required)
Options:
-h, --help Show this help message
Example:
tiny-sprites ./assets
bun --hot src/dev/server.tsx ./assets
`)
process.exit(helpFlag ? 0 : 1)
}
// Resolve to absolute path
const assetsDir = resolve(assetsDirArg)
// Verify assets directory exists
try {
const glob = new Glob("*")
await glob.scan(assetsDir).next()
} catch {
console.error(`Error: Assets directory not found: ${assetsDir}`)
process.exit(1)
}
console.log(`Assets directory: ${assetsDir}`)
const getImageList = async (): Promise<string[]> => {
const glob = new Glob("**/*.{png,jpg,jpeg,gif,webp}")
const images: string[] = []
for await (const path of glob.scan(assetsDir)) {
images.push(path)
}
return images.sort()
}
Bun.serve({ Bun.serve({
port: 3000, port: 3000,
routes: { routes: {
"/": index, "/": index,
"/public/*": async (req) => { "/api/images": async () => {
const path = new URL(req.url).pathname.replace("/public/", "") const images = await getImageList()
const file = Bun.file(`./public/${path}`) return Response.json({ assetsDir, images })
},
},
async fetch(req) {
const url = new URL(req.url)
if (url.pathname.startsWith("/assets/")) {
const path = decodeURIComponent(url.pathname.replace("/assets/", ""))
const file = Bun.file(`${assetsDir}/${path}`)
if (await file.exists()) { if (await file.exists()) {
return new Response(file) return new Response(file)
} }
return new Response("Not found", { status: 404 }) return new Response("Not found", { status: 404 })
}, }
return new Response("Not found", { status: 404 })
}, },
development: true, development: true,
}) })

View File

@ -1,59 +1,57 @@
import { test, expect } from "bun:test" import { test, expect } from "bun:test"
import { Sprite } from "./sprite" import { Sprite, SpriteStyles } from "./sprite"
test("Sprite renders div with correct dimensions", () => { const baseProps = { src: "/test.png", frames: 4, frameDuration: 100, sheetWidth: 128, sheetHeight: 32 }
const html = (<Sprite src="/test.png" width={32} height={32} frames={4} frameDuration={100} />).toString()
expect(html).toContain('width:32px') test("SpriteStyles renders global keyframe", () => {
expect(html).toContain('height:32px') const html = (<SpriteStyles />).toString()
expect(html).toContain("<style>")
expect(html).toContain("@keyframes sprite")
expect(html).toContain("var(--x)")
expect(html).toContain("var(--ex)")
}) })
test("Sprite sets background-image", () => { test("Sprite renders div with CSS variables", () => {
const html = (<Sprite src="/warrior.png" width={32} height={32} frames={4} frameDuration={100} />).toString() const html = (<Sprite {...baseProps} />).toString()
expect(html).toContain("background-image:url(&#39;/warrior.png&#39;)") expect(html).toContain("<div")
expect(html).toContain("--x:")
expect(html).toContain("--y:")
expect(html).toContain("--ex:")
}) })
test("Sprite calculates background-size for horizontal strip", () => { test("Sprite uses correct frame count in animation", () => {
const html = (<Sprite src="/test.png" width={32} height={32} frames={4} frameDuration={100} />).toString() const html = (<Sprite {...baseProps} frames={6} sheetWidth={192} />).toString()
expect(html).toContain('background-size:128px 32px') expect(html).toContain("steps(6)")
}) })
test("Sprite generates keyframes animation", () => { test("Sprite calculates duration from frames and frameDuration", () => {
const html = (<Sprite src="/test.png" width={32} height={32} frames={4} frameDuration={100} />).toString() const html = (<Sprite {...baseProps} frames={4} frameDuration={150} />).toString()
expect(html).toContain('@keyframes sprite-') expect(html).toContain("600ms")
expect(html).toContain('steps(4)')
expect(html).toContain('400ms')
}) })
test("Sprite keyframes animate background-position", () => { test("Sprite calculates frame dimensions from sheet size", () => {
const html = (<Sprite src="/test.png" width={32} height={32} frames={4} frameDuration={100} />).toString() const html = (<Sprite {...baseProps} sheetWidth={128} sheetHeight={32} frames={4} />).toString()
expect(html).toContain('from{background-position:0 0}') expect(html).toContain("width:32px")
expect(html).toContain('to{background-position:-128px 0}') expect(html).toContain("height:32px")
})
test("Sprite with columns calculates grid background-size", () => {
const html = (<Sprite src="/test.png" width={32} height={32} frames={8} frameDuration={100} columns={4} />).toString()
expect(html).toContain('background-size:128px 64px')
})
test("Sprite with columns generates grid keyframes", () => {
const html = (<Sprite src="/test.png" width={32} height={32} frames={4} frameDuration={100} columns={2} />).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", () => { test("Sprite passes through class", () => {
const html = (<Sprite src="/test.png" width={32} height={32} frames={4} frameDuration={100} class="my-sprite" />).toString() const html = (<Sprite {...baseProps} class="my-sprite" />).toString()
expect(html).toContain('class="my-sprite"') expect(html).toContain('class="my-sprite"')
}) })
test("Sprite passes through style", () => { test("Sprite passes through style", () => {
const html = (<Sprite src="/test.png" width={32} height={32} frames={4} frameDuration={100} style="opacity:0.5" />).toString() const html = (<Sprite {...baseProps} style="opacity:0.5" />).toString()
expect(html).toContain('opacity:0.5') expect(html).toContain("opacity:0.5")
}) })
test("Sprite pauses when playing is false", () => { test("Sprite pauses when playing is false", () => {
const html = (<Sprite src="/test.png" width={32} height={32} frames={4} frameDuration={100} playing={false} />).toString() const html = (<Sprite {...baseProps} playing={false} />).toString()
expect(html).toContain('animation-play-state:paused') expect(html).toContain("animation-play-state:paused")
})
test("Sprite with crop uses crop dimensions for display size", () => {
const html = (<Sprite {...baseProps} crop={{ x: 10, y: 5, width: 20, height: 25 }} />).toString()
expect(html).toContain("width:20px")
expect(html).toContain("height:25px")
}) })

View File

@ -1,82 +1,74 @@
type SpriteProps = { type Crop = {
src: string x: number
y: number
width: number width: number
height: number height: number
}
type SpriteProps = {
src: string
frames: number frames: number
frameDuration: number frameDuration: number
columns?: number sheetWidth: number
sheetHeight: number
crop?: Crop
scale?: number
class?: string class?: string
style?: string style?: string
playing?: boolean playing?: boolean
} }
// Include this once in your <head>
export const SpriteStyles = () => (
<style>
{`@keyframes sprite{from{background-position:var(--x)var(--y)}to{background-position:var(--ex)var(--y)}}`}
</style>
)
export const Sprite = (props: SpriteProps) => { export const Sprite = (props: SpriteProps) => {
const { const {
src, src,
width,
height,
frames, frames,
frameDuration, frameDuration,
columns, sheetWidth,
sheetHeight,
crop,
scale = 1,
class: className, class: className,
style, style = "",
playing = true, playing = true,
} = props } = props
const isGrid = columns !== undefined const frameWidth = Math.floor(sheetWidth / frames)
const rows = isGrid ? Math.ceil(frames / columns!) : 1 const frameHeight = sheetHeight
const cols = isGrid ? columns! : frames
const sheetWidth = cols * width const displayWidth = (crop ? crop.width : frameWidth) * scale
const sheetHeight = rows * height const displayHeight = (crop ? crop.height : frameHeight) * scale
const totalDuration = frames * frameDuration const offsetX = crop ? crop.x : 0
const offsetY = crop ? crop.y : 0
const keyframeId = `sprite-${Bun.hash( const scaledSheetWidth = sheetWidth * scale
`${src}-${width}-${height}-${frames}-${frameDuration}-${columns}` const scaledSheetHeight = sheetHeight * scale
).toString(36)}` const duration = frames * frameDuration
const keyframes = isGrid const fromX = -offsetX * scale
? generateGridKeyframes(keyframeId, width, height, frames, columns!) const toX = (-sheetWidth - offsetX) * scale
: generateStripKeyframes(keyframeId, sheetWidth) const y = -offsetY * scale
const divStyle = [ const divStyle = [
`width:${width}px`, `--x:${fromX}px`,
`height:${height}px`, `--y:${y}px`,
`--ex:${toX}px`,
`width:${displayWidth}px`,
`height:${displayHeight}px`,
`background-image:url('${src}')`, `background-image:url('${src}')`,
`background-size:${sheetWidth}px ${sheetHeight}px`, `background-size:${scaledSheetWidth}px ${scaledSheetHeight}px`,
`animation:${keyframeId} ${totalDuration}ms steps(${frames}) infinite`, `animation:sprite ${duration}ms steps(${frames}) infinite`,
playing ? "" : "animation-play-state:paused", playing ? "" : "animation-play-state:paused",
style, style,
] ]
.filter(Boolean) .filter(Boolean)
.join(";") .join(";")
return ( return <div class={className} style={divStyle} />
<>
<style>{keyframes}</style>
<div class={className} style={divStyle} />
</>
)
}
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("")}}`
} }