Something went wrong. Try again.
An atproto Tilemap editor
Something went wrong. Try again.
20 kB · 554 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555import { useCallback, useEffect, useRef, useState } from 'react'import { Plus, X } from 'lucide-react'import { useWorldStore } from '../store/worldStore'import { listMaps, loadMapIntoEditor, subscribeMapLibrary, type MapIndexEntry,} from '../store/mapPersistence'import { getMapMinimap, invalidateMinimap, type MapMinimap } from '../lib/minimap'import { useUiStore } from '../store/uiStore'import Library from './Library'import ToolsHeader from './ToolsHeader'import DocumentActions from './DocumentActions'import { Button } from '@/components/ui/button'import { Input } from '@/components/ui/input'import { Label } from '@/components/ui/label'import { cn } from '@/lib/utils'
const MIN_ZOOM = 0.1const MAX_ZOOM = 4/** Base on-screen pixels per cell at zoom 1 (display-only, abstract cell units). */const CELL = 20/** Below this on-screen cell size, the world grid is hidden to avoid noise. */const MIN_GRID_PX = 4
function clamp(z: number) { return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z))}
const GRID_STROKE = '#495062'const SLOT_STROKE = 'rgba(73, 80, 98, 0.6)'const SELECT_STROKE = '#22d3ee'
export default function WorldEditor() { const name = useWorldStore((s) => s.name) const gridWidth = useWorldStore((s) => s.gridWidth) const gridHeight = useWorldStore((s) => s.gridHeight) const placements = useWorldStore((s) => s.placements) const selectedMapId = useWorldStore((s) => s.selectedMapId) const setGrid = useWorldStore((s) => s.setGrid) const addMap = useWorldStore((s) => s.addMap) const removeMap = useWorldStore((s) => s.removeMap) const moveMap = useWorldStore((s) => s.moveMap) const select = useWorldStore((s) => s.select)
const setEditorKind = useUiStore((s) => s.setEditorKind) const sidebarView = useUiStore((s) => s.sidebarView)
// ---- view state ---- const [zoom, setZoom] = useState(0.5) // pan = screen-pixel position of cell (0,0)'s top-left corner const [pan, setPan] = useState({ x: 120, y: 120 }) const [isPanning, setIsPanning] = useState(false)
const canvasRef = useRef<HTMLCanvasElement | null>(null) const viewportRef = useRef<HTMLDivElement | null>(null) const zoomRef = useRef(zoom) const panRef = useRef(pan) const panningRef = useRef<{ x: number; y: number } | null>(null) // active map move-drag: which map, and the pointer-to-grid offset at grab const dragRef = useRef<{ mapId: string; offGx: number; offGy: number } | null>(null)
// ---- minimaps (async, cached) ---- const minimapsRef = useRef(new Map<string, MapMinimap>()) const inflightRef = useRef(new Set<string>()) // bump to force a redraw when an async minimap (or the maps list) resolves const [, setTick] = useState(0) const redraw = () => setTick((t) => t + 1)
// ---- saved-map library (for the sidebar picker + placement labels) ---- const [mapEntries, setMapEntries] = useState<MapIndexEntry[]>([])
useEffect(() => { zoomRef.current = zoom panRef.current = pan })
// load the saved-map list; refresh + invalidate minimaps when it changes useEffect(() => { let alive = true const refresh = () => { listMaps().then((list) => { if (alive) setMapEntries(list) }) invalidateMinimap() minimapsRef.current.clear() redraw() } refresh() const unsub = subscribeMapLibrary(refresh) return () => { alive = false unsub() } }, [])
/** Ensure a minimap is loaded for a placed map; redraw when it arrives. */ const ensureMinimap = useCallback((mapId: string) => { if (minimapsRef.current.has(mapId) || inflightRef.current.has(mapId)) return inflightRef.current.add(mapId) getMapMinimap(mapId) .then((m) => { if (m) minimapsRef.current.set(mapId, m) }) .finally(() => { inflightRef.current.delete(mapId) redraw() }) }, [])
/** A placement's footprint in cells: the map's own extent if its minimap is * loaded, else the grid slot as a fallback. */ const footprint = useCallback( (mapId: string) => { const m = minimapsRef.current.get(mapId) return { cols: m?.cols ?? gridWidth, rows: m?.rows ?? gridHeight } }, [gridWidth, gridHeight], )
// ---- drawing ------------------------------------------------------------- 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`
ctx.setTransform(dpr, 0, 0, dpr, 0, 0) ctx.clearRect(0, 0, vw, vh)
const px = CELL * zoom ctx.imageSmoothingEnabled = false
// ---- placed maps (composited minimaps) ---- for (const p of placements) { ensureMinimap(p.mapId) const m = minimapsRef.current.get(p.mapId) const fp = footprint(p.mapId) const ox = pan.x + p.gx * gridWidth * px const oy = pan.y + p.gy * gridHeight * px const w = fp.cols * px const h = fp.rows * px if (ox > vw || oy > vh || ox + w < 0 || oy + h < 0) continue
if (m) { ctx.drawImage(m.bitmap, ox, oy, w, h) } else { // not loaded yet — faint placeholder block ctx.fillStyle = 'rgba(120, 130, 150, 0.15)' ctx.fillRect(ox, oy, w, h) }
// grid-slot outline (gridWidth × gridHeight), so over/under-sized maps read const slotW = gridWidth * px const slotH = gridHeight * px ctx.strokeStyle = SLOT_STROKE ctx.lineWidth = 1 ctx.strokeRect(ox + 0.5, oy + 0.5, slotW - 1, slotH - 1)
if (p.mapId === selectedMapId) { ctx.strokeStyle = SELECT_STROKE ctx.lineWidth = 2 ctx.strokeRect(ox + 1, oy + 1, w - 2, h - 2) } }
// ---- world grid (lines every gridWidth / gridHeight cells) ---- const stepX = gridWidth * px const stepY = gridHeight * px if (stepX >= MIN_GRID_PX && stepY >= MIN_GRID_PX) { ctx.strokeStyle = GRID_STROKE ctx.lineWidth = 1 ctx.beginPath() const g0 = Math.floor((0 - pan.x) / stepX) const g1 = Math.ceil((vw - pan.x) / stepX) for (let g = g0; g <= g1; g++) { const x = Math.round(pan.x + g * stepX) + 0.5 ctx.moveTo(x, 0) ctx.lineTo(x, vh) } const h0 = Math.floor((0 - pan.y) / stepY) const h1 = Math.ceil((vh - pan.y) / stepY) for (let g = h0; g <= h1; g++) { const y = Math.round(pan.y + g * stepY) + 0.5 ctx.moveTo(0, y) ctx.lineTo(vw, y) } ctx.stroke() } }, [zoom, pan, placements, selectedMapId, gridWidth, gridHeight, ensureMinimap, footprint])
useEffect(() => { draw() }, [draw])
// redraw on viewport resize useEffect(() => { const viewport = viewportRef.current if (!viewport) return const ro = new ResizeObserver(() => draw()) ro.observe(viewport) return () => ro.disconnect() }, [draw])
// ---- zoom (Ctrl/Cmd + wheel or trackpad pinch), centered on cursor ---- useEffect(() => { const viewport = viewportRef.current if (!viewport) return const onWheel = (e: WheelEvent) => { if (!(e.ctrlKey || e.metaKey)) return e.preventDefault() const rect = viewport.getBoundingClientRect() const mx = e.clientX - rect.left const my = e.clientY - rect.top const old = zoomRef.current const next = clamp(old * Math.exp(-e.deltaY * 0.01)) if (next === old) return const wx = (mx - panRef.current.x) / (CELL * old) const wy = (my - panRef.current.y) / (CELL * old) setZoom(next) setPan({ x: mx - wx * CELL * next, y: my - wy * CELL * next }) } viewport.addEventListener('wheel', onWheel, { passive: false }) return () => viewport.removeEventListener('wheel', onWheel) }, [])
/** Zoom by a factor, keeping the viewport center fixed. */ const zoomStep = (factor: number) => { const vp = viewportRef.current const old = zoomRef.current const next = clamp(old * factor) if (next === old) return if (!vp) { setZoom(next) return } const rect = vp.getBoundingClientRect() const mx = rect.width / 2 const my = rect.height / 2 const wx = (mx - panRef.current.x) / (CELL * old) const wy = (my - panRef.current.y) / (CELL * old) setZoom(next) setPan({ x: mx - wx * CELL * next, y: my - wy * CELL * next }) }
// ---- pointer handling ---------------------------------------------------- /** Float cell coordinates under the pointer (no flooring). */ const screenToCellFloat = (clientX: number, clientY: number) => { const vp = viewportRef.current if (!vp) return null const rect = vp.getBoundingClientRect() const px = CELL * zoomRef.current return { x: (clientX - rect.left - panRef.current.x) / px, y: (clientY - rect.top - panRef.current.y) / px, } }
/** Topmost placement whose footprint covers a cell point (last placed wins). */ const placementAt = (cx: number, cy: number) => { for (let i = placements.length - 1; i >= 0; i--) { const p = placements[i] const fp = footprint(p.mapId) const x0 = p.gx * gridWidth const y0 = p.gy * gridHeight if (cx >= x0 && cx < x0 + fp.cols && cy >= y0 && cy < y0 + fp.rows) return p } return null }
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => { const vp = viewportRef.current if (!vp) return // middle-mouse always pans if (e.button === 1) { e.preventDefault() panningRef.current = { x: e.clientX, y: e.clientY } setIsPanning(true) vp.setPointerCapture(e.pointerId) return } if (e.button !== 0) return
const f = screenToCellFloat(e.clientX, e.clientY) const hit = f ? placementAt(f.x, f.y) : null if (hit && f) { e.preventDefault() select(hit.mapId) // remember where in grid units the pointer grabbed, relative to the slot dragRef.current = { mapId: hit.mapId, offGx: f.x / gridWidth - hit.gx, offGy: f.y / gridHeight - hit.gy, } vp.setPointerCapture(e.pointerId) return } // empty space → pan + clear selection e.preventDefault() select(null) panningRef.current = { x: e.clientX, y: e.clientY } setIsPanning(true) vp.setPointerCapture(e.pointerId) }
const onPointerMove = (e: React.PointerEvent<HTMLDivElement>) => { if (panningRef.current) { const dx = e.clientX - panningRef.current.x const dy = e.clientY - panningRef.current.y panningRef.current = { x: e.clientX, y: e.clientY } setPan((p) => ({ x: p.x + dx, y: p.y + dy })) return } if (dragRef.current) { const f = screenToCellFloat(e.clientX, e.clientY) if (!f) return const d = dragRef.current const gx = Math.round(f.x / gridWidth - d.offGx) const gy = Math.round(f.y / gridHeight - d.offGy) moveMap(d.mapId, gx, gy) } }
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => { if (panningRef.current) { viewportRef.current?.releasePointerCapture(e.pointerId) panningRef.current = null setIsPanning(false) } if (dragRef.current) { viewportRef.current?.releasePointerCapture(e.pointerId) dragRef.current = null } }
const onDoubleClick = (e: React.MouseEvent<HTMLDivElement>) => { const f = screenToCellFloat(e.clientX, e.clientY) const hit = f ? placementAt(f.x, f.y) : null if (!hit) return e.preventDefault() // open the map in the Map editor (the load is a no-op stub until full map // persistence ships; switching the shell is the user-visible effect today) loadMapIntoEditor(hit.mapId) setEditorKind('map') }
// delete the selected placement with Backspace/Delete (ignores text inputs) useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key !== 'Backspace' && e.key !== 'Delete') return const el = document.activeElement as HTMLElement | null if (el && (el.tagName === 'INPUT' || el.tagName === 'TEXTAREA' || el.isContentEditable)) return if (selectedMapId) { e.preventDefault() removeMap(selectedMapId) } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [selectedMapId, removeMap])
// ---- sidebar helpers ---- const placedIds = new Set(placements.map((p) => p.mapId)) const addable = mapEntries.filter((e) => !placedIds.has(e.id)) const nameOf = (mapId: string) => mapEntries.find((e) => e.id === mapId)?.name || mapId
/** Drop a new map near the view center, snapped to the world grid. */ const addAtViewCenter = (mapId: string) => { const vp = viewportRef.current const px = CELL * zoomRef.current const cx = vp ? (vp.clientWidth / 2 - panRef.current.x) / px : 0 const cy = vp ? (vp.clientHeight / 2 - panRef.current.y) / px : 0 addMap(mapId, Math.round(cx / gridWidth), Math.round(cy / gridHeight)) }
return ( <div className="grid h-full w-full grid-cols-[240px_1fr] overflow-hidden bg-background text-foreground"> <aside className="flex min-h-0 w-60 flex-col border-r border-border bg-card"> {sidebarView === 'library' ? ( <div className="min-h-0 flex-1 overflow-y-auto p-3"> <Library /> </div> ) : ( <> <div className="shrink-0 p-3 pb-0"> <ToolsHeader /> </div>
<section className="flex shrink-0 flex-col gap-2 border-t border-border p-3"> <h2 className="text-xs font-medium tracking-wide text-muted-foreground uppercase"> World grid (cells) </h2> <div className="flex items-center gap-2"> <Label htmlFor="grid-w" className="w-4 text-sm font-normal"> W </Label> <Input id="grid-w" type="number" min={1} className="h-7 flex-1 text-sm" value={gridWidth} onChange={(e) => setGrid(Number(e.target.value), gridHeight)} /> <Label htmlFor="grid-h" className="w-4 text-sm font-normal"> H </Label> <Input id="grid-h" type="number" min={1} className="h-7 flex-1 text-sm" value={gridHeight} onChange={(e) => setGrid(gridWidth, Number(e.target.value))} /> </div> </section>
<section className="flex min-h-0 flex-1 flex-col gap-2 border-t border-border p-3"> <h2 className="text-xs font-medium tracking-wide text-muted-foreground uppercase"> Maps in world </h2> <ul className="flex list-none flex-col gap-1 overflow-y-auto p-0"> {placements.map((p) => ( <li key={p.mapId} className={cn( 'flex items-center gap-2 rounded-md border border-transparent px-1.5 py-1 hover:bg-accent', p.mapId === selectedMapId && 'border-ring bg-accent', )} > <button type="button" className="flex-1 truncate text-left text-sm" onClick={() => select(p.mapId)} title={`(${p.gx}, ${p.gy})`} > {nameOf(p.mapId)} <span className="ml-1 text-xs text-muted-foreground"> ({p.gx}, {p.gy}) </span> </button> <Button type="button" variant="ghost" size="icon-sm" title="Remove from world" className="shrink-0 text-muted-foreground hover:text-destructive" onClick={() => removeMap(p.mapId)} > <X /> </Button> </li> ))} {placements.length === 0 && ( <li className="text-xs text-muted-foreground">No maps placed yet.</li> )} </ul>
<h2 className="mt-2 text-xs font-medium tracking-wide text-muted-foreground uppercase"> Add map </h2> {addable.length > 0 ? ( <ul className="flex list-none flex-col gap-1 overflow-y-auto p-0"> {addable.map((e) => ( <li key={e.id}> <Button type="button" variant="outline" size="sm" className="w-full justify-start gap-2" onClick={() => addAtViewCenter(e.id)} > <Plus className="size-4" /> <span className="truncate">{e.name || 'Untitled map'}</span> </Button> </li> ))} </ul> ) : ( <p className="text-[0.7rem] text-muted-foreground"> {mapEntries.length === 0 ? 'Saved maps appear here once map persistence ships.' : 'All saved maps are placed.'} </p> )} </section>
<section className="flex shrink-0 flex-col gap-2 border-t border-border p-3"> <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={() => { setZoom(0.5) setPan({ x: 120, y: 120 }) }} > Reset </Button> </div> </section>
<div className="shrink-0 border-t border-border p-3"> <DocumentActions /> </div> </> )} </aside>
<div className={cn( 'relative touch-none overflow-hidden bg-background select-none', isPanning ? 'cursor-grabbing' : 'cursor-grab', )} ref={viewportRef} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onDoubleClick={onDoubleClick} > <canvas ref={canvasRef} className="absolute inset-0 block [image-rendering:pixelated]" /> {name && ( <div className="pointer-events-none absolute top-2 left-1/2 -translate-x-1/2 rounded-md bg-card/80 px-3 py-1 text-sm font-medium backdrop-blur"> {name} </div> )} </div> </div> )}