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:
parent
6dcfcdce2a
commit
311b91d0c5
|
|
@ -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
160
src/dev/analyze.ts
Normal 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 }
|
||||||
|
}
|
||||||
470
src/dev/app.tsx
470
src/dev/app.tsx
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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('/warrior.png')")
|
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")
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -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("")}}`
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user