Something went wrong. Try again.
An atproto Tilemap editor
Something went wrong. Try again.
77 kB · 1989 lines
TSX
at main
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267126812691270127112721273127412751276127712781279128012811282128312841285128612871288128912901291129212931294129512961297129812991300130113021303130413051306130713081309131013111312131313141315131613171318131913201321132213231324132513261327132813291330133113321333133413351336133713381339134013411342134313441345134613471348134913501351135213531354135513561357135813591360136113621363136413651366136713681369137013711372137313741375137613771378137913801381138213831384138513861387138813891390139113921393139413951396139713981399140014011402140314041405140614071408140914101411141214131414141514161417141814191420142114221423142414251426142714281429143014311432143314341435143614371438143914401441144214431444144514461447144814491450145114521453145414551456145714581459146014611462146314641465146614671468146914701471147214731474147514761477147814791480148114821483148414851486148714881489149014911492149314941495149614971498149915001501150215031504150515061507150815091510151115121513151415151516151715181519152015211522152315241525152615271528152915301531153215331534153515361537153815391540154115421543154415451546154715481549155015511552155315541555155615571558155915601561156215631564156515661567156815691570157115721573157415751576157715781579158015811582158315841585158615871588158915901591159215931594159515961597159815991600160116021603160416051606160716081609161016111612161316141615161616171618161916201621162216231624162516261627162816291630163116321633163416351636163716381639164016411642164316441645164616471648164916501651165216531654165516561657165816591660166116621663166416651666166716681669167016711672167316741675167616771678167916801681168216831684168516861687168816891690169116921693169416951696169716981699170017011702170317041705170617071708170917101711171217131714171517161717171817191720172117221723172417251726172717281729173017311732173317341735173617371738173917401741174217431744174517461747174817491750175117521753175417551756175717581759176017611762176317641765176617671768176917701771177217731774177517761777177817791780178117821783178417851786178717881789179017911792179317941795179617971798179918001801180218031804180518061807180818091810181118121813181418151816181718181819182018211822182318241825182618271828182918301831183218331834183518361837183818391840184118421843184418451846184718481849185018511852185318541855185618571858185918601861186218631864186518661867186818691870187118721873187418751876187718781879188018811882188318841885188618871888188918901891189218931894189518961897189818991900190119021903190419051906190719081909191019111912191319141915191619171918191919201921192219231924192519261927192819291930193119321933193419351936193719381939194019411942194319441945194619471948194919501951195219531954195519561957195819591960196119621963196419651966196719681969197019711972197319741975197619771978197919801981198219831984198519861987198819891990import { useCallback, useEffect, useMemo, useRef, useState } from 'react'import { SECTIONS, type GridConfig, type MaterialAnimation, type TileObject, VOID_ID, animationMembership, isTransition, objectAnimationModeOf,} from '../store/editorStore'import { useLoadedTilesetsStore } from '../store/loadedTilesetsStore'import { bareMaterial, type Box, cellKey, cornerKey, type CornerDir, edgeKey, findNode, type GroupNode, materialTileset, isCanvas, isAreaNode, isCollisionNode, isLinkNode, isMarkerNode, type CanvasLayer, type CanvasNode, type MapTool, type MarkerNode, type NodeKind, type ObjectNode, PAINT_TOOLS, parseCell, type SceneNode, SUBCELL, tileKey, tileRefSource, tileRefValue, useMapStore, visibleAreas, visibleLinks, visibleMarkers, walkVisibleComposite,} from '../store/mapStore'import { objectAnimationFrameOf, objectAnimationOnceOf, objectAnimationPlayingOf, objectAnimationReverseOf,} from '../lexicon/map'import { buildWangIndex, displaySections, drawQuad, resolveDisplayTile, type QuadResult, type WangIndex,} from '../lib/wang'import { animFrameIndex, depthKeyOffset, type ObjTransformFields, resolveTransform, transformRect } from '../lib/objectTransform'import MapNodeTree from './MapNodeTree'import MapBackgroundPanel from './MapBackgroundPanel'import MapProperties from './MapProperties'import CanvasInspector from './CanvasInspector'import ObjectInspector from './ObjectInspector'import AreaInspector from './AreaInspector'import MarkerInspector from './MarkerInspector'import LinkInspector from './LinkInspector'import GroupInspector from './GroupInspector'import Library from './Library'import ToolsHeader from './ToolsHeader'import DocumentActions from './DocumentActions'import Sandbox from './Sandbox'import WindowPortal from './WindowPortal'import { Gamepad2 } from 'lucide-react'import { useUiStore } from '../store/uiStore'import { Button } from '@/components/ui/button'import { Checkbox } from '@/components/ui/checkbox'import { Input } from '@/components/ui/input'import { Label } from '@/components/ui/label'import { cn } from '@/lib/utils'
const MIN_ZOOM = 0.1const MAX_ZOOM = 8/** Below this on-screen cell size, the fine grid is hidden to avoid noise. */const MIN_CELL_PX = 6/** Pixel radius for grabbing a box corner resize handle. */const HANDLE = 8/** Missing-art signal: a config the tileset can't satisfy renders red. */const MISSING = '#ff3b3b'
const AREA_STROKE = '#22d3ee'const AREA_FILL = 'rgba(34, 211, 238, 0.15)'const COLLISION_STROKE = '#ef4444'const COLLISION_FILL = 'rgba(239, 68, 68, 0.18)'const MARKER_STROKE = '#a78bfa'const MARKER_FILL = 'rgba(167, 139, 250, 0.9)'/** Link edge overlay (editor-only; links are never exported as geometry). */const LINK_STROKE = '#34d399'/** On-screen pixel radius of a marker's ring (drawn fixed-size, not zoomed). */const MARKER_RADIUS = 7/** Dynamic-layer slice cuts: existing cuts (faint) and the live pick (bright). */const SLICE_STROKE = 'rgba(245, 158, 11, 0.55)'const SLICE_STROKE_ACTIVE = '#f59e0b'
/** Tool to enter when a node of each kind becomes active. */const PRIMARY_TOOL: Record<NodeKind | 'none', MapTool> = { canvas: 'pan', area: 'area', object: 'move', marker: 'move', link: 'pan', group: 'pan', none: 'pan',}
/** Tools valid for the selected node's kind (used to keep the active tool sane). */const TOOLS_BY_KIND: Record<NodeKind | 'none', MapTool[]> = { canvas: ['pan', 'tile', 'paint', 'fill', 'rect', 'circle', 'path', 'dot', 'erase', 'slice'], area: ['pan', 'area', 'erase'], object: ['move', 'rotate', 'scale', 'stamp', 'pan'], marker: ['move', 'pan'], link: ['pan'], group: ['pan'], none: ['pan'],}
type Corner = 'nw' | 'ne' | 'sw' | 'se'
/** Everything the renderer needs to draw one tileset's materials + objects: * its sheet, sampling grid, colour/animation/object lookups, and a prebuilt * Wang index. Keyed by tileset URI; a map draws a pass per referenced tileset. */interface TilesetRender { uri: string image: HTMLImageElement | null grid: GridConfig materialColor: Map<string, string> wangIndex: WangIndex animMembership: ReturnType<typeof animationMembership> animById: Map<string, MaterialAnimation> objMap: Map<string, TileObject> /** sheet + grid + Wang art are all present (so the art path can run). */ artReady: boolean}
function clamp(z: number) { return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z))}
const snap = (v: number, step: number) => (step > 0 ? Math.round(v / step) * step : v)
/** World-space footprint of a placed object: each tileset cell is scaled to one * map cell (mapTw×mapTh) regardless of the tileset's own tile size, with the * origin (feet anchor) pinned to (x,y). The origin — authored in tileset px — is * scaled by the same ratio. * * The object's own scale/rotation/flip then transform it about that anchor. * `local` is the untransformed footprint relative to the anchor (the rect to * hand `drawImage` under the matching ctx transform); `tlx/tly/wpx/hpx` is the * transformed world-space AABB (exact, since rotations are 90° multiples) for * culling, hit-testing, and the selection outline. */function objectFootprint( def: TileObject, o: { x: number; y: number } & ObjTransformFields, mapTw: number, mapTh: number, tsTw: number, tsTh: number,) { const wpx = def.cols * mapTw const hpx = def.rows * mapTh const ox = tsTw > 0 ? def.origin.x * (mapTw / tsTw) : def.origin.x const oy = tsTh > 0 ? def.origin.y * (mapTh / tsTh) : def.origin.y const local = { x: -ox, y: -(hpx - oy), w: wpx, h: hpx } const t = resolveTransform(o) const aabb = transformRect(local, t) return { local, t, tlx: o.x + aabb.x, tly: o.y + aabb.y, wpx: aabb.w, hpx: aabb.h, }}
function normBox(x0: number, y0: number, x1: number, y1: number): Box { return { x: Math.min(x0, x1), y: Math.min(y0, y1), w: Math.abs(x1 - x0), h: Math.abs(y1 - y0) }}
/** Endpoints (in cell units) of a grid edge key. */function edgeSegment(key: string): [number, number, number, number] { const orient = key[0] const [c, r] = parseCell(key.slice(2)) return orient === 'H' ? [c, r, c + 1, r] : [c, r, c, r + 1]}
export default function MapEditor() { const sidebarView = useUiStore((s) => s.sidebarView) const tool = useMapStore((s) => s.tool) const name = useMapStore((s) => s.name) const setName = useMapStore((s) => s.setName) const mapGrid = useMapStore((s) => s.grid) const setGrid = useMapStore((s) => s.setGrid) const depthAxis = useMapStore((s) => s.depthAxis) const setDepthAxis = useMapStore((s) => s.setDepthAxis) const background = useMapStore((s) => s.background) const showCells = useMapStore((s) => s.showCells) const setShowCells = useMapStore((s) => s.setShowCells) const showTextures = useMapStore((s) => s.showTextures) const setShowTextures = useMapStore((s) => s.setShowTextures) const showAnimations = useMapStore((s) => s.showAnimations) const setShowAnimations = useMapStore((s) => s.setShowAnimations) const showSlices = useMapStore((s) => s.showSlices) const nodes = useMapStore((s) => s.nodes) const activeNodeId = useMapStore((s) => s.activeNodeId) const activeLayerId = useMapStore((s) => s.activeLayerId) const fillMaterialId = useMapStore((s) => s.fillMaterialId) const strokeMaterialId = useMapStore((s) => s.strokeMaterialId) const paintCell = useMapStore((s) => s.paintCell) const fillArea = useMapStore((s) => s.fillArea) const paintRect = useMapStore((s) => s.paintRect) const paintCircle = useMapStore((s) => s.paintCircle) const paintEdgePath = useMapStore((s) => s.paintEdgePath) const setCornerDot = useMapStore((s) => s.setCornerDot) const tilePick = useMapStore((s) => s.tilePick) const areaSnapCells = useMapStore((s) => s.areaSnapCells) const stampTile = useMapStore((s) => s.stampTile) const eraseEdge = useMapStore((s) => s.eraseEdge) const eraseCorner = useMapStore((s) => s.eraseCorner) const setObjectPos = useMapStore((s) => s.setObjectPos) const setMarkerPos = useMapStore((s) => s.setMarkerPos) const stampObject = useMapStore((s) => s.stampObject) const setObjectScale = useMapStore((s) => s.setObjectScale) const setObjectRotation = useMapStore((s) => s.setObjectRotation) const setLayerSlices = useMapStore((s) => s.setLayerSlices) const slicePick = useMapStore((s) => s.slicePick) const setSlicePick = useMapStore((s) => s.setSlicePick) const setTool = useMapStore((s) => s.setTool) const setActiveNode = useMapStore((s) => s.setActiveNode) const removeNode = useMapStore((s) => s.removeNode) const addAreaBox = useMapStore((s) => s.addAreaBox) const updateAreaBox = useMapStore((s) => s.updateAreaBox) const removeAreaBox = useMapStore((s) => s.removeAreaBox)
// ---- loaded tilesets — the map draws materials/objects from each, resolving // every painted value against the tileset its scope names ---- const loadedTilesets = useLoadedTilesetsStore((s) => s.tilesets)
// Per-tileset render data keyed by URI (insertion order = the map's tileset // order). Rebuilt only when the loaded set changes. const tilesetRenders = useMemo(() => { const map = new Map<string, TilesetRender>() for (const t of loadedTilesets) { const wangIndex = buildWangIndex(t.sectionAssignments, t.tileProperties) map.set(t.uri, { uri: t.uri, image: t.image, grid: t.grid, materialColor: new Map(t.materials.map((m) => [m.id, m.color])), wangIndex, animMembership: animationMembership(t.materialAnimations), animById: new Map(t.materialAnimations.map((a) => [a.id, a])), objMap: new Map(t.objects.map((o) => [o.id, o])), artReady: t.image != null && t.grid.tileWidth > 0 && t.grid.tileHeight > 0 && wangIndex.whole.size > 0, }) } return map }, [loadedTilesets])
// the primary tileset (first), used where a single one is implied (tile backdrop). const primaryTileset = loadedTilesets[0] ? tilesetRenders.get(loadedTilesets[0].uri) ?? null : null
// resolve a placed object node to its def + the tileset it's drawn from. const objectDefOf = useCallback( (o: ObjectNode): { def: TileObject; tr: TilesetRender } | null => { const tr = tilesetRenders.get(o.tileset) const def = tr?.objMap.get(o.objectId) return def && tr ? { def, tr } : null }, [tilesetRenders], )
const activeNode = activeNodeId ? findNode(nodes, activeNodeId) : null const activeCanvas = activeNode && isCanvas(activeNode) ? activeNode : null const activeLayer: CanvasLayer | null = activeCanvas?.layers.find((l) => l.id === activeLayerId) ?? null const activeAreaId = activeNode?.kind === 'area' ? activeNode.id : null const activeKind: NodeKind | 'none' = activeNode?.kind ?? 'none'
const tw = mapGrid.tileWidth const th = mapGrid.tileHeight
// ---- animation playback (render-time material/object frame substitution) ---- // does any content actually animate? gates the rAF loop so it only runs when useful const hasAnimatedContent = useMemo( () => loadedTilesets.some( (t) => t.materialAnimations.length > 0 || t.objects.some((o) => o.frames.length > 0), ), [loadedTilesets], ) // elapsed ms on this renderer's wall clock; per-material lockstep frame timing const animTimeRef = useRef(0)
// ---- background backdrop (color / gradient / image / tile) --------------- // An image background decodes its data URL to an <img> for the renderer; a // tile background bakes the chosen tileset tile to a small offscreen canvas // (so it can repeat as a pattern). Both are cached by their inputs. const bgImageUrl = background?.kind === 'image' ? background.dataUrl : null const [bgImage, setBgImage] = useState<HTMLImageElement | null>(null) useEffect(() => { if (!bgImageUrl) { setBgImage(null) return } let cancelled = false const img = new Image() img.onload = () => { if (!cancelled) setBgImage(img) } img.src = bgImageUrl return () => { cancelled = true } }, [bgImageUrl]) const bgTileCanvas = useMemo(() => { if (background?.kind !== 'tile' || !primaryTileset?.image) return null const img = primaryTileset.image const { tileWidth: bw, tileHeight: bh, offsetX, offsetY } = primaryTileset.grid if (bw <= 0 || bh <= 0) return null const c = document.createElement('canvas') c.width = bw c.height = bh const cx = c.getContext('2d') if (!cx) return null cx.imageSmoothingEnabled = false cx.drawImage(img, offsetX + background.col * bw, offsetY + background.row * bh, bw, bh, 0, 0, bw, bh) return c }, [background, primaryTileset])
// title draft: edit freely, commit (trim/default) to the store on blur const [titleDraft, setTitleDraft] = useState(name) useEffect(() => setTitleDraft(name), [name])
// ---- sandbox window ---- // The sandbox runs in a chromeless popup, rendered there via a React portal so // it shares the live map/tileset stores (no cross-window serialization). The // window is opened here, in the click handler, so the popup blocker allows it. const [sandboxWin, setSandboxWin] = useState<Window | null>(null) const openSandbox = useCallback(() => { if (sandboxWin && !sandboxWin.closed) { sandboxWin.focus() return } const w = window.open('', 'tilemap-sandbox', 'popup,width=1024,height=768') if (w) { w.focus() setSandboxWin(w) } }, [sandboxWin])
// ---- view state ---- const [zoom, setZoom] = useState(1) // pan = screen-pixel position of world origin (0,0) const [pan, setPan] = useState({ x: 120, y: 120 }) // float WORLD-pixel position of the cursor const [hoverW, setHoverW] = useState<{ x: number; y: number } | null>(null)
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) const paintingRef = useRef(false) // start cell (active-layer coords) of an in-progress paint box (rect/circle) const boxStartRef = useRef<{ col: number; row: number } | null>(null) const [boxPreview, setBoxPreview] = useState<{ c0: number; r0: number; c1: number; r1: number } | null>(null) // in-progress area box drag (world px) const areaStartRef = useRef<{ x: number; y: number } | null>(null) const [areaPreview, setAreaPreview] = useState<Box | null>(null) // in-progress Path-tool edge set + its live preview const pathRef = useRef<Set<string> | null>(null) const [pathPreview, setPathPreview] = useState<string[]>([]) // in-progress slice pick: live depth-axis coord (px), or null when idle const slicingRef = useRef(false) const [slicePreview, setSlicePreview] = useState<number | null>(null) // active move/resize of an existing area box const boxDragRef = useRef< | { id: string; index: number; mode: 'move'; sx: number; sy: number; orig: Box } | { id: string; index: number; mode: 'resize'; fx: number; fy: number } | null >(null) // active drag of a placed object (move tool); dx/dy = object origin − pointer const objDragRef = useRef<{ id: string; dx: number; dy: number } | null>(null) const markerDragRef = useRef<{ id: string; dx: number; dy: number } | null>(null) // active rotate/scale gesture about an object's anchor (ax,ay = anchor world px) const objGestureRef = useRef< | { id: string; mode: 'rotate'; ax: number; ay: number; baseAngle: number; startRot: number } | { id: string; mode: 'scale'; ax: number; ay: number; startDist: number; startScale: number } | null >(null)
useEffect(() => { zoomRef.current = zoom panRef.current = pan })
// world → screen and back const sx = useCallback((wx: number) => pan.x + wx * zoom, [pan.x, zoom]) const sy = useCallback((wy: number) => pan.y + wy * zoom, [pan.y, zoom])
// ---- 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) ctx.imageSmoothingEnabled = false
// ---- background backdrop (behind every node, incl. the ground canvas) --- // color / gradient fill the viewport (screen space); image (tile) and tile // repeat in world space, anchored at the origin and scaled with the view; // image cover/contain/stretch fit one copy to the viewport. drawBackground(ctx, vw, vh)
// visible world rect const wx0 = (0 - pan.x) / zoom const wx1 = (vw - pan.x) / zoom const wy0 = (0 - pan.y) / zoom const wy1 = (vh - pan.y) / zoom
// Map a painted material id to the member shown at the current playback // time, within its own tileset. Non-members (and VOID/undefined) pass // through. With playback off animTimeRef stays 0 → each animation's frame 0. const animFrameIdFor = (tr: TilesetRender, id: string | undefined): string | undefined => { if (!showAnimations || id === undefined || id === VOID_ID) return id const m = tr.animMembership.get(id) if (!m) return id const anim = tr.animById.get(m.animationId) const n = anim?.frameMaterialIds.length ?? 0 if (!anim || n === 0) return id const f = Math.floor(animTimeRef.current / Math.max(16, anim.frameDurationMs)) % n return anim.frameMaterialIds[f] }
// ---- a stamped tile: the exact source tile, scaled to one map cell ------ // Drawn even in the flat-colour view (a stamp names a tile, not a material, // so there's no colour to fall back to); missing art shows the red signal. const drawStampedTile = (v: string, dx: number, dy: number, dw: number, dh: number) => { const uri = materialTileset(v) const tr = uri ? tilesetRenders.get(uri) : undefined if (tr?.image && tr.grid.tileWidth > 0 && tr.grid.tileHeight > 0) { const [tc, trow] = tileRefSource(v) ctx.drawImage( tr.image, tr.grid.offsetX + tc * tr.grid.tileWidth, tr.grid.offsetY + trow * tr.grid.tileHeight, tr.grid.tileWidth, tr.grid.tileHeight, dx, dy, dw, dh, ) } else { ctx.fillStyle = MISSING ctx.fillRect(dx, dy, dw, dh) } }
// ---- one painted layer: a pass per tileset it references ---------------- // A layer can mix tilesets, so we draw one pass per referenced tileset. Each // pass resolves only its own tileset's cells — every other value (foreign // tileset, or void) reads as empty — so Wang transitions join within a // tileset and never across one. Passes composite in the map's tileset order. const drawLayer = (layer: CanvasLayer) => { const ox = layer.offsetX const oy = layer.offsetY
const i0 = Math.floor((wx0 - ox) / tw) - 1 const i1 = Math.ceil((wx1 - ox) / tw) + 1 const j0 = Math.floor((wy0 - oy) / th) - 1 const j1 = Math.ceil((wy1 - oy) / th) + 1
// which tilesets does this layer actually reference? const present = new Set<string>() for (const channel of [layer.cells, layer.edges, layer.corners]) { for (const v of Object.values(channel)) { const uri = materialTileset(v) if (uri) present.add(uri) } }
for (const tr of tilesetRenders.values()) { if (!present.has(tr.uri)) continue const htw = tr.grid.tileWidth / 2 const hth = tr.grid.tileHeight / 2 const hasArt = showTextures && tr.artReady // resolve a stored value to this tileset's (playback-resolved) bare // material, or undefined when it belongs to another tileset / is void — // so it reads as empty space for this pass. const own = (v: string | undefined): string | undefined => v === undefined || materialTileset(v) !== tr.uri ? undefined : animFrameIdFor(tr, bareMaterial(v)) const base = (c: number, r: number) => own(layer.cells[cellKey(c, r)]) const corner = (c: number, r: number, w: CornerDir) => own(layer.corners[cornerKey(c, r, w)]) const edge = (o: 'H' | 'V', c: number, r: number) => own(layer.edges[edgeKey(o, c, r)])
// display tiles on the OFFSET grid (one per cell-corner vertex) for (let j = j0; j <= j1; j++) { for (let i = i0; i <= i1; i++) { const left = Math.floor(sx(ox + (i - 0.5) * tw)) const top = Math.floor(sy(oy + (j - 0.5) * th)) const right = Math.ceil(sx(ox + (i + 0.5) * tw)) const bottom = Math.ceil(sy(oy + (j + 0.5) * th)) if (left > vw || top > vh || right < 0 || bottom < 0) continue const mx = Math.floor(sx(ox + i * tw)) const my = Math.floor(sy(oy + j * th)) if (hasArt) { const dt = resolveDisplayTile(i, j, base, corner, tr.wangIndex, edge) if (!dt) continue const quad = (q: QuadResult, dqx: 0 | 1, dqy: 0 | 1) => { if (q === null) return const dx = dqx === 0 ? left : mx const dy = dqy === 0 ? top : my const dw = (dqx === 0 ? mx : right) - dx const dh = (dqy === 0 ? my : bottom) - dy if (q === 'red') { ctx.fillStyle = MISSING ctx.fillRect(dx, dy, dw, dh) return } const sqx = q.quad === 'tr' || q.quad === 'br' ? 1 : 0 const sqy = q.quad === 'bl' || q.quad === 'br' ? 1 : 0 drawQuad( ctx, tr.image!, tr.grid.offsetX + q.col * tr.grid.tileWidth + sqx * htw, tr.grid.offsetY + q.row * tr.grid.tileHeight + sqy * hth, htw, hth, dx, dy, dw, dh, q.transform, ) } quad(dt.tl, 0, 0) quad(dt.tr, 1, 0) quad(dt.bl, 0, 1) quad(dt.br, 1, 1) } else { const sections = displaySections(i, j, base, corner, edge) if (!sections) continue const bx = (k: number) => Math.round(sx(ox + (i - 0.5) * tw + (k / 6) * tw)) const by = (k: number) => Math.round(sy(oy + (j - 0.5) * th + (k / 6) * th)) const fill = (m: string | undefined, gx0: number, gy0: number, gx1: number, gy1: number) => { if (m === undefined || m === VOID_ID) return const color = tr.materialColor.get(m) if (!color) return const x0 = bx(gx0) const y0 = by(gy0) ctx.fillStyle = color ctx.fillRect(x0, y0, bx(gx1) - x0, by(gy1) - y0) } for (let srow = 0; srow < 3; srow++) { for (let scol = 0; scol < 3; scol++) { const v = sections[SECTIONS[srow * 3 + scol]] if (v === undefined) continue const gx = scol * 2 const gy = srow * 2 if (isTransition(v)) { const [c00, c10, c01, c11] = v.corners fill(c00, gx, gy, gx + 1, gy + 1) fill(c10, gx + 1, gy, gx + 2, gy + 1) fill(c01, gx, gy + 1, gx + 1, gy + 2) fill(c11, gx + 1, gy + 1, gx + 2, gy + 2) } else { fill(v, gx, gy, gx + 2, gy + 2) } } } } } } }
// stamped tiles draw over this layer's auto-tiled passes: each sits on // the offset grid, centred on its vertex (half a cell to each side) for (const [key, v] of Object.entries(layer.tiles)) { const [i, j] = parseCell(key) const left = Math.floor(sx(ox + (i - 0.5) * tw)) const top = Math.floor(sy(oy + (j - 0.5) * th)) const right = Math.ceil(sx(ox + (i + 0.5) * tw)) const bottom = Math.ceil(sy(oy + (j + 0.5) * th)) if (left > vw || top > vh || right < 0 || bottom < 0) continue drawStampedTile(v, left, top, right - left, bottom - top) }
// corner dots (editor overlay), coloured by each corner's own tileset const dotR = Math.max(2, Math.min(tw, th) * zoom * 0.14) for (const [key, scoped] of Object.entries(layer.corners)) { const colon = key.indexOf(':') const [col, row] = parseCell(key.slice(0, colon)) const which = key.slice(colon + 1) as CornerDir const cx = sx(ox + (col + (which.includes('e') ? 1 : 0)) * tw) const cy = sy(oy + (row + (which.includes('s') ? 1 : 0)) * th) const uri = materialTileset(scoped) const color = (uri ? tilesetRenders.get(uri)?.materialColor.get(bareMaterial(scoped)) : undefined) ?? MISSING ctx.fillStyle = color ctx.beginPath() ctx.arc(cx, cy, dotR, 0, Math.PI * 2) ctx.fill() } }
// ---- depth-sorted drawables: canvases (per band) + placed objects ------- // A canvas sorts as one unit; for `dynamic`, by the far edge (max coord // along depthAxis) of its painted content. const canvasSortDepth = (canvas: CanvasNode): number => { let max = -Infinity for (const layer of canvas.layers) { for (const k of Object.keys(layer.cells)) { const [c, r] = parseCell(k) const far = depthAxis === 'y' ? layer.offsetY + (r + 1) * th : layer.offsetX + (c + 1) * tw if (far > max) max = far } // stamped tiles sit on the offset grid: vertex (i,j) covers ±half a cell for (const k of Object.keys(layer.tiles)) { const [i, j] = parseCell(k) const far = depthAxis === 'y' ? layer.offsetY + (j + 0.5) * th : layer.offsetX + (i + 0.5) * tw if (far > max) max = far } } return max === -Infinity ? 0 : max } // First pass: every visible drawable with its own far-edge `base` key and // the composite group (if any) it belongs to. A composite collapses all its // members onto one shared key so the whole unit sorts together; members keep // their panel order via `order` (a stable, monotonically increasing index). const pending: { composite: GroupNode | null; base: number; order: number; draw: () => void }[] = [] let order = 0 walkVisibleComposite(nodes, (n, composite) => { if (n.kind === 'canvas') { const band = n.sort === 'ground' ? -Infinity : n.sort === 'sky' ? Infinity : canvasSortDepth(n) for (const layer of n.layers) { if (layer.hidden) continue const captured = layer pending.push({ composite, base: band, order: order++, draw: () => drawLayer(captured) }) } } else if (n.kind === 'object') { const resolved = objectDefOf(n) if (!resolved) return const { def, tr } = resolved const fp = objectFootprint(def, n, tw, th, tr.grid.tileWidth, tr.grid.tileHeight) const base = (depthAxis === 'y' ? n.y : n.x) + depthKeyOffset(fp.local, fp.t, depthAxis) pending.push({ composite, base, order: order++, draw: () => drawObject(ctx, n, def, tr) }) } }) // Resolve each composite to one key: its manual anchor, else the deepest // (frontmost) member's far edge — so the unit occludes like a single object. const compositeKey = new Map<string, number>() for (const p of pending) { if (!p.composite) continue const g = p.composite const k = g.anchor ? depthAxis === 'y' ? g.anchor.y : g.anchor.x : Math.max(compositeKey.get(g.id) ?? -Infinity, p.base) compositeKey.set(g.id, k) } const drawables = pending.map((p) => ({ key: p.composite ? (compositeKey.get(p.composite.id) ?? p.base) : p.base, order: p.order, draw: p.draw, })) drawables.sort((a, b) => a.key - b.key || a.order - b.order) for (const d of drawables) d.draw()
// ---- object rendering (defined inline to capture geometry) -------------- function drawObject(ctx: CanvasRenderingContext2D, o: ObjectNode, def: TileObject, tr: TilesetRender) { const { local, t, tlx, tly, wpx, hpx } = objectFootprint(def, o, tw, th, tr.grid.tileWidth, tr.grid.tileHeight) // screen-space AABB of the (possibly transformed) footprint — used for // culling and the selection outline. const odx = Math.floor(sx(tlx)) const ody = Math.floor(sy(tly)) const odw = Math.ceil(sx(tlx + wpx)) - odx const odh = Math.ceil(sy(tly + hpx)) - ody if (odx > vw || ody > vh || odx + odw < 0 || ody + odh < 0) return // pick the current animation frame's source tile (frame 0 = the object's // own col/row; def.frames holds frames 1..n). A stopped instance holds its // chosen frame; a playing instance cycles only while the preview is on. let srcCol = def.col let srcRow = def.row const total = def.frames.length + 1 if (total > 1) { const playing = objectAnimationPlayingOf(o.properties) let f = 0 if (!playing) { f = Math.min(objectAnimationFrameOf(o.properties), total - 1) } else if (showAnimations) { f = animFrameIndex(animTimeRef.current, def.frameDurationMs, total, objectAnimationModeOf(def.properties), { reverse: objectAnimationReverseOf(o.properties), once: objectAnimationOnceOf(o.properties), }) } if (f > 0) { srcCol = def.frames[f - 1].col srcRow = def.frames[f - 1].row } } if (tr.image && tr.grid.tileWidth > 0 && tr.grid.tileHeight > 0) { // Anchor the ctx at the object's origin, drop into world-px units, then // apply scale → flip → rotate about that anchor (the dpr base transform // composes underneath). The untransformed `local` rect is drawn into; // the ctx matrix does the rest, so 90°/flip/scale stay exact. ctx.save() ctx.translate(sx(o.x), sy(o.y)) ctx.scale(zoom, zoom) ctx.rotate((t.rotation * Math.PI) / 2) ctx.scale(t.scale * (t.flipX ? -1 : 1), t.scale * (t.flipY ? -1 : 1)) ctx.drawImage( tr.image, tr.grid.offsetX + srcCol * tr.grid.tileWidth, tr.grid.offsetY + srcRow * tr.grid.tileHeight, def.cols * tr.grid.tileWidth, def.rows * tr.grid.tileHeight, local.x, local.y, local.w, local.h, ) ctx.restore() } else { ctx.fillStyle = 'rgba(255, 213, 79, 0.2)' ctx.fillRect(odx, ody, odw, odh) ctx.strokeStyle = '#ffd54f' ctx.lineWidth = 1 ctx.strokeRect(odx + 0.5, ody + 0.5, odw - 1, odh - 1) } if (o.id === activeNodeId) { ctx.strokeStyle = '#ffd54f' ctx.lineWidth = 2 ctx.setLineDash([5, 3]) ctx.strokeRect(odx + 1, ody + 1, odw - 2, odh - 2) ctx.setLineDash([]) } }
// ---- background backdrop drawing (closes over pan/zoom/the bg caches) --- function fillTiled( ctx: CanvasRenderingContext2D, vw: number, vh: number, src: CanvasImageSource, srcW: number, srcH: number, cellW: number, cellH: number, ) { const pat = ctx.createPattern(src, 'repeat') if (!pat || srcW <= 0 || srcH <= 0) return // each repeat occupies cellW×cellH world px, anchored at the origin and // scaled with the view (the ctx's dpr transform composes on top). const a = (cellW * zoom) / srcW const d = (cellH * zoom) / srcH pat.setTransform(new DOMMatrix([a, 0, 0, d, pan.x, pan.y])) ctx.fillStyle = pat ctx.fillRect(0, 0, vw, vh) } function drawBackground(ctx: CanvasRenderingContext2D, vw: number, vh: number) { const bg = background if (!bg) return if (bg.kind === 'color') { ctx.fillStyle = bg.color ctx.fillRect(0, 0, vw, vh) return } if (bg.kind === 'gradient') { const rad = (bg.angle * Math.PI) / 180 const dx = Math.cos(rad) const dy = Math.sin(rad) const cx = vw / 2 const cy = vh / 2 const len = Math.abs(vw * dx) + Math.abs(vh * dy) const g = ctx.createLinearGradient( cx - (dx * len) / 2, cy - (dy * len) / 2, cx + (dx * len) / 2, cy + (dy * len) / 2, ) for (const s of [...bg.stops].sort((m, n) => m.position - n.position)) { g.addColorStop(Math.min(1, Math.max(0, s.position / 100)), s.color) } ctx.fillStyle = g ctx.fillRect(0, 0, vw, vh) return } if (bg.kind === 'tile') { // one tile fills one map cell, repeated — consistent with the map grid. if (bgTileCanvas) fillTiled(ctx, vw, vh, bgTileCanvas, bgTileCanvas.width, bgTileCanvas.height, tw, th) return } // image if (!bgImage) return const iw = bgImage.naturalWidth const ih = bgImage.naturalHeight if (iw <= 0 || ih <= 0) return if (bg.fit === 'tile') { // each repeat = the image's natural size in world px fillTiled(ctx, vw, vh, bgImage, iw, ih, iw, ih) return } if (bg.fit === 'stretch') { ctx.drawImage(bgImage, 0, 0, vw, vh) return } const scale = bg.fit === 'cover' ? Math.max(vw / iw, vh / ih) : Math.min(vw / iw, vh / ih) const dw = iw * scale const dh = ih * scale ctx.drawImage(bgImage, (vw - dw) / 2, (vh - dh) / 2, dw, dh) }
// ---- main grid (aligned to the active layer's offset) ---- const pxw = tw * zoom const pxh = th * zoom const gridOx = activeLayer?.offsetX ?? 0 const gridOy = activeLayer?.offsetY ?? 0 const c0 = Math.floor((wx0 - gridOx) / tw) const c1 = Math.ceil((wx1 - gridOx) / tw) const r0 = Math.floor((wy0 - gridOy) / th) const r1 = Math.ceil((wy1 - gridOy) / th) if (showCells && pxw >= MIN_CELL_PX && pxh >= MIN_CELL_PX) { ctx.strokeStyle = '#495062' ctx.lineWidth = 1 ctx.beginPath() for (let c = c0; c <= c1; c++) { const x = Math.round(sx(gridOx + c * tw)) + 0.5 ctx.moveTo(x, 0) ctx.lineTo(x, vh) } for (let r = r0; r <= r1; r++) { const y = Math.round(sy(gridOy + r * th)) + 0.5 ctx.moveTo(0, y) ctx.lineTo(vw, y) } ctx.stroke() }
// ---- hovered cell highlight (active layer; cell-targeted tools) ---- if (hoverW && activeLayer && (PAINT_TOOLS.includes(tool) || tool === 'erase') && !boxPreview) { const col = Math.floor((hoverW.x - activeLayer.offsetX) / tw) const row = Math.floor((hoverW.y - activeLayer.offsetY) / th) ctx.fillStyle = 'rgba(0, 229, 255, 0.15)' ctx.fillRect(sx(activeLayer.offsetX + col * tw), sy(activeLayer.offsetY + row * th), pxw, pxh) }
// ---- paint box preview (rect / circle), active-layer cells ---- if (boxPreview && activeLayer) { const ox = activeLayer.offsetX const oy = activeLayer.offsetY const aMinC = Math.min(boxPreview.c0, boxPreview.c1) const aMinR = Math.min(boxPreview.r0, boxPreview.r1) const aw = (Math.abs(boxPreview.c1 - boxPreview.c0) + 1) * pxw const ah = (Math.abs(boxPreview.r1 - boxPreview.r0) + 1) * pxh const ax = sx(ox + aMinC * tw) const ay = sy(oy + aMinR * th) ctx.fillStyle = 'rgba(0, 229, 255, 0.18)' ctx.strokeStyle = '#00e5ff' ctx.lineWidth = 2 ctx.setLineDash([6, 4]) if (tool === 'circle') { ctx.beginPath() ctx.ellipse(ax + aw / 2, ay + ah / 2, aw / 2, ah / 2, 0, 0, Math.PI * 2) ctx.fill() ctx.stroke() } else { ctx.fillRect(ax, ay, aw, ah) ctx.strokeRect(ax + 1, ay + 1, aw - 2, ah - 2) } ctx.setLineDash([]) }
// ---- area box preview (world px) ---- if (areaPreview) { ctx.fillStyle = AREA_FILL ctx.strokeStyle = AREA_STROKE ctx.lineWidth = 2 ctx.setLineDash([6, 4]) ctx.fillRect(sx(areaPreview.x), sy(areaPreview.y), areaPreview.w * zoom, areaPreview.h * zoom) ctx.strokeRect(sx(areaPreview.x) + 1, sy(areaPreview.y) + 1, areaPreview.w * zoom - 2, areaPreview.h * zoom - 2) ctx.setLineDash([]) }
// ---- path-tool preview (active layer edges) ---- if (tool === 'path' && activeLayer) { const ox = activeLayer.offsetX const oy = activeLayer.offsetY ctx.lineCap = 'round' ctx.lineWidth = Math.max(2, Math.min(pxw, pxh) * 0.16) ctx.strokeStyle = '#00e5ff' const drawEdge = (key: string) => { const [ex0, ey0, ex1, ey1] = edgeSegment(key) ctx.beginPath() ctx.moveTo(sx(ox + ex0 * tw), sy(oy + ey0 * th)) ctx.lineTo(sx(ox + ex1 * tw), sy(oy + ey1 * th)) ctx.stroke() } for (const key of pathPreview) drawEdge(key) if (hoverW) { const fx = (hoverW.x - ox) / tw const fy = (hoverW.y - oy) / th ctx.strokeStyle = 'rgba(0, 229, 255, 0.5)' drawEdge(nearestEdgeKey(fx, fy)) } ctx.lineCap = 'butt' }
// ---- dot-tool preview (active layer corner) ---- if (tool === 'dot' && activeLayer && hoverW) { const ox = activeLayer.offsetX const oy = activeLayer.offsetY const { col, row, which } = cornerOfCell((hoverW.x - ox) / tw, (hoverW.y - oy) / th) const cx = sx(ox + (col + (which.includes('e') ? 1 : 0)) * tw) const cy = sy(oy + (row + (which.includes('s') ? 1 : 0)) * th) ctx.strokeStyle = '#00e5ff' ctx.lineWidth = 2 ctx.beginPath() ctx.arc(cx, cy, Math.max(3, Math.min(pxw, pxh) * 0.16), 0, Math.PI * 2) ctx.stroke() }
// ---- tile-tool preview (ghost of the picked tile at the hovered vertex) ---- if (tool === 'tile' && activeLayer && hoverW) { const ox = activeLayer.offsetX const oy = activeLayer.offsetY const i = Math.round((hoverW.x - ox) / tw) const j = Math.round((hoverW.y - oy) / th) const dx = sx(ox + (i - 0.5) * tw) const dy = sy(oy + (j - 0.5) * th) if (tilePick) { ctx.globalAlpha = 0.6 drawStampedTile(tileRefValue(tilePick.tileset, tilePick.col, tilePick.row), dx, dy, pxw, pxh) ctx.globalAlpha = 1 } ctx.strokeStyle = '#00e5ff' ctx.lineWidth = 2 ctx.setLineDash([6, 4]) ctx.strokeRect(dx + 1, dy + 1, pxw - 2, pxh - 2) ctx.setLineDash([]) }
// ---- dynamic layer slice cuts + the live pick ---- // Each cut is a full-extent line perpendicular to the depth axis at its // coordinate. Shown whenever a dynamic layer is selected and the Slices // overlay is on, or always while the slice tool is armed (for context). The // cut being (re)picked is hidden so the bright live preview stands in for it. if ( activeCanvas?.sort === 'dynamic' && activeLayer && (showSlices || tool === 'slice') ) { const drawCut = (coord: number, color: string, dash: boolean) => { ctx.strokeStyle = color ctx.lineWidth = 2 ctx.setLineDash(dash ? [6, 4] : []) ctx.beginPath() if (depthAxis === 'y') { const y = Math.round(sy(coord)) + 0.5 ctx.moveTo(0, y) ctx.lineTo(vw, y) } else { const x = Math.round(sx(coord)) + 0.5 ctx.moveTo(x, 0) ctx.lineTo(x, vh) } ctx.stroke() ctx.setLineDash([]) } activeLayer.slices.forEach((coord, i) => { if (slicePreview !== null && slicePick?.index === i) return drawCut(coord, SLICE_STROKE, true) }) if (slicePreview !== null) drawCut(slicePreview, SLICE_STROKE_ACTIVE, false) }
// ---- area overlays (boxes in px; collision styled red) ---- for (const a of visibleAreas(nodes)) { const isCol = isCollisionNode(a) const active = a.id === activeNodeId ctx.fillStyle = isCol ? COLLISION_FILL : AREA_FILL ctx.strokeStyle = isCol ? COLLISION_STROKE : AREA_STROKE for (const b of a.boxes) { const rx = sx(b.x) const ry = sy(b.y) const rw = b.w * zoom const rh = b.h * zoom ctx.fillRect(rx, ry, rw, rh) ctx.lineWidth = active ? 2 : 1 ctx.strokeRect(rx + 0.5, ry + 0.5, rw - 1, rh - 1) } const first = a.boxes[0] if (first) { ctx.fillStyle = isCol ? COLLISION_STROKE : AREA_STROKE ctx.font = '10px system-ui, sans-serif' ctx.fillText(a.name, sx(first.x) + 4, sy(first.y) + 12) } // resize handles on the active area's boxes if (active) { ctx.fillStyle = isCol ? COLLISION_STROKE : AREA_STROKE for (const b of a.boxes) { for (const [hx, hy] of [ [b.x, b.y], [b.x + b.w, b.y], [b.x, b.y + b.h], [b.x + b.w, b.y + b.h], ]) { ctx.fillRect(sx(hx) - 3, sy(hy) - 3, 6, 6) } } } }
// ---- marker overlays (meaning-carrying points; fixed on-screen size) ---- for (const m of visibleMarkers(nodes)) { const cx = sx(m.x) const cy = sy(m.y) const active = m.id === activeNodeId const r = MARKER_RADIUS // ring ctx.beginPath() ctx.arc(cx, cy, r, 0, Math.PI * 2) ctx.fillStyle = active ? MARKER_FILL : 'rgba(167, 139, 250, 0.25)' ctx.fill() ctx.lineWidth = active ? 2 : 1.5 ctx.strokeStyle = MARKER_STROKE ctx.stroke() // centre dot ctx.beginPath() ctx.arc(cx, cy, 1.75, 0, Math.PI * 2) ctx.fillStyle = active ? '#ffffff' : MARKER_STROKE ctx.fill() // label ctx.fillStyle = MARKER_STROKE ctx.font = '10px system-ui, sans-serif' ctx.fillText(m.name, cx + r + 3, cy + 3) }
// ---- link edges (editor-only; drawn when the link or an endpoint is // selected). Source → target with an arrowhead at the target. ---- for (const link of visibleLinks(nodes)) { const involved = activeNodeId === link.id || activeNodeId === link.source || activeNodeId === link.target if (!involved) continue const a = nodeAnchor(findNode(nodes, link.source)) const b = nodeAnchor(findNode(nodes, link.target)) if (!a || !b) continue const ax = sx(a.x) const ay = sy(a.y) const bx = sx(b.x) const by = sy(b.y) ctx.strokeStyle = LINK_STROKE ctx.fillStyle = LINK_STROKE ctx.lineWidth = 2 ctx.setLineDash([5, 4]) ctx.beginPath() ctx.moveTo(ax, ay) ctx.lineTo(bx, by) ctx.stroke() ctx.setLineDash([]) // arrowhead at the target const ang = Math.atan2(by - ay, bx - ax) const h = 9 ctx.beginPath() ctx.moveTo(bx, by) ctx.lineTo(bx - h * Math.cos(ang - Math.PI / 6), by - h * Math.sin(ang - Math.PI / 6)) ctx.lineTo(bx - h * Math.cos(ang + Math.PI / 6), by - h * Math.sin(ang + Math.PI / 6)) ctx.closePath() ctx.fill() // source dot ctx.beginPath() ctx.arc(ax, ay, 3, 0, Math.PI * 2) ctx.fill() } }, [ zoom, pan, hoverW, tool, showCells, tilesetRenders, showTextures, tw, th, depthAxis, background, bgImage, bgTileCanvas, nodes, activeNodeId, activeCanvas, activeLayer, showAnimations, showSlices, boxPreview, areaPreview, pathPreview, slicePreview, slicePick, tilePick, objectDefOf, sx, sy, ])
useEffect(() => { draw() }, [draw])
useEffect(() => { const viewport = viewportRef.current if (!viewport) return const ro = new ResizeObserver(() => draw()) ro.observe(viewport) return () => ro.disconnect() }, [draw])
// ---- animation playback loop: advance the wall clock and redraw each frame. // Always calls the latest `draw` via a ref so the loop need not restart when // draw's identity changes (pan/zoom/edits). Runs only while playback is on and // something actually animates; resets the clock to frame 0 when it stops. const drawRef = useRef(draw) useEffect(() => { drawRef.current = draw }) useEffect(() => { if (!showAnimations || !hasAnimatedContent) return let raf = 0 let start = 0 const tick = (t: number) => { if (!start) start = t animTimeRef.current = t - start drawRef.current() raf = requestAnimationFrame(tick) } raf = requestAnimationFrame(tick) return () => { cancelAnimationFrame(raf) animTimeRef.current = 0 } }, [showAnimations, hasAnimatedContent])
// ---- 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) / old const wy = (my - panRef.current.y) / old setZoom(next) setPan({ x: mx - wx * next, y: my - wy * next }) } viewport.addEventListener('wheel', onWheel, { passive: false }) return () => viewport.removeEventListener('wheel', onWheel) }, [])
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) / old const wy = (my - panRef.current.y) / old setZoom(next) setPan({ x: mx - wx * next, y: my - wy * next }) }
// ---- pointer handling ---------------------------------------------------- const screenToWorld = (clientX: number, clientY: number) => { const vp = viewportRef.current if (!vp) return null const rect = vp.getBoundingClientRect() return { x: (clientX - rect.left - panRef.current.x) / zoomRef.current, y: (clientY - rect.top - panRef.current.y) / zoomRef.current, } }
/** Active-layer cell (col,row) for a world point. */ const worldToCell = (w: { x: number; y: number }) => { const ox = activeLayer?.offsetX ?? 0 const oy = activeLayer?.offsetY ?? 0 return { col: Math.floor((w.x - ox) / tw), row: Math.floor((w.y - oy) / th) } }
/** Topmost placed object whose footprint covers a world point (for erase). * Mirrors the draw order: composite members share their unit's key and break * ties by panel order, so the visually-frontmost object is the one erased. */ const topObjectAt = (w: { x: number; y: number }) => { const hits: { o: ObjectNode; composite: GroupNode | null; base: number; order: number }[] = [] let order = 0 walkVisibleComposite(nodes, (n, composite) => { if (n.kind !== 'object') return const seq = order++ const resolved = objectDefOf(n) if (!resolved) return const { def, tr } = resolved const fp = objectFootprint(def, n, tw, th, tr.grid.tileWidth, tr.grid.tileHeight) const { tlx, tly, wpx, hpx } = fp if (w.x >= tlx && w.x < tlx + wpx && w.y >= tly && w.y < tly + hpx) { const base = (depthAxis === 'y' ? n.y : n.x) + depthKeyOffset(fp.local, fp.t, depthAxis) hits.push({ o: n, composite, base, order: seq }) } }) // composite unit key = anchor, else the deepest member among the hits const compositeKey = new Map<string, number>() for (const h of hits) { if (!h.composite) continue const g = h.composite const k = g.anchor ? depthAxis === 'y' ? g.anchor.y : g.anchor.x : Math.max(compositeKey.get(g.id) ?? -Infinity, h.base) compositeKey.set(g.id, k) } let hit: { id: string } | null = null let hitKey = -Infinity let hitOrder = -Infinity for (const h of hits) { const key = h.composite ? (compositeKey.get(h.composite.id) ?? h.base) : h.base if (key > hitKey || (key === hitKey && h.order >= hitOrder)) { hitKey = key hitOrder = h.order hit = h.o } } return hit }
/** The topmost visible marker whose ring contains world point `w`, or null. * The ring is a fixed on-screen size, so the hit radius is in screen px * converted back to world units by the current zoom. */ const markerAt = (w: { x: number; y: number }): MarkerNode | null => { const r = (MARKER_RADIUS + 4) / zoomRef.current let hit: MarkerNode | null = null for (const m of visibleMarkers(nodes)) { if (Math.hypot(w.x - m.x, w.y - m.y) <= r) hit = m // last (topmost) wins } return hit }
const stepX = areaSnapCells ? tw / SUBCELL : 1 const stepY = areaSnapCells ? th / SUBCELL : 1
const onPointerDown = (e: React.PointerEvent<HTMLDivElement>) => { const vp = viewportRef.current if (!vp) return if ((tool === 'pan' && e.button === 0) || e.button === 1) { e.preventDefault() panningRef.current = { x: e.clientX, y: e.clientY } setIsPanning(true) vp.setPointerCapture(e.pointerId) return }
const w = screenToWorld(e.clientX, e.clientY) if (!w) return const canvasId = activeCanvas?.id ?? null const layerId = activeLayer?.id ?? null
// stamp: drop a copy of the selected object at the cursor, click after click. // The store keeps the template selected so we stay in the stamp tool. if (tool === 'stamp' && e.button === 0) { const tmpl = activeNodeId ? findNode(nodes, activeNodeId) : null if (tmpl && tmpl.kind === 'object') { e.preventDefault() stampObject(w.x, w.y) } return }
// tile: stamp the picked source tile onto the nearest offset-grid vertex; // drag to keep stamping. if (tool === 'tile' && e.button === 0) { if (!canvasId || !layerId || !activeLayer || !tilePick) return e.preventDefault() paintingRef.current = true vp.setPointerCapture(e.pointerId) const i = Math.round((w.x - activeLayer.offsetX) / tw) const j = Math.round((w.y - activeLayer.offsetY) / th) stampTile(canvasId, layerId, i, j, tileRefValue(tilePick.tileset, tilePick.col, tilePick.row)) return }
// move: grab the marker or object under the cursor and drag it. Markers are // small points drawn on top, so they take priority over objects beneath. if (tool === 'move' && e.button === 0) { const marker = markerAt(w) if (marker) { e.preventDefault() setActiveNode(marker.id) markerDragRef.current = { id: marker.id, dx: marker.x - w.x, dy: marker.y - w.y } vp.setPointerCapture(e.pointerId) return } const hit = topObjectAt(w) const node = hit ? findNode(nodes, hit.id) : null if (node && node.kind === 'object') { e.preventDefault() setActiveNode(node.id) objDragRef.current = { id: node.id, dx: node.x - w.x, dy: node.y - w.y } vp.setPointerCapture(e.pointerId) } return }
// rotate / scale: gesture about the object's anchor. Prefer the object under // the cursor; otherwise act on the current selection so the user can drag // anywhere on the canvas to spin/size the selected object. if ((tool === 'rotate' || tool === 'scale') && e.button === 0) { const hit = topObjectAt(w) const hitNode = hit ? findNode(nodes, hit.id) : null const sel = activeNodeId ? findNode(nodes, activeNodeId) : null const target = hitNode && hitNode.kind === 'object' ? hitNode : sel && sel.kind === 'object' ? sel : null if (target) { e.preventDefault() setActiveNode(target.id) if (tool === 'rotate') { objGestureRef.current = { id: target.id, mode: 'rotate', ax: target.x, ay: target.y, baseAngle: Math.atan2(w.y - target.y, w.x - target.x), startRot: target.rotation ?? 0, } } else { objGestureRef.current = { id: target.id, mode: 'scale', ax: target.x, ay: target.y, startDist: Math.hypot(w.x - target.x, w.y - target.y), startScale: target.scale ?? 1, } } vp.setPointerCapture(e.pointerId) } return }
// paint / erase (cell drag) if ((tool === 'paint' || tool === 'erase') && e.button === 0) { if (tool === 'erase') { // area mode: click a box to delete it (or the whole area if it's the last box) if (activeAreaId) { const area = findNode(nodes, activeAreaId) if (area && area.kind === 'area') { for (let bi = area.boxes.length - 1; bi >= 0; bi--) { const b = area.boxes[bi] if (w.x >= b.x && w.x <= b.x + b.w && w.y >= b.y && w.y <= b.y + b.h) { e.preventDefault() if (area.boxes.length === 1) removeNode(activeAreaId) else removeAreaBox(activeAreaId, bi) return } } } return } const hit = topObjectAt(w) if (hit) { e.preventDefault() removeNode(hit.id) return } if (!canvasId || !layerId || !activeLayer) return const ox = activeLayer.offsetX const oy = activeLayer.offsetY const fx = (w.x - ox) / tw const fy = (w.y - oy) / th const dot = cornerOfCell(fx, fy) const dotKey = cornerKey(dot.col, dot.row, dot.which) if (nearCorner(fx, fy) && activeLayer.corners[dotKey] !== undefined) { e.preventDefault() eraseCorner(canvasId, layerId, dotKey) return } const edgeK = nearestEdgeKey(fx, fy) if (nearEdge(fx, fy) && activeLayer.edges[edgeK] !== undefined) { e.preventDefault() eraseEdge(canvasId, layerId, edgeK) return } // a stamped tile covers the nearest vertex's display tile, so it sits // over any cell fill here — erase it first; the next click reaches the // fill beneath. const ti = Math.round(fx) const tj = Math.round(fy) if (activeLayer.tiles[tileKey(ti, tj)] !== undefined) { e.preventDefault() stampTile(canvasId, layerId, ti, tj, null) return } const c = worldToCell(w) e.preventDefault() paintingRef.current = true vp.setPointerCapture(e.pointerId) paintCell(canvasId, layerId, c.col, c.row, null) return } if (!canvasId || !layerId || fillMaterialId == null) return const c = worldToCell(w) e.preventDefault() paintingRef.current = true vp.setPointerCapture(e.pointerId) paintCell(canvasId, layerId, c.col, c.row, fillMaterialId) return }
// fill if (tool === 'fill' && e.button === 0) { if (!canvasId || !layerId || fillMaterialId == null) return const c = worldToCell(w) e.preventDefault() fillArea(canvasId, layerId, c.col, c.row, fillMaterialId) return }
// rect / circle: drag a cell box on the active layer. Either a fill or a // stroke is enough — a stroke-only box draws just the outline (no fill). if ((tool === 'rect' || tool === 'circle') && e.button === 0) { if (!canvasId || !layerId || (fillMaterialId == null && strokeMaterialId == null)) return const c = worldToCell(w) e.preventDefault() boxStartRef.current = c setBoxPreview({ c0: c.col, r0: c.row, c1: c.col, r1: c.row }) vp.setPointerCapture(e.pointerId) return }
// area: move/resize an existing box of the active area, or draw a new one if (tool === 'area' && e.button === 0) { if (!activeAreaId) return const area = findNode(nodes, activeAreaId) if (area && area.kind === 'area') { for (let bi = 0; bi < area.boxes.length; bi++) { const b = area.boxes[bi] const corners: [Corner, number, number][] = [ ['nw', b.x, b.y], ['ne', b.x + b.w, b.y], ['sw', b.x, b.y + b.h], ['se', b.x + b.w, b.y + b.h], ] for (const [c, cx, cy] of corners) { if (Math.abs((w.x - cx) * zoomRef.current) <= HANDLE && Math.abs((w.y - cy) * zoomRef.current) <= HANDLE) { const fx = c === 'nw' || c === 'sw' ? b.x + b.w : b.x const fy = c === 'nw' || c === 'ne' ? b.y + b.h : b.y e.preventDefault() boxDragRef.current = { id: activeAreaId, index: bi, mode: 'resize', fx, fy } vp.setPointerCapture(e.pointerId) return } } } for (let bi = area.boxes.length - 1; bi >= 0; bi--) { const b = area.boxes[bi] if (w.x >= b.x && w.x <= b.x + b.w && w.y >= b.y && w.y <= b.y + b.h) { e.preventDefault() boxDragRef.current = { id: activeAreaId, index: bi, mode: 'move', sx: w.x, sy: w.y, orig: b } vp.setPointerCapture(e.pointerId) return } } } e.preventDefault() areaStartRef.current = { x: snap(w.x, stepX), y: snap(w.y, stepY) } setAreaPreview({ x: areaStartRef.current.x, y: areaStartRef.current.y, w: 0, h: 0 }) vp.setPointerCapture(e.pointerId) return }
// path: drag along grid edges, committing on release if (tool === 'path' && e.button === 0) { if (!canvasId || !layerId || !activeLayer) return const fx = (w.x - activeLayer.offsetX) / tw const fy = (w.y - activeLayer.offsetY) / th e.preventDefault() const set = new Set<string>([nearestEdgeKey(fx, fy)]) pathRef.current = set setPathPreview([...set]) vp.setPointerCapture(e.pointerId) return }
// dot: set a single corner override if (tool === 'dot' && e.button === 0) { if (!canvasId || !layerId || !activeLayer || fillMaterialId == null) return const fx = (w.x - activeLayer.offsetX) / tw const fy = (w.y - activeLayer.offsetY) / th e.preventDefault() const { col, row, which } = cornerOfCell(fx, fy) setCornerDot(canvasId, layerId, col, row, which, fillMaterialId) return }
// slice: place a dynamic-layer cut by clicking a point or dragging a line. // Both yield one coordinate along the depth axis; a drag just previews the // cut live. The target slice index is armed from the Layers panel. if (tool === 'slice' && e.button === 0) { if (!activeCanvas || activeCanvas.sort !== 'dynamic' || !activeLayer) return if (!slicePick || slicePick.layerId !== activeLayer.id) return e.preventDefault() slicingRef.current = true setSlicePreview(Math.round(depthAxis === 'y' ? w.y : w.x)) vp.setPointerCapture(e.pointerId) return } }
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 }
const w = screenToWorld(e.clientX, e.clientY) setHoverW(w) if (!w) return
// dragging a placed object (move tool) if (objDragRef.current) { const d = objDragRef.current setObjectPos(d.id, Math.round(w.x + d.dx), Math.round(w.y + d.dy)) return }
// dragging a marker (move tool) if (markerDragRef.current) { const d = markerDragRef.current setMarkerPos(d.id, Math.round(w.x + d.dx), Math.round(w.y + d.dy)) return }
// rotating / scaling a placed object about its anchor if (objGestureRef.current) { const g = objGestureRef.current if (g.mode === 'rotate') { const ang = Math.atan2(w.y - g.ay, w.x - g.ax) const quarters = Math.round((ang - g.baseAngle) / (Math.PI / 2)) setObjectRotation(g.id, g.startRot + quarters) } else { const dist = Math.hypot(w.x - g.ax, w.y - g.ay) const ratio = dist / Math.max(g.startDist, 1) const s = Math.min(100, Math.max(0.05, Math.round(g.startScale * ratio * 100) / 100)) setObjectScale(g.id, s) } return }
// area move / resize if (boxDragRef.current) { const d = boxDragRef.current if (d.mode === 'move') { const nx = snap(d.orig.x + (w.x - d.sx), stepX) const ny = snap(d.orig.y + (w.y - d.sy), stepY) updateAreaBox(d.id, d.index, { ...d.orig, x: nx, y: ny }) } else { updateAreaBox(d.id, d.index, normBox(d.fx, d.fy, snap(w.x, stepX), snap(w.y, stepY))) } return }
if (boxStartRef.current && activeLayer) { const c = worldToCell(w) const s = boxStartRef.current setBoxPreview({ c0: s.col, r0: s.row, c1: c.col, r1: c.row }) return } if (areaStartRef.current) { const s = areaStartRef.current setAreaPreview(normBox(s.x, s.y, snap(w.x, stepX), snap(w.y, stepY))) return } if (pathRef.current && activeLayer) { const fx = (w.x - activeLayer.offsetX) / tw const fy = (w.y - activeLayer.offsetY) / th pathRef.current.add(nearestEdgeKey(fx, fy)) setPathPreview([...pathRef.current]) return } if (slicingRef.current) { setSlicePreview(Math.round(depthAxis === 'y' ? w.y : w.x)) return } if (paintingRef.current && activeCanvas && activeLayer) { if (tool === 'tile') { if (!tilePick) return const i = Math.round((w.x - activeLayer.offsetX) / tw) const j = Math.round((w.y - activeLayer.offsetY) / th) stampTile(activeCanvas.id, activeLayer.id, i, j, tileRefValue(tilePick.tileset, tilePick.col, tilePick.row)) return } const c = worldToCell(w) paintCell(activeCanvas.id, activeLayer.id, c.col, c.row, tool === 'erase' ? null : fillMaterialId) } }
const onPointerUp = (e: React.PointerEvent<HTMLDivElement>) => { if (panningRef.current) { viewportRef.current?.releasePointerCapture(e.pointerId) panningRef.current = null setIsPanning(false) } if (paintingRef.current) { viewportRef.current?.releasePointerCapture(e.pointerId) paintingRef.current = false } if (boxStartRef.current && activeCanvas && activeLayer) { viewportRef.current?.releasePointerCapture(e.pointerId) const w = screenToWorld(e.clientX, e.clientY) const s = boxStartRef.current const c = w ? worldToCell(w) : s if (fillMaterialId != null || strokeMaterialId != null) { if (tool === 'circle') { paintCircle(activeCanvas.id, activeLayer.id, s.col, s.row, c.col, c.row, fillMaterialId, strokeMaterialId) } else { paintRect(activeCanvas.id, activeLayer.id, s.col, s.row, c.col, c.row, fillMaterialId, strokeMaterialId) } } boxStartRef.current = null setBoxPreview(null) } if (areaStartRef.current) { viewportRef.current?.releasePointerCapture(e.pointerId) const w = screenToWorld(e.clientX, e.clientY) const s = areaStartRef.current if (activeAreaId && w) { const box = normBox(s.x, s.y, snap(w.x, stepX), snap(w.y, stepY)) if (box.w >= 1 && box.h >= 1) addAreaBox(activeAreaId, box) } areaStartRef.current = null setAreaPreview(null) } if (pathRef.current) { viewportRef.current?.releasePointerCapture(e.pointerId) const mat = strokeMaterialId ?? fillMaterialId if (activeCanvas && activeLayer && mat != null) { paintEdgePath(activeCanvas.id, activeLayer.id, [...pathRef.current], mat) } pathRef.current = null setPathPreview([]) } if (slicingRef.current) { viewportRef.current?.releasePointerCapture(e.pointerId) slicingRef.current = false const w = screenToWorld(e.clientX, e.clientY) if (w && activeCanvas && activeLayer && slicePick && slicePick.layerId === activeLayer.id) { const coord = Math.round(depthAxis === 'y' ? w.y : w.x) const next = [...activeLayer.slices] if (slicePick.index >= next.length) next.push(coord) else next[slicePick.index] = coord setLayerSlices(activeCanvas.id, activeLayer.id, next) } setSlicePreview(null) setSlicePick(null) setTool('pan') } if (boxDragRef.current) { viewportRef.current?.releasePointerCapture(e.pointerId) boxDragRef.current = null } if (objDragRef.current) { viewportRef.current?.releasePointerCapture(e.pointerId) objDragRef.current = null } if (markerDragRef.current) { viewportRef.current?.releasePointerCapture(e.pointerId) markerDragRef.current = null } if (objGestureRef.current) { viewportRef.current?.releasePointerCapture(e.pointerId) objGestureRef.current = null } }
// switch to a tool that applies to the newly-selected node's kind useEffect(() => { const s = useMapStore.getState() const node = activeNodeId ? findNode(s.nodes, activeNodeId) : null const kind = node?.kind ?? 'none' if (!TOOLS_BY_KIND[kind].includes(s.tool)) s.setTool(PRIMARY_TOOL[kind]) }, [activeNodeId])
// delete the selected object / area with Backspace/Delete 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 const active = activeNodeId ? findNode(nodes, activeNodeId) : null if ( active && (active.kind === 'object' || isAreaNode(active) || isMarkerNode(active) || isLinkNode(active)) ) { e.preventDefault() removeNode(active.id) } } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [activeNodeId, nodes, removeNode])
// Esc cancels an in-progress slice pick (and drops back to the pan tool) useEffect(() => { if (tool !== 'slice') return const onKey = (e: KeyboardEvent) => { if (e.key !== 'Escape') return slicingRef.current = false setSlicePreview(null) setSlicePick(null) setTool('pan') } window.addEventListener('keydown', onKey) return () => window.removeEventListener('keydown', onKey) }, [tool, setSlicePick, setTool])
return ( <div className="grid h-full w-full grid-cols-[260px_1fr_300px] overflow-hidden bg-background text-foreground"> {sandboxWin && ( <WindowPortal win={sandboxWin} title="Sandbox — Tilemap World" onClose={() => setSandboxWin(null)}> <Sandbox /> </WindowPortal> )} <aside className="flex min-h-0 w-65 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>
<div className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto p-3"> <section className="flex flex-col gap-1"> <h2 className="text-xs font-medium tracking-wide text-muted-foreground uppercase">Title</h2> <Input className="h-8 text-sm" value={titleDraft} placeholder="Untitled map" onChange={(e) => setTitleDraft(e.target.value)} onBlur={() => setName(titleDraft)} onKeyDown={(e) => { if (e.key === 'Enter') (e.target as HTMLInputElement).blur() }} /> </section>
<section className="flex flex-col gap-2 border-t border-border pt-3"> <h2 className="text-xs font-medium tracking-wide text-muted-foreground uppercase">Grid (px)</h2> <div className="flex gap-2"> <label className="flex flex-1 flex-col gap-0.5"> <span className="text-[11px] text-muted-foreground">Tile W</span> <Input type="number" className="h-7 text-sm" min={1} value={mapGrid.tileWidth} onChange={(e) => setGrid({ tileWidth: Math.max(1, Number(e.target.value) || 1) })} /> </label> <label className="flex flex-1 flex-col gap-0.5"> <span className="text-[11px] text-muted-foreground">Tile H</span> <Input type="number" className="h-7 text-sm" min={1} value={mapGrid.tileHeight} onChange={(e) => setGrid({ tileHeight: Math.max(1, Number(e.target.value) || 1) })} /> </label> </div> <label className="flex items-center justify-between gap-2"> <span className="text-[11px] text-muted-foreground">Depth axis</span> <select className="h-7 flex-1 rounded-md border border-input bg-transparent px-2 text-sm" value={depthAxis} onChange={(e) => setDepthAxis(e.target.value as 'y' | 'x')} > <option value="y">Y (top-down)</option> <option value="x">X (left-right)</option> </select> </label> </section>
<MapBackgroundPanel />
<MapProperties />
<div className="border-t border-border pt-3"> <MapNodeTree /> </div>
<section className="flex flex-col gap-2 border-t border-border pt-3"> <h2 className="text-xs font-medium tracking-wide text-muted-foreground uppercase">View</h2> <div className="flex items-center gap-2"> <Checkbox id="show-cells" checked={showCells} onCheckedChange={(c) => setShowCells(c === true)} /> <Label htmlFor="show-cells" className="text-sm font-normal">Cells</Label> </div> <div className="flex items-center gap-2"> <Checkbox id="show-textures" checked={showTextures} onCheckedChange={(c) => setShowTextures(c === true)} /> <Label htmlFor="show-textures" className="text-sm font-normal">Textures</Label> </div> <div className="flex items-center gap-2"> <Checkbox id="show-animations" checked={showAnimations} onCheckedChange={(c) => setShowAnimations(c === true)} /> <Label htmlFor="show-animations" className="text-sm font-normal">Animation</Label> </div> <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(1) setPan({ x: 120, y: 120 }) }} > Reset </Button> </div> </section> </div>
<div className="shrink-0 border-t border-border p-3"> <Button type="button" variant="outline" size="sm" className="mb-2 w-full justify-start gap-2.5" onClick={openSandbox} > <Gamepad2 className="size-4" /> Enter Sandbox </Button> <DocumentActions /> </div> </> )} </aside>
<div className={cn( 'relative touch-none overflow-hidden bg-background select-none', tool === 'pan' ? (isPanning ? 'cursor-grabbing' : 'cursor-grab') : 'cursor-crosshair', )} ref={viewportRef} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerLeave={() => setHoverW(null)} > <canvas ref={canvasRef} className="absolute inset-0 block [image-rendering:pixelated]" /> </div>
<aside className="flex min-h-0 w-75 flex-col border-l border-border bg-card"> <div className="min-h-0 flex-1 overflow-y-auto p-3"> {activeKind === 'object' ? ( <ObjectInspector /> ) : activeKind === 'canvas' ? ( <CanvasInspector /> ) : activeKind === 'area' ? ( <AreaInspector /> ) : activeKind === 'marker' ? ( <MarkerInspector /> ) : activeKind === 'link' ? ( <LinkInspector /> ) : activeKind === 'group' ? ( <GroupInspector /> ) : ( <p className="text-xs text-muted-foreground"> Select a node on the left to edit it. Groups organise the scene; canvases, objects and areas carry the content. </p> )} </div> </aside> </div> )}
/** A world-px point representing a node, for drawing a link edge to/from it. * Objects/markers use their anchor; an area uses its first box's centre. Nodes * with no clear point (canvas/group/link) return null and the edge end is * skipped. */function nodeAnchor(node: SceneNode | null): { x: number; y: number } | null { if (!node) return null if (node.kind === 'object' || node.kind === 'marker') return { x: node.x, y: node.y } if (node.kind === 'area') { const b = node.boxes[0] return b ? { x: b.x + b.w / 2, y: b.y + b.h / 2 } : null } return null}
// ---- snapping helpers (module scope; pure; in cell units) ------------------
/** The grid edge nearest a float cell position, as an edge key. */function nearestEdgeKey(fx: number, fy: number): string { const rx = Math.round(fx) const ry = Math.round(fy) const dV = Math.abs(fx - rx) const dH = Math.abs(fy - ry) return dV <= dH ? `V:${rx},${Math.floor(fy)}` : `H:${Math.floor(fx)},${ry}`}
function nearEdge(fx: number, fy: number): boolean { return Math.min(Math.abs(fx - Math.round(fx)), Math.abs(fy - Math.round(fy))) < 0.28}
function cornerOfCell(fx: number, fy: number): { col: number; row: number; which: CornerDir } { const col = Math.floor(fx) const row = Math.floor(fy) const which = `${fy - row < 0.5 ? 'n' : 's'}${fx - col < 0.5 ? 'w' : 'e'}` as CornerDir return { col, row, which }}
function nearCorner(fx: number, fy: number): boolean { const dx = Math.abs(fx - Math.round(fx)) const dy = Math.abs(fy - Math.round(fy)) return dx < 0.28 && dy < 0.28}