const { useState, useEffect, useRef, useMemo } = React; // ---------- Palettes ---------- const PALETTES = { ink: { bg: "#0A0500", fg: "#FF5C6E", alt: "#15D528", name: "ink & ember" }, forest: { bg: "#022D07", fg: "#FF001F", alt: "#15D528", name: "forest & flare" }, bone: { bg: "#F2EBDD", fg: "#1A1A1A", alt: "#FF001F", name: "bone & black" }, bruise: { bg: "#0B0820", fg: "#FF4DA6", alt: "#7A4DFF", name: "bruise & bubblegum" }, }; // ---------- Primitive Stamps ---------- // Each stamp is a function that returns SVG given (cx, cy, size, color, alt) const STAMPS = { dot: (cx, cy, s, c) => ( ), block: (cx, cy, s, c) => ( ), bar: (cx, cy, s, c) => ( ), arch: (cx, cy, s, c) => ( ), cup: (cx, cy, s, c) => ( ), tri: (cx, cy, s, c) => ( ), triDown: (cx, cy, s, c) => ( ), ring: (cx, cy, s, c) => ( ), cross: (cx, cy, s, c) => ( ), notch: (cx, cy, s, c) => ( ), half: (cx, cy, s, c) => ( ), }; const STAMP_KEYS = Object.keys(STAMPS); // ---------- Preset creatures ---------- // 6x6 grids — each cell is { stamp, color: 'fg'|'alt' } or null const blank = () => Array.from({ length: 6 }, () => Array(6).fill(null)); const PRESETS = { doc: (() => { const g = blank(); // doc: a tall figure with a cup (hat) and dot eyes g[0][2] = { stamp: "cup", color: "fg" }; g[0][3] = { stamp: "cup", color: "fg" }; g[1][1] = { stamp: "arch", color: "fg" }; g[1][2] = { stamp: "block", color: "fg" }; g[1][3] = { stamp: "block", color: "fg" }; g[1][4] = { stamp: "arch", color: "fg" }; g[2][2] = { stamp: "dot", color: "alt" }; g[2][3] = { stamp: "dot", color: "alt" }; g[3][1] = { stamp: "block", color: "fg" }; g[3][2] = { stamp: "bar", color: "fg" }; g[3][3] = { stamp: "bar", color: "fg" }; g[3][4] = { stamp: "block", color: "fg" }; g[4][2] = { stamp: "block", color: "fg" }; g[4][3] = { stamp: "block", color: "fg" }; g[5][1] = { stamp: "half", color: "fg" }; g[5][4] = { stamp: "half", color: "fg" }; return g; })(), duck: (() => { const g = blank(); // duck: a squat round body, beak triangle g[1][3] = { stamp: "arch", color: "alt" }; g[1][4] = { stamp: "block", color: "alt" }; g[2][2] = { stamp: "tri", color: "fg" }; g[2][3] = { stamp: "ring", color: "alt" }; g[2][4] = { stamp: "arch", color: "alt" }; g[3][1] = { stamp: "half", color: "alt" }; g[3][2] = { stamp: "block", color: "alt" }; g[3][3] = { stamp: "block", color: "alt" }; g[3][4] = { stamp: "block", color: "alt" }; g[4][1] = { stamp: "cup", color: "alt" }; g[4][2] = { stamp: "cup", color: "alt" }; g[4][3] = { stamp: "cup", color: "alt" }; g[4][4] = { stamp: "cup", color: "alt" }; g[5][2] = { stamp: "tri", color: "fg" }; g[5][3] = { stamp: "tri", color: "fg" }; return g; })(), totem: (() => { const g = blank(); g[0][2] = { stamp: "tri", color: "fg" }; g[0][3] = { stamp: "tri", color: "fg" }; g[1][2] = { stamp: "ring", color: "fg" }; g[1][3] = { stamp: "ring", color: "fg" }; g[2][1] = { stamp: "bar", color: "fg" }; g[2][2] = { stamp: "cross", color: "alt" }; g[2][3] = { stamp: "cross", color: "alt" }; g[2][4] = { stamp: "bar", color: "fg" }; g[3][2] = { stamp: "block", color: "fg" }; g[3][3] = { stamp: "block", color: "fg" }; g[4][1] = { stamp: "half", color: "alt" }; g[4][2] = { stamp: "notch", color: "fg" }; g[4][3] = { stamp: "notch", color: "fg" }; g[4][4] = { stamp: "half", color: "alt" }; g[5][2] = { stamp: "arch", color: "fg" }; g[5][3] = { stamp: "arch", color: "fg" }; return g; })(), bug: (() => { const g = blank(); g[1][1] = { stamp: "dot", color: "fg" }; g[1][4] = { stamp: "dot", color: "fg" }; g[2][1] = { stamp: "half", color: "alt" }; g[2][2] = { stamp: "ring", color: "fg" }; g[2][3] = { stamp: "ring", color: "fg" }; g[2][4] = { stamp: "half", color: "alt" }; g[3][2] = { stamp: "block", color: "alt" }; g[3][3] = { stamp: "block", color: "alt" }; g[4][1] = { stamp: "tri", color: "fg" }; g[4][2] = { stamp: "cup", color: "alt" }; g[4][3] = { stamp: "cup", color: "alt" }; g[4][4] = { stamp: "tri", color: "fg" }; g[5][2] = { stamp: "dot", color: "fg" }; g[5][3] = { stamp: "dot", color: "fg" }; return g; })(), }; // ---------- Renderer for a 6x6 grid ---------- function CreatureSVG({ grid, size = 360, palette, animated = false, t = 0 }) { const cell = size / 6; return ( {grid.flatMap((row, ry) => row.map((c, cx) => { if (!c) return null; const color = c.color === "alt" ? palette.alt : palette.fg; const px = cx * cell + cell / 2; const py = ry * cell + cell / 2; const wob = animated ? Math.sin((t + ry * 7 + cx * 11) / 18) * 0.04 : 0; const s = cell * (1 + wob); return ( {STAMPS[c.stamp](px, py, s, color)} ); }) )} ); } // ---------- Stamp swatch icon (for palette) ---------- function StampIcon({ stamp, color, bg }) { return ( {STAMPS[stamp](20, 20, 40, color)} ); } // ---------- Main App ---------- function App() { const [tweaks, setTweak] = useTweaks(/*EDITMODE-BEGIN*/{ "paletteKey": "forest", "creatureName": "doc", "showGuides": true, "wobble": true }/*EDITMODE-END*/); const palette = PALETTES[tweaks.paletteKey] || PALETTES.forest; const [grid, setGrid] = useState(() => PRESETS.doc.map(r => r.slice())); const [activeStamp, setActiveStamp] = useState("block"); const [activeColor, setActiveColor] = useState("fg"); const [tool, setTool] = useState("paint"); // paint | erase const [history, setHistory] = useState([]); const [t, setT] = useState(0); const [savedCreatures, setSavedCreatures] = useState([]); const [name, setName] = useState("doc"); const [shake, setShake] = useState(false); // animation tick useEffect(() => { if (!tweaks.wobble) return; let raf; const tick = () => { setT(v => v + 1); raf = requestAnimationFrame(tick); }; raf = requestAnimationFrame(tick); return () => cancelAnimationFrame(raf); }, [tweaks.wobble]); const pushHistory = (g) => { setHistory(h => [...h.slice(-30), g.map(r => r.slice())]); }; const setCell = (r, c) => { pushHistory(grid); setGrid(g => { const ng = g.map(row => row.slice()); if (tool === "erase") ng[r][c] = null; else ng[r][c] = { stamp: activeStamp, color: activeColor }; return ng; }); }; const undo = () => { setHistory(h => { if (!h.length) return h; const last = h[h.length - 1]; setGrid(last); return h.slice(0, -1); }); }; const clear = () => { pushHistory(grid); setGrid(blank()); }; const loadPreset = (key) => { pushHistory(grid); setGrid(PRESETS[key].map(r => r.slice())); setName(key); setTweak("creatureName", key); }; const randomize = () => { pushHistory(grid); const ng = blank(); const fillCount = 12 + Math.floor(Math.random() * 14); for (let i = 0; i < fillCount; i++) { const r = Math.floor(Math.random() * 6); const c = Math.floor(Math.random() * 6); // mirror for symmetry const stamp = STAMP_KEYS[Math.floor(Math.random() * STAMP_KEYS.length)]; const color = Math.random() < 0.7 ? "fg" : "alt"; ng[r][c] = { stamp, color }; ng[r][5 - c] = { stamp, color }; } setGrid(ng); setShake(true); setTimeout(() => setShake(false), 400); }; const save = () => { if (!grid.flat().some(Boolean)) return; setSavedCreatures(s => [{ id: Date.now(), grid: grid.map(r => r.slice()), name: name || "untitled" }, ...s].slice(0, 12)); }; const deleteSaved = (id) => { setSavedCreatures(s => s.filter(x => x.id !== id)); }; const exportSVG = () => { const size = 600; const cell = size / 6; let inner = ``; grid.forEach((row, ry) => row.forEach((c, cx) => { if (!c) return; const color = c.color === "alt" ? palette.alt : palette.fg; const px = cx * cell + cell / 2; const py = ry * cell + cell / 2; const s = cell; const stamps = { dot: ``, block: ``, bar: ``, arch: ``, cup: ``, tri: ``, triDown: ``, ring: ``, cross: ``, notch: ``, half: ``, }; inner += stamps[c.stamp] || ""; })); const svg = `${inner}`; const blob = new Blob([svg], { type: "image/svg+xml" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = `${name || "creature"}.svg`; a.click(); URL.revokeObjectURL(url); }; const cellCount = grid.flat().filter(Boolean).length; return (
block party a creature studio
{Object.entries(PALETTES).map(([k, p]) => ( ))}
{Array.from({ length: 36 }).map((_, i) => { const r = Math.floor(i / 6), c = i % 6; return (
setName(e.target.value)} className="name-input" spellCheck={false} maxLength={24} /> {cellCount} blocks · {palette.name}
drag across cells to paint · hold shift on a stamp to flip · this is yours, not mine
setTweak("paletteKey", v)} options={Object.keys(PALETTES).map(k => ({ value: k, label: k }))} /> setTweak("showGuides", v)} /> setTweak("wobble", v)} />
{Object.keys(PRESETS).map(k => ( loadPreset(k)}>{k} ))}
); } ReactDOM.createRoot(document.getElementById("root")).render();