import { DEFAULT_ENTITIES, DEFAULT_THEMES, APP_VERSION, APP_CHANGE } from './js/constants/data.js'; import { drawEntityFace } from './js/utils/entityFaces.js'; import { formatTime } from './js/utils/time.js'; import { generateMaze, spawnEntities } from './js/engine/mapGen.js'; import { updateEntities, updateEntitiesTick, checkLineOfSight } from './js/engine/ai.js'; import { createPlayer, updatePlayer, updatePlayerSnap, getFloatPos, getFloatAngle } from './js/engine/player.js'; import { createKeyState, getIntent, getMapIntent } from './js/engine/input.js'; import { WebGLMazeRenderer } from './js/engine/renderers/webgl/index.js'; import { drawOverlay } from './js/engine/renderers/overlay.js'; import { DEATH_MESSAGES } from './js/constants/deathMessages.js'; import { MenuIcon, XIcon, DatabaseIcon, LogoIcon, BrushIcon, PlusIcon, CopyIcon, TrashIcon, ClockIcon, UserIcon, LayersIcon, RotateCcwIcon, SettingsIcon, InfoIcon, LockIcon, SaveIcon, PlayIcon, QuitIcon } from './js/components/icons.js'; import HUD from './js/components/HUD.js'; import EntityEditor from './js/components/EntityEditor.js'; import DataHUD from './js/components/DataHUD.js'; const { useState, useEffect, useRef, useCallback } = React; const zoneMapColor = (zone) => { if (!zone) return '#334155'; const m = zone.textureMode || 'solid'; if (m === 'brick') return zone.brickColor || zone.color || '#334155'; if (m === 'tiles' || m === 'hex') return zone.tileColor || zone.color || '#334155'; if (m === 'wood') return zone.woodColor || zone.color || '#334155'; if (m === 'concrete') return zone.panelColor || zone.color || '#334155'; if (m === 'checker') return zone.colorA || zone.color || '#334155'; if (m === 'metal') return zone.metalColor || zone.color || '#334155'; if (m === 'stucco') return zone.stuccoColor || zone.color || '#334155'; if (m === 'circuit') return zone.boardColor || zone.color || '#334155'; return zone.color || '#334155'; }; const resolveMapWallColor = (theme) => { const zones = theme.wallZones || []; const mode = theme.mapWallMode || 'z0'; if (mode === 'custom') return theme.mapWallColor || '#334155'; if (mode === 'cap') return theme.capColor || zoneMapColor(zones[0]) || '#334155'; if (mode === 'wallOutline') return theme.wallOutlineColor || zoneMapColor(zones[0]) || '#334155'; const zIdx = mode.startsWith('z') ? (parseInt(mode.slice(1)) || 0) : 0; return zoneMapColor(zones[zIdx]) || '#334155'; }; const resolveMapFloorColor = (theme) => { const mode = theme.mapFloorMode || 'auto'; if (mode === 'custom') return theme.mapFloorColor || '#f8fafc'; if (mode === 'floorOutline') return theme.floorOutlineColor || theme.floor || '#f8fafc'; return theme.floor || '#f8fafc'; }; const resolveMapExitColor = (theme) => { const mode = theme.mapExitMode || 'auto'; if (mode === 'custom') return theme.mapExitColor || '#ef4444'; if (mode === 'exitOutline') return theme.exitOutlineColor || theme.exitColor || '#ef4444'; return theme.exitColor || '#ef4444'; }; function App() { const canvasRef = useRef(null); const minimapRef = useRef(null); const [menuOpen, setMenuOpen] = useState(false); const [activeModal, setActiveModal] = useState(null); const [gameState, setGameState] = useState('menu'); const [gameData, setGameData] = useState(() => { const saved = localStorage.getItem('maze_data_v1'); if (saved) return JSON.parse(saved); return { themes: DEFAULT_THEMES, entities: DEFAULT_ENTITIES }; }); const [savedData, setSavedData] = useState(() => { const saved = localStorage.getItem('maze_data_v1'); if (saved) return JSON.parse(saved); return { themes: DEFAULT_THEMES, entities: DEFAULT_ENTITIES }; }); const [appSettings, setAppSettings] = useState(() => { const saved = localStorage.getItem('maze_settings_v1'); return saved ? JSON.parse(saved) : { theme: 'system' }; }); const [settingsTab, setSettingsTab] = useState('appearance'); const [startThemeId, setStartThemeId] = useState(gameData.themes[0].id); const startThemeIdRef = useRef(gameData.themes[0].id); const [lives, setLives] = useState(3); const [designerTab, setDesignerTab] = useState('themes'); const [themeSubTab, setThemeSubTab] = useState('surfaces'); const [surfacePill, setSurfacePill] = useState('wall'); const [editingThemeId, setEditingThemeId] = useState(gameData.themes[0].id); const [editingEntityId, setEditingEntityId] = useState(gameData.entities[0]?.id); const [deleteConfirmItem, setDeleteConfirmItem] = useState(null); // { id, type: 'theme' | 'entity' } const savePreset = (preset, type) => { setSavedData(prev => { const next = { ...prev }; if (type === 'theme') { const idx = next.themes.findIndex(t => t.id === preset.id); if (idx >= 0) next.themes = [...next.themes.slice(0, idx), JSON.parse(JSON.stringify(preset)), ...next.themes.slice(idx + 1)]; else next.themes = [...next.themes, JSON.parse(JSON.stringify(preset))]; } else { const idx = next.entities.findIndex(e => e.id === preset.id); if (idx >= 0) next.entities = [...next.entities.slice(0, idx), JSON.parse(JSON.stringify(preset)), ...next.entities.slice(idx + 1)]; else next.entities = [...next.entities, JSON.parse(JSON.stringify(preset))]; } localStorage.setItem('maze_data_v1', JSON.stringify(next)); return next; }); }; const [clearConfirm, setClearConfirm] = useState(false); const clearConfirmRef = useRef(false); const [viewMode, setViewMode] = useState('3d'); const [devMode, setDevMode] = useState(false); const [devInvis, setDevInvis] = useState(false); const [devRadar, setDevRadar] = useState(true); const [debugFlags, setDebugFlags] = useState({ map: true, lines: true, floor: true, entities: true, ai: true, exits: true, lights: true, radar: true, raycast: true, webgl: false }); const [level, setLevel] = useState(1); const [currentGridSize, setCurrentGridSize] = useState(21); const [scores, setScores] = useState(() => JSON.parse(localStorage.getItem('maze_scores_v1') || '{}')); const [mapGenPreview, setMapGenPreview] = useState(null); const [mapGenPreviewSize, setMapGenPreviewSize] = useState(21); const [textureFiles, setTextureFiles] = useState([]); const [entityImageFiles, setEntityImageFiles] = useState([]); const [entityFolders, setEntityFolders] = useState([]); const [listSearch, setListSearch] = useState(''); const [listSort, setListSort] = useState(null); // null | 'az' | 'za' const [themeFilterCat, setThemeFilterCat] = useState(null); // Two separate questions, deliberately not conflated: // - isMobile: "how much space is there?" — width-only, matches Tailwind's md // breakpoint (768px) exactly, same signal the rest of the app's responsive CSS // already uses. Drives LAYOUT SHAPE: compact single-card HUD vs the wide // Info/Map sidebar, main stacking, canvas aspect ratio. // - isTouchDevice: "can this device press a key?" — capability-based, via // navigator.maxTouchPoints, a static hardware fact that doesn't need a live // listener the way width does. Drives WHICH CONTROLS show: floating icon pill // vs keyboard hint text. A pointer/hover media-query check was tried first but // proved unreliable across DevTools device emulation; maxTouchPoints is the // more dependable signal for real hardware. // Without this split, a wide touch device (iPad Pro, 1024px) would get the width- // based "desktop" treatment and be shown keyboard hints with no way to act on them. const MOBILE_QUERY = '(max-width: 767px)'; const [isMobile, setIsMobile] = useState(() => typeof window !== 'undefined' && window.matchMedia && window.matchMedia(MOBILE_QUERY).matches ); useEffect(() => { if (typeof window === 'undefined' || !window.matchMedia) return; const mql = window.matchMedia(MOBILE_QUERY); const onChange = () => setIsMobile(mql.matches); mql.addEventListener('change', onChange); return () => mql.removeEventListener('change', onChange); }, []); const [isTouchDevice] = useState(() => typeof navigator !== 'undefined' && navigator.maxTouchPoints > 0 ); const showTouchControls = isMobile || isTouchDevice; // Genuinely short viewports (landscape phones, ~550px height or less) — separate // from isMobile (width) on purpose. Caps the game box's height so it can't force // page scroll on these, WITHOUT touching typical laptop screens: a common laptop's // browser viewport (often ~650-750px tall) is well above this threshold, so the // box keeps its normal size there — this cap only exists for the extreme case. const SHORT_QUERY = '(max-height: 550px)'; const [isShortViewport, setIsShortViewport] = useState(() => typeof window !== 'undefined' && window.matchMedia && window.matchMedia(SHORT_QUERY).matches ); useEffect(() => { if (typeof window === 'undefined' || !window.matchMedia) return; const mql = window.matchMedia(SHORT_QUERY); const onChange = () => setIsShortViewport(mql.matches); mql.addEventListener('change', onChange); return () => mql.removeEventListener('change', onChange); }, []); const gameStateRef = useRef('menu'); const devModeRef = useRef(false); const devGridRef = useRef(true); const devInvisRef = useRef(false); const devRadarRef = useRef(true); const debugFlagsRef = useRef({ map: true, lines: true, floor: true, entities: true, ai: true, exits: true, lights: true, radar: true, raycast: true, webgl: false }); const viewModeRef = useRef('3d'); const webglCanvasRef = useRef(null); const gameAreaRef = useRef(null); const touchGestureRef = useRef({ active: false, startX: 0, startY: 0, key: null }); const sidebarRef = useRef(null); const webglRendererRef = useRef(null); const pixelPreviewCanvasRef = useRef(null); const webglPreviewCanvasRef = useRef(null); const webglPreviewRendererRef = useRef(null); const mapGenPreviewCanvasRef = useRef(null); const gridSizeRef = useRef(21); const levelRef = useRef(1); const livesRef = useRef(3); const fpsRef = useRef(null); const previewClockRef = useRef(null); const mapGenPreviewThemeRef = useRef(null); // tracks which theme the current 2D preview was generated for const fpsCanvasRef = useRef(null); const entitiesRef = useRef(null); const entityCountRef = useRef(null); const proximityCanvasRef = useRef(null); const overlayCanvasRef = useRef(null); const overlayPreviewCanvasRef = useRef(null); const mappedPercentRef = useRef(null); const keyBufferRef = useRef(''); const pendingIntentRef = useRef(null); const activeModalRef = useRef(activeModal); const designerSnapshotRef = useRef(null); const themeRef = useRef(gameData.themes[0].theme); const dataRef = useRef(gameData); const editingThemeIdRef = useRef(editingThemeId); // Drag State for Preview Panning const isDraggingRef = useRef(false); const lastMouseXRef = useRef(0); const handlePreviewMouseDown = useCallback((e) => { isDraggingRef.current = true; lastMouseXRef.current = e.clientX; }, []); const handlePreviewMouseMove = useCallback((e) => { if (!isDraggingRef.current) return; engine.current.player.renderAngle -= (e.clientX - lastMouseXRef.current) * 0.5; lastMouseXRef.current = e.clientX; }, []); const handlePreviewMouseUp = useCallback(() => { isDraggingRef.current = false; }, []); // Mobile touch-and-hold movement: swipe direction picks a virtual key (w/a/s/d), // held until release — feeds the same engine.current.keys state the keyboard // uses, so getIntent/getMapIntent need no changes for either view mode. const TOUCH_DEADZONE = 12; const TOUCH_SPRINT_THRESHOLD = 70; // drag past this (along the locked axis) engages sprint const handleGameTouchStart = useCallback((e) => { if (gameStateRef.current !== 'playing') return; const t = e.touches[0]; touchGestureRef.current = { active: true, startX: t.clientX, startY: t.clientY, key: null }; }, []); const handleGameTouchMove = useCallback((e) => { const g = touchGestureRef.current; if (!g.active) return; // Must call this on every touchmove, starting with the first — the browser // decides whether the gesture is a scroll on that first event. Calling it // only after the deadzone is crossed lets the browser commit to scrolling // first, after which every subsequent touchmove is cancelable:false and // preventDefault() throws an "ignored" intervention warning on each one. if (e.cancelable) e.preventDefault(); const t = e.touches[0]; const dx = t.clientX - g.startX, dy = t.clientY - g.startY; if (!g.key) { if (Math.hypot(dx, dy) < TOUCH_DEADZONE) return; const horizontal = Math.abs(dx) > Math.abs(dy); g.key = horizontal ? (dx < 0 ? 'a' : 'd') : (dy < 0 ? 'w' : 's'); engine.current.keys[g.key] = true; } // Sprint tiers off the same continuous drag distance along the locked axis — // drag further to sprint, pull back toward the start point to drop to walk. const mag = (g.key === 'a' || g.key === 'd') ? Math.abs(dx) : Math.abs(dy); engine.current.keys.Shift = mag > TOUCH_SPRINT_THRESHOLD; }, []); const handleGameTouchEnd = useCallback(() => { const g = touchGestureRef.current; if (g.key) engine.current.keys[g.key] = false; engine.current.keys.Shift = false; touchGestureRef.current = { active: false, startX: 0, startY: 0, key: null }; }, []); // React binds JSX touch handlers as passive, so e.preventDefault() inside // handleGameTouchMove throws ("preventDefault inside passive event listener"). // Attach natively with { passive: false } instead. Always attached, independent // of isMobile (which only controls layout/width) — a real touch-capable device // (e.g. an iPad at 820px, above the mobile layout breakpoint) should still get // swipe controls; these listeners are simply inert on devices that never fire // touch events. useEffect(() => { const el = gameAreaRef.current; if (!el) return; el.addEventListener('touchstart', handleGameTouchStart, { passive: true }); el.addEventListener('touchmove', handleGameTouchMove, { passive: false }); el.addEventListener('touchend', handleGameTouchEnd, { passive: true }); el.addEventListener('touchcancel', handleGameTouchEnd, { passive: true }); return () => { el.removeEventListener('touchstart', handleGameTouchStart); el.removeEventListener('touchmove', handleGameTouchMove); el.removeEventListener('touchend', handleGameTouchEnd); el.removeEventListener('touchcancel', handleGameTouchEnd); }; }, [handleGameTouchStart, handleGameTouchMove, handleGameTouchEnd]); useEffect(() => { dataRef.current = gameData; if (!gameData.themes.find(t => t.id === startThemeId)) setStartThemeId(gameData.themes[0].id); }, [gameData, startThemeId]); // MHCore Auto Backups useEffect(() => { if (typeof window.MHCore !== 'undefined' && window.MHCore.backups) { window.MHCore.backups.startAutoBackup('maze_data', { getStateFn: () => localStorage.getItem('maze_data_v1') || '{}', intervalMinutes: 10, maxRecent: 5, maxHourly: 24, maxDaily: 7 }); } return () => { if (typeof window.MHCore !== 'undefined' && window.MHCore.backups) { window.MHCore.backups.stopAutoBackup('maze_data'); } }; }, []); useEffect(() => { localStorage.setItem('maze_settings_v1', JSON.stringify(appSettings)); const isDark = appSettings.theme === 'dark' || (appSettings.theme === 'system' && window.matchMedia('(prefers-color-scheme: dark)').matches); if (isDark) { document.documentElement.classList.add('dark'); } else { document.documentElement.classList.remove('dark'); } }, [appSettings.theme]); useEffect(() => { clearConfirmRef.current = clearConfirm; }, [clearConfirm]); useEffect(() => { activeModalRef.current = activeModal; if (activeModal === 'level_designer') { designerSnapshotRef.current = JSON.parse(JSON.stringify(gameData)); } }, [activeModal]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { editingThemeIdRef.current = editingThemeId; }, [editingThemeId]); useEffect(() => { startThemeIdRef.current = startThemeId; }, [startThemeId]); useEffect(() => { if (gameState !== 'playing') document.exitPointerLock?.(); }, [gameState]); // Keep sidebar height locked to the game canvas height useEffect(() => { const area = gameAreaRef.current; const sidebar = sidebarRef.current; if (!area || !sidebar) return; const sync = () => { sidebar.style.height = area.offsetHeight + 'px'; }; const ro = new ResizeObserver(sync); ro.observe(area); sync(); return () => ro.disconnect(); }, []); useEffect(() => { const wr = new WebGLMazeRenderer(); wr.init(webglCanvasRef.current); webglRendererRef.current = wr; webglPreviewRendererRef.current = new WebGLMazeRenderer(); return () => { wr.dispose(); webglPreviewRendererRef.current?.dispose(); }; }, []); const loadTextureManifest = useCallback(async () => { try { const res = await fetch('assets/textures/manifest.json?t=' + Date.now()); const list = await res.json(); setTextureFiles(Array.isArray(list) ? list : []); } catch { setTextureFiles([]); } }, []); useEffect(() => { loadTextureManifest(); }, [loadTextureManifest]); const loadEntityManifest = useCallback(async () => { try { const res = await fetch('assets/entities/manifest.json?t=' + Date.now()); const data = await res.json(); const files = Array.isArray(data) ? data : (data.files ?? []); const folders = Array.isArray(data) ? [] : (data.folders ?? []); setEntityImageFiles(files); const folderMetas = await Promise.all(folders.map(async f => { try { const m = await fetch(`assets/entities/${f}/manifest.json`).then(r => r.json()); return { folder: f, ...m }; } catch { return { folder: f, name: f, sheet: 'sheet.png', cols: 4, rows: 1 }; } })); setEntityFolders(folderMetas); } catch { setEntityImageFiles([]); setEntityFolders([]); } }, []); useEffect(() => { loadEntityManifest(); }, [loadEntityManifest]); const engine = useRef({ map: [], discovered: [], entities: [], rooms: [], isolatedClusters: [], clusterOf: [], clusterPerimeters: [], exit: { x: 0, y: 0 }, timeOfDay: 12, lastFrames: [], player: createPlayer(1, 1, 0, -1, 0), keys: createKeyState(), isPreviewMode: false, textureCache: {} }); const staminaBarFillRef = useRef(null); const lastTimeRef = useRef(0); const requestRef = useRef(null); // Single function — generates one map and applies it to BOTH the 2D preview state // and the 3D engine. Always called with a fully-constructed lvl object so there is // no timing dependency on React state settling. const syncPreview = useCallback((lvl) => { const mg = lvl.theme.mapGen || {}; const size = lvl.gridSize || 21; const { map, exitCandidates } = generateMaze(size, size, { loopFactor: mg.loopFactor ?? 0.08, mode: mg.mode ?? 'maze', roomDensity: mg.roomDensity ?? 0.15, roomMinSize: mg.roomMinSize ?? 4, roomMaxSize: mg.roomMaxSize ?? 8, columnDensity: mg.columnDensity ?? 0.15, columnMaxW: mg.columnMaxW ?? 3, columnMaxH: mg.columnMaxH ?? 2, seed: mg.seed ?? null, }); map[1][1] = 0; const validExits = exitCandidates ?? (() => { const e = []; for (let y = 1; y < size - 1; y++) for (let x = 1; x < size - 1; x++) if (map[y][x] === 0 && Math.hypot(x - 1, y - 1) > size / 2) e.push({ x, y }); return e; })(); let exit = validExits.length > 0 ? validExits[Math.floor(Math.random() * validExits.length)] : null; if (!exit || map[exit.y]?.[exit.x] !== 0) { // fallback: furthest reachable floor cell from spawn (handles single-room maps // too small to satisfy the hypot > size/2 threshold above) let bestDist = -1; for (let fy = 1; fy < size - 1; fy++) for (let fx = 1; fx < size - 1; fx++) if (map[fy][fx] === 0) { const d = Math.hypot(fx - 1, fy - 1); if (d > bestDist) { bestDist = d; exit = { x: fx, y: fy }; } } exit = exit || { x: size - 2, y: size - 2 }; } map[exit.y][exit.x] = 2; // 2D preview setMapGenPreview(map); // 3D engine themeRef.current = lvl.theme; let startDirX = 0, startDirY = -1, startAngle = 0; if (map[2]?.[1] === 0) { startDirX = 0; startDirY = 1; startAngle = 180; } else if (map[1]?.[2] === 0) { startDirX = 1; startDirY = 0; startAngle = 90; } engine.current.map = map; engine.current.exit = exit; engine.current.discovered = Array.from({ length: size }, () => Array(size).fill(true)); let previewFloor = 0; for (let y = 1; y < size - 1; y++) for (let x = 1; x < size - 1; x++) if (map[y][x] !== 1) previewFloor++; const entCount = Math.max(1, Math.floor(previewFloor * ((lvl.theme.entityDensity ?? 1) + 4 * (lvl.theme.entityDensityScaling ?? 0.12)) / 100)); engine.current.entities = spawnEntities(map, entCount, size, lvl.theme, 5, dataRef.current.entities); engine.current.timeOfDay = lvl.theme.timeOfDay ?? 12; engine.current.isPreviewMode = true; engine.current.player = createPlayer(1, 1, startDirX, startDirY, startAngle); engine.current.tickAccumulator = 0; }, []); // eslint-disable-line react-hooks/exhaustive-deps // Thin wrapper kept for the Map-tab Regenerate button (uses mapGenPreviewSize slider) const regenerateMapGenPreview = useCallback((lvl, sizeOverride) => { syncPreview(sizeOverride ? { ...lvl, gridSize: sizeOverride } : lvl); }, [syncPreview]); useEffect(() => { if (activeModal !== 'level_designer') return; const lvl = dataRef.current.themes.find(t => t.id === editingThemeId) || dataRef.current.themes[0]; if (lvl) { mapGenPreviewThemeRef.current = editingThemeId; setMapGenPreviewSize(lvl.gridSize || 21); syncPreview(lvl); } }, [editingThemeId, activeModal, syncPreview]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (themeSubTab === 'map' && activeModal === 'level_designer') { const lvl = gameData.themes.find(t => t.id === editingThemeId); if (lvl && mapGenPreviewThemeRef.current !== editingThemeId) { mapGenPreviewThemeRef.current = editingThemeId; syncPreview(lvl); } } }, [themeSubTab, editingThemeId, activeModal]); // eslint-disable-line react-hooks/exhaustive-deps useEffect(() => { if (themeSubTab !== 'map' || !mapGenPreview || !mapGenPreviewCanvasRef.current) return; const lvl = gameData.themes.find(t => t.id === editingThemeId); if (!lvl) return; const canvas = mapGenPreviewCanvasRef.current; const ctx = canvas.getContext('2d'); const theme = lvl.theme; const size = mapGenPreview.length; const cs = canvas.width / size; ctx.fillStyle = '#0f172a'; ctx.fillRect(0, 0, canvas.width, canvas.height); const mapWallClr = resolveMapWallColor(theme); const mapFloorClr = resolveMapFloorColor(theme); for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { ctx.fillStyle = mapGenPreview[y][x] === 1 ? mapWallClr : mapGenPreview[y][x] === 2 ? resolveMapExitColor(theme) : mapFloorClr; ctx.fillRect(Math.floor(x * cs), Math.floor(y * cs), Math.ceil(cs) + 1, Math.ceil(cs) + 1); } } ctx.fillStyle = theme.player || '#10b981'; ctx.beginPath(); ctx.arc(1.5 * cs, 1.5 * cs, cs * 0.4, 0, Math.PI * 2); ctx.fill(); }, [mapGenPreview, themeSubTab, editingThemeId, gameData]); const recordScore = useCallback((themeId, completedLevel) => { setScores(prev => { const cur = prev[themeId]?.bestLevel || 0; if (completedLevel <= cur) return prev; const next = { ...prev, [themeId]: { bestLevel: completedLevel } }; localStorage.setItem('maze_scores_v1', JSON.stringify(next)); return next; }); }, []); const initGame = useCallback((levelOverride = 1) => { const themeData = dataRef.current.themes.find(t => t.id === startThemeIdRef.current) || dataRef.current.themes[0]; levelRef.current = levelOverride; setLevel(levelOverride); themeRef.current = themeData.theme; const baseSize = themeData.gridSize ?? 21; const sizeInc = themeData.gridSizeIncrement ?? 0; const size = baseSize + (levelOverride - 1) * sizeInc; gridSizeRef.current = size; setCurrentGridSize(size); const mg = themeData.theme.mapGen || {}; const effectiveSeed = mg.seed != null ? mg.seed + levelOverride - 1 : null; const { map: newMap, exitCandidates, rooms } = generateMaze(size, size, { loopFactor: mg.loopFactor ?? 0.08, mode: mg.mode ?? 'maze', roomDensity: mg.roomDensity ?? 0.15, roomMinSize: mg.roomMinSize ?? 4, roomMaxSize: mg.roomMaxSize ?? 8, columnDensity: mg.columnDensity ?? 0.15, columnMaxW: mg.columnMaxW ?? 3, columnMaxH: mg.columnMaxH ?? 2, seed: effectiveSeed, }); newMap[1][1] = 0; let startDirX = 0, startDirY = -1, startAngle = 0; if (newMap[2][1] === 0) { startDirX = 0; startDirY = 1; startAngle = 180; } else if (newMap[1][2] === 0) { startDirX = 1; startDirY = 0; startAngle = 90; } const newDiscovered = Array.from({ length: size }, () => Array(size).fill(false)); for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { if (x === 0 || x === size - 1 || y === 0 || y === size - 1) { newDiscovered[y][x] = true; } } } const validExits = exitCandidates ?? (() => { const e = []; for (let y = 1; y < size - 1; y++) for (let x = 1; x < size - 1; x++) if (newMap[y][x] === 0 && Math.hypot(x - 1, y - 1) > size / 2) e.push({x, y}); return e; })(); let chosen = validExits.length > 0 ? validExits[Math.floor(Math.random() * validExits.length)] : null; if (!chosen || newMap[chosen.y]?.[chosen.x] !== 0) { // fallback: furthest reachable floor cell from spawn let bestDist = -1; for (let fy = 1; fy < size - 1; fy++) for (let fx = 1; fx < size - 1; fx++) if (newMap[fy][fx] === 0) { const d = Math.hypot(fx - 1, fy - 1); if (d > bestDist) { bestDist = d; chosen = { x: fx, y: fy }; } } chosen = chosen || { x: size - 2, y: size - 2 }; } newMap[chosen.y][chosen.x] = 2; // Precompute isolated wall clusters (columns) for instant reveal on first ray hit const isolatedClusters = []; const clusterOf = Array.from({ length: size }, () => Array(size).fill(-1)); const clusterVisited = Array.from({ length: size }, () => Array(size).fill(false)); for (let sy = 0; sy < size; sy++) { for (let sx = 0; sx < size; sx++) { if (newMap[sy][sx] === 1 && !clusterVisited[sy][sx]) { const cells = []; let touchesBoundary = false; const q = [{ x: sx, y: sy }]; clusterVisited[sy][sx] = true; while (q.length > 0) { const { x, y } = q.shift(); cells.push({ x, y }); if (x === 0 || x === size - 1 || y === 0 || y === size - 1) touchesBoundary = true; for (const [dx, dy] of [[-1, 0], [1, 0], [0, -1], [0, 1]]) { const nx = x + dx, ny = y + dy; if (nx >= 0 && nx < size && ny >= 0 && ny < size && newMap[ny][nx] === 1 && !clusterVisited[ny][nx]) { clusterVisited[ny][nx] = true; q.push({ x: nx, y: ny }); } } } if (!touchesBoundary) { const idx = isolatedClusters.length; isolatedClusters.push(cells); cells.forEach(({ x, y }) => { clusterOf[y][x] = idx; }); } } } } // Precompute perimeter cells for each isolated cluster (cells adjacent to floor) const clusterPerimeters = isolatedClusters.map(cells => cells.filter(({ x, y }) => [[-1, 0], [1, 0], [0, -1], [0, 1]].some(([dx, dy]) => { const nx = x + dx, ny = y + dy; return nx >= 0 && nx < size && ny >= 0 && ny < size && newMap[ny][nx] !== 1; }) ) ); engine.current.map = newMap; engine.current.discovered = newDiscovered; engine.current.rooms = rooms ?? []; engine.current.isolatedClusters = isolatedClusters; engine.current.clusterOf = clusterOf; engine.current.clusterPerimeters = clusterPerimeters; engine.current.prevRoomPos = null; engine.current.exit = chosen; let floorCells = 0; for (let y = 1; y < size - 1; y++) for (let x = 1; x < size - 1; x++) if (newMap[y][x] !== 1) floorCells++; const t = themeData.theme; const entCount = Math.max(0, Math.floor( floorCells * ((t.entityDensity ?? 0) + (levelOverride - 1) * (t.entityDensityScaling ?? 0)) / 100 )); engine.current.entities = spawnEntities(newMap, entCount, size, t, levelOverride, dataRef.current.entities); if (levelOverride === 1) engine.current.timeOfDay = themeData.theme.timeOfDay ?? 12; engine.current.isPreviewMode = false; engine.current.player = createPlayer(1, 1, startDirX, startDirY, startAngle); engine.current.tickAccumulator = 0; if (levelOverride === 1) { livesRef.current = 3; setLives(3); } engine.current.overlay = null; engine.current.deathFade = null; setGameState('playing'); gameStateRef.current = 'playing'; lastTimeRef.current = 0; setActiveModal(null); setMenuOpen(false); }, []); const clearOverlay = () => { engine.current.overlay = null; if (overlayCanvasRef.current) overlayCanvasRef.current.style.pointerEvents = 'none'; }; const pickDeathQuip = () => { const set = themeRef.current?.deathMessageSet; const msgs = set && DEATH_MESSAGES[set]; return msgs?.length ? msgs[Math.floor(Math.random() * msgs.length)] : null; }; const handleOverlayMouseMove = (e) => { const ov = engine.current.overlay; const canvas = overlayCanvasRef.current; if (!ov || !ov._itemRects?.length || !canvas) return; const scaleX = canvas.width / canvas.clientWidth; const scaleY = canvas.height / canvas.clientHeight; const ix = e.nativeEvent.offsetX * scaleX; const iy = e.nativeEvent.offsetY * scaleY; let found = -1; ov._itemRects.forEach((r, i) => { if (ix >= r.x && ix <= r.x + r.w && iy >= r.y && iy <= r.y + r.h) found = i; }); if (found !== -1 && ov.selectedIdx !== found) ov.selectedIdx = found; canvas.style.cursor = found !== -1 ? 'pointer' : 'default'; }; const handleOverlayClick = () => { const ov = engine.current.overlay; if (!ov?.items?.length) return; const action = ov.items[ov.selectedIdx ?? 0]?.action; if (action) action(); }; const handleOverlayMouseLeave = () => { if (overlayCanvasRef.current) overlayCanvasRef.current.style.cursor = 'default'; }; const showOverlay = (type) => { const o = { type, selectedIdx: 0, subtitle: null, supertitle: null }; if (type === 'pause') { o.title = 'PAUSED'; o.items = [ { label: 'Resume', action: () => { clearOverlay(); setGameState('playing'); gameStateRef.current = 'playing'; lastTimeRef.current = 0; } }, { label: 'Quit', action: () => showOverlay('quit') }, ]; } else if (type === 'death') { const remaining = livesRef.current; o.title = 'YOU DIED'; o.supertitle = pickDeathQuip(); o.subtitle = remaining > 0 ? `${'♥ '.repeat(remaining).trim()} ${remaining} ${remaining === 1 ? 'life' : 'lives'} remaining` : null; o.items = [ { label: 'Retry', action: () => { clearOverlay(); initGame(levelRef.current); } }, { label: 'Quit', action: () => showOverlay('quit') }, ]; } else if (type === 'gameover') { o.title = 'GAME OVER'; o.items = [ { label: 'Play Again', action: () => { clearOverlay(); initGame(1); } }, { label: 'Quit', action: () => showOverlay('quit') }, ]; } else if (type === 'win') { o.title = 'ESCAPED'; o.items = [ { label: 'Next Level', action: () => { clearOverlay(); initGame(levelRef.current + 1); } }, ]; } else if (type === 'quit') { o.title = 'QUIT?'; o._prevType = engine.current.overlay?.type ?? null; o._prevGameState = gameStateRef.current; o.items = [ { label: 'Yes', action: () => { clearOverlay(); setGameState('menu'); gameStateRef.current = 'menu'; setActiveModal(null); } }, { label: 'No', action: () => { const prevType = engine.current.overlay?._prevType; const prevGS = engine.current.overlay?._prevGameState; clearOverlay(); if (prevType) { showOverlay(prevType); } else if (prevGS === 'playing') { setGameState('playing'); gameStateRef.current = 'playing'; lastTimeRef.current = 0; } }}, ]; } engine.current.overlay = o; if (overlayCanvasRef.current) { overlayCanvasRef.current.style.pointerEvents = o.items?.length > 0 ? 'auto' : 'none'; } }; useEffect(() => { if ((themeSubTab !== 'environment' && themeSubTab !== 'menu') || !overlayPreviewCanvasRef.current || activeModal !== 'level_designer') return; const lvl = gameData.themes.find(t => t.id === editingThemeId); if (!lvl) return; const msgSet = lvl.theme.deathMessageSet; const msgs = msgSet && DEATH_MESSAGES[msgSet]; const sampleQuip = msgs?.length ? msgs[Math.floor(Math.random() * msgs.length)] : null; const syntheticOverlay = { type: 'death', title: 'YOU DIED', supertitle: sampleQuip, subtitle: null, selectedIdx: 0, items: [{ label: 'Retry Level' }], }; drawOverlay(overlayPreviewCanvasRef.current, syntheticOverlay, null, lvl.theme); }, [themeSubTab, editingThemeId, gameData, activeModal]); useEffect(() => { const handleKeyDown = (e) => { if (document.activeElement && ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) return; // Overlay navigation — intercepts all movement/confirm/cancel keys const ov = engine.current.overlay; if (ov?.items?.length) { if (e.key === 'ArrowUp' || e.key === 'w' || e.key === 'W') { ov.selectedIdx = (ov.selectedIdx - 1 + ov.items.length) % ov.items.length; e.preventDefault(); return; } if (e.key === 'ArrowDown' || e.key === 's' || e.key === 'S') { ov.selectedIdx = (ov.selectedIdx + 1) % ov.items.length; e.preventDefault(); return; } if (e.code === 'Enter' || e.code === 'Space') { ov.items[ov.selectedIdx].action(); e.preventDefault(); return; } if (e.code === 'Escape') { if (ov.type === 'pause') { clearOverlay(); setGameState('playing'); gameStateRef.current = 'playing'; lastTimeRef.current = 0; } else if (ov.type === 'quit') { ov.items[ov.items.length - 1].action(); // No } // death / gameover / win: ESC does nothing e.preventDefault(); return; } } if (engine.current.keys.hasOwnProperty(e.key)) { // Arrow keys/Space would otherwise scroll the page (or, on itch.io, // the embedding page around the iframe) — they're fully claimed by // movement during play, so stop the browser's default scroll. e.preventDefault(); engine.current.keys[e.key] = true; // Buffer intent for classic-mode tick consumption if (themeRef.current?.moveMode === 'classic') { const ki = (viewModeRef.current === 'map' && activeModalRef.current !== 'level_designer') ? getMapIntent(engine.current.keys) : getIntent(engine.current.keys); if (ki.action !== null) pendingIntentRef.current = ki; } } if (e.code === 'Enter' && activeModalRef.current && activeModalRef.current !== 'level_designer') { e.preventDefault(); if (activeModalRef.current === 'data') { if (!clearConfirmRef.current) { setClearConfirm(true); } else { localStorage.removeItem('maze_data_v2'); const defaults = { themes: DEFAULT_THEMES, entities: DEFAULT_ENTITIES }; setGameData(defaults); dataRef.current = defaults; themeRef.current = DEFAULT_THEMES[0].theme; setStartThemeId(DEFAULT_THEMES[0].id); setEditingThemeId(DEFAULT_THEMES[0].id); setActiveModal(null); setMenuOpen(false); setClearConfirm(false); } } return; } if (e.code === 'Space') { e.preventDefault(); if (gameStateRef.current === 'playing') { setGameState('paused'); gameStateRef.current = 'paused'; showOverlay('pause'); } else if (gameStateRef.current === 'paused' && !engine.current.overlay) { setGameState('playing'); gameStateRef.current = 'playing'; lastTimeRef.current = 0; } } if (e.code === 'Escape') { if (activeModalRef.current === 'data' && clearConfirmRef.current) { setClearConfirm(false); } else if (activeModalRef.current !== null) { setActiveModal(null); setMenuOpen(false); } else if (gameStateRef.current === 'playing') { setGameState('paused'); gameStateRef.current = 'paused'; showOverlay('pause'); } // paused with overlay: ESC handled in overlay block above // dying / gameover / win / menu: ESC does nothing } let devToggled = false; if (e.key.length === 1 || e.key === '/') { keyBufferRef.current = (keyBufferRef.current + e.key).slice(-3); if (keyBufferRef.current === '///') { const newMode = !devModeRef.current; setDevMode(newMode); devModeRef.current = newMode; keyBufferRef.current = ''; devToggled = true; } } if (e.key.toLowerCase() === 'v' && gameStateRef.current !== 'menu' && !devToggled && activeModalRef.current !== 'level_designer') { const newView = viewModeRef.current === 'map' ? '3d' : 'map'; setViewMode(newView); viewModeRef.current = newView; } if (devModeRef.current) { const size = gridSizeRef.current; if (e.key === 'n' || e.key === 'N') initGame(levelRef.current + 1); else if (e.key === 'g' || e.key === 'G') { devGridRef.current = !devGridRef.current; } else if (e.key === 'i' || e.key === 'I') { devInvisRef.current = !devInvisRef.current; setDevInvis(devInvisRef.current); } else if (e.key === 'r' || e.key === 'R') { devRadarRef.current = !devRadarRef.current; setDevRadar(devRadarRef.current); } else if (e.key === '+' || e.key === '=') { const occupied = new Set([`${Math.round(engine.current.player.x)},${Math.round(engine.current.player.y)}`, ...engine.current.entities.map(e => `${e.x},${e.y}`)]); const spawned = spawnEntities(engine.current.map, 1, size, themeRef.current, levelRef.current, dataRef.current.entities, occupied); if (spawned.length) engine.current.entities = [...engine.current.entities, ...spawned]; } else if (e.key === '-' || e.key === '_') { if (engine.current.entities.length > 0) engine.current.entities = engine.current.entities.slice(0, -1); } else if (e.key === 'h') { for (let y = 1; y < size - 1; y++) for (let x = 1; x < size - 1; x++) if (engine.current.map[y][x] === 1 && Math.random() < 0.3) engine.current.map[y][x] = 0; // New reference so WebGL renderer detects the change (it checks referential equality) engine.current.map = engine.current.map.slice(); } } }; const handleKeyUp = (e) => { if (document.activeElement && ['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement.tagName)) return; if (engine.current.keys.hasOwnProperty(e.key)) engine.current.keys[e.key] = false; }; window.addEventListener('keydown', handleKeyDown); window.addEventListener('keyup', handleKeyUp); return () => { window.removeEventListener('keydown', handleKeyDown); window.removeEventListener('keyup', handleKeyUp); }; }, []); useEffect(() => { debugFlagsRef.current = debugFlags; }, [debugFlags]); const updateEngine = useCallback(async (time) => { if (lastTimeRef.current === 0) lastTimeRef.current = time; const deltaTime = time - lastTimeRef.current; lastTimeRef.current = time; const state = engine.current; // FPS Tracking const now = performance.now(); state.lastFrames.push(now); while (state.lastFrames.length > 0 && state.lastFrames[0] <= now - 1000) { state.lastFrames.shift(); } if (devModeRef.current) { if (fpsRef.current) fpsRef.current.innerText = state.lastFrames.length; if (entitiesRef.current) entitiesRef.current.innerText = engine.current.entities.length; if (fpsCanvasRef.current && (!engine.current.lastCanvasUpdate || now - engine.current.lastCanvasUpdate > 250)) { engine.current.lastCanvasUpdate = now; const ctx = fpsCanvasRef.current.getContext('2d', { willReadFrequently: true }); const w = fpsCanvasRef.current.width; const h = fpsCanvasRef.current.height; const imageData = ctx.getImageData(1, 0, w - 1, h); ctx.putImageData(imageData, 0, 0); ctx.fillStyle = '#0f172a'; ctx.fillRect(w - 1, 0, 1, h); const frames = state.lastFrames.length; const normalizedFps = Math.min(60, frames) / 60; const barHeight = Math.max(1, Math.floor(normalizedFps * h)); if (frames >= 55) ctx.fillStyle = '#4ade80'; else if (frames >= 30) ctx.fillStyle = '#fbbf24'; else ctx.fillStyle = '#ef4444'; ctx.fillRect(w - 1, h - barHeight, 1, barHeight); } } if (entityCountRef.current) { entityCountRef.current.innerText = engine.current.entities.length; } // Death fade update — runs even when not playable if (gameStateRef.current === 'dying' && engine.current.deathFade) { const df = engine.current.deathFade; const fadeInMs = themeRef.current?.deathFadeInMs ?? 800; const maxAlpha = themeRef.current?.deathFadeMaxAlpha ?? 0.5; if (df.phase === 'in') { df.alpha = Math.min(maxAlpha, df.alpha + deltaTime / fadeInMs * maxAlpha); if (df.alpha >= maxAlpha) { const remaining = livesRef.current; setGameState('gameover'); gameStateRef.current = 'gameover'; showOverlay(remaining > 0 ? 'death' : 'gameover'); df.phase = 'done'; } } } const isPlayable = gameStateRef.current === 'playing' || activeModalRef.current === 'level_designer'; // Real-time synchronization of entity templates for the Designer preview state.entities.forEach(ent => { if (ent.template && ent.template.id) { const freshTemplate = dataRef.current.entities.find(e => e.id === ent.template.id); if (freshTemplate) ent.template = freshTemplate; } }); if (!isPlayable) { const isMenuOrEnd = ['menu', 'gameover', 'gamewin'].includes(gameStateRef.current); if (!isMenuOrEnd) { if (viewModeRef.current === '3d') { await webglRendererRef.current?.render(state, themeRef.current, devModeRef.current && devGridRef.current, deltaTime, debugFlagsRef.current); } else { drawMapView(); } } else if (viewModeRef.current === 'map') { const canvas = canvasRef.current; if (canvas) { const ctx = canvas.getContext('2d'); ctx.fillStyle = '#0f172a'; ctx.fillRect(0, 0, canvas.width, canvas.height); } } drawOverlay(overlayCanvasRef.current, engine.current.overlay ?? null, engine.current.deathFade ?? null, themeRef.current); requestRef.current = requestAnimationFrame(updateEngine); return; } if (themeRef.current.timeSpeed > 0) { state.timeOfDay = (state.timeOfDay + (deltaTime / 1000) * themeRef.current.timeSpeed) % 24; } const timeStr = formatTime(state.timeOfDay); if (previewClockRef.current) previewClockRef.current.innerText = timeStr; const p = state.player; const isClassic = themeRef.current?.moveMode === 'classic'; let skipFrameLogic = false; if (isClassic) { state.tickAccumulator = (state.tickAccumulator || 0) + deltaTime; const effectiveInterval = Math.max(200, (themeRef.current.tickInterval ?? 1000) * Math.pow(themeRef.current.tickSpeedFactor ?? 0.95, levelRef.current - 1)); if (state.tickAccumulator >= effectiveInterval) { state.tickAccumulator -= effectiveInterval; const heldIntent = (viewModeRef.current === 'map' && activeModalRef.current !== 'level_designer') ? getMapIntent(state.keys) : getIntent(state.keys); const intent = heldIntent.action !== null ? heldIntent : (pendingIntentRef.current || { action: null, sprint: false }); pendingIntentRef.current = null; const { hitExit } = updatePlayerSnap(p, intent, state.map); if (hitExit && !state.isPreviewMode) { setGameState('gamewin'); gameStateRef.current = 'gamewin'; showOverlay('win'); recordScore(startThemeIdRef.current, levelRef.current); } if (!debugFlagsRef.current || debugFlagsRef.current.ai !== false) { updateEntitiesTick(state.entities, state.map, p, (debugFlagsRef.current?.raycast === false) ? 0 : (themeRef.current?.sightRadius ?? Infinity), themeRef.current?.entityBaseSpeed ?? 1.0, themeRef.current?.entitySpeedScaling ?? 0.0, levelRef.current, themeRef.current?.chaseMemory ?? 10, true, state.isPreviewMode || devInvisRef.current); } } else { skipFrameLogic = true; } } else { const intent = viewModeRef.current === 'map' ? getMapIntent(state.keys) : getIntent(state.keys); const { hitExit } = updatePlayer(p, intent, state.map, deltaTime); if (hitExit && !state.isPreviewMode) { setGameState('gamewin'); gameStateRef.current = 'gamewin'; showOverlay('win'); recordScore(startThemeIdRef.current, levelRef.current); } if (staminaBarFillRef.current) { const pct = (p.stamina / p.maxStamina) * 100; staminaBarFillRef.current.style.width = pct + '%'; staminaBarFillRef.current.style.backgroundColor = pct > 25 ? '#10b981' : pct > 10 ? '#f59e0b' : '#ef4444'; } updateEntities(state.entities, state.map, p, deltaTime, state.isPreviewMode || devInvisRef.current, themeRef.current?.sightRadius ?? Infinity); } if (!skipFrameLogic) { // Room reveal: entering any cell of a room reveals its whole interior if (state.rooms && state.rooms.length > 0) { const px = Math.round(p.renderX), py = Math.round(p.renderY); const prev = state.prevRoomPos; if (!prev || prev.x !== px || prev.y !== py) { state.prevRoomPos = { x: px, y: py }; const room = state.rooms.find(r => px >= r.x && px < r.x + r.w && py >= r.y && py < r.y + r.h); if (room) { const mapH = state.map.length, mapW = state.map[0].length; for (let ry = room.y - 1; ry <= room.y + room.h; ry++) for (let rx = room.x - 1; rx <= room.x + room.w; rx++) if (ry >= 0 && ry < mapH && rx >= 0 && rx < mapW) state.discovered[ry][rx] = true; } } } // Line-of-sight discovery — 180° FOV centred on player facing direction. // renderAngle 0=north, 90=east; convert to math angle (0=east): subtract 90. const centerRayAngle = p.renderAngle - 90; const maxRayDist = themeRef.current?.sightRadius ?? null; const initDiscMapX = Math.floor(p.renderX + 0.5); const initDiscMapY = Math.floor(p.renderY + 0.5); for (let offset = -90; offset <= 90; offset += 2) { const rad = (centerRayAngle + offset) * (Math.PI / 180); const dirX = Math.cos(rad); const dirY = Math.sin(rad); let mapX = Math.floor(p.renderX + 0.5); let mapY = Math.floor(p.renderY + 0.5); const deltaDistX = Math.abs(1 / (dirX || 1e-10)); const deltaDistY = Math.abs(1 / (dirY || 1e-10)); let stepX, stepY, sideDistX, sideDistY; if (dirX < 0) { stepX = -1; sideDistX = (p.renderX + 0.5 - mapX) * deltaDistX; } else { stepX = 1; sideDistX = (mapX + 1.0 - (p.renderX + 0.5)) * deltaDistX; } if (dirY < 0) { stepY = -1; sideDistY = (p.renderY + 0.5 - mapY) * deltaDistY; } else { stepY = 1; sideDistY = (mapY + 1.0 - (p.renderY + 0.5)) * deltaDistY; } if (mapY >= 0 && mapY < state.map.length && mapX >= 0 && mapX < state.map[0].length) { state.discovered[mapY][mapX] = true; } let hit = false; while (!hit) { if (sideDistX < sideDistY) { sideDistX += deltaDistX; mapX += stepX; } else { sideDistY += deltaDistY; mapY += stepY; } if (mapX < 0 || mapX >= state.map[0].length || mapY < 0 || mapY >= state.map.length) break; if (maxRayDist !== null && Math.hypot(mapX - initDiscMapX, mapY - initDiscMapY) > maxRayDist) break; state.discovered[mapY][mapX] = true; if (state.map[mapY][mapX] > 0) hit = true; } } // Fill isolated clusters whose every perimeter cell is now discovered if (state.clusterPerimeters) { state.clusterPerimeters.forEach((perim, ci) => { if (perim.length > 0 && perim.every(c => state.discovered[c.y]?.[c.x])) { state.isolatedClusters[ci].forEach(c => { state.discovered[c.y][c.x] = true; }); } }); } } // Update entity render positions if (!isClassic) { state.entities.forEach(ent => { const eFloat = getFloatPos(ent); ent.renderX = eFloat.x; ent.renderY = eFloat.y; ent.renderAngle = getFloatAngle(ent); }); } else { state.entities.forEach(ent => { ent.renderX = ent.x; ent.renderY = ent.y; ent.renderAngle = ent.angle ?? 180; }); } // Collision / catch check state.entities.forEach(ent => { if (!state.isPreviewMode && !devInvisRef.current && Math.hypot(p.renderX - (ent.renderX ?? ent.x), p.renderY - (ent.renderY ?? ent.y)) < 0.8 && gameStateRef.current === 'playing') { const newLives = Math.max(0, livesRef.current - 1); livesRef.current = newLives; setLives(newLives); const dm = themeRef.current?.deathMode ?? 'instant'; if (dm === 'fade') { setGameState('dying'); gameStateRef.current = 'dying'; engine.current.deathFade = { alpha: 0, phase: 'in', color: themeRef.current?.deathFadeColor ?? '#cc0000', }; } else { setGameState('gameover'); gameStateRef.current = 'gameover'; showOverlay(newLives > 0 ? 'death' : 'gameover'); } } }); if (activeModalRef.current === 'level_designer') { const pwr = webglPreviewRendererRef.current; const previewCanvas = webglPreviewCanvasRef.current; if (pwr && previewCanvas) { if (!pwr.threeRenderer || pwr.threeRenderer.domElement !== previewCanvas) { pwr.dispose(); pwr.init(previewCanvas); } await pwr.render(state, themeRef.current, devModeRef.current && devGridRef.current, deltaTime); const ps = themeRef.current?.pixelScale ?? 1; if (ps < 1 && pixelPreviewCanvasRef.current) { const wc = previewCanvas, pc = pixelPreviewCanvasRef.current; if (pc.width !== wc.width || pc.height !== wc.height) { pc.width = wc.width; pc.height = wc.height; pc.style.width = wc.width + 'px'; pc.style.height = wc.height + 'px'; } pc.getContext('2d')?.drawImage(wc, 0, 0); } } } else if (viewModeRef.current === '3d') { await webglRendererRef.current?.render(state, themeRef.current, devModeRef.current && devGridRef.current, deltaTime, debugFlagsRef.current); } else { drawMapView(); } if (activeModalRef.current === null && viewModeRef.current !== 'map') renderMinimap(); if (viewModeRef.current !== 'map' && (themeRef.current?.showRadar ?? true)) { drawProximityRadar(proximityCanvasRef.current, engine.current); } else { const pc = proximityCanvasRef.current; if (pc) pc.getContext('2d').clearRect(0, 0, pc.width, pc.height); } drawOverlay(overlayCanvasRef.current, engine.current.overlay ?? null, engine.current.deathFade ?? null, themeRef.current); requestRef.current = requestAnimationFrame(updateEngine); }, []); useEffect(() => { requestRef.current = requestAnimationFrame(updateEngine); return () => cancelAnimationFrame(requestRef.current); }, [updateEngine]); const drawMapView = () => { const canvas = canvasRef.current; if (!canvas) return; const ctx = canvas.getContext('2d'); const theme = themeRef.current; const state = engine.current; if (!state.map || !state.discovered || state.map.length === 0) return; const W = canvas.width, H = canvas.height; const size = state.map.length; const cs = Math.ceil(Math.min(W, H) / size); const ox = 0, oy = 0; ctx.fillStyle = '#0f172a'; ctx.fillRect(0, 0, W, H); const baseWallColor = resolveMapWallColor(theme); for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { if (!state.discovered[y]?.[x] && !devModeRef.current) continue; const dx = ox + x * cs, dy = oy + y * cs; const v = state.map[y][x]; if (v === 1) ctx.fillStyle = baseWallColor; else if (v === 2) ctx.fillStyle = resolveMapExitColor(theme); else ctx.fillStyle = resolveMapFloorColor(theme); ctx.fillRect(dx, dy, cs + 1, cs + 1); } } // Exit outline — mirrors minimap logic if (theme.exitOutlineColor) { ctx.strokeStyle = theme.exitOutlineColor; ctx.lineWidth = Math.max(1, cs * 0.2); for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) { if ((state.discovered[y]?.[x] || devModeRef.current) && state.map[y][x] === 2) { const lw = ctx.lineWidth; ctx.strokeRect(ox + x * cs + lw * 0.5, oy + y * cs + lw * 0.5, cs + 1 - lw, cs + 1 - lw); } } } const floorHex = resolveMapFloorColor(theme).replace('#', ''); const floorLuma = 0.299 * parseInt(floorHex.slice(0,2), 16) + 0.587 * parseInt(floorHex.slice(2,4), 16) + 0.114 * parseInt(floorHex.slice(4,6), 16); const devOutlineColor = floorLuma > 128 ? 'rgba(0,0,0,0.75)' : 'rgba(255,255,255,0.75)'; const sightR = theme.sightRadius ?? Infinity; const p = state.player; state.entities.forEach(ent => { const ex = ox + (ent.renderX + 0.5) * cs, ey = oy + (ent.renderY + 0.5) * cs; if (devModeRef.current) { ctx.strokeStyle = devOutlineColor; ctx.lineWidth = 1; ctx.beginPath(); ctx.arc(ex, ey, cs * 0.3, 0, Math.PI * 2); ctx.stroke(); } const inLOS = checkLineOfSight(ent.x, ent.y, p.x, p.y, state.map, sightR); if (inLOS || (devModeRef.current && (debugFlagsRef.current?.radar ?? devRadarRef.current))) { const t = ent.template; const entState = ent.state === 'chasing' ? 'chasing' : 'wandering'; if (t?.renderType === 'file' || !t) { ctx.fillStyle = getEntityMapColor(t, entState); ctx.beginPath(); ctx.arc(ex, ey, cs * 0.3, 0, Math.PI * 2); ctx.fill(); } else { const bodyColor = entState === 'chasing' ? (t.chaseColor || '#ef4444') : (t.primaryColor || '#ffff00'); const featureColor = entState === 'chasing' ? (t.chaseFeatureColor || t.featureColor || '#000000') : (t.featureColor || '#000000'); ctx.save(); ctx.translate(ex - cs / 2, ey - cs / 2); drawEntityFace(ctx, cs, cs, t.type, bodyColor, featureColor, t.faceScale2D ?? 1.0, entState, 0, true); ctx.restore(); } } }); const px = ox + (p.renderX + 0.5) * cs, py = oy + (p.renderY + 0.5) * cs; const pr = cs * 0.4; ctx.save(); ctx.translate(px, py); ctx.rotate((p.renderAngle * Math.PI) / 180); ctx.fillStyle = theme.player; ctx.beginPath(); ctx.arc(0, 0, pr, 0, Math.PI * 2); ctx.fill(); ctx.fillStyle = '#ffffff'; ctx.beginPath(); ctx.moveTo(0, -pr * 1.2); ctx.lineTo(-pr * 0.4, -pr * 0.2); ctx.lineTo(pr * 0.4, -pr * 0.2); ctx.fill(); ctx.restore(); }; const drawProximityRadar = (canvas, state) => { if (!canvas) return; const W = canvas.offsetWidth, H = canvas.offsetHeight; if (W === 0 || H === 0) return; if (canvas.width !== W || canvas.height !== H) { canvas.width = W; canvas.height = H; } const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, W, H); const gs = gameStateRef.current; if (gs !== 'playing' && gs !== 'paused') return; const player = state.player; if (!player || !state.entities) return; // Full-canvas centred overlay const cx = W / 2, cy = H / 2; const R = Math.min(W, H) * 0.46; // Ring radii — equal width (0.155R) per ring, 0.03R gaps, center hole 0.28R const r1i = R * 0.280, r1o = R * 0.435; const r2i = R * 0.465, r2o = R * 0.620; const r3i = R * 0.650, r3o = R * 0.805; const r4i = R * 0.835, r4o = R * 0.990; // Segment half-spans (active arc / 2, in radians) const hs1 = 0.3316; // 38° / 2 (45° slot − 7° gap) const hs2 = 0.1571; // 18° / 2 (22.5° slot − 4.5° gap) const hs3 = 0.1571; // 22.5° spurs, same visual gap as ring 2 const hs4 = 0.1571; const ring1 = new Array(8).fill(null); const ring2 = new Array(16).fill(null); const spur3 = new Array(4).fill(null); const spur4 = new Array(4).fill(null); const cardinalRels = [0, Math.PI / 2, Math.PI, -Math.PI / 2]; const PI2 = Math.PI * 2; // Buckets hold { state, color } — chasing beats idle; first entity wins on ties const worse = (cur, next) => (!cur || (next.state === 'chasing' && cur.state !== 'chasing')) ? next : cur; const playerRad = (player.renderAngle - 90) * Math.PI / 180; const radarSightR = themeRef.current?.sightRadius ?? Infinity; for (const ent of state.entities) { const dx = ent.renderX - player.renderX; const dy = ent.renderY - player.renderY; const dist = Math.hypot(dx, dy); if (dist > 5) continue; if (!checkLineOfSight(ent.x, ent.y, player.x, player.y, state.map, radarSightR)) continue; let rel = Math.atan2(dy, dx) - playerRad; while (rel > Math.PI) rel -= PI2; while (rel < -Math.PI) rel += PI2; const isChasing = ent.state === 'chasing'; const t = ent.template; const col = isChasing ? getEntityMapColor(t, 'chasing') : getEntityMapColor(t, 'wandering'); const seg = { state: isChasing ? 'chasing' : 'idle', color: col }; if (dist <= 1.5) { const k = Math.floor(((rel + Math.PI / 8 + PI2) % PI2) / (Math.PI / 4)) % 8; ring1[k] = worse(ring1[k], seg); } else if (dist <= 3) { const k = Math.floor(((rel + Math.PI / 16 + PI2) % PI2) / (Math.PI / 8)) % 16; ring2[k] = worse(ring2[k], seg); } else { for (let c = 0; c < 4; c++) { let d = rel - cardinalRels[c]; while (d > Math.PI) d -= PI2; while (d < -Math.PI) d += PI2; if (Math.abs(d) <= Math.PI / 16) { if (dist <= 4) spur3[c] = worse(spur3[c], seg); else spur4[c] = worse(spur4[c], seg); } } } } const segPath = (drawA, halfSpan, ri, ro) => { ctx.beginPath(); ctx.arc(cx, cy, ro, drawA - halfSpan, drawA + halfSpan, false); ctx.arc(cx, cy, ri, drawA + halfSpan, drawA - halfSpan, true); ctx.closePath(); }; const drawSeg = (seg, drawA, halfSpan, ri, ro) => { if (!seg) return; ctx.globalAlpha = 0.75; ctx.fillStyle = seg.color; segPath(drawA, halfSpan, ri, ro); ctx.fill(); ctx.globalAlpha = 1; }; const cardinalDraw = [-Math.PI / 2, 0, Math.PI / 2, Math.PI]; // Dev radar overlay — green outlines of all segments if (devModeRef.current && devRadarRef.current) { ctx.strokeStyle = 'rgba(74,222,128,0.4)'; ctx.lineWidth = 1; for (let k = 0; k < 8; k++) { segPath(k * Math.PI / 4 - Math.PI / 2, hs1, r1i, r1o); ctx.stroke(); } for (let k = 0; k < 16; k++) { segPath(k * Math.PI / 8 - Math.PI / 2, hs2, r2i, r2o); ctx.stroke(); } for (let c = 0; c < 4; c++) { segPath(cardinalDraw[c], hs3, r3i, r3o); ctx.stroke(); segPath(cardinalDraw[c], hs4, r4i, r4o); ctx.stroke(); } } // Ring 1: 8 segments, forward = draw angle -π/2 for (let k = 0; k < 8; k++) drawSeg(ring1[k], k * Math.PI / 4 - Math.PI / 2, hs1, r1i, r1o); // Ring 2: 16 segments for (let k = 0; k < 16; k++) drawSeg(ring2[k], k * Math.PI / 8 - Math.PI / 2, hs2, r2i, r2o); // Rings 3 & 4: cardinal spurs (fwd=-π/2, right=0, back=π/2, left=π) for (let c = 0; c < 4; c++) { drawSeg(spur3[c], cardinalDraw[c], hs3, r3i, r3o); drawSeg(spur4[c], cardinalDraw[c], hs4, r4i, r4o); } }; const getEntityMapColor = (t, entState) => { if (!t) return '#ffffff'; const isChasing = entState === 'chasing'; if (isChasing && t.mapChaseColor) return t.mapChaseColor; if (!isChasing && t.mapIdleColor) return t.mapIdleColor; if (!t.mapColor || t.mapColor === 'primary') return isChasing ? (t.chaseColor || t.primaryColor || '#ffffff') : (t.primaryColor || '#ffffff'); if (t.mapColor === 'feature') return isChasing ? (t.chaseFeatureColor || t.featureColor || '#000000') : (t.featureColor || '#000000'); return t.mapColor; }; const getEntityMapArrowColor = (t, entState) => { if (!t) return '#ffffff'; const isChasing = entState === 'chasing'; if (t.renderType === 'file') { return isChasing ? (t.mapArrowChaseColor || '#ffffff') : (t.mapArrowIdleColor || '#ffffff'); } // Built-in: use the color NOT used for the dot if (t.mapColor === 'feature') return isChasing ? (t.chaseColor || t.primaryColor || '#ffffff') : (t.primaryColor || '#ffffff'); return isChasing ? (t.chaseFeatureColor || t.featureColor || '#ffffff') : (t.featureColor || '#ffffff'); }; const renderMinimap = () => { const mCanvas = minimapRef.current; if (!mCanvas) return; const mCtx = mCanvas.getContext('2d'); const theme = themeRef.current; const state = engine.current; mCtx.fillStyle = '#0f172a'; mCtx.fillRect(0, 0, mCanvas.width, mCanvas.height); if (!state.map || state.map.length === 0 || !state.discovered) return; const size = state.map.length, mCellSize = mCanvas.width / size; const baseWallColor = resolveMapWallColor(theme); const baseFloorColor = resolveMapFloorColor(theme); let discoveredCount = 0; for (let y = 0; y < size; y++) { for (let x = 0; x < size; x++) { if (state.discovered[y][x]) discoveredCount++; if (state.discovered[y][x] || devModeRef.current) { let drawX = Math.floor(x * mCellSize); let drawY = Math.floor(y * mCellSize); let drawW = Math.ceil(mCellSize) + 1; let drawH = Math.ceil(mCellSize) + 1; if (state.map[y][x] === 1) { mCtx.fillStyle = baseWallColor; mCtx.fillRect(drawX, drawY, drawW, drawH); } else if (state.map[y][x] === 2) { mCtx.fillStyle = resolveMapExitColor(theme); mCtx.fillRect(drawX, drawY, drawW, drawH); } else { mCtx.fillStyle = baseFloorColor; mCtx.fillRect(drawX, drawY, drawW, drawH); } } } } if (mappedPercentRef.current && size > 0) mappedPercentRef.current.innerText = Math.floor((discoveredCount / (size * size)) * 100); // Exit outline drawn after all fills to prevent adjacent cells overwriting the border if (theme.exitOutlineColor) { mCtx.strokeStyle = theme.exitOutlineColor; mCtx.lineWidth = Math.max(1, mCellSize * 0.2); for (let y = 0; y < size; y++) for (let x = 0; x < size; x++) { if ((state.discovered[y]?.[x] || devModeRef.current) && state.map[y][x] === 2) { const dx = Math.floor(x * mCellSize), dy = Math.floor(y * mCellSize); const dw = Math.ceil(mCellSize) + 1, dh = Math.ceil(mCellSize) + 1; const lw = mCtx.lineWidth; mCtx.strokeRect(dx + lw * 0.5, dy + lw * 0.5, dw - lw, dh - lw); } } } const floorHex = resolveMapFloorColor(theme).replace('#', ''); const floorLuma = 0.299 * parseInt(floorHex.slice(0,2), 16) + 0.587 * parseInt(floorHex.slice(2,4), 16) + 0.114 * parseInt(floorHex.slice(4,6), 16); const devOutlineColor = floorLuma > 128 ? 'rgba(0,0,0,0.75)' : 'rgba(255,255,255,0.75)'; // Player drawn first so entities render on top when overlapping const ppx = (state.player.renderX + 0.5) * mCellSize; const ppy = (state.player.renderY + 0.5) * mCellSize; const pr2 = mCellSize * 0.4; mCtx.save(); mCtx.translate(ppx, ppy); mCtx.rotate((state.player.renderAngle * Math.PI) / 180); mCtx.fillStyle = theme.player; mCtx.beginPath(); mCtx.arc(0, 0, pr2, 0, Math.PI * 2); mCtx.fill(); mCtx.fillStyle = '#ffffff'; mCtx.beginPath(); mCtx.moveTo(0, -pr2 * 1.2); mCtx.lineTo(-pr2 * 0.4, -pr2 * 0.2); mCtx.lineTo(pr2 * 0.4, -pr2 * 0.2); mCtx.fill(); mCtx.restore(); state.entities.forEach(ent => { const ex = (ent.renderX + 0.5) * mCellSize, ey = (ent.renderY + 0.5) * mCellSize; const er = mCellSize * 0.4; if (devModeRef.current) { mCtx.strokeStyle = devOutlineColor; mCtx.lineWidth = 1; mCtx.beginPath(); mCtx.arc(ex, ey, er, 0, Math.PI * 2); mCtx.stroke(); } const sightR = themeRef.current?.sightRadius ?? Infinity; const inLOS = checkLineOfSight(ent.x, ent.y, state.player.x, state.player.y, state.map, sightR); if (inLOS) { const t = ent.template; const entState = ent.state === 'chasing' ? 'chasing' : 'wandering'; mCtx.save(); mCtx.translate(ex, ey); mCtx.rotate(((ent.renderAngle ?? 0) * Math.PI) / 180); mCtx.fillStyle = getEntityMapColor(t, entState); mCtx.beginPath(); mCtx.arc(0, 0, er, 0, Math.PI * 2); mCtx.fill(); mCtx.fillStyle = getEntityMapArrowColor(t, entState); mCtx.beginPath(); mCtx.moveTo(0, -er * 1.2); mCtx.lineTo(-er * 0.4, -er * 0.2); mCtx.lineTo(er * 0.4, -er * 0.2); mCtx.fill(); mCtx.restore(); } }); }; const handleMenuClick = (modalName) => { setActiveModal(modalName); setMenuOpen(false); }; const closeModal = () => { setActiveModal(null); setMenuOpen(false); setClearConfirm(false); }; const updateTheme = (thm, t, updates) => { const newTheme = { ...t, ...updates }; themeRef.current = newTheme; if (updates.timeOfDay !== undefined) engine.current.timeOfDay = updates.timeOfDay; setGameData(prev => ({ ...prev, themes: prev.themes.map(x => x.id === thm.id ? { ...x, theme: newTheme } : x) })); return newTheme; }; const updateThemeEntry = (thm, updates) => { setGameData(prev => ({ ...prev, themes: prev.themes.map(x => x.id === thm.id ? { ...x, ...updates } : x) })); }; const updateEntity = (ent, updates) => { setGameData(prev => ({ ...prev, entities: prev.entities.map(x => x.id === ent.id ? { ...x, ...updates } : x) })); }; const updateMapGen = (lvl, mg, updates) => { updateTheme(lvl, lvl.theme, { mapGen: { ...mg, ...updates } }); }; const applyMapGen = (lvl, mg, updates) => { const newMg = { ...mg, ...updates }; const freshLvl = { ...lvl, theme: { ...lvl.theme, mapGen: newMg }, gridSize: mapGenPreviewSize }; updateTheme(lvl, lvl.theme, { mapGen: newMg }); mapGenPreviewThemeRef.current = editingThemeId; syncPreview(freshLvl); }; return (
{gameState === 'menu' && ( )}

M A Z E

{menuOpen && gameState === 'menu' && (
)}
{gameState !== 'menu' && showTouchControls && (
)}
{gameState === 'menu' && (
{!isShortViewport &&
}

M A Z E

{gameData.themes.find(t => t.id === startThemeId)?.tagline || `Find the exit. Don't get caught.`}

)} {gameState !== 'menu' && !showTouchControls && (() => { const isClassic = (gameData.themes.find(t => t.id === startThemeId)?.theme?.moveMode ?? 'smooth') === 'classic'; return (
W A S D / ↑↓←→  Move {!isClassic && <>·Shift  Sprint} · Space  Pause · V  2D/3D · ESC  Quit
); })()} {devMode && (
[N] Next level  ·  [G] Grid  ·  [R] Radar  ·  [+/-] Entities  ·  [H] Walls  ·  [I] Invincible
DEV MODE v{APP_VERSION} ENGINE: {localStorage.getItem('maze_webgl_fallback') === 'true' ? 'WebGL2' : 'WebGPU'} 0 FPS OBJ: 0
{['map', 'lines', 'floor', 'entities', 'ai', 'exits', 'lights'].map(k => ( ))}
{['radar', 'raycast'].map(k => ( ))}
)}
{gameState === 'menu' ? (

Controls

{[ ['W A S D / ↑ ↓ ← →', 'Move'], ['Shift', 'Sprint'], ['Space', 'Pause'], ['V', '2D / 3D toggle'], ['ESC', 'Quit'], ].map(([key, action]) => (
{key} {action}
))}
{Object.keys(scores).length > 0 && (

High Scores

{gameData.themes .filter(t => scores[t.id]) .sort((a, b) => (scores[b.id]?.bestLevel || 0) - (scores[a.id]?.bestLevel || 0)) .map(t => (
{t.name} Lvl {scores[t.id].bestLevel}
)) }
)}
) : ( t.id === startThemeId)?.theme?.moveMode ?? 'smooth') !== 'classic'} themeName={gameData.themes.find(t => t.id === startThemeId)?.name ?? ''} isMobile={isMobile} /> )}
{activeModal && activeModal !== 'data' && (
{activeModal !== 'level_designer' && activeModal !== 'appearance' && activeModal !== 'about' && (

{activeModal}

{/* other default modals */}
)} {activeModal === 'appearance' && (

Appearance

{['light', 'dark', 'system'].map(t => ( ))}

Note: UI theme does not affect 3D game rendering.

)} {activeModal === 'about' && (

M A Z E

v{APP_VERSION}

Get lost in a thrilling, endless 3D labyrinth! Explore procedurally generated worlds, discover hidden secrets, and see if you have what it takes to escape.

)} {activeModal === 'level_designer' && (
{/* Search / sort / category filter strip */}
setListSearch(e.target.value)} placeholder="Search…" className="flex-1 min-w-0 text-xs border border-slate-200 dark:border-slate-700 rounded-lg px-2 py-1.5 bg-slate-50 dark:bg-slate-900 text-slate-900 dark:text-white focus:outline-none focus:ring-1 focus:ring-emerald-400" />
{designerTab === 'themes' && (() => { const cats = []; const seen = new Set(); gameData.themes.forEach(t => { const c = t.category || 'Other'; if (!seen.has(c)) { seen.add(c); cats.push(c); } }); if (cats.length <= 1) return null; return
{cats.map(cat => ( ))}
; })()}
{designerTab === 'themes' && (() => { const renderThemeRow = (t) => { const isBuiltIn = DEFAULT_THEMES.some(dt => dt.id === t.id); const savedState = savedData.themes.find(st => st.id === t.id); const isDirty = !savedState || JSON.stringify(t) !== JSON.stringify(savedState); return (
setEditingThemeId(t.id)}>
{isBuiltIn ? : }
{t.name}{isDirty ? '*' : ''} {t.tagline && {t.tagline}}
{isBuiltIn ? ( <> ) : ( <> {gameData.themes.length > 1 && ( )} )}
); }; let themes = gameData.themes; if (themeFilterCat) themes = themes.filter(t => (t.category || 'Other') === themeFilterCat); if (listSearch) themes = themes.filter(t => t.name.toLowerCase().includes(listSearch.toLowerCase())); if (listSort === 'az') themes = [...themes].sort((a, b) => a.name.localeCompare(b.name)); if (listSort === 'za') themes = [...themes].sort((a, b) => b.name.localeCompare(a.name)); if (themes.length === 0) return
No themes match
; return themes.map(renderThemeRow); })()} {designerTab === 'entities' && (() => { let ents = gameData.entities; if (listSearch) ents = ents.filter(e => e.name.toLowerCase().includes(listSearch.toLowerCase())); if (listSort === 'az') ents = [...ents].sort((a, b) => a.name.localeCompare(b.name)); if (listSort === 'za') ents = [...ents].sort((a, b) => b.name.localeCompare(a.name)); return ents; })().map(e => { const isBuiltIn = DEFAULT_ENTITIES.some(de => de.id === e.id); const savedState = savedData.entities.find(se => se.id === e.id); const isDirty = !savedState || JSON.stringify(e) !== JSON.stringify(savedState); return (
setEditingEntityId(e.id)}>
{isBuiltIn ? : }
{e.name}{isDirty ? '*' : ''}
{isBuiltIn ? ( <> ) : ( <> {gameData.entities.length > 1 && ( )} )}
); })}
{designerTab === 'themes' && themeSubTab === 'map' && (
2D PREVIEW
)} {designerTab === 'themes' && themeSubTab !== 'map' && (
{ isDraggingRef.current = true; lastMouseXRef.current = e.touches[0].clientX; }} onTouchMove={(e) => { if (!isDraggingRef.current) return; let d = e.touches[0].clientX - lastMouseXRef.current; engine.current.player.renderAngle -= d * 0.5; lastMouseXRef.current = e.touches[0].clientX; }} onTouchEnd={() => { isDraggingRef.current = false; }} /> {(gameData.themes.find(t => t.id === editingThemeId)?.theme?.pixelScale ?? 1) < 1 && (
native px
)}
12:00
LIVE PREVIEW
[W/A/S/D] move
)}
{designerTab === 'themes' && gameData.themes.find(t => t.id === editingThemeId) && ( (() => { const lvl = gameData.themes.find(t => t.id === editingThemeId); const t = lvl.theme || {}; const wallZones = t.wallZones || [{ height: 1.0, color: '#334155' }]; const surfaceOn = (s) => { const f = t[`${s}Outline`]; if (f !== undefined) return !!f; return t.style === 'outlined'; }; const subKeys = (s) => { const leg = s === 'exterior' ? 'outerFloor' : null; return { outColor: `${s}OutlineColor`, legacyOutColor: leg ? `${leg}OutlineColor` : null, outWidth: `${s}OutlineWidth`, outStyle: `${s}OutlineStyle`, sx: `${s}SubdivX`, legacySx: leg ? `${leg}SubdivX` : null, sy: `${s}SubdivY`, legacySy: leg ? `${leg}SubdivY` : null, }; }; const setZone = (i, patch) => updateTheme(lvl, t, { wallZones: wallZones.map((z, idx) => idx === i ? { ...z, ...patch } : z) }); const addZone = () => updateTheme(lvl, t, { wallZones: [...wallZones, { height: 0.5, color: '#ffffff', outlineColor: '#000000', subdivX: 1, subdivY: 1 }] }); const removeZone = (i) => updateTheme(lvl, t, { wallZones: wallZones.filter((_, idx) => idx !== i) }); const perimWallZones = t.perimeterWallZones || [{ height: 1.0, color: '#334155' }]; const setPerimZone = (i, patch) => updateTheme(lvl, t, { perimeterWallZones: perimWallZones.map((z, idx) => idx === i ? { ...z, ...patch } : z) }); const texCtrls = (data, setter, noFile, solidColorStr, solidColorSetter) => { const tmode = data.textureMode ?? 'solid'; const modes = [['solid','Solid'],['brick','Brick'],['tiles','Tiles'],['hex','Hex'],['wood','Wood'],['concrete','Concrete'],['checker','Checker'],['metal','Metal'],['stucco','Stucco'],['circuit','Circuit'],...(noFile?[]:[['file','File']])]; return (<>
{modes.map(([m,lbl]) => ())}
{tmode==='solid' && (
solidColorSetter&&solidColorSetter(e.target.value)} className="w-full h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
)} {tmode==='brick' && (
setter({brickColor:e.target.value})} className="w-full h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
setter({mortarColor:e.target.value})} className="w-full h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
setter({textureScale:parseFloat(e.target.value)})} className="w-full mt-2" />
setter({brickCols:parseInt(e.target.value)})} className="w-full mt-2" />
setter({brickAspect:parseFloat(e.target.value)})} className="w-full mt-2" />
)} {tmode==='tiles' && (
setter({tileColor:e.target.value})} className="w-full h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
setter({groutColor:e.target.value})} className="w-full h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
setter({textureScale:parseFloat(e.target.value)})} className="w-full mt-2" />
setter({tileCols:parseInt(e.target.value)})} className="w-full mt-2" />
setter({groutWidth:parseInt(e.target.value)})} className="w-full mt-2" />
setter({heightScale:parseFloat(e.target.value)})} className="w-full mt-2" />
)} {tmode==='hex' && (
setter({tileColor:e.target.value})} className="w-full h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
setter({groutColor:e.target.value})} className="w-full h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
setter({textureScale:parseFloat(e.target.value)})} className="w-full mt-2" />
setter({hexCols:parseInt(e.target.value)})} className="w-full mt-2" />
setter({groutWidth:parseInt(e.target.value)})} className="w-full mt-2" />
setter({heightScale:parseFloat(e.target.value)})} className="w-full mt-2" />
)} {tmode==='wood' && (
setter({woodColor:e.target.value})} className="w-full h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
setter({grainColor:e.target.value})} className="w-full h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
setter({textureScale:parseFloat(e.target.value)})} className="w-full mt-2" />
setter({plankCols:parseInt(e.target.value)})} className="w-full mt-2" />
)} {tmode==='concrete' && (
setter({panelColor:e.target.value})} className="w-full h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
setter({seamColor:e.target.value})} className="w-full h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
setter({textureScale:parseFloat(e.target.value)})} className="w-full mt-2" />
setter({panelsX:parseInt(e.target.value)})} className="w-full mt-2" />
setter({panelsY:parseInt(e.target.value)})} className="w-full mt-2" />
)} {tmode==='checker' && (
setter({colorA:e.target.value})} className="w-full h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
setter({colorB:e.target.value})} className="w-full h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
setter({textureScale:parseFloat(e.target.value)})} className="w-full mt-2" />
setter({squares:parseInt(e.target.value)})} className="w-full mt-2" />
)} {tmode==='metal' && (
setter({metalColor:e.target.value})} className="w-full h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
setter({seamColor:e.target.value})} className="w-full h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
setter({textureScale:parseFloat(e.target.value)})} className="w-full mt-2" />
setter({highlightColor:e.target.value})} className="w-full h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
setter({panelsX:parseInt(e.target.value)})} className="w-full mt-2" />
setter({panelsY:parseInt(e.target.value)})} className="w-full mt-2" />
)} {tmode==='stucco' && (
setter({baseColor:e.target.value})} className="w-full h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
setter({darkColor:e.target.value})} className="w-full h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
setter({textureScale:parseFloat(e.target.value)})} className="w-full mt-2" />
)} {tmode==='circuit' && (
setter({bgColor:e.target.value})} className="w-full h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
setter({traceColor:e.target.value})} className="w-full h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
setter({textureScale:parseFloat(e.target.value)})} className="w-full mt-2" />
setter({nodeColor:e.target.value})} className="w-full h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
setter({grid:parseInt(e.target.value)})} className="w-full mt-2" />
)} {tmode==='file' && !noFile && (
{textureFiles.length===0 ?

No textures found. Add PNG files to assets/textures/ and update manifest.json, then click Rescan.

: }
setter({textureScale:parseFloat(e.target.value)})} className="w-full mt-2" />
)} {tmode!=='solid' && (
setter({tintColor:e.target.value})} className="w-10 h-7 rounded cursor-pointer" title="Multiply-tint the texture (white = no tint)" /> {data.tintColor && data.tintColor!=='#ffffff' ? data.tintColor : 'none'}
)} ); }; const outlineCtrls = (surfKey) => { const sk = subKeys(surfKey); const state = t[`${surfKey}Outline`]; const outCol = t[sk.outColor] ?? (sk.legacyOutColor ? t[sk.legacyOutColor] : undefined) ?? t.wallOutlineColor ?? '#000000'; const outStyle = t[sk.outStyle] ?? (surfKey === 'exit' ? t.wallOutlineStyle ?? 'box' : 'box'); const subX = t[sk.sx] ?? (sk.legacySx ? t[sk.legacySx] : undefined) ?? 1; const subY = t[sk.sy] ?? (sk.legacySy ? t[sk.legacySy] : undefined) ?? 1; return (
Outline: {state === undefined && (
{surfKey === 'exit' && {t.wallOutlineStyle ?? 'box'}}
)} {state === true && (
updateTheme(lvl, t, { [sk.outColor]: e.target.value })} className="w-8 h-7 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" /> {surfKey === 'exit' && ( ['box', 'edge', 'none'].map(style => ( )) )} {((t[sk.outStyle] ?? t.wallOutlineStyle ?? 'box') === 'box' || surfKey !== 'exit') && <> ÷X updateTheme(lvl, t, { [sk.sx]: parseInt(e.target.value)||1 })} className="w-12 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-200 rounded p-1 text-center text-xs" /> ÷Y updateTheme(lvl, t, { [sk.sy]: parseInt(e.target.value)||1 })} className="w-12 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-200 rounded p-1 text-center text-xs" /> }
)}
); }; const pillItem = (key, label, pillHeader, pillBody) => { const open = surfacePill === key; return (
{open &&
{pillBody}
}
); }; const wallZoneEditor = (z, i, setZ, removable) => (
Zone #{i+1} setZ({height:parseFloat(e.target.value)})} className="w-24" /> {(z.height??1.0).toFixed(2)} {removable && }
{texCtrls(z, setZ, false, z.color||'#334155', c=>setZ({color:c}))}
Outline: {z.outline === undefined && (
{t.wallOutlineStyle ?? 'box'}
)} {z.outline === true && (
setZ({ outlineColor: e.target.value })} className="w-8 h-7 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" /> {['box', 'edge', 'none'].map(style => ( ))} {(z.outlineStyle ?? t.wallOutlineStyle ?? 'box') === 'box' && <> ÷X setZ({ subdivX: parseInt(e.target.value)||1 })} className="w-12 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-200 rounded p-1 text-center text-xs" /> ÷Y setZ({ subdivY: parseInt(e.target.value)||1 })} className="w-12 bg-white dark:bg-slate-900 border border-slate-200 dark:border-slate-700 text-slate-800 dark:text-slate-200 rounded p-1 text-center text-xs" /> }
)}
); return (
{ setGameData(prev => ({ ...prev, themes: prev.themes.map(x => x.id === lvl.id ? { ...x, name: e.target.value } : x) })); }} className="w-full border border-slate-200 dark:border-slate-700 rounded-lg p-2.5 bg-slate-50 dark:bg-slate-900 focus:outline-none focus:ring-2 focus:ring-emerald-500" />
{ setGameData(prev => ({ ...prev, themes: prev.themes.map(x => x.id === lvl.id ? { ...x, category: e.target.value } : x) })); }} className="w-full border border-slate-200 dark:border-slate-700 rounded-lg p-2.5 bg-slate-50 dark:bg-slate-900 focus:outline-none focus:ring-2 focus:ring-emerald-500" placeholder="e.g. Neon" />
{ setGameData(prev => ({ ...prev, themes: prev.themes.map(x => x.id === lvl.id ? { ...x, tagline: e.target.value } : x) })); }} className="w-full border border-slate-200 dark:border-slate-700 rounded-lg p-2.5 bg-slate-50 dark:bg-slate-900 focus:outline-none focus:ring-2 focus:ring-emerald-500 italic text-slate-600 dark:text-slate-400 dark:text-slate-500" placeholder="e.g. The lights don't flicker. They watch." />
{['map', 'levels', 'surfaces', 'environment', 'modes', 'player', 'menu'].map(tab => ( ))}
{themeSubTab === 'map' && (() => { const mg = lvl.theme.mapGen || {}; const mode = mg.mode ?? 'maze'; return (
setMapGenPreviewSize(parseInt(e.target.value))} onMouseUp={e => { const s = parseInt(e.target.value); setMapGenPreviewSize(s); regenerateMapGenPreview(lvl, s); }} className="w-full mt-1" />
{['maze', 'rooms', 'columns'].map(m => ( ))}
{mode === 'maze' && (
updateMapGen(lvl, mg, { loopFactor: parseFloat(e.target.value) })} onMouseUp={e => applyMapGen(lvl, mg, { loopFactor: parseFloat(e.target.value) })} className="w-full mt-1" />
)} {mode === 'rooms' && (
updateMapGen(lvl, mg, { roomDensity: parseFloat(e.target.value) })} onMouseUp={e => applyMapGen(lvl, mg, { roomDensity: parseFloat(e.target.value) })} className="w-full mt-1" />
updateMapGen(lvl, mg, { roomMinSize: parseInt(e.target.value) })} onMouseUp={e => applyMapGen(lvl, mg, { roomMinSize: parseInt(e.target.value) })} className="w-full mt-1" />
updateMapGen(lvl, mg, { roomMaxSize: parseInt(e.target.value) })} onMouseUp={e => applyMapGen(lvl, mg, { roomMaxSize: parseInt(e.target.value) })} className="w-full mt-1" />
)} {mode === 'columns' && (
updateMapGen(lvl, mg, { columnDensity: parseFloat(e.target.value) })} onMouseUp={e => applyMapGen(lvl, mg, { columnDensity: parseFloat(e.target.value) })} className="w-full mt-1" />
updateMapGen(lvl, mg, { columnMaxW: parseInt(e.target.value) })} onMouseUp={e => applyMapGen(lvl, mg, { columnMaxW: parseInt(e.target.value) })} className="w-full mt-1" />
updateMapGen(lvl, mg, { columnMaxH: parseInt(e.target.value) })} onMouseUp={e => applyMapGen(lvl, mg, { columnMaxH: parseInt(e.target.value) })} className="w-full mt-1" />
)}
updateMapGen(lvl, mg, { seed: e.target.value === '' ? null : parseInt(e.target.value) || null })} onBlur={e => applyMapGen(lvl, mg, { seed: e.target.value === '' ? null : parseInt(e.target.value) || null })} className="flex-1 border border-slate-200 dark:border-slate-700 rounded-lg p-2 font-mono text-sm" />

Set a seed to get the same layout every game (incremented per level). Leave blank for a new layout each run.

{!mg.seed && ( )}
{[ { rowLabel: 'Wall', mode: t.mapWallMode || 'z0', resolved: resolveMapWallColor(t), swatches: [ ...wallZones.map((z, i) => ({ key: `z${i}`, label: `zone ${i+1}`, color: zoneMapColor(z) })), ...(t.capColor ? [{ key: 'cap', label: 'cap', color: t.capColor }] : []), ...(t.wallOutlineColor ? [{ key: 'wallOutline', label: 'outline', color: t.wallOutlineColor }] : []), ], onSelect: (key) => updateTheme(lvl, t, {mapWallMode: key === 'z0' ? undefined : key, mapWallColor: undefined}), onCustom: (hex) => updateTheme(lvl, t, {mapWallMode: 'custom', mapWallColor: hex}), customVal: t.mapWallColor || resolveMapWallColor({...t, mapWallMode: undefined}), }, { rowLabel: 'Floor', mode: t.mapFloorMode || 'auto', resolved: resolveMapFloorColor(t), swatches: [ { key: 'auto', label: 'floor', color: t.floor || '#f8fafc' }, ...(t.floorOutlineColor ? [{ key: 'floorOutline', label: 'outline', color: t.floorOutlineColor }] : []), ], onSelect: (key) => updateTheme(lvl, t, {mapFloorMode: key === 'auto' ? undefined : key, mapFloorColor: undefined}), onCustom: (hex) => updateTheme(lvl, t, {mapFloorMode: 'custom', mapFloorColor: hex}), customVal: t.mapFloorColor || t.floor || '#f8fafc', }, { rowLabel: 'Exit', mode: t.mapExitMode || 'auto', resolved: resolveMapExitColor(t), swatches: [ { key: 'auto', label: 'exit', color: t.exitColor || '#ef4444' }, ...(t.exitOutlineColor ? [{ key: 'exitOutline', label: 'outline', color: t.exitOutlineColor }] : []), ], onSelect: (key) => updateTheme(lvl, t, {mapExitMode: key === 'auto' ? undefined : key, mapExitColor: undefined}), onCustom: (hex) => updateTheme(lvl, t, {mapExitMode: 'custom', mapExitColor: hex}), customVal: t.mapExitColor || t.exitColor || '#ef4444', }, ].map(({rowLabel, mode, resolved, swatches, onSelect, onCustom, customVal}) => (
{rowLabel}
{/* Current — display only */}
current
{/* Selectable swatches */} {swatches.map(({key, label, color}) => (
))} {/* Custom */}
custom
))}
); })()} {themeSubTab === 'surfaces' && (
updateTheme(lvl,t,{wallOutlineColor:e.target.value})} className="w-8 h-7 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" /> Style: {['box','edge','none'].map(style=>( ))}
{/* ── Wall ── */} {pillItem('wall', 'Wall Zones',
{wallZones.map((z,i)=>)}
,
{wallZones.map((z,i)=>wallZoneEditor(z,i,patch=>setZone(i,patch),wallZones.length>1?()=>removeZone(i):null))}
)} {/* ── Floor ── */} {pillItem('floor', 'Floor', ,
{texCtrls(t.floorTexture||{}, patch=>updateTheme(lvl,t,{floorTexture:{...(t.floorTexture||{}),...patch}}), false, t.floor||'#f8fafc', c=>updateTheme(lvl,t,{floor:c}))} {outlineCtrls('floor')}
)} {/* ── Exit ── */} {pillItem('exit', 'Exit', ,
{texCtrls(t.exitTexture||{}, patch=>updateTheme(lvl,t,{exitTexture:{...(t.exitTexture||{}),...patch}}), false, t.exitColor||'#ef4444', c=>updateTheme(lvl,t,{exitColor:c}))} {outlineCtrls('exit')}
{t.linkExitToCeiling===false &&
updateTheme(lvl,t,{exitHeight:parseFloat(e.target.value)})} className="w-full mt-1" />
}
updateTheme(lvl,t,{exitText:e.target.value})} className="w-full border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 text-slate-900 dark:text-white rounded p-2 text-center font-bold font-mono" />
updateTheme(lvl,t,{exitTextColor:e.target.value})} className="w-full h-10 rounded cursor-pointer" />
updateTheme(lvl,t,{exitTextSize:parseInt(e.target.value)})} className="w-full mt-2" />
{t.linkExitTextToMid===false &&
updateTheme(lvl,t,{exitTextY:parseFloat(e.target.value)})} className="w-full mt-1" />
}
)} {/* ── Ceiling ── */} {pillItem('ceiling', 'Ceiling', <>{!t.hasCeiling&&off},
{t.hasCeiling && <> {texCtrls(t.ceilingTexture||{}, patch=>updateTheme(lvl,t,{ceilingTexture:{...(t.ceilingTexture||{}),...patch}}), true, t.ceiling||'#1e293b', c=>updateTheme(lvl,t,{ceiling:c}))} {outlineCtrls('ceiling')}
{t.linkCeilingToWall===false &&
updateTheme(lvl,t,{manualCeilingHeight:parseFloat(e.target.value)})} className="w-full mt-1" />
}
updateTheme(lvl,t,{ceilingLightDensity:parseFloat(e.target.value)})} className="w-full" />
{(t.ceilingLightDensity||0)>0 &&
updateTheme(lvl,t,{ceilingLightColor:e.target.value})} className="w-16 h-10 rounded cursor-pointer" />
updateTheme(lvl,t,{ceilingLightIntensity:parseFloat(e.target.value)})} className="w-full mt-2" />
}
}
)} {/* ── Cap ── */} {pillItem('cap', 'Cap', <>{!t.capColor&&uses wall},

Cap is the top face of walls. Unset = inherits top wall zone colour.

{t.capColor && }
{texCtrls(t.capTexture||{}, patch=>updateTheme(lvl,t,{capTexture:{...(t.capTexture||{}),...patch}}), true, t.capColor||(wallZones[wallZones.length-1]?.color||'#334155'), c=>updateTheme(lvl,t,{capColor:c}))} {outlineCtrls('cap')}
)} {/* ── Exterior Floor ── */} {pillItem('exterior', 'Exterior Floor', ,
{texCtrls(t.exteriorTexture||{}, patch=>updateTheme(lvl,t,{exteriorTexture:{...(t.exteriorTexture||{}),...patch}}), false, t.exterior||t.outerFloor||'#1e293b', c=>updateTheme(lvl,t,{exterior:c}))} {outlineCtrls('exterior')}
)} {/* ── Perimeter Wall ── */} {pillItem('perimeterWall', 'Perimeter Wall', <>{t.usePerimeterWall?
{perimWallZones.map((z,i)=>)}
:uses wall},

The outer boundary wall of the maze grid. Relevant for Maze and Columns map types.

{t.usePerimeterWall && <> {perimWallZones.map((z,i)=>wallZoneEditor(z,i,patch=>setPerimZone(i,patch),perimWallZones.length>1?()=>updateTheme(lvl,t,{perimeterWallZones:perimWallZones.filter((_,idx)=>idx!==i)}):null))}
Renderer support coming soon
}
)} {/* spacer so the last pill isn't flush with the tab bottom */}
)} {themeSubTab === 'levels' && (
{ const v = parseInt(e.target.value) || 21; updateThemeEntry(lvl, { gridSize: v % 2 === 0 ? v + 1 : Math.max(7, v) }); }} className="w-full border border-slate-200 dark:border-slate-700 rounded p-2 bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
{ const v = parseInt(e.target.value) || 0; updateThemeEntry(lvl, { gridSizeIncrement: v % 2 === 0 ? v : v + 1 }); }} className="w-full border border-slate-200 dark:border-slate-700 rounded p-2 bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
Level {levelRef.current} size: {(lvl.gridSize ?? 21) + ((levelRef.current - 1) * (lvl.gridSizeIncrement ?? 0))}×{(lvl.gridSize ?? 21) + ((levelRef.current - 1) * (lvl.gridSizeIncrement ?? 0))}
{ const newDensity = parseFloat(e.target.value) || 0; const newTheme = updateTheme(lvl, t, { entityDensity: newDensity }); let fc = 0; const ms = engine.current.map.length; for (let y = 1; y < ms-1; y++) for (let x = 1; x < ms-1; x++) if (engine.current.map[y][x] !== 1) fc++; const nc = Math.max(0, Math.floor(fc * ((newDensity) + 4 * (newTheme.entityDensityScaling ?? 0)) / 100)); engine.current.entities = spawnEntities(engine.current.map, nc, ms, newTheme, 5, dataRef.current.entities); }} className="w-full border border-slate-200 dark:border-slate-700 rounded p-2 bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
updateTheme(lvl, t, { entityDensityScaling: parseFloat(e.target.value) || 0 })} className="w-full border border-slate-200 dark:border-slate-700 rounded p-2 bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
{(() => { const gameSize = (lvl.gridSize ?? 11) + (levelRef.current - 1) * (lvl.gridSizeIncrement ?? 2); const approxFloor = 2 * Math.floor(gameSize / 2) ** 2 - 1; const approxCount = Math.floor(approxFloor * ((t.entityDensity ?? 0) + (levelRef.current - 1) * (t.entityDensityScaling ?? 0)) / 100); return `≈ ${approxCount} entities at level ${levelRef.current} (${gameSize}×${gameSize} maze)`; })()}
updateTheme(lvl, t, { entityBaseSpeed: parseFloat(e.target.value) })} className="w-full border border-slate-200 dark:border-slate-700 rounded p-2 bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
updateTheme(lvl, t, { entitySpeedScaling: parseFloat(e.target.value) })} className="w-full border border-slate-200 dark:border-slate-700 rounded p-2 bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
{gameData.entities.map(ent => { const activeConfig = (t.activeEntities || []).find(ae => ae.id === ent.id); return (
); })}
)} {themeSubTab === 'modes' && (
{['smooth', 'classic'].map(mode => ( ))}
{t.moveMode === 'classic' && (
updateTheme(lvl, t, { tickInterval: parseInt(e.target.value) })} className="w-full" />
{(() => { const pct = Math.round((1 - (t.tickSpeedFactor ?? 0.95)) * 100); return ( <> updateTheme(lvl, t, { tickSpeedFactor: 1 - parseInt(e.target.value) / 100 })} className="w-full" /> ); })()}
)}
{[['on', true], ['off', false]].map(([label, val]) => ( ))}
)} {themeSubTab === 'environment' && (() => { const skyMode = t.skyMode ?? 'realistic'; const SKY_MODES = [ { id: 'realistic', label: 'Realistic' }, ]; return (
{/* Lighting + render resolution */}
updateTheme(lvl, t, { ambientLightIntensity: parseFloat(e.target.value) })} className="w-full mt-2" />
updateTheme(lvl, t, { pixelScale: parseFloat(e.target.value) })} className="w-full mt-2" /> {(t.pixelScale ?? 1) < 1 && ( )}
{ const v = parseInt(e.target.value); updateTheme(lvl, t, { sightRadius: v === 0 ? null : v }); }} className="w-full mt-2" />
{/* Sky mode */}
{t.hasCeiling ? (

Sky disabled — ceiling is on (Surfaces tab).

) : (<>
{SKY_MODES.map(sm => ( ))}
{/* Time controls — all sky modes */}
updateTheme(lvl, t, { timeOfDay: parseFloat(e.target.value) })} className="w-full mt-1" />
updateTheme(lvl, t, { timeSpeed: parseFloat(e.target.value) })} className="w-full mt-1" />
{/* Realistic: sun size + lat + day-of-year */} {skyMode === 'realistic' && (
updateTheme(lvl, t, { sunSize: parseFloat(e.target.value) })} className="w-full mt-1" />
updateTheme(lvl, t, { sunColor: e.target.value })} className="w-10 h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" /> {t.sunColor && }
updateTheme(lvl, t, { latitude: parseFloat(e.target.value) })} className="w-full mt-1" />
updateTheme(lvl, t, { dayOfYear: parseInt(e.target.value) })} className="w-full mt-1" />
)} )}
{/* Stars */} {!t.hasCeiling && (
updateTheme(lvl, t, { starDensity: parseFloat(e.target.value) })} className="w-full mt-1" />
updateTheme(lvl, t, { starBrightness: parseFloat(e.target.value) })} className="w-full mt-1" />
updateTheme(lvl, t, { starTwinkle: parseFloat(e.target.value) })} className="w-full mt-1" />
updateTheme(lvl, t, { starRotationSpeed: parseFloat(e.target.value) })} className="w-full mt-1" />
updateTheme(lvl, t, { starSize: parseFloat(e.target.value) })} className="w-full mt-1" />
)} {/* Overlay */}
updateTheme(lvl, t, { overlayAccentColor: e.target.value })} className="w-10 h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" /> {t.overlayAccentColor && }
updateTheme(lvl, t, { overlayTextColor: e.target.value })} className="w-10 h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" /> {t.overlayTextColor && }
updateTheme(lvl, t, { overlayBgColor: e.target.value })} className="w-10 h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" /> {t.overlayBgColor && }
{[{value: 'instant', label: 'None'}, {value: 'fade', label: 'Fade'}].map(({value, label}) => ( ))}
{(t.deathMode ?? 'instant') === 'fade' && (
updateTheme(lvl, t, { deathFadeColor: e.target.value })} className="w-full h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
updateTheme(lvl, t, { deathFadeInMs: parseInt(e.target.value) })} className="w-full mt-1" />
updateTheme(lvl, t, { deathFadeMaxAlpha: parseFloat(e.target.value) })} className="w-full mt-1" />
)}
); })()} {themeSubTab === 'player' && (
updateTheme(lvl, t, { player: e.target.value })} className="w-full h-10 rounded cursor-pointer" />
)} {themeSubTab === 'menu' && (() => { const OVERLAY_FONTS = [ { name: 'System', value: 'monospace' }, { name: 'Press Start 2P', value: 'Press Start 2P' }, { name: 'VT323', value: 'VT323' }, { name: 'Silkscreen', value: 'Silkscreen' }, ]; const curFont = t.overlayFont ?? 'monospace'; const curPS = t.overlayPixelScale ?? (t.pixelScale ?? 1); return (
{OVERLAY_FONTS.map(f => ( ))}
updateTheme(lvl, t, { overlayPixelScale: parseFloat(e.target.value) })} className="w-full mt-1" />

Values below 0.3 are clamped at render time

updateTheme(lvl, t, { overlayAccentColor: e.target.value })} className="w-10 h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" /> {t.overlayAccentColor && }
updateTheme(lvl, t, { overlayTextColor: e.target.value })} className="w-10 h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" /> {t.overlayTextColor && }
updateTheme(lvl, t, { overlayBgColor: e.target.value })} className="w-10 h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" /> {t.overlayBgColor && }
{[{value: 'instant', label: 'None'}, {value: 'fade', label: 'Fade'}].map(({value, label}) => ( ))}
{(t.deathMode ?? 'instant') === 'fade' && (
updateTheme(lvl, t, { deathFadeColor: e.target.value })} className="w-full h-8 rounded cursor-pointer bg-transparent dark:bg-slate-900 text-slate-900 dark:text-white" />
updateTheme(lvl, t, { deathFadeInMs: parseInt(e.target.value) })} className="w-full mt-1" />
updateTheme(lvl, t, { deathFadeMaxAlpha: parseFloat(e.target.value) })} className="w-full mt-1" />
)}
); })()}
); })() )} {designerTab === 'entities' && gameData.entities.find(e => e.id === editingEntityId) && ( (() => { const ent = gameData.entities.find(e => e.id === editingEntityId); return (
); })() )}
)}
)} {deleteConfirmItem && (

Delete {deleteConfirmItem.type === 'theme' ? 'Theme' : 'Entity'}

Are you sure you want to delete "{deleteConfirmItem.name}"? This cannot be undone.

)} {activeModal === 'data' && ( setActiveModal(null)} /> )}
); } const root = ReactDOM.createRoot(document.getElementById('root')); root.render();