import React, { useState, useEffect, useRef, useCallback } from 'react'; import { state, subscribe, addModule, emit, addConnection, removeModule, updateModulePosition, deserialize } from '../engine/state.js'; import { startAudio, stopAudio, connectWire, rebuildGraph } from '../engine/audioEngine.js'; import { getModuleDef } from '../engine/moduleRegistry.js'; import ModuleNode from '../components/ModuleNode.jsx'; import WireLayer from '../components/WireLayer.jsx'; import { playTarget, stopTarget, isTargetPlaying } from './targetAudio.js'; import LevelComplete from './LevelComplete.jsx'; import { completeLevel, saveLevelPatch, getLevelPatch, markHintUsed, wasHintUsed } from './gameState.js'; import { playLevelComplete, playFail, playHint, playEngineStart, playEngineStop, playNav } from '../engine/uiSounds.js'; export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onNextLevel }) { const [, forceUpdate] = useState(0); const containerRef = useRef(null); const portPositions = useRef({}); const [tempWire, setTempWire] = useState(null); const connectingRef = useRef(null); const [hintUsed, setHintUsed] = useState(false); const [showHint, setShowHint] = useState(false); const [result, setResult] = useState(null); const [targetPlaying, setTargetPlaying] = useState(false); useEffect(() => { const unsub = subscribe(() => { forceUpdate(n => n + 1); // Auto-save patch on every state change (debounced below) scheduleSave(); }); return unsub; }, [level.id]); // Debounced auto-save of the current patch const saveTimerRef = useRef(null); const scheduleSave = useCallback(() => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); saveTimerRef.current = setTimeout(() => { if (state.modules.length > 0) { saveLevelPatch(level.id, state.modules, state.connections); } }, 1000); }, [level.id]); useEffect(() => { return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); }; }, []); useEffect(() => { loadLevel(); return () => { stopAudio(); stopTarget(); }; }, [level.id]); const loadLevel = useCallback((forceReset = false) => { // Check for a saved patch first (unless explicitly resetting) const saved = !forceReset ? getLevelPatch(level.id) : null; if (saved) { const data = { modules: saved.modules.map(m => ({ id: m.id, type: m.type, x: m.x, y: m.y, params: { ...m.params }, })), connections: saved.connections.map(c => ({ ...c })), camera: { camX: 0, camY: 0, zoom: 1 }, }; deserialize(data); } else { const data = { modules: (level.preplacedModules || []).map(m => ({ id: m.id, type: m.type, x: m.x, y: m.y, params: { ...m.params }, })), connections: [], camera: { camX: 0, camY: 0, zoom: 1 }, }; deserialize(data); } setResult(null); // Restore persisted hint state — no cheating by reloading! const hintPersisted = wasHintUsed(level.id); setHintUsed(hintPersisted); setShowHint(hintPersisted); // If they used it before, show it again if (state.isRunning) stopAudio(); }, [level]); const handlePortPosition = useCallback((moduleId, portName, direction, el) => { const key = `${moduleId}-${portName}-${direction}`; portPositions.current[key] = el; }, []); const handleStartConnect = useCallback((info) => { connectingRef.current = info; const containerRect = containerRef.current.getBoundingClientRect(); setTempWire({ portType: info.portType, startX: info.startX - containerRect.left, startY: info.startY - containerRect.top, endX: info.startX - containerRect.left, endY: info.startY - containerRect.top, }); }, []); // Robust port detection — searches all port-dots by bounding rect distance // instead of elementFromPoint (which gets blocked by SVG wire overlay) const findPortAtPoint = (clientX, clientY) => { const portDots = document.querySelectorAll('.port-dot[data-module-id]'); let closest = null; let closestDist = 18; for (const dot of portDots) { const rect = dot.getBoundingClientRect(); const cx = rect.left + rect.width / 2; const cy = rect.top + rect.height / 2; const dist = Math.sqrt((clientX - cx) ** 2 + (clientY - cy) ** 2); if (dist < closestDist) { closestDist = dist; closest = dot; } } return closest; }; const handlePointerDown = useCallback((e) => { if (e.button === 1 || (e.button === 0 && e.altKey) || e.button === 2) { state.panning = true; state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY }; e.preventDefault(); } else if (e.button === 0 && !connectingRef.current) { state.selectedModuleId = null; emit(); } }, []); const handlePointerMove = useCallback((e) => { if (state.panning && state.panStart) { state.camX = e.clientX - state.panStart.x; state.camY = e.clientY - state.panStart.y; emit(); return; } if (state.dragging) { const newX = e.clientX / state.zoom - state.dragging.offsetX; const newY = e.clientY / state.zoom - state.dragging.offsetY; updateModulePosition(state.dragging.moduleId, newX, newY); return; } if (connectingRef.current && containerRef.current) { const containerRect = containerRef.current.getBoundingClientRect(); setTempWire(prev => prev ? { ...prev, endX: e.clientX - containerRect.left, endY: e.clientY - containerRect.top, } : null); } }, []); const handlePointerUp = useCallback((e) => { if (state.panning) { state.panning = false; state.panStart = null; } if (state.dragging) { state.dragging = null; emit(); } if (connectingRef.current) { const portEl = findPortAtPoint(e.clientX, e.clientY); if (portEl) finishConnection(portEl); connectingRef.current = null; setTempWire(null); } }, []); const finishConnection = (portEl) => { const from = connectingRef.current; if (!from) return; const targetModuleId = parseInt(portEl.dataset.moduleId); const targetPort = portEl.dataset.portName; const targetDirection = portEl.dataset.portDirection; if (!targetModuleId || !targetPort || !targetDirection) return; if (targetModuleId === from.moduleId && targetPort === from.port) return; let fromMod, fromPort, toMod, toPort; if (from.direction === 'output' && targetDirection === 'input') { fromMod = from.moduleId; fromPort = from.port; toMod = targetModuleId; toPort = targetPort; } else if (from.direction === 'input' && targetDirection === 'output') { fromMod = targetModuleId; fromPort = targetPort; toMod = from.moduleId; toPort = from.port; } else return; const connId = addConnection(fromMod, fromPort, toMod, toPort); if (connId && state.isRunning) { const conn = state.connections.find(c => c.id === connId); if (conn) connectWire(conn); } }; const handleWheel = useCallback((e) => { e.preventDefault(); const delta = -e.deltaY * 0.001; state.zoom = Math.max(0.3, Math.min(3, state.zoom + delta)); emit(); }, []); const handleContextMenu = useCallback((e) => e.preventDefault(), []); // Zoom controls (Google Maps style) const handleZoomIn = useCallback(() => { state.zoom = Math.min(3, state.zoom * 1.25); emit(); }, []); const handleZoomOut = useCallback(() => { state.zoom = Math.max(0.3, state.zoom / 1.25); emit(); }, []); const handleZoomReset = useCallback(() => { state.zoom = 1; state.camX = 0; state.camY = 0; emit(); }, []); const handleAddModule = (type) => { const x = (-state.camX + 250) / state.zoom + Math.random() * 30; const y = (-state.camY + 150) / state.zoom + Math.random() * 30; addModule(type, x, y); if (state.isRunning) rebuildGraph(); }; const handleToggleAudio = async () => { if (state.isRunning) { stopAudio(); playEngineStop(); } else { await startAudio(); playEngineStart(); } emit(); }; const handlePlayTarget = async () => { if (isTargetPlaying()) { stopTarget(); setTargetPlaying(false); } else { setTargetPlaying(true); await playTarget(level.target); setTimeout(() => setTargetPlaying(false), (level.target.duration || 2) * 1000 + 100); } }; // Reveal hint — PERMANENTLY caps this level at 2 stars (persisted, survives reload) const handleRevealHint = () => { setHintUsed(true); setShowHint(true); markHintUsed(level.id); playHint(); }; const handleCheck = () => { const mods = state.modules; const conns = state.connections; const checks = level.checks.map(check => ({ ...check, passed: check.test(mods, conns), })); let stars = 0; for (const check of checks) { if (check.passed) stars = check.star; else break; } // Cap at 2 stars if hint was used if (hintUsed && stars > 2) stars = 2; setResult({ stars, checks, hintPenalty: hintUsed && stars >= 2 }); if (stars >= 1) { completeLevel(level.id, stars); playLevelComplete(); } else { playFail(); } }; const isLastLevel = levelIndex >= worldLevels.length - 1; return (
{level.description}
{level.concept}