import React, { useEffect, useMemo, useRef, useState } from "react"; /** * Akari (Light Up) 2D Editor – React Prototype * ------------------------------------------------- * NOW WITH: Doors (edge toggles) & Markers (Start/Exit/Spawn/Treasure) * * Features * - Edit mode: paint Empty / Wall / Numbered (0–4) * - NEW: Door tool – click tiny edge bars between empty cells to cycle: None → Door → Locked * - NEW: Marker tool – place Start/Exit/Spawn/Treasure on empty cells (right-click to remove) * - Play mode: place/remove lamps * - Live lighting with line-of-sight (blocked by black cells) * - Rule checks: coverage, lamp LOS conflicts, numbered cell adjacency * - Resize grid, export/import **UE JSON** (includes doors/markers/tileSize) * * Keyboard shortcuts (focus component): * - e = Empty, w = Wall, 0..4 = Numbered * - space = toggle Edit/Play */ // -------------------- Types -------------------- type Cell = | { kind: "empty" } | { kind: "wall" } | { kind: "number"; value: 0 | 1 | 2 | 3 | 4 }; type DoorState = "none" | "door" | "locked"; type MarkerType = "Start" | "Exit" | "Spawn" | "Treasure"; interface DoorEdge { a: number; b: number; state: DoorState; unlockTag?: string; } interface Marker { cell: number; type: MarkerType; } interface PuzzleJSON { w: number; h: number; cells: string; // rows concatenated or with newlines (\n) notes?: string; // UE extensions tileSize?: number; // default 400 doors?: { a: number; b: number; state?: DoorState; bLocked?: boolean; unlockTag?: string }[]; markers?: { cell: number; type: MarkerType }[]; } // -------------------- Helpers -------------------- const idx = (x: number, y: number, w: number) => y * w + x; const inBounds = (x: number, y: number, w: number, h: number) => x >= 0 && y >= 0 && x < w && y < h; const isBlack = (c: Cell) => c.kind === "wall" || c.kind === "number"; const neighborsCardinal = ( i: number, w: number, h: number ): number[] => { const x = i % w; const y = Math.floor(i / w); const out: number[] = []; if (y > 0) out.push(idx(x, y - 1, w)); if (y < h - 1) out.push(idx(x, y + 1, w)); if (x > 0) out.push(idx(x - 1, y, w)); if (x < w - 1) out.push(idx(x + 1, y, w)); return out; }; const makeEmptyGrid = (w: number, h: number): Cell[] => Array.from({ length: w * h }, () => ({ kind: "empty" as const })); const clampGridSize = (n: number) => Math.max(3, Math.min(30, Math.floor(n))); // Safe cell accessor const cellAt = (cells: Cell[], i: number): Cell | undefined => i >= 0 && i < cells.length ? cells[i] : undefined; // Encode grid to a compact string (row-major): // '.' empty, '#' wall, '0'..'4' numbered const encodeCells = (cells: Cell[], w: number, h: number): string => { let s = ""; for (let y = 0; y < h; y++) { for (let x = 0; x < w; x++) { const c = cells[idx(x, y, w)]; if (c.kind === "empty") s += "."; else if (c.kind === "wall") s += "#"; else s += String(c.value); } if (y < h - 1) s += "\n"; // escaped newline } return s; }; const decodeCells = (s: string, w: number, h: number): Cell[] => { // Handle both Unix (\n) and Windows (\r\n) newlines const rows = s.split(/\r?\n/); const out: Cell[] = []; for (let y = 0; y < h; y++) { const row = rows[y] ?? ""; for (let x = 0; x < w; x++) { const ch = row[x] ?? "."; if (ch === ".") out.push({ kind: "empty" }); else if (ch === "#") out.push({ kind: "wall" }); else if (/[0-4]/.test(ch)) out.push({ kind: "number", value: Number(ch) as 0|1|2|3|4 }); else out.push({ kind: "empty" }); } } return out; }; const edgeKey = (a: number, b: number) => (a < b ? `${a}-${b}` : `${b}-${a}`); // -------------------- Lighting & Checks -------------------- interface Analysis { lit: Set; conflicts: Set; // lamp indices in conflict numberedStatus: Map; unlitEmpty: number[]; allLit: boolean; noConflicts: boolean; numbersAllExact: boolean; } function analyze( cells: Cell[], w: number, h: number, lamps: Set ): Analysis { const lit = new Set(); const conflicts = new Set(); // Light from each lamp along four directions until a black cell const dirs = [ [0,-1], [0,1], [-1,0], [1,0] ] as const; const isLamp = (i: number) => lamps.has(i); for (const L of lamps) { lit.add(L); const x0 = L % w; const y0 = Math.floor(L / w); for (const [dx, dy] of dirs) { let x = x0 + dx, y = y0 + dy; while (inBounds(x, y, w, h)) { const j = idx(x, y, w); const c = cells[j]; if (isBlack(c)) break; lit.add(j); if (isLamp(j)) { conflicts.add(L); conflicts.add(j); } x += dx; y += dy; } } } // Numbered statuses const numberedStatus = new Map(); cells.forEach((c, i) => { if (c.kind === "number") { const neigh = neighborsCardinal(i, w, h); let have = 0; for (const n of neigh) if (lamps.has(n)) have++; const need = c.value; const exact = have === need; const over = have > need; numberedStatus.set(i, { need, have, over, exact }); } }); // Unlit empties const unlitEmpty: number[] = []; cells.forEach((c, i) => { if (c.kind === "empty" && !lit.has(i)) unlitEmpty.push(i); }); const allLit = unlitEmpty.length === 0; const noConflicts = conflicts.size === 0; let numbersAllExact = true; for (const { exact } of numberedStatus.values()) if (!exact) { numbersAllExact = false; break; } return { lit, conflicts, numberedStatus, unlitEmpty, allLit, noConflicts, numbersAllExact }; } // -------------------- UI -------------------- type Mode = "edit" | "play"; type Paint = | { tool: "empty" } | { tool: "wall" } | { tool: "number"; value: 0|1|2|3|4 } | { tool: "door"; state: Exclude } | { tool: "marker"; mtype: MarkerType }; const LampIcon: React.FC<{ className?: string }> = ({ className }) => ( ); const NumberBadge: React.FC<{ n: number; warn?: boolean; ok?: boolean }> = ({ n, warn, ok }) => (
{n}
); const ToolbarButton: React.FC> = ({ className = "", ...props }) => ( ); const StatPill: React.FC<{ ok: boolean; label: string }>=({ ok, label }) => (
{label}
); const MarkerBadge: React.FC<{ type: MarkerType }>=({ type }) => (
{type}
); // -------------------- Self Tests -------------------- function deepEqualCells(a: Cell[], b: Cell[]): boolean { if (a.length !== b.length) return false; for (let i = 0; i < a.length; i++) { const ca = a[i], cb = b[i]; if (ca.kind !== cb.kind) return false; if (ca.kind === "number" && cb.kind === "number" && ca.value !== cb.value) return false; } return true; } // Safe helper used by renderEdgeButtons; exposed for tests function allowedEdgeForTest(cells: Cell[], w: number, h: number, a: number, b: number): boolean { const ca = cellAt(cells, a); const cb = cellAt(cells, b); return !!(ca && cb && ca.kind === "empty" && cb.kind === "empty" && inBounds(a % w, Math.floor(a / w), w, h) && inBounds(b % w, Math.floor(b / w), w, h)); } // Build export payload (pure) so we can unit test schema function buildExportPayload( w: number, h: number, cells: Cell[], tileSize: number, doors: Map, markers: Map ): PuzzleJSON { const doorArray = Array.from(doors.values()).map(d => ({ a: d.a, b: d.b, state: d.state, bLocked: d.state === "locked", unlockTag: d.unlockTag })); const markerArray = Array.from(markers.entries()).map(([cell, type]) => ({ cell, type })); return { w, h, cells: encodeCells(cells, w, h), notes: "Akari 2D editor export", tileSize, doors: doorArray, markers: markerArray, }; } function runSelfTests(): { passed: boolean; details: string[] } { const details: string[] = []; try { // Test 1: encode/decode roundtrip with newlines const w = 3, h = 3; const base = makeEmptyGrid(w, h); base[1] = { kind: "wall" }; base[3] = { kind: "number", value: 2 }; const enc = encodeCells(base, w, h); if (!/\n/.test(enc)) throw new Error("encodeCells should insert newlines"); const dec = decodeCells(enc, w, h); if (!deepEqualCells(base, dec)) throw new Error("decodeCells(encodeCells(..)) mismatch"); details.push("Test 1 OK: roundtrip encode/decode"); // Test 2: Windows newlines support (\r\n) const win = ".#.\r\n2..\r\n..."; const dec2 = decodeCells(win, 3, 3); const enc2 = encodeCells(dec2, 3, 3); if (!/\n/.test(enc2)) throw new Error("Re-encode from Windows newlines should contain \\n"); details.push("Test 2 OK: Windows newlines handled"); // Test 3: analyze lighting from center lamp on empty 3x3 const empty = makeEmptyGrid(3, 3); const lamps = new Set([4]); // center const a = analyze(empty, 3, 3, lamps); const litCount = a.lit.size; // center + row/col (5 cells) if (litCount !== 5) throw new Error(`Analyze lit count expected 5, got ${litCount}`); if (!a.noConflicts) throw new Error("Unexpected conflicts in single-lamp scenario"); details.push("Test 3 OK: analyze center lamp cross lighting"); // Test 4: allowedEdgeForTest must be safe on out-of-bounds indices const small = makeEmptyGrid(2, 2); const safeCheck = allowedEdgeForTest(small, 10, 10, 0, 99); // wildly OOB b if (safeCheck !== false) throw new Error("allowedEdgeForTest should return false for OOB"); details.push("Test 4 OK: safe edge check guards OOB"); // Test 5: export schema sanity const cells2 = makeEmptyGrid(2,2); const doors2 = new Map(); doors2.set("0-1", { a:0, b:1, state:"door" }); const markers2 = new Map([[0, "Start"]]); const payload = buildExportPayload(2,2,cells2,400,doors2,markers2); const json = JSON.stringify(payload); const obj = JSON.parse(json); if (obj.w !== 2 || obj.h !== 2) throw new Error("Export w/h wrong"); if (!Array.isArray(obj.doors) || obj.doors.length !== 1 || obj.doors[0].a !== 0 || obj.doors[0].b !== 1) throw new Error("Export doors wrong"); if (!Array.isArray(obj.markers) || obj.markers[0].cell !== 0 || obj.markers[0].type !== "Start") throw new Error("Export markers wrong"); details.push("Test 5 OK: export schema valid"); return { passed: true, details }; } catch (err) { details.push("FAIL: " + (err as Error).message); return { passed: false, details }; } } // -------------------- Main Component -------------------- export default function Akari2DEditor() { const containerRef = useRef(null); const [w, setW] = useState(10); const [h, setH] = useState(10); const [tileSize, setTileSize] = useState(400); const [cells, setCells] = useState(() => makeEmptyGrid(10, 10)); const [mode, setMode] = useState("edit"); const [paint, setPaint] = useState({ tool: "wall" }); const [lamps, setLamps] = useState>(new Set()); // Doors: store only one edge per pair using key a-b (a>(new Map()); // Markers by cell index const [markers, setMarkers] = useState>(new Map()); // Self-test outcome const [selfTest, setSelfTest] = useState<{ passed: boolean; details: string[] } | null>(null); // Drag paint state const [dragging, setDragging] = useState(false); const dragActionRef = useRef(null); // Recompute analysis on changes const analysis = useMemo(() => analyze(cells, w, h, lamps), [cells, w, h, lamps]); // Focus for keyboard shortcuts & run tests once useEffect(() => { containerRef.current?.focus(); setSelfTest(runSelfTests()); }, []); // Clean doors if cells changed (only between empties); guard OOB indices useEffect(() => { setDoors(prev => { const next = new Map(prev); for (const [k, d] of next) { const ca = cellAt(cells, d.a); const cb = cellAt(cells, d.b); if (!ca || !cb || ca.kind !== "empty" || cb.kind !== "empty") next.delete(k); } return next; }); }, [cells]); // Keyboard shortcuts const onKeyDown: React.KeyboardEventHandler = (e) => { if (e.key === " ") { e.preventDefault(); setMode(m => (m === "edit" ? "play" : "edit")); return; } if (e.key === "e") setPaint({ tool: "empty" }); if (e.key === "w") setPaint({ tool: "wall" }); if (/^[0-4]$/.test(e.key)) setPaint({ tool: "number", value: Number(e.key) as 0|1|2|3|4 }); }; // Resize grid (clears extras) const applyResize = (nw: number, nh: number) => { nw = clampGridSize(nw); nh = clampGridSize(nh); setW(nw); setH(nh); setCells(makeEmptyGrid(nw, nh)); setLamps(new Set()); setDoors(new Map()); setMarkers(new Map()); }; // Painting logic (Edit mode on cells) const paintCell = (i: number, p: Paint) => { if (p.tool === "door" || p.tool === "marker") return; // not a cell paint setCells(prev => { const next = prev.slice(); if (p.tool === "empty") next[i] = { kind: "empty" }; else if (p.tool === "wall") next[i] = { kind: "wall" }; else next[i] = { kind: "number", value: p.value }; return next; }); // If cell is no longer empty, remove lamp & adjacent doors/marker setLamps(prev => { const n = new Set(prev); n.delete(i); return n; }); setMarkers(prev => { const n = new Map(prev); n.delete(i); return n; }); setDoors(prev => { const n = new Map(prev); const neigh = neighborsCardinal(i, w, h); for (const j of neigh) n.delete(edgeKey(Math.min(i, j), Math.max(i, j))); return n; }); }; // Door toggling const toggleDoorBetween = (a: number, b: number, cycleLocked = false) => { if (!inBounds(a % w, Math.floor(a / w), w, h) || !inBounds(b % w, Math.floor(b / w), w, h)) return; if (cellAt(cells, a)?.kind !== "empty" || cellAt(cells, b)?.kind !== "empty") return; const k = edgeKey(a, b); setDoors(prev => { const n = new Map(prev); const cur = n.get(k); if (!cur) { n.set(k, { a: Math.min(a, b), b: Math.max(a, b), state: cycleLocked ? "locked" : "door" }); } else { // cycle: door → locked → none OR door ↔ none depending on input if (cycleLocked) { if (cur.state === "door") cur.state = "locked"; else n.delete(k); } else { if (cur.state === "door") n.delete(k); else cur.state = "door"; } } return n; }); }; // Marker placement const placeMarker = (i: number, t: MarkerType | null) => { if (cellAt(cells, i)?.kind !== "empty") return; setMarkers(prev => { const n = new Map(prev); if (t) n.set(i, t); else n.delete(i); return n; }); }; // Cell interactions const onCellMouseDown = (i: number, e: React.MouseEvent) => { e.preventDefault(); setDragging(true); if (mode === "edit") { // Marker tool if (paint.tool === "marker") { if (e.button === 2) placeMarker(i, null); else placeMarker(i, paint.mtype); return; } // Right click cycles numbers let p = paint; if (p.tool !== "marker" && e.button === 2) { const c = cells[i]; if (c.kind === "number") { const nv = ((c.value + 1) % 5) as 0|1|2|3|4; p = { tool: "number", value: nv }; } else { p = { tool: "number", value: 0 }; } } dragActionRef.current = p; paintCell(i, p); } else { // Play mode: toggle lamp on left click for empty cells const c = cells[i]; if (c.kind === "empty" && e.button === 0) { setLamps(prev => { const n = new Set(prev); if (n.has(i)) n.delete(i); else n.add(i); return n; }); } } }; const onCellMouseEnter = (i: number) => { if (!dragging) return; if (mode === "edit" && dragActionRef.current) paintCell(i, dragActionRef.current); }; const onMouseUp = () => { setDragging(false); dragActionRef.current = null; }; // Export / Import const buildDoorArray = (): DoorEdge[] => Array.from(doors.values()).map(d => ({ ...d })); const buildMarkerArray = (): Marker[] => Array.from(markers.entries()).map(([cell, type]) => ({ cell, type })); const exportJSON = (): string => { const payload = buildExportPayload(w, h, cells, tileSize, doors, markers); return JSON.stringify(payload, null, 2); }; const importJSON = (text: string) => { try { const obj = JSON.parse(text) as PuzzleJSON; if (!obj || typeof obj.w !== "number" || typeof obj.h !== "number" || typeof obj.cells !== "string") throw new Error("Invalid format"); const nw = clampGridSize(obj.w); const nh = clampGridSize(obj.h); const newCells = decodeCells(obj.cells, nw, nh); setW(nw); setH(nh); setCells(newCells); setLamps(new Set()); setTileSize(typeof obj.tileSize === "number" ? obj.tileSize : 400); // Doors const map = new Map(); for (const d of obj.doors ?? []) { const a = Math.min(d.a, d.b), b = Math.max(d.a, d.b); map.set(edgeKey(a, b), { a, b, state: d.state ?? (d.bLocked ? "locked" : "door"), unlockTag: d.unlockTag }); } setDoors(map); // Markers const mm = new Map(); for (const m of obj.markers ?? []) { if (typeof m.cell === "number" && m.cell >= 0) mm.set(m.cell, m.type); } setMarkers(mm); } catch (err) { alert("Import failed: " + (err as Error).message); } }; const copyExport = async () => { const text = exportJSON(); try { await navigator.clipboard.writeText(text); alert("JSON copied to clipboard."); } catch { // Fallback for browsers without clipboard API (e.g., some Safari contexts) const ta = document.createElement("textarea"); ta.value = text; ta.style.position = "fixed"; ta.style.opacity = "0"; document.body.appendChild(ta); ta.focus(); ta.select(); try { document.execCommand("copy"); alert("JSON copied to clipboard."); } catch {} document.body.removeChild(ta); } }; const downloadExport = () => { const text = exportJSON(); const filename = `akari_${w}x${h}.json`; // Attempt 1: data-URL anchor (very compatible in constrained embeds) try { const a = document.createElement("a"); a.setAttribute("href", "data:application/json;charset=utf-8," + encodeURIComponent(text)); a.setAttribute("download", filename); a.style.display = "none"; document.body.appendChild(a); a.click(); document.body.removeChild(a); return; } catch (e) { // fall through } // Attempt 2: Blob + ObjectURL try { const blob = new Blob([text], { type: "application/json" }); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = filename; a.rel = "noopener"; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 0); return; } catch (e2) { // Attempt 3: Open in new tab (may be blocked by popup blockers) try { const dataUrl = "data:application/json;charset=utf-8," + encodeURIComponent(text); const win = window.open(dataUrl, "_blank"); if (!win) throw new Error("Popup blocked"); return; } catch (e3) { // Final fallback: copy to clipboard so the user can paste into a file copyExport(); alert("Download was blocked by the browser. JSON has been copied to your clipboard."); } } }; const clearLamps = () => setLamps(new Set()); const clearGrid = () => { setCells(makeEmptyGrid(w, h)); clearLamps(); setDoors(new Map()); setMarkers(new Map()); }; const summaryOK = analysis.allLit && analysis.noConflicts && analysis.numbersAllExact; // Render helpers for edge hit targets (E and S edges only to avoid duplicates) const renderEdgeButtons = (i: number) => { const x = i % w; const y = Math.floor(i / w); const items: JSX.Element[] = []; const isEmpty = cellAt(cells, i)?.kind === "empty"; // East edge (i < right) if (x < w - 1) { const j = idx(x + 1, y, w); const key = edgeKey(i, j); const door = doors.get(key); const allowed = allowedEdgeForTest(cells, w, h, i, j); items.push(
{ e.preventDefault(); if (!allowed) return; if (paint.tool === "door") toggleDoorBetween(i, j, e.button===2); }} title={allowed ? (door?.state ?? "none") : "(edge blocked)"} className={`absolute top-1/2 -right-0.5 -translate-y-1/2 w-1.5 h-6 rounded ${!allowed ? "opacity-20" : door ? (door.state === "locked" ? "bg-rose-500" : "bg-amber-400") : "bg-slate-600 hover:bg-slate-400"} ${paint.tool === "door" ? "cursor-pointer" : "pointer-events-none"}`} /> ); } // South edge (i < bottom) if (y < h - 1) { const j = idx(x, y + 1, w); const key = edgeKey(i, j); const door = doors.get(key); const allowed = allowedEdgeForTest(cells, w, h, i, j); items.push(
{ e.preventDefault(); if (!allowed) return; if (paint.tool === "door") toggleDoorBetween(i, j, e.button===2); }} title={allowed ? (door?.state ?? "none") : "(edge blocked)"} className={`absolute left-1/2 -bottom-0.5 -translate-x-1/2 w-6 h-1.5 rounded ${!allowed ? "opacity-20" : door ? (door.state === "locked" ? "bg-rose-500" : "bg-amber-400") : "bg-slate-600 hover:bg-slate-400"} ${paint.tool === "door" ? "cursor-pointer" : "pointer-events-none"}`} /> ); } return items; }; return (
{/* Header */}

Akari 2D Editor – Prototype

setMode("edit")}>Edit setMode("play")}>Play / Test
{/* Controls */}
{/* Grid / Size */}
Grid
setW(clampGridSize(Number(e.target.value)||10))} /> × setH(clampGridSize(Number(e.target.value)||10))} /> applyResize(w,h)}>Resize
{/* Cell tool */}
Cell Tool
setPaint({ tool: "empty" })}>Empty setPaint({ tool: "wall" })}>Wall {[0,1,2,3,4].map(n=> ( setPaint({ tool: "number", value: n as 0|1|2|3|4 })}>#{n} ))}
Right-click to cycle numbers
{/* Door tool */}
Edge Door
setPaint({ tool: "door", state: "door" })}>Door setPaint({ tool: "door", state: "locked" })}>Locked
Click edge bars; right-click = next
{/* Markers & IO */}
Marker
{["Start","Exit","Spawn","Treasure"].map(t => ( setPaint({ tool: "marker", mtype: t as MarkerType })}>{t} ))}
{/* IO row */}
Tile Size
setTileSize(Number(e.target.value)||400)} /> Clear Grid Clear Lamps
Export UE JSON Copy
{/* Status */}
All empty cells lit No lamp LOS conflicts Numbered cells exact
{summaryOK ? "Looks solved ✔" : "Not solved"}
{/* Self-test report */}
Self-tests
{selfTest ? (
{selfTest.passed ? "PASS" : "FAIL"}
    {selfTest.details.map((d, i) =>
  • {d}
  • )}
) : (
Running…
)}
{/* Grid */}
e.preventDefault()} className="inline-block rounded-3xl p-3 bg-slate-900 border border-slate-800 shadow-inner" style={{ userSelect: "none" }} >
{cells.map((c, i) => { const isEmpty = c.kind === "empty"; const isWall = c.kind === "wall"; const isNumber = c.kind === "number"; const hasLamp = lamps.has(i); const lit = analysis.lit.has(i); const conflict = hasLamp && analysis.conflicts.has(i); const numStatus = isNumber ? analysis.numberedStatus.get(i) : undefined; const numberOK = !!numStatus?.exact && !numStatus?.over; const numberOver = !!numStatus?.over; return (
onCellMouseDown(i,e)} onMouseEnter={()=>onCellMouseEnter(i)} className={`relative w-9 h-9 rounded-md flex items-center justify-center select-none border ${isWall || isNumber ? "bg-slate-700 border-slate-600" : lit ? "bg-amber-200/70 border-amber-300" : "bg-slate-800 border-slate-700"} ${mode === "play" && isEmpty ? "cursor-pointer" : mode === "edit" ? "cursor-crosshair" : ""} `} title={`(${i % w}, ${Math.floor(i / w)})`} > {/* Numbered cell badge */} {isNumber && ( )} {/* Lamp */} {hasLamp && ( ) } {/* Marker */} {markers.has(i) && } {/* Unlit empty hint */} {isEmpty && !lit && (
)} {/* Conflict aura */} {conflict && (
)} {/* Edge buttons (only render in Edit mode) */} {mode === "edit" && renderEdgeButtons(i)}
); })}
{/* Footer hints */}
Edit Tips
  • Pick a Cell tool (Empty / Wall / #0–#4) and drag to paint.
  • Select Door tool, then click tiny edge bars between two empty cells to toggle door/locked.
  • Select Marker tool to place Start/Exit/Spawn/Treasure. Right-click a cell to remove the marker.
Play/Test Tips
  • Click empty cells to place/remove lamps.
  • Lamps light orthogonally until blocked by black cells.
  • Red glow = lamp sees another lamp. Yellow ring = cell is still dark.
); }