Files
reaktor/src/game/PuzzleView.jsx
Jose Luis a1be6df355 feat: UI sounds, live LFO visualization, wire fix, worlds 7-12, bug fixes
- 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>
2026-03-21 03:03:29 +01:00

468 lines
16 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}