feat: add SynthQuest game mode with World 1 (Waves) — 8 puzzle levels

Gamified synth learning inspired by Turing Complete. Progressive puzzle
system teaches oscillators, waveforms, frequency, and mixing through
hands-on module patching. Includes world map, level progression with
3-star rating, target audio playback, solution validation, and
localStorage persistence. Sandbox mode still accessible via button.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-21 02:04:26 +01:00
parent d0755413f3
commit 08206e996e
10 changed files with 1500 additions and 3 deletions

379
src/game/PuzzleView.jsx Normal file
View File

@@ -0,0 +1,379 @@
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 } from './gameState.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 [showConcept, setShowConcept] = useState(true);
const [result, setResult] = useState(null); // { stars, checks }
const [targetPlaying, setTargetPlaying] = useState(false);
// Subscribe to state changes
useEffect(() => {
const unsub = subscribe(() => forceUpdate(n => n + 1));
return unsub;
}, []);
// Load level on mount
useEffect(() => {
loadLevel();
return () => {
stopAudio();
stopTarget();
};
}, [level.id]);
const loadLevel = useCallback(() => {
// Clear state and load preplaced modules
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);
setShowConcept(true);
if (state.isRunning) stopAudio();
}, [level]);
// Port position reporting
const handlePortPosition = useCallback((moduleId, portName, direction, el) => {
const key = `${moduleId}-${portName}-${direction}`;
portPositions.current[key] = el;
}, []);
// Start connecting wire
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,
});
}, []);
const findPortAtPoint = (x, y) => {
for (const [dx, dy] of [[0,0],[-8,0],[8,0],[0,-8],[0,8],[-6,-6],[6,-6],[-6,6],[6,6]]) {
const hit = document.elementFromPoint(x + dx, y + dy);
if (hit && hit.classList.contains('port-dot') && hit.dataset.moduleId) {
return hit;
}
}
return null;
};
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(), []);
// Add module from palette
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();
};
// Toggle player audio
const handleToggleAudio = async () => {
if (state.isRunning) {
stopAudio();
} else {
await startAudio();
}
emit();
};
// Play target sound
const handlePlayTarget = async () => {
if (isTargetPlaying()) {
stopTarget();
setTargetPlaying(false);
} else {
setTargetPlaying(true);
await playTarget(level.target);
// Auto-update when target stops
setTimeout(() => setTargetPlaying(false), (level.target.duration || 2) * 1000 + 100);
}
};
// Validate solution
const handleCheck = () => {
const mods = state.modules;
const conns = state.connections;
const checks = level.checks.map(check => ({
...check,
passed: check.test(mods, conns),
}));
// Stars: sequential — need all previous stars to earn next
let stars = 0;
for (const check of checks) {
if (check.passed) stars = check.star;
else break;
}
setResult({ stars, checks });
if (stars >= 1) {
completeLevel(level.id, stars);
}
};
const isLastLevel = levelIndex >= worldLevels.length - 1;
return (
<div className="gm-puzzle">
{/* Top bar */}
<div className="gm-puzzle-bar">
<button className="gm-btn icon" onClick={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: concept + module palette */}
<div className="gm-puzzle-sidebar">
{/* Concept panel */}
<div className="gm-concept-panel">
<div className="gm-concept-header" onClick={() => setShowConcept(!showConcept)}>
<span>💡 Concepto</span>
<span>{showConcept ? '▼' : '▶'}</span>
</div>
{showConcept && (
<div className="gm-concept-body">
<p className="gm-concept-desc">{level.description}</p>
<div className="gm-concept-tip">
<strong>Pista:</strong> {level.concept}
</div>
</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;
return (
<div key={i} className={`gm-obj ${passed === true ? 'passed' : passed === false ? 'failed' : ''}`}>
<span className="gm-obj-star">{'★'.repeat(check.star)}</span>
<span className="gm-obj-name">{check.desc}</span>
{passed === true && <span className="gm-obj-check"></span>}
{passed === false && <span className="gm-obj-x"></span>}
</div>
);
})}
</div>
{/* Module palette for this level */}
{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>
)}
{/* Reset button */}
<button className="gm-btn danger" onClick={loadLevel} 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}
>
{/* Grid */}
<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>
{/* Wires */}
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} zoom={state.zoom} camX={state.camX} camY={state.camY} />
{/* Modules */}
<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>
{/* Canvas hints */}
{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}
onRetry={loadLevel}
onMap={onBack}
onNext={onNextLevel}
/>
)}
</div>
);
}