Something went wrong. Try again.
An atproto Tilemap editor
Something went wrong. Try again.
46 kB · 1178 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179import { useCallback, useEffect, useMemo, useRef, useState } from 'react'import { computeTransitions, ERASER_ID, isTransition, SECTIONS, tileKey, useEditorStore, VOID_ID, type EditMode, type ImageSource, type Quad, type Section,} from '../store/editorStore'import { sha256Hex } from '@/lib/hash'import { rememberSuppliedImage } from '../store/loadedTilesetsStore'import MaterialPalette from './MaterialPalette'import MaterialProperties from './MaterialProperties'import ObjectPanel from './ObjectPanel'import TilePanel from './TilePanel'import Library from './Library'import TilesetMeta from './TilesetMeta'import ToolsHeader from './ToolsHeader'import DocumentActions from './DocumentActions'import { useUiStore } from '../store/uiStore'import { Alert, AlertAction, AlertDescription, AlertTitle } from '@/components/ui/alert'import { Button } from '@/components/ui/button'import { Input } from '@/components/ui/input'import { Label } from '@/components/ui/label'import { Checkbox } from '@/components/ui/checkbox'import { Slider } from '@/components/ui/slider'import { ScrollArea } from '@/components/ui/scroll-area'import { Separator } from '@/components/ui/separator'import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group'import { cn } from '@/lib/utils'
const MIN_ZOOM = 0.1const MAX_ZOOM = 32
function clamp(z: number) { return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z))}
/** Black or white, whichever contrasts the given #rrggbb fill — used to outline * painted sections so they stay visible even against a matching ground colour. */function contrastInk(hex: string): string { const h = hex.replace('#', '') const r = parseInt(h.slice(0, 2), 16) const g = parseInt(h.slice(2, 4), 16) const b = parseInt(h.slice(4, 6), 16) // perceptual luminance; light fills get a dark outline and vice-versa return 0.299 * r + 0.587 * g + 0.114 * b > 140 ? '#000000' : '#ffffff'}
export default function TilesetEditor() { // ---- shared state (store) ---- const sidebarView = useUiStore((s) => s.sidebarView) const tilesetId = useEditorStore((s) => s.id) const tilesetName = useEditorStore((s) => s.tilesetName) const setTilesetName = useEditorStore((s) => s.setTilesetName) const image = useEditorStore((s) => s.image) const imageName = useEditorStore((s) => s.imageName) const setImage = useEditorStore((s) => s.setImage) const imageSource = useEditorStore((s) => s.imageSource) const setImageSource = useEditorStore((s) => s.setImageSource) const grid = useEditorStore((s) => s.grid) const setGrid = useEditorStore((s) => s.setGrid) const mode = useEditorStore((s) => s.mode) const setMode = useEditorStore((s) => s.setMode) const materials = useEditorStore((s) => s.materials) const activeMaterialId = useEditorStore((s) => s.activeMaterialId) const sectionAssignments = useEditorStore((s) => s.sectionAssignments) const assignSection = useEditorStore((s) => s.assignSection) 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 setSelection = useEditorStore((s) => s.setSelection) const setObjectOrigin = useEditorStore((s) => s.setObjectOrigin) const addCollisionBox = useEditorStore((s) => s.addCollisionBox) const addFrame = useEditorStore((s) => s.addFrame) const activeTileKey = useEditorStore((s) => s.activeTileKey) const setActiveTile = useEditorStore((s) => s.setActiveTile)
const activeObject = objects.find((o) => o.id === activeObjectId) ?? null
const { tileWidth, tileHeight, offsetX, offsetY } = grid
const isMaterialsMode = mode === 'materials' const isObjectMode = mode === 'object' const isTileMode = mode === 'tile' // the pointer handlers act on the active object's region/origin/frame tooling. const activeRegion = activeObject const regionTool = objectTool
// ---- local view state ---- const [zoom, setZoom] = useState(2) const [showGrid, setShowGrid] = useState(true) const [gridColor, setGridColor] = useState('#00e5ff') const [showMaterials, setShowMaterials] = useState(true) // canvas backdrop behind the tileset sheet (white by default; was a checkerboard) const [bgColor, setBgColor] = useState('#ffffff') const [pendingBox, setPendingBox] = useState<{ x: number y: number w: number h: number } | null>(null) // hovered top-left tile while placing an animation frame (object 'frame' tool) const [framePreview, setFramePreview] = useState<{ col: number; row: number } | null>(null)
const canvasRef = useRef<HTMLCanvasElement | null>(null) const viewportRef = useRef<HTMLDivElement | null>(null) const objectUrlRef = useRef<string | null>(null) const zoomRef = useRef(zoom) useEffect(() => { zoomRef.current = zoom }, [zoom])
// 8×8 checkerboard used to render VOID_ID anchors/quads in the section overlay const checkerCanvas = useMemo(() => { const c = document.createElement('canvas') c.width = 8 c.height = 8 const cx = c.getContext('2d') if (!cx) return null cx.fillStyle = '#cfcfcf' cx.fillRect(0, 0, 8, 8) cx.fillStyle = '#8a8a8a' cx.fillRect(0, 0, 4, 4) cx.fillRect(4, 4, 4, 4) return c }, [])
// ---- image loading ------------------------------------------------------- // SHA-256 of the sheet file the user loaded this session, tagged with the // tileset it belongs to so a stale hash is ignored after switching tilesets. // Used to stamp an external sheet's expected checksum and verify supplied files. const [sheetSha, setSheetSha] = useState<{ id: string; sha: string } | null>(null) const currentSha = sheetSha && sheetSha.id === tilesetId ? sheetSha.sha : null
const loadFile = useCallback( (file: File) => { if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current) const url = URL.createObjectURL(file) objectUrlRef.current = url const img = new Image() img.onload = () => { setImage(img, file.name) // keep the supplied sheet by tileset id so it survives switching away // (external sheets carry no bytes — see loadedTilesetsStore cache) rememberSuppliedImage(useEditorStore.getState().id, img) viewportRef.current?.scrollTo(0, 0) } img.src = url void sha256Hex(file).then((sha) => setSheetSha({ id: useEditorStore.getState().id, sha }), ) }, [setImage], )
useEffect(() => { return () => { if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current) } }, [])
// ---- embedded vs external sheet ------------------------------------------ const isExternal = imageSource.kind === 'external' // an external sheet whose bytes haven't been supplied this session const needsExternalFile = isExternal && !image const expectedSha = imageSource.kind === 'external' ? imageSource.sha256 : undefined // null = nothing to check; true/false = matched/mismatched the expected hash const shaVerified = expectedSha != null && currentSha != null ? expectedSha === currentSha : null
const switchImageMode = useCallback( (kind: ImageSource['kind']) => { if (kind === 'external') { const prev = useEditorStore.getState().imageSource const ext = prev.kind === 'external' ? prev : null setImageSource({ kind: 'external', width: image?.naturalWidth ?? ext?.width ?? 0, height: image?.naturalHeight ?? ext?.height ?? 0, // stamp the checksum from the file loaded this session, if any sha256: currentSha ?? ext?.sha256, source: ext?.source, author: ext?.author, license: ext?.license, }) } else { setImageSource({ kind: 'embedded' }) } }, [image, currentSha, setImageSource], )
const patchExternal = useCallback( (patch: Partial<Extract<ImageSource, { kind: 'external' }>>) => { const prev = useEditorStore.getState().imageSource if (prev.kind !== 'external') return setImageSource({ ...prev, ...patch }) }, [setImageSource], )
const cols = image && tileWidth > 0 ? Math.ceil((image.naturalWidth - offsetX) / tileWidth) : 0 const rows = image && tileHeight > 0 ? Math.ceil((image.naturalHeight - offsetY) / tileHeight) : 0
// ---- drawing ------------------------------------------------------------- // Only the visible viewport is rendered, so the backing canvas stays small // regardless of image size or zoom (no giant-canvas / blank-render limits). const draw = useCallback(() => { const canvas = canvasRef.current const viewport = viewportRef.current if (!canvas || !viewport) return const ctx = canvas.getContext('2d') if (!ctx) return
const dpr = window.devicePixelRatio || 1 const vw = viewport.clientWidth const vh = viewport.clientHeight
if (canvas.width !== Math.round(vw * dpr) || canvas.height !== Math.round(vh * dpr)) { canvas.width = Math.round(vw * dpr) canvas.height = Math.round(vh * dpr) } canvas.style.width = `${vw}px` canvas.style.height = `${vh}px`
const sx = viewport.scrollLeft const sy = viewport.scrollTop // keep the canvas pinned over the visible region as the sizer scrolls canvas.style.transform = `translate(${sx}px, ${sy}px)`
ctx.setTransform(dpr, 0, 0, dpr, 0, 0) ctx.clearRect(0, 0, vw, vh) if (!image) return
ctx.imageSmoothingEnabled = false ctx.drawImage( image, -sx, -sy, image.naturalWidth * zoom, image.naturalHeight * zoom, )
// ---- wang section overlay (3×3 grid: corners + edges + center) ---- if (showMaterials && isMaterialsMode && materials.length > 0 && tileWidth > 0 && tileHeight > 0) { const colorById = new Map(materials.map((m) => [m.id, m.color])) const inkById = new Map(materials.map((m) => [m.id, contrastInk(m.color)])) const tw = tileWidth * zoom const th = tileHeight * zoom const cw = tw / 3 const ch = th / 3 const onScreen = (x: number, y: number) => !(x > vw || y > vh || x + tw < 0 || y + th < 0) const voidFill = checkerCanvas ? ctx.createPattern(checkerCanvas, 'repeat') : null
// fill one quad — a material colour, the void checker, or nothing const fillQuad = (mid: Quad, qx: number, qy: number, qw: number, qh: number) => { if (mid === undefined) return if (mid === VOID_ID) { if (!voidFill) return ctx.fillStyle = voidFill } else { const color = colorById.get(mid) if (!color) return ctx.fillStyle = color } ctx.fillRect(qx, qy, qw, qh) }
// pass 1: translucent fills — uniform sections solid, transitions in quarters. // Edge/center transitions are *computed* from the four painted corners. ctx.globalAlpha = 0.6 for (const [key, raw] of Object.entries(sectionAssignments)) { const comma = key.indexOf(',') const col = Number(key.slice(0, comma)) const row = Number(key.slice(comma + 1)) const x = (offsetX + col * tileWidth) * zoom - sx const y = (offsetY + row * tileHeight) * zoom - sy if (!onScreen(x, y)) continue const resolved = computeTransitions(raw) SECTIONS.forEach((sec, idx) => { const v = resolved[sec] if (v === undefined) return const px = x + (idx % 3) * cw const py = y + Math.floor(idx / 3) * ch if (isTransition(v)) { const [qtl, qtr, qbl, qbr] = v.corners fillQuad(qtl, px, py, cw / 2, ch / 2) fillQuad(qtr, px + cw / 2, py, cw / 2, ch / 2) fillQuad(qbl, px, py + ch / 2, cw / 2, ch / 2) fillQuad(qbr, px + cw / 2, py + ch / 2, cw / 2, ch / 2) } else { fillQuad(v, px, py, cw, ch) } }) }
// pass 2: edges only at tile borders and transition borders. Walk the 6×6 // sub-subgrid of quadrant materials and stroke a segment wherever two // adjacent quads differ — uniform interiors stay line-free. ctx.globalAlpha = 1 ctx.lineWidth = 1 const qw = tw / 6 const qh = th / 6 for (const [key, raw] of Object.entries(sectionAssignments)) { const comma = key.indexOf(',') const col = Number(key.slice(0, comma)) const row = Number(key.slice(comma + 1)) const x = (offsetX + col * tileWidth) * zoom - sx const y = (offsetY + row * tileHeight) * zoom - sy if (!onScreen(x, y)) continue const resolved = computeTransitions(raw) // material of quad (gx,gy) within this tile, or undefined outside it const qMat = (gx: number, gy: number): Quad => { if (gx < 0 || gy < 0 || gx > 5 || gy > 5) return undefined const v = resolved[SECTIONS[Math.floor(gy / 2) * 3 + Math.floor(gx / 2)]] if (v === undefined) return undefined return isTransition(v) ? v.corners[(gy % 2) * 2 + (gx % 2)] : v } for (let gy = 0; gy < 6; gy++) { for (let gx = 0; gx < 6; gx++) { const m = qMat(gx, gy) if (m === undefined) continue const px = x + gx * qw const py = y + gy * qh ctx.strokeStyle = m === VOID_ID ? '#888888' : inkById.get(m) ?? '#000000' ctx.beginPath() if (qMat(gx, gy - 1) !== m) { ctx.moveTo(px, py); ctx.lineTo(px + qw, py) } if (qMat(gx, gy + 1) !== m) { ctx.moveTo(px, py + qh); ctx.lineTo(px + qw, py + qh) } if (qMat(gx - 1, gy) !== m) { ctx.moveTo(px, py); ctx.lineTo(px, py + qh) } if (qMat(gx + 1, gy) !== m) { ctx.moveTo(px + qw, py); ctx.lineTo(px + qw, py + qh) } ctx.stroke() } } } ctx.globalAlpha = 1 }
// ---- faint 3×3 section sub-grid (wang mode), to show paint targets ---- if (showGrid && isMaterialsMode && tileWidth > 0 && tileHeight > 0) { const stepX = tileWidth * zoom const stepY = tileHeight * zoom const baseX = (((offsetX % tileWidth) + tileWidth) % tileWidth) * zoom const baseY = (((offsetY % tileHeight) + tileHeight) % tileHeight) * zoom ctx.strokeStyle = gridColor ctx.lineWidth = 1 ctx.globalAlpha = 0.25 ctx.beginPath() for (let k = 1; k <= 2; k++) { const ox = baseX + (k / 3) * stepX for (let cx = ox + Math.ceil((sx - ox) / stepX) * stepX; cx - sx <= vw; cx += stepX) { const px = Math.round(cx - sx) + 0.5 ctx.moveTo(px, 0) ctx.lineTo(px, vh) } const oy = baseY + (k / 3) * stepY for (let cy = oy + Math.ceil((sy - oy) / stepY) * stepY; cy - sy <= vh; cy += stepY) { const py = Math.round(cy - sy) + 0.5 ctx.moveTo(0, py) ctx.lineTo(vw, py) } } ctx.stroke() ctx.globalAlpha = 1 }
// ---- grid lines (on top so they stay visible over overlays) ---- if (showGrid && tileWidth > 0 && tileHeight > 0) { ctx.strokeStyle = gridColor ctx.lineWidth = 1 ctx.globalAlpha = 0.85
const stepX = tileWidth * zoom const stepY = tileHeight * zoom const originX = (((offsetX % tileWidth) + tileWidth) % tileWidth) * zoom const originY = (((offsetY % tileHeight) + tileHeight) % tileHeight) * zoom const firstX = originX + Math.ceil((sx - originX) / stepX) * stepX const firstY = originY + Math.ceil((sy - originY) / stepY) * stepY
ctx.beginPath() for (let cx = firstX; cx - sx <= vw; cx += stepX) { const px = Math.round(cx - sx) + 0.5 ctx.moveTo(px, 0) ctx.lineTo(px, vh) } for (let cy = firstY; cy - sy <= vh; cy += stepY) { const py = Math.round(cy - sy) + 0.5 ctx.moveTo(0, py) ctx.lineTo(vw, py) } ctx.stroke() ctx.globalAlpha = 1 }
// ---- object regions + live selection (object mode) ---- if (isObjectMode) { const regionRect = (r: { col: number row: number cols: number rows: number }) => { const rx = (offsetX + r.col * tileWidth) * zoom - sx const ry = (offsetY + r.row * tileHeight) * zoom - sy return [rx, ry, r.cols * tileWidth * zoom, r.rows * tileHeight * zoom] as const }
ctx.lineWidth = 2 ctx.font = '12px system-ui, sans-serif' ctx.textBaseline = 'top' for (const o of objects) { const [rx, ry, rw, rh] = regionRect(o) if (rx > vw || ry > vh || rx + rw < 0 || ry + rh < 0) continue const active = o.id === activeObjectId ctx.strokeStyle = active ? '#ffd54f' : 'rgba(255, 213, 79, 0.45)' ctx.strokeRect(rx + 1, ry + 1, rw - 2, rh - 2) // name label const tw2 = ctx.measureText(o.name).width ctx.globalAlpha = active ? 0.95 : 0.7 ctx.fillStyle = active ? '#ffd54f' : 'rgba(255, 213, 79, 0.55)' ctx.fillRect(rx + 1, ry + 1, tw2 + 8, 16) ctx.fillStyle = '#1b1d23' ctx.globalAlpha = 1 ctx.fillText(o.name, rx + 5, ry + 3) }
if (selection && objectTool === 'region') { const [rx, ry, rw, rh] = regionRect(selection) ctx.fillStyle = 'rgba(0, 229, 255, 0.18)' ctx.fillRect(rx, ry, rw, rh) ctx.strokeStyle = '#00e5ff' ctx.lineWidth = 2 ctx.setLineDash([6, 4]) ctx.strokeRect(rx + 1, ry + 1, rw - 2, rh - 2) ctx.setLineDash([]) }
// ---- active object: origin marker + collision boxes ---- if (activeObject) { const objX = (offsetX + activeObject.col * tileWidth) * zoom - sx const objY = (offsetY + activeObject.row * tileHeight) * zoom - sy const objH = activeObject.rows * tileHeight const frameW = activeObject.cols * tileWidth * zoom const frameH = activeObject.rows * tileHeight * zoom
// additional animation frames (frame 0 is the yellow object outline) ctx.font = '12px system-ui, sans-serif' ctx.textBaseline = 'top' activeObject.frames.forEach((f, i) => { const fx = (offsetX + f.col * tileWidth) * zoom - sx const fy = (offsetY + f.row * tileHeight) * zoom - sy ctx.strokeStyle = '#40c4ff' ctx.lineWidth = 2 ctx.strokeRect(fx + 1, fy + 1, frameW - 2, frameH - 2) const label = String(i + 2) ctx.fillStyle = '#40c4ff' ctx.fillRect(fx + 1, fy + 1, ctx.measureText(label).width + 8, 16) ctx.fillStyle = '#08222a' ctx.fillText(label, fx + 5, fy + 3) })
// frame-placement preview if (objectTool === 'frame' && framePreview) { const fx = (offsetX + framePreview.col * tileWidth) * zoom - sx const fy = (offsetY + framePreview.row * tileHeight) * zoom - sy ctx.fillStyle = 'rgba(64, 196, 255, 0.18)' ctx.fillRect(fx, fy, frameW, frameH) ctx.strokeStyle = '#40c4ff' ctx.lineWidth = 2 ctx.setLineDash([6, 4]) ctx.strokeRect(fx + 1, fy + 1, frameW - 2, frameH - 2) ctx.setLineDash([]) }
// collision boxes (committed) + the in-progress one ctx.lineWidth = 2 const boxes = pendingBox ? [...activeObject.collision, pendingBox] : activeObject.collision boxes.forEach((b, i) => { const bx = objX + b.x * zoom const by = objY + b.y * zoom const bw = b.w * zoom const bh = b.h * zoom const live = pendingBox && i === boxes.length - 1 ctx.fillStyle = 'rgba(255, 82, 82, 0.2)' ctx.fillRect(bx, by, bw, bh) ctx.strokeStyle = '#ff5252' if (live) ctx.setLineDash([5, 4]) ctx.strokeRect(bx + 1, by + 1, Math.max(0, bw - 2), Math.max(0, bh - 2)) if (live) ctx.setLineDash([]) })
// origin (stored from bottom-left -> convert to top-left screen) const oxs = objX + activeObject.origin.x * zoom const oys = objY + (objH - activeObject.origin.y) * zoom ctx.strokeStyle = '#ff2d9b' ctx.lineWidth = 1.5 ctx.beginPath() ctx.moveTo(oxs - 7, oys) ctx.lineTo(oxs + 7, oys) ctx.moveTo(oxs, oys - 7) ctx.lineTo(oxs, oys + 7) ctx.stroke() ctx.fillStyle = '#ff2d9b' ctx.beginPath() ctx.arc(oxs, oys, 3, 0, Math.PI * 2) ctx.fill() } }
// ---- selected tile highlight (tile mode) ---- if (isTileMode && activeTileKey) { const comma = activeTileKey.indexOf(',') const col = Number(activeTileKey.slice(0, comma)) const row = Number(activeTileKey.slice(comma + 1)) const rx = (offsetX + col * tileWidth) * zoom - sx const ry = (offsetY + row * tileHeight) * zoom - sy const rw = tileWidth * zoom const rh = tileHeight * zoom ctx.fillStyle = 'rgba(0, 229, 255, 0.18)' ctx.fillRect(rx, ry, rw, rh) ctx.strokeStyle = '#00e5ff' ctx.lineWidth = 2 ctx.strokeRect(rx + 1, ry + 1, rw - 2, rh - 2) } }, [ image, zoom, tileWidth, tileHeight, offsetX, offsetY, showGrid, gridColor, showMaterials, isMaterialsMode, isObjectMode, isTileMode, activeTileKey, materials, checkerCanvas, sectionAssignments, objects, selection, activeObjectId, activeObject, objectTool, pendingBox, framePreview, ])
useEffect(() => { draw() }, [draw])
// redraw on scroll (panning) and viewport resize useEffect(() => { const viewport = viewportRef.current if (!viewport) return let raf = 0 const onScroll = () => { cancelAnimationFrame(raf) raf = requestAnimationFrame(draw) } viewport.addEventListener('scroll', onScroll, { passive: true }) const ro = new ResizeObserver(() => draw()) ro.observe(viewport) return () => { viewport.removeEventListener('scroll', onScroll) ro.disconnect() cancelAnimationFrame(raf) } }, [draw])
/** Set zoom while keeping the content point under (clientX, clientY) fixed. */ const zoomAt = useCallback((newZoom: number, clientX: number, clientY: number) => { const viewport = viewportRef.current if (!viewport) return const oldZoom = zoomRef.current const target = clamp(newZoom) if (target === oldZoom) return
const rect = viewport.getBoundingClientRect() const px = clientX - rect.left const py = clientY - rect.top const imgX = (viewport.scrollLeft + px) / oldZoom const imgY = (viewport.scrollTop + py) / oldZoom
setZoom(target) requestAnimationFrame(() => { viewport.scrollLeft = imgX * target - px viewport.scrollTop = imgY * target - py }) }, [])
// ---- wheel zoom (Ctrl/Cmd + wheel, or trackpad pinch) -------------------- useEffect(() => { const viewport = viewportRef.current if (!viewport) return const onWheel = (e: WheelEvent) => { if (!(e.ctrlKey || e.metaKey)) return // plain scroll pans natively e.preventDefault() const factor = Math.exp(-e.deltaY * 0.01) zoomAt(zoomRef.current * factor, e.clientX, e.clientY) } viewport.addEventListener('wheel', onWheel, { passive: false }) return () => viewport.removeEventListener('wheel', onWheel) }, [zoomAt])
const zoomStep = (factor: number) => { const vp = viewportRef.current if (!vp) { setZoom((z) => clamp(z * factor)) return } const rect = vp.getBoundingClientRect() zoomAt(zoomRef.current * factor, rect.left + rect.width / 2, rect.top + rect.height / 2) }
// ---- wang section painting (3×3 grid) ------------------------------------ /** The tile + 3×3 section under the cursor (with its current material), or * null when off-image. */ const clientToSection = (clientX: number, clientY: number) => { const vp = viewportRef.current if (!vp || tileWidth <= 0 || tileHeight <= 0) return null const rect = vp.getBoundingClientRect() const ix = (vp.scrollLeft + clientX - rect.left) / zoom const iy = (vp.scrollTop + clientY - rect.top) / zoom const gx = ix - offsetX const gy = iy - offsetY if (gx < 0 || gy < 0) return null
const col = Math.floor(gx / tileWidth) const row = Math.floor(gy / tileHeight) if (col < 0 || row < 0 || col >= cols || row >= rows) return null
const lx = gx - col * tileWidth const ly = gy - row * tileHeight const scol = Math.min(2, Math.max(0, Math.floor((lx / tileWidth) * 3))) const srow = Math.min(2, Math.max(0, Math.floor((ly / tileHeight) * 3))) const section = SECTIONS[srow * 3 + scol] const current = sectionAssignments[tileKey(col, row)]?.[section] return { col, row, section, current } }
// ---- object selection (drag a rectangle of tiles) ------------------------ // 'region' = drag a tile rectangle to select; 'box' = drag a pixel collision box const dragKindRef = useRef<'region' | 'box' | null>(null) const dragStartRef = useRef<{ col: number; row: number } | null>(null) const boxStartRef = useRef<{ x: number; y: number } | null>(null) // mirror of pendingBox so endDrag commits the latest value without a stale closure const pendingBoxRef = useRef<{ x: number; y: number; w: number; h: number } | null>(null) // active materials drag-paint: the value to assign to each section the cursor // passes over (null = clear), and which sections this drag has already painted const paintRef = useRef<{ value: string | null } | null>(null) const paintedRef = useRef<Set<string>>(new Set())
/** Paint one section during a materials drag, skipping repeats within the drag. */ const paintSection = (s: { col: number; row: number; section: Section }, value: string | null) => { const id = `${s.col},${s.row},${s.section}` if (paintedRef.current.has(id)) return paintedRef.current.add(id) assignSection(s.col, s.row, s.section, value) }
const clientToTile = (clientX: number, clientY: number) => { const vp = viewportRef.current if (!vp || tileWidth <= 0 || tileHeight <= 0) return null const rect = vp.getBoundingClientRect() const ix = (vp.scrollLeft + clientX - rect.left) / zoom const iy = (vp.scrollTop + clientY - rect.top) / zoom const col = Math.max(0, Math.min(cols - 1, Math.floor((ix - offsetX) / tileWidth))) const row = Math.max(0, Math.min(rows - 1, Math.floor((iy - offsetY) / tileHeight))) return { col, row } }
/** Click position in the active object's local pixels (top-left origin), * clamped to it. */ const clientToRegionPx = (clientX: number, clientY: number) => { const vp = viewportRef.current if (!vp || !activeRegion) return null const rect = vp.getBoundingClientRect() const ix = (vp.scrollLeft + clientX - rect.left) / zoom const iy = (vp.scrollTop + clientY - rect.top) / zoom const w = activeRegion.cols * tileWidth const h = activeRegion.rows * tileHeight const ox = offsetX + activeRegion.col * tileWidth const oy = offsetY + activeRegion.row * tileHeight const x = Math.max(0, Math.min(w, Math.round(ix - ox))) const y = Math.max(0, Math.min(h, Math.round(iy - oy))) return { x, y, w, h } }
/** Top-left tile for a frame/variation placement, clamped so the region-sized * footprint stays in bounds. */ const clientToRegionCell = (clientX: number, clientY: number) => { const t = clientToTile(clientX, clientY) if (!t || !activeRegion) return null return { col: Math.max(0, Math.min(cols - activeRegion.cols, t.col)), row: Math.max(0, Math.min(rows - activeRegion.rows, t.row)), } }
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => { if (!image || e.button !== 0) return
// materials: paint the section under the cursor; a drag continues the action if (isMaterialsMode) { if (!activeMaterialId) return const s = clientToSection(e.clientX, e.clientY) if (!s) return e.preventDefault() const erase = activeMaterialId === ERASER_ID // the start cell fixes the action for the whole drag: erase, toggle-off // (cell already holds the active material), or paint const value = erase || s.current === activeMaterialId ? null : activeMaterialId paintRef.current = { value } paintedRef.current = new Set() paintSection(s, value) return }
// tiles: a single click selects the cell under the cursor for property editing if (isTileMode) { const t = clientToTile(e.clientX, e.clientY) if (!t) return e.preventDefault() setActiveTile(t.col, t.row) return }
if (!isObjectMode) return
// frame: click places the next animation frame at the hovered tile if (regionTool === 'frame' && activeObject) { const c = clientToRegionCell(e.clientX, e.clientY) if (!c) return e.preventDefault() addFrame(activeObject.id, c) return }
// origin: single click sets the anchor (no drag) if (regionTool === 'origin' && activeObject) { const p = clientToRegionPx(e.clientX, e.clientY) if (!p) return e.preventDefault() setObjectOrigin(activeObject.id, { x: p.x, y: p.h - p.y }) return }
// collision: drag a pixel-precise box on the active object (object mode only) if (objectTool === 'collision' && isObjectMode && activeObject) { const p = clientToRegionPx(e.clientX, e.clientY) if (!p) return e.preventDefault() dragKindRef.current = 'box' boxStartRef.current = { x: p.x, y: p.y } const b = { x: p.x, y: p.y, w: 0, h: 0 } pendingBoxRef.current = b setPendingBox(b) return }
// region: drag a tile rectangle (default / when no active object) const t = clientToTile(e.clientX, e.clientY) if (!t) return e.preventDefault() dragKindRef.current = 'region' dragStartRef.current = t setSelection({ col: t.col, row: t.row, cols: 1, rows: 1 }) }
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => { // materials drag-paint: paint every section the cursor passes over if (paintRef.current) { const s = clientToSection(e.clientX, e.clientY) if (s) paintSection(s, paintRef.current.value) return } // frame placement preview (no drag needed) if (regionTool === 'frame' && activeObject && !dragKindRef.current) { setFramePreview(clientToRegionCell(e.clientX, e.clientY)) return } if (dragKindRef.current === 'box') { const start = boxStartRef.current const p = clientToRegionPx(e.clientX, e.clientY) if (!start || !p) return const b = { x: Math.min(start.x, p.x), y: Math.min(start.y, p.y), w: Math.abs(p.x - start.x), h: Math.abs(p.y - start.y), } pendingBoxRef.current = b setPendingBox(b) return } if (dragKindRef.current === 'region') { const start = dragStartRef.current const t = clientToTile(e.clientX, e.clientY) if (!start || !t) return setSelection({ col: Math.min(start.col, t.col), row: Math.min(start.row, t.row), cols: Math.abs(t.col - start.col) + 1, rows: Math.abs(t.row - start.row) + 1, }) } }
const endDrag = () => { paintRef.current = null if (dragKindRef.current === 'box' && activeObject) { const b = pendingBoxRef.current if (b && b.w >= 1 && b.h >= 1) addCollisionBox(activeObject.id, b) setPendingBox(null) } pendingBoxRef.current = null dragKindRef.current = null dragStartRef.current = null boxStartRef.current = null }
const contentW = image ? image.naturalWidth * zoom : 0 const contentH = image ? image.naturalHeight * zoom : 0 const interacting = !!image && (isObjectMode || isTileMode || !!activeMaterialId)
return ( <div className="grid h-full w-full grid-cols-[280px_1fr_280px] grid-rows-1 overflow-hidden bg-background text-foreground"> <ScrollArea className="min-h-0 border-r border-border bg-card"> <aside className="flex w-70 flex-col gap-4 p-4"> {sidebarView === 'library' ? ( <Library /> ) : ( <> <ToolsHeader />
<Input className="h-8" value={tilesetName} placeholder="Untitled tileset" onChange={(e) => setTilesetName(e.target.value)} />
<Separator />
<section className="flex flex-col gap-2"> <Button asChild variant="outline" className="w-full cursor-pointer"> <label> {image ? 'Replace image…' : 'Import image…'} <input type="file" accept="image/*" className="hidden" onChange={(e) => { const f = e.target.files?.[0] if (f) loadFile(f) }} /> </label> </Button> {image && ( <p className="text-xs break-all text-muted-foreground"> {imageName} — {image.naturalWidth}×{image.naturalHeight}px </p> )}
{/* How the sheet is saved. Only meaningful once a sheet is referenced. */} {(image || isExternal) && ( <> <ToggleGroup type="single" value={imageSource.kind} onValueChange={(v) => v && switchImageMode(v as ImageSource['kind'])} className="w-full" > <ToggleGroupItem value="embedded" className="flex-1"> Embedded </ToggleGroupItem> <ToggleGroupItem value="external" className="flex-1"> External </ToggleGroupItem> </ToggleGroup> <p className="text-[0.7rem] text-muted-foreground"> {isExternal ? 'Referenced, not bundled — the image isn’t stored with the tileset, so whoever opens it supplies the file locally. Use this for art you can’t redistribute.' : 'Bundled — the image is saved inside the tileset and travels with it.'} </p> </> )}
{/* Prompt to supply the file when an external sheet's bytes are absent. */} {needsExternalFile && ( <Alert> <AlertTitle>Image needed</AlertTitle> <AlertDescription> This tileset references{' '} <span className="font-medium">{imageName || 'an image'}</span> {imageSource.kind === 'external' && imageSource.width > 0 && ` (${imageSource.width}×${imageSource.height}px)`}{' '} that isn’t bundled. Provide the file to view and edit it. {imageSource.kind === 'external' && imageSource.source && ( <> {' '} <a href={imageSource.source} target="_blank" rel="noreferrer"> Where to get it </a> . </> )} </AlertDescription> <AlertAction> <Button asChild size="sm" variant="outline" className="cursor-pointer"> <label> Provide… <input type="file" accept="image/*" className="hidden" onChange={(e) => { const f = e.target.files?.[0] if (f) loadFile(f) }} /> </label> </Button> </AlertAction> </Alert> )}
{/* Checksum verification once a file is supplied for an external sheet. */} {isExternal && image && shaVerified === true && ( <p className="text-[0.7rem] text-muted-foreground"> ✓ Checksum matches the expected image. </p> )} {isExternal && image && shaVerified === false && ( <p className="text-[0.7rem] text-destructive"> ⚠ This file doesn’t match the expected checksum — it may be the wrong image or a different version. </p> )}
{/* Attribution / provenance for an external sheet. */} {imageSource.kind === 'external' && ( <div className="flex flex-col gap-1.5"> <Input className="h-7 text-sm" placeholder="Source URL (where to obtain it)" value={imageSource.source ?? ''} onChange={(e) => patchExternal({ source: e.target.value || undefined })} /> <Input className="h-7 text-sm" placeholder="Author / attribution" value={imageSource.author ?? ''} onChange={(e) => patchExternal({ author: e.target.value || undefined })} /> <Input className="h-7 text-sm" placeholder="License / usage note" value={imageSource.license ?? ''} onChange={(e) => patchExternal({ license: e.target.value || undefined })} /> </div> )} </section>
<section className="flex flex-col gap-2"> <h2 className="text-xs font-medium tracking-wide text-muted-foreground uppercase"> Grid size </h2> <div className="flex gap-2"> <NumberField label="Tile W" value={tileWidth} min={1} onChange={(v) => setGrid({ tileWidth: v })} /> <NumberField label="Tile H" value={tileHeight} min={1} onChange={(v) => setGrid({ tileHeight: v })} /> </div> <div className="flex gap-2"> <NumberField label="Offset X" value={offsetX} min={0} onChange={(v) => setGrid({ offsetX: v })} /> <NumberField label="Offset Y" value={offsetY} min={0} onChange={(v) => setGrid({ offsetY: v })} /> </div> </section>
<Separator />
<TilesetMeta />
<Separator />
<section className="flex flex-col gap-2"> <h2 className="text-xs font-medium tracking-wide text-muted-foreground uppercase">View</h2> <div className="flex items-center gap-1.5"> <Button type="button" variant="outline" size="icon" onClick={() => zoomStep(1 / 1.25)}> − </Button> <span className="flex-1 rounded-lg border border-input px-2 py-1 text-center text-sm tabular-nums"> {Math.round(zoom * 100)}% </span> <Button type="button" variant="outline" size="icon" onClick={() => zoomStep(1.25)}> + </Button> <Button type="button" variant="outline" size="sm" onClick={() => zoomStep(1 / zoom)}> Reset </Button> </div> <Slider min={MIN_ZOOM} max={MAX_ZOOM} step={0.1} value={[zoom]} onValueChange={([v]) => zoomStep(v / zoom)} /> <div className="flex items-center gap-2"> <Checkbox id="show-grid" checked={showGrid} onCheckedChange={(c) => setShowGrid(c === true)} /> <Label htmlFor="show-grid" className="text-sm font-normal"> Show grid </Label> </div> <div className="flex items-center gap-2"> <Checkbox id="show-materials" checked={showMaterials} onCheckedChange={(c) => setShowMaterials(c === true)} /> <Label htmlFor="show-materials" className="text-sm font-normal"> Show materials </Label> </div> <label className="flex items-center gap-2 text-sm"> <input type="color" value={gridColor} className="size-7 shrink-0 cursor-pointer rounded border border-border bg-transparent p-0" onChange={(e) => setGridColor(e.target.value)} /> Grid color </label> <label className="flex items-center gap-2 text-sm"> <input type="color" value={bgColor} className="size-7 shrink-0 cursor-pointer rounded border border-border bg-transparent p-0" onChange={(e) => setBgColor(e.target.value)} /> Background </label> </section>
{image && ( <section className="flex flex-col gap-2"> <h2 className="text-xs font-medium tracking-wide text-muted-foreground uppercase"> Tiles </h2> <p className="text-sm text-muted-foreground"> {cols} × {rows} ={' '} <strong className="text-foreground">{cols * rows}</strong> tiles </p> </section> )}
<Separator /> <DocumentActions /> </> )} </aside> </ScrollArea>
<div className={cn('relative min-h-0 min-w-0 overflow-auto', interacting && 'cursor-crosshair')} style={{ backgroundColor: bgColor }} ref={viewportRef} onMouseDown={handleMouseDown} onMouseMove={handleMouseMove} onMouseUp={endDrag} onMouseLeave={() => { endDrag() setFramePreview(null) }} > <canvas ref={canvasRef} className="pointer-events-none absolute top-0 left-0 z-[1] block [image-rendering:pixelated]" /> {image ? ( <div className="shrink-0" style={{ width: contentW, height: contentH }} /> ) : ( <div className="sticky top-0 left-0 z-[2] flex h-full w-full flex-col items-center justify-center gap-1.5 text-muted-foreground"> <p>Import an image to begin.</p> <p className="text-xs">Scroll to pan · ⌘/Ctrl-scroll or pinch to zoom</p> </div> )} </div>
{/* right tools panel: the active mode's editor, scrolls independently */} {/* Right bar: mode tabs fixed on top, then independently-scrollable tool panes that share the remaining viewport height. */} <aside className="flex min-h-0 w-70 flex-col border-l border-border bg-card"> <div className="shrink-0 p-3"> <ToggleGroup type="single" value={mode} onValueChange={(v) => v && setMode(v as EditMode)} className="w-full" > <ToggleGroupItem value="materials" className="flex-1"> Materials </ToggleGroupItem> <ToggleGroupItem value="object" className="flex-1"> Objects </ToggleGroupItem> <ToggleGroupItem value="tile" className="flex-1"> Tiles </ToggleGroupItem> </ToggleGroup> </div>
{isObjectMode ? ( <ObjectPanel /> ) : isTileMode ? ( <TilePanel /> ) : ( <> <MaterialPalette /> <MaterialProperties /> </> )} </aside> </div> )}
function NumberField({ label, value, min, onChange,}: { label: string value: number min?: number onChange: (v: number) => void}) { return ( <label className="flex flex-1 flex-col gap-0.5"> <span className="text-[11px] text-muted-foreground">{label}</span> <Input type="number" className="h-7 text-sm" min={min} value={value} onChange={(e) => { const n = Number(e.target.value) if (Number.isNaN(n)) return onChange(min !== undefined ? Math.max(min, n) : n) }} /> </label> )}