- Add procedural UI sound effects (connect/disconnect, engine start/stop, level complete/fail, star earned, hint, navigation) via Tone.js - Live LFO modulation visualization: knobs animate in real-time showing modulated value, ghost dot shows base value, number glows cyan - Fix wire recalculation on zoom/pan/level re-entry (post-layout refresh) - Fix retry button to keep current patch instead of reloading level - Fix default param detection: newly added modules now populate all default params so level checkers work without manual param changes - Add worlds 7-12: Secuencias y Ritmos, Texturas de Ruido, Síntesis Sustractiva, Espacio y Stereo, Técnicas Avanzadas, Gran Final (48 new levels, 144 new possible stars, 288 total stars) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
468 lines
16 KiB
JavaScript
468 lines
16 KiB
JavaScript
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 (
|
||
<div className="gm-puzzle">
|
||
{/* Top bar */}
|
||
<div className="gm-puzzle-bar">
|
||
<button className="gm-btn icon" onClick={() => { playNav(); onBack(); }}>← Mapa</button>
|
||
<div className="gm-puzzle-title">
|
||
<span className="gm-puzzle-num">{levelIndex + 1}/{worldLevels.length}</span>
|
||
<span className="gm-puzzle-name">{level.title}</span>
|
||
</div>
|
||
<div className="gm-puzzle-actions">
|
||
<button
|
||
className={`gm-btn ${targetPlaying ? 'active' : 'target'}`}
|
||
onClick={handlePlayTarget}
|
||
>
|
||
{targetPlaying ? '⏹ Parar' : '🎯 Objetivo'}
|
||
</button>
|
||
<button
|
||
className={`gm-btn ${state.isRunning ? 'active' : ''}`}
|
||
onClick={handleToggleAudio}
|
||
>
|
||
{state.isRunning ? '⏹ Parar' : '▶ Mi Sonido'}
|
||
</button>
|
||
<button className="gm-btn check" onClick={handleCheck}>
|
||
✓ Comprobar
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="gm-puzzle-content">
|
||
{/* Left sidebar */}
|
||
<div className="gm-puzzle-sidebar">
|
||
{/* Description — always visible */}
|
||
<div className="gm-concept-panel">
|
||
<div className="gm-concept-header">
|
||
<span>📖 Mision</span>
|
||
</div>
|
||
<div className="gm-concept-body">
|
||
<p className="gm-concept-desc">{level.description}</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Hint — hidden, reveals with penalty */}
|
||
<div className="gm-hint-panel">
|
||
{!showHint ? (
|
||
<button className="gm-hint-btn" onClick={handleRevealHint}>
|
||
<span className="gm-hint-icon">💡</span>
|
||
<span className="gm-hint-label">Mostrar Pista</span>
|
||
<span className="gm-hint-penalty">max ★★</span>
|
||
</button>
|
||
) : (
|
||
<div className="gm-hint-revealed">
|
||
<div className="gm-hint-header">
|
||
<span>💡 Pista</span>
|
||
<span className="gm-hint-penalty-tag">max ★★</span>
|
||
</div>
|
||
<p className="gm-hint-text">{level.concept}</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Objectives */}
|
||
<div className="gm-objectives">
|
||
<div className="gm-obj-title">Objetivos</div>
|
||
{level.checks.map((check, i) => {
|
||
const passed = result?.checks?.[i]?.passed;
|
||
const cappedByStar = hintUsed && check.star === 3;
|
||
return (
|
||
<div key={i} className={`gm-obj ${passed === true ? (cappedByStar ? 'capped' : 'passed') : passed === false ? 'failed' : ''}`}>
|
||
<span className="gm-obj-star">{'★'.repeat(check.star)}</span>
|
||
<span className="gm-obj-name">
|
||
{check.desc}
|
||
{cappedByStar && <span className="gm-obj-locked"> 🔒</span>}
|
||
</span>
|
||
{passed === true && !cappedByStar && <span className="gm-obj-check">✓</span>}
|
||
{passed === false && <span className="gm-obj-x">✗</span>}
|
||
</div>
|
||
);
|
||
})}
|
||
{hintUsed && (
|
||
<div className="gm-hint-warning">
|
||
Pista usada — maximo 2 estrellas en este nivel (permanente).
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Module palette */}
|
||
{level.availableModules.length > 0 && (
|
||
<div className="gm-module-palette">
|
||
<div className="gm-palette-title">Modulos Disponibles</div>
|
||
{level.availableModules.map(type => {
|
||
const def = getModuleDef(type);
|
||
if (!def) return null;
|
||
return (
|
||
<div key={type} className="gm-palette-item" onClick={() => handleAddModule(type)}>
|
||
<span className="gm-palette-icon">{def.icon}</span>
|
||
<span className="gm-palette-name">{def.name}</span>
|
||
<span className="gm-palette-add">+</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
|
||
<button className="gm-btn danger" onClick={() => loadLevel(true)} style={{ marginTop: 'auto' }}>
|
||
↺ Reiniciar Nivel
|
||
</button>
|
||
</div>
|
||
|
||
{/* Main canvas */}
|
||
<div className="gm-puzzle-canvas-wrap">
|
||
<div
|
||
ref={containerRef}
|
||
className={`node-canvas ${state.panning ? 'grabbing' : ''} ${connectingRef.current ? 'connecting' : ''}`}
|
||
onPointerDown={handlePointerDown}
|
||
onPointerMove={handlePointerMove}
|
||
onPointerUp={handlePointerUp}
|
||
onWheel={handleWheel}
|
||
onContextMenu={handleContextMenu}
|
||
>
|
||
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', zIndex: 0 }}>
|
||
<defs>
|
||
<pattern id="puzzle-grid" width={20 * state.zoom} height={20 * state.zoom}
|
||
patternUnits="userSpaceOnUse"
|
||
x={state.camX % (20 * state.zoom)} y={state.camY % (20 * state.zoom)}>
|
||
<circle cx={1} cy={1} r={0.8} fill="#1a1a30" />
|
||
</pattern>
|
||
</defs>
|
||
<rect width="100%" height="100%" fill="url(#puzzle-grid)" />
|
||
</svg>
|
||
|
||
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} zoom={state.zoom} camX={state.camX} camY={state.camY} />
|
||
|
||
<div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
|
||
{state.modules.map(mod => (
|
||
<ModuleNode
|
||
key={mod.id}
|
||
mod={mod}
|
||
zoom={state.zoom}
|
||
onStartConnect={handleStartConnect}
|
||
onPortPosition={handlePortPosition}
|
||
/>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Zoom controls — top right */}
|
||
<div className="zoom-controls">
|
||
<button className="zoom-btn" onClick={handleZoomIn} title="Acercar">+</button>
|
||
<button className="zoom-btn zoom-label" onClick={handleZoomReset} title="Resetear zoom">
|
||
{(state.zoom * 100).toFixed(0)}%
|
||
</button>
|
||
<button className="zoom-btn" onClick={handleZoomOut} title="Alejar">−</button>
|
||
</div>
|
||
|
||
{state.modules.length > 0 && state.connections.length === 0 && (
|
||
<div className="gm-canvas-hint">
|
||
Arrastra de un puerto (circulo) a otro para conectar modulos
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Level complete overlay */}
|
||
{result && result.stars >= 1 && (
|
||
<LevelComplete
|
||
stars={result.stars}
|
||
checks={result.checks}
|
||
levelTitle={level.title}
|
||
isLastLevel={isLastLevel}
|
||
hintPenalty={result.hintPenalty}
|
||
onRetry={() => setResult(null)}
|
||
onMap={onBack}
|
||
onNext={onNextLevel}
|
||
/>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|