Something went wrong. Try again.
An atproto Tilemap editor
Something went wrong. Try again.
18 kB · 478 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479import { useEffect, useRef, useState } from 'react'import { X } from 'lucide-react'import { objectAnimationModeOf, useEditorStore, type GridConfig, type TileObject, type TileRegion,} from '../store/editorStore'import { Button } from '@/components/ui/button'import { Input } from '@/components/ui/input'import { playOrder } from '../lib/objectTransform'import { cn } from '@/lib/utils'import PropertyList from './PropertyList'import CollapsibleSection from './CollapsibleSection'
const THUMB_MAX = 56
/** Cycles an object's frames (frame 0 = base region) at its configured timing. */function AnimatedPreview({ image, grid, object,}: { image: HTMLImageElement | null grid: GridConfig object: TileObject}) { const ref = useRef<HTMLCanvasElement | null>(null)
useEffect(() => { const canvas = ref.current if (!canvas) return const ctx = canvas.getContext('2d') if (!ctx) return
const cells = playOrder( [{ col: object.col, row: object.row }, ...object.frames], objectAnimationModeOf(object.properties), ) const sw = object.cols * grid.tileWidth const sh = object.rows * grid.tileHeight const scale = Math.min(72 / sw, 72 / sh, 8) const dw = Math.max(1, Math.round(sw * scale)) const dh = Math.max(1, Math.round(sh * scale)) const dpr = window.devicePixelRatio || 1 canvas.width = Math.round(dw * dpr) canvas.height = Math.round(dh * dpr) canvas.style.width = `${dw}px` canvas.style.height = `${dh}px` ctx.setTransform(dpr, 0, 0, dpr, 0, 0) ctx.imageSmoothingEnabled = false
const dur = Math.max(16, object.frameDurationMs) let idx = 0 let acc = 0 let prev = performance.now() let raf = 0
const render = () => { const cell = cells[idx] ctx.clearRect(0, 0, dw, dh) if (image) { ctx.drawImage( image, grid.offsetX + cell.col * grid.tileWidth, grid.offsetY + cell.row * grid.tileHeight, sw, sh, 0, 0, dw, dh, ) } }
const loop = (now: number) => { acc += now - prev prev = now while (acc >= dur) { acc -= dur idx = (idx + 1) % cells.length render() } raf = requestAnimationFrame(loop) } render() raf = requestAnimationFrame(loop) return () => cancelAnimationFrame(raf) }, [image, grid, object])
return <canvas ref={ref} className="bg-checker rounded border border-border [image-rendering:pixelated]" />}
/** Renders a region of the tileset image cropped + scaled into a small canvas. */function ObjectThumb({ image, grid, region,}: { image: HTMLImageElement | null grid: GridConfig region: TileRegion}) { const ref = useRef<HTMLCanvasElement | null>(null)
useEffect(() => { const canvas = ref.current if (!canvas) return const ctx = canvas.getContext('2d') if (!ctx) return
const sw = region.cols * grid.tileWidth const sh = region.rows * grid.tileHeight const scale = Math.min(THUMB_MAX / sw, THUMB_MAX / sh, 8) const dw = Math.max(1, Math.round(sw * scale)) const dh = Math.max(1, Math.round(sh * scale)) const dpr = window.devicePixelRatio || 1
canvas.width = Math.round(dw * dpr) canvas.height = Math.round(dh * dpr) canvas.style.width = `${dw}px` canvas.style.height = `${dh}px`
ctx.setTransform(dpr, 0, 0, dpr, 0, 0) ctx.clearRect(0, 0, dw, dh) if (!image) return ctx.imageSmoothingEnabled = false const sx = grid.offsetX + region.col * grid.tileWidth const sy = grid.offsetY + region.row * grid.tileHeight ctx.drawImage(image, sx, sy, sw, sh, 0, 0, dw, dh) }, [image, grid, region])
return <canvas ref={ref} className="shrink-0 bg-checker rounded border border-border [image-rendering:pixelated]" />}
function NumInput({ label, value, min, max, onChange,}: { label: string value: number min?: number max?: number onChange: (v: number) => void}) { return ( <label className="flex min-w-0 flex-1 flex-col gap-0.5"> <span className="whitespace-nowrap text-[10px] text-muted-foreground">{label}</span> <Input type="number" className="h-7 text-sm" value={value} min={min} max={max} onClick={(e) => e.stopPropagation()} onChange={(e) => { let n = Math.round(Number(e.target.value)) if (Number.isNaN(n)) return if (min !== undefined) n = Math.max(min, n) if (max !== undefined) n = Math.min(max, n) onChange(n) }} /> </label> )}
export default function ObjectPanel() { const image = useEditorStore((s) => s.image) const grid = useEditorStore((s) => s.grid) const objects = useEditorStore((s) => s.objects) const selection = useEditorStore((s) => s.selection) const activeObjectId = useEditorStore((s) => s.activeObjectId) const objectTool = useEditorStore((s) => s.objectTool) const addObject = useEditorStore((s) => s.addObject) const updateObject = useEditorStore((s) => s.updateObject) const removeObject = useEditorStore((s) => s.removeObject) const setActiveObject = useEditorStore((s) => s.setActiveObject) const setObjectTool = useEditorStore((s) => s.setObjectTool) const setObjectOrigin = useEditorStore((s) => s.setObjectOrigin) const updateCollisionBox = useEditorStore((s) => s.updateCollisionBox) const removeCollisionBox = useEditorStore((s) => s.removeCollisionBox) const removeFrame = useEditorStore((s) => s.removeFrame) const setFrameDuration = useEditorStore((s) => s.setFrameDuration) const setObjectPlayMode = useEditorStore((s) => s.setObjectPlayMode) const addProperty = useEditorStore((s) => s.addProperty) const updateProperty = useEditorStore((s) => s.updateProperty) const removeProperty = useEditorStore((s) => s.removeProperty)
const [name, setName] = useState('') const active = objects.find((o) => o.id === activeObjectId) ?? null const w = active ? active.cols * grid.tileWidth : 0 const h = active ? active.rows * grid.tileHeight : 0
const create = () => { addObject(name) setName('') }
return ( <> <CollapsibleSection title="Objects" grow> {selection ? ( <div className="flex flex-col gap-1.5 rounded-md border border-border bg-card p-2"> <p className="text-xs text-muted-foreground"> Selected{' '} <strong className="text-foreground"> {selection.cols}×{selection.rows} </strong>{' '} tiles </p> <div className="flex gap-1.5"> <Input className="h-7 flex-1 text-sm" placeholder={`Object ${objects.length + 1}`} value={name} onChange={(e) => setName(e.target.value)} onKeyDown={(e) => { if (e.key === 'Enter') create() }} /> <Button type="button" size="sm" onClick={create}> Add </Button> </div> </div> ) : ( <p className="text-xs text-muted-foreground">Drag on the tileset to select tiles.</p> )}
<ul className="flex list-none flex-col gap-1 p-0"> {objects.map((o) => { const isActive = o.id === activeObjectId return ( <li key={o.id} className={cn( 'flex cursor-pointer items-center gap-2 rounded-md border border-transparent px-1.5 py-1 hover:bg-accent', isActive && 'border-ring bg-accent', )} onClick={() => setActiveObject(o.id)} > <ObjectThumb image={image} grid={grid} region={o} /> <div className="flex min-w-0 flex-1 flex-col gap-0.5"> <Input className="h-7 text-sm" value={o.name} onClick={(e) => e.stopPropagation()} onChange={(e) => updateObject(o.id, { name: e.target.value })} /> <span className="text-[11px] text-muted-foreground"> {o.cols}×{o.rows} · {o.collision.length} box {o.collision.length === 1 ? '' : 'es'} </span> </div> <Button type="button" variant="ghost" size="icon-sm" title="Delete object" className="shrink-0 text-muted-foreground hover:text-destructive" onClick={(e) => { e.stopPropagation() removeObject(o.id) }} > <X /> </Button> </li> ) })} {objects.length === 0 && ( <li className="px-1.5 text-xs text-muted-foreground">No objects yet.</li> )} </ul> </CollapsibleSection>
<CollapsibleSection title="Object properties" grow> {active ? ( <div className="flex flex-col gap-3"> <h3 className="flex items-baseline justify-between gap-2 text-sm font-semibold"> Edit “{active.name}”{' '} <span className="text-[11px] font-normal text-muted-foreground"> {w}×{h}px </span> </h3>
{/* origin */} <div className="flex flex-col gap-1.5"> <div className="flex items-center justify-between text-xs font-medium tracking-wide text-muted-foreground uppercase"> <span>Origin</span> <Button type="button" size="xs" variant={objectTool === 'origin' ? 'default' : 'outline'} onClick={() => setObjectTool(objectTool === 'origin' ? 'region' : 'origin')} > {objectTool === 'origin' ? 'Picking…' : 'Pick'} </Button> </div> <div className="flex gap-1.5"> <NumInput label="X (from left)" value={active.origin.x} min={0} max={w} onChange={(v) => setObjectOrigin(active.id, { ...active.origin, x: v })} /> <NumInput label="Y (from bottom)" value={active.origin.y} min={0} max={h} onChange={(v) => setObjectOrigin(active.id, { ...active.origin, y: v })} /> </div> </div>
{/* collision boxes */} <div className="flex flex-col gap-1.5"> <div className="flex items-center justify-between text-xs font-medium tracking-wide text-muted-foreground uppercase"> <span>Collision boxes</span> <Button type="button" size="xs" variant={objectTool === 'collision' ? 'default' : 'outline'} onClick={() => setObjectTool(objectTool === 'collision' ? 'region' : 'collision')} > {objectTool === 'collision' ? 'Drawing…' : '+ Draw box'} </Button> </div> {active.collision.length === 0 && ( <p className="text-xs text-muted-foreground"> None. Click “Draw box”, then drag a rectangle on the sprite. </p> )} <ul className="flex list-none flex-col gap-1.5 p-0"> {active.collision.map((b, i) => ( <li key={i} className="flex items-end gap-1"> <div className="flex flex-1 gap-1.5"> <NumInput label="X" value={b.x} min={0} max={w} onChange={(v) => updateCollisionBox(active.id, i, { x: v })} /> <NumInput label="Y" value={b.y} min={0} max={h} onChange={(v) => updateCollisionBox(active.id, i, { y: v })} /> <NumInput label="W" value={b.w} min={1} max={w} onChange={(v) => updateCollisionBox(active.id, i, { w: v })} /> <NumInput label="H" value={b.h} min={1} max={h} onChange={(v) => updateCollisionBox(active.id, i, { h: v })} /> </div> <Button type="button" variant="ghost" size="icon-sm" title="Delete box" className="shrink-0 text-muted-foreground hover:text-destructive" onClick={() => removeCollisionBox(active.id, i)} > <X /> </Button> </li> ))} </ul> </div>
{/* animation */} <div className="flex flex-col gap-1.5"> <div className="flex items-center justify-between text-xs font-medium tracking-wide text-muted-foreground uppercase"> <span>Animation</span> <Button type="button" size="xs" variant={objectTool === 'frame' ? 'default' : 'outline'} onClick={() => setObjectTool(objectTool === 'frame' ? 'region' : 'frame')} > {objectTool === 'frame' ? 'Placing…' : '+ Add frame'} </Button> </div> {active.frames.length === 0 ? ( <p className="text-xs text-muted-foreground"> Static. Click “Add frame”, then click a same-size region in the tileset. </p> ) : ( <> <div className="flex items-end gap-2.5"> <AnimatedPreview image={image} grid={grid} object={active} /> <label className="flex w-[70px] flex-col gap-0.5"> <span className="text-[10px] text-muted-foreground">Frame ms</span> <Input type="number" className="h-7 text-sm" min={16} step={10} value={active.frameDurationMs} onChange={(e) => { const n = Math.round(Number(e.target.value)) if (!Number.isNaN(n)) setFrameDuration(active.id, n) }} /> </label> </div> <div className="flex flex-col gap-0.5"> <span className="text-[10px] text-muted-foreground">Playback</span> <div className="flex gap-2"> <Button type="button" size="xs" variant={objectAnimationModeOf(active.properties) === 'loop' ? 'default' : 'outline'} className="flex-1" onClick={() => setObjectPlayMode(active.id, 'loop')} title="Cycle the frames 0→n, then jump back to 0" > Loop </Button> <Button type="button" size="xs" variant={objectAnimationModeOf(active.properties) === 'pingpong' ? 'default' : 'outline'} className="flex-1" onClick={() => setObjectPlayMode(active.id, 'pingpong')} title="Play the frames forward, then backward" > Ping-pong </Button> </div> </div> <ul className="flex list-none flex-wrap gap-1.5 p-0"> <li className="relative flex flex-col items-center gap-0.5"> <ObjectThumb image={image} grid={grid} region={active} /> <span className="text-[11px] text-muted-foreground">1 · base</span> </li> {active.frames.map((f, i) => ( <li key={i} className="relative flex flex-col items-center gap-0.5"> <ObjectThumb image={image} grid={grid} region={{ col: f.col, row: f.row, cols: active.cols, rows: active.rows }} /> <span className="text-[11px] text-muted-foreground"> {i + 2} · @{f.col},{f.row} </span> <Button type="button" variant="ghost" size="icon-xs" title="Delete frame" className="absolute -top-1.5 -right-1.5 rounded border border-border bg-card text-muted-foreground hover:text-destructive" onClick={() => removeFrame(active.id, i)} > <X /> </Button> </li> ))} </ul> </> )} </div>
{/* custom properties */} <PropertyList label="Properties" items={active.properties} onAdd={() => addProperty(active.id)} onUpdate={(i, patch) => updateProperty(active.id, i, patch)} onRemove={(i) => removeProperty(active.id, i)} /> </div> ) : ( <p className="text-xs text-muted-foreground">Select an object to edit it.</p> )} </CollapsibleSection> </> )}