Something went wrong. Try again.
An atproto Tilemap editor
Something went wrong. Try again.
11 kB · 318 lines
TSX
at main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319import { useEffect, useRef } from 'react'import { useEditorStore } from '../store/editorStore'import { type BackgroundFit, DEFAULT_BACKGROUNDS, type GradientStop, type MapBackground, useMapStore,} from '../store/mapStore'import { Button } from '@/components/ui/button'import { Input } from '@/components/ui/input'import { cn } from '@/lib/utils'
type BgKind = MapBackground['kind']
const KINDS: { id: 'none' | BgKind; label: string }[] = [ { id: 'none', label: 'None' }, { id: 'color', label: 'Color' }, { id: 'gradient', label: 'Gradient' }, { id: 'image', label: 'Image' }, { id: 'tile', label: 'Tile' },]
const FITS: BackgroundFit[] = ['tile', 'cover', 'contain', 'stretch']
/** A color picker chip + hex text, sharing one value. */function ColorField({ value, onChange }: { value: string; onChange: (v: string) => void }) { return ( <div className="flex items-center gap-1.5"> <input type="color" className="h-7 w-8 shrink-0 cursor-pointer rounded border border-input bg-transparent p-0.5" value={/^#[0-9a-fA-F]{6}$/.test(value) ? value : '#000000'} onChange={(e) => onChange(e.target.value)} /> <Input className="h-7 flex-1 font-mono text-xs" value={value} onChange={(e) => onChange(e.target.value)} /> </div> )}
/** Click-to-pick grid over the loaded tileset sheet; highlights the chosen tile. */function TilePicker({ col, row, onPick,}: { col: number row: number onPick: (col: number, row: number) => void}) { const image = useEditorStore((s) => s.image) const grid = useEditorStore((s) => s.grid) const canvasRef = useRef<HTMLCanvasElement | null>(null) const wrapRef = useRef<HTMLDivElement | null>(null) const tw = grid.tileWidth const th = grid.tileHeight const cols = image && tw > 0 ? Math.max(1, Math.floor((image.naturalWidth - grid.offsetX) / tw)) : 0 const rows = image && th > 0 ? Math.max(1, Math.floor((image.naturalHeight - grid.offsetY) / th)) : 0
useEffect(() => { const canvas = canvasRef.current const wrap = wrapRef.current if (!canvas || !wrap || !image || cols === 0 || rows === 0) return const dispW = wrap.clientWidth const scale = dispW / image.naturalWidth const dispH = image.naturalHeight * scale const dpr = window.devicePixelRatio || 1 canvas.width = Math.round(dispW * dpr) canvas.height = Math.round(dispH * dpr) canvas.style.width = `${dispW}px` canvas.style.height = `${dispH}px` const ctx = canvas.getContext('2d') if (!ctx) return ctx.setTransform(dpr, 0, 0, dpr, 0, 0) ctx.imageSmoothingEnabled = false ctx.clearRect(0, 0, dispW, dispH) ctx.drawImage(image, 0, 0, dispW, dispH) // grid ctx.strokeStyle = 'rgba(255,255,255,0.18)' ctx.lineWidth = 1 ctx.beginPath() for (let c = 0; c <= cols; c++) { const x = Math.round((grid.offsetX + c * tw) * scale) + 0.5 ctx.moveTo(x, 0) ctx.lineTo(x, dispH) } for (let r = 0; r <= rows; r++) { const y = Math.round((grid.offsetY + r * th) * scale) + 0.5 ctx.moveTo(0, y) ctx.lineTo(dispW, y) } ctx.stroke() // selection if (col >= 0 && col < cols && row >= 0 && row < rows) { ctx.strokeStyle = '#00e5ff' ctx.lineWidth = 2 ctx.strokeRect( (grid.offsetX + col * tw) * scale + 1, (grid.offsetY + row * th) * scale + 1, tw * scale - 2, th * scale - 2, ) } }, [image, grid, tw, th, cols, rows, col, row])
if (!image) { return ( <p className="text-xs text-muted-foreground"> No tileset loaded — import one in the Tileset editor to pick a tile. </p> ) }
return ( <div ref={wrapRef} className="w-full"> <canvas ref={canvasRef} className="block w-full cursor-crosshair rounded border border-input [image-rendering:pixelated]" onClick={(e) => { const canvas = canvasRef.current if (!canvas || cols === 0 || rows === 0) return const rect = canvas.getBoundingClientRect() const scale = rect.width / image.naturalWidth const ix = (e.clientX - rect.left) / scale const iy = (e.clientY - rect.top) / scale const c = Math.floor((ix - grid.offsetX) / tw) const r = Math.floor((iy - grid.offsetY) / th) if (c >= 0 && c < cols && r >= 0 && r < rows) onPick(c, r) }} /> <p className="mt-1 text-[11px] text-muted-foreground"> Tile {col},{row} · repeats across the map </p> </div> )}
/** The map's backdrop editor: none / solid colour / linear gradient / loaded * image / repeated tileset tile. */export default function MapBackgroundPanel() { const background = useMapStore((s) => s.background) const setBackground = useMapStore((s) => s.setBackground) const fileRef = useRef<HTMLInputElement | null>(null) const kind: 'none' | BgKind = background?.kind ?? 'none'
const selectKind = (next: 'none' | BgKind) => { if (next === 'none') return setBackground(null) if (next === kind) return if (next === 'image') { // keep an existing loaded image if any, else open the file picker if (background?.kind === 'image' && background.dataUrl) return setBackground(DEFAULT_BACKGROUNDS.image) fileRef.current?.click() return } setBackground(DEFAULT_BACKGROUNDS[next]) }
const loadFile = (file: File) => { const reader = new FileReader() reader.onload = () => { const fit = background?.kind === 'image' ? background.fit : 'tile' setBackground({ kind: 'image', name: file.name, dataUrl: String(reader.result), fit }) } reader.readAsDataURL(file) }
return ( <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">Background</h2>
<div className="flex flex-wrap gap-1"> {KINDS.map((k) => ( <button key={k.id} type="button" className={cn( 'rounded-md border px-2 py-1 text-xs', kind === k.id ? 'border-ring bg-accent text-foreground' : 'border-input text-muted-foreground hover:bg-accent', )} onClick={() => selectKind(k.id)} > {k.label} </button> ))} </div>
{background?.kind === 'color' && ( <ColorField value={background.color} onChange={(color) => setBackground({ ...background, color })} /> )}
{background?.kind === 'gradient' && ( <div className="flex flex-col gap-2"> <label className="flex items-center justify-between gap-2"> <span className="text-[11px] text-muted-foreground">Angle°</span> <Input type="number" className="h-7 w-20 text-sm" value={background.angle} onChange={(e) => setBackground({ ...background, angle: Number(e.target.value) || 0 }) } /> </label> <div className="flex flex-col gap-1.5"> {background.stops.map((stop, i) => ( <div key={i} className="flex items-center gap-1.5"> <div className="flex-1"> <ColorField value={stop.color} onChange={(color) => { const stops = background.stops.map((s, j) => (j === i ? { ...s, color } : s)) setBackground({ ...background, stops }) }} /> </div> <Input type="number" min={0} max={100} className="h-7 w-14 text-xs" value={stop.position} onChange={(e) => { const position = Math.min(100, Math.max(0, Number(e.target.value) || 0)) const stops = background.stops.map((s, j) => (j === i ? { ...s, position } : s)) setBackground({ ...background, stops }) }} /> <button type="button" className="px-1 text-muted-foreground hover:text-foreground disabled:opacity-40" disabled={background.stops.length <= 2} onClick={() => setBackground({ ...background, stops: background.stops.filter((_, j) => j !== i), }) } aria-label="Remove stop" > ✕ </button> </div> ))} </div> <Button type="button" variant="outline" size="sm" onClick={() => { const last = background.stops[background.stops.length - 1] const newStop: GradientStop = { color: last?.color ?? '#ffffff', position: 100 } setBackground({ ...background, stops: [...background.stops, newStop] }) }} > + Add stop </Button> </div> )}
{background?.kind === 'image' && ( <div className="flex flex-col gap-2"> <div className="flex items-center gap-2"> <Button type="button" variant="outline" size="sm" onClick={() => fileRef.current?.click()}> {background.dataUrl ? 'Replace image…' : 'Load image…'} </Button> {background.name && ( <span className="min-w-0 flex-1 truncate text-xs text-muted-foreground">{background.name}</span> )} </div> <label className="flex items-center justify-between gap-2"> <span className="text-[11px] text-muted-foreground">Fit</span> <select className="h-7 flex-1 rounded-md border border-input bg-transparent px-2 text-sm capitalize" value={background.fit} onChange={(e) => setBackground({ ...background, fit: e.target.value as BackgroundFit })} > {FITS.map((f) => ( <option key={f} value={f}> {f} </option> ))} </select> </label> </div> )}
{background?.kind === 'tile' && ( <TilePicker col={background.col} row={background.row} onPick={(col, row) => setBackground({ kind: 'tile', col, row })} /> )}
<input ref={fileRef} type="file" accept="image/*" className="hidden" onChange={(e) => { const file = e.target.files?.[0] if (file) loadFile(file) e.target.value = '' }} /> </section> )}