feat: add Worlds 2-3, patch persistence, and zoom controls

- World 2 (Filtros): 8 levels teaching filters, resonance, LFO modulation, acid bass
- World 3 (Envelopes): 8 levels teaching VCA, ADSR, pluck, tremolo, full synth lead
- Star-based world unlock system (12 stars for W2, 24 for W3)
- Level patch persistence: auto-saves player patches, restores on revisit
- Google Maps-style zoom controls (+/−/reset) in both puzzle and sandbox views
- Multi-world navigation in GameApp and WorldMap
- Target audio now supports filter chain for World 2 levels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jose Luis
2026-03-21 02:28:36 +01:00
parent 00c4ec8e00
commit 41d993183f
9 changed files with 1292 additions and 95 deletions

View File

@@ -6,7 +6,7 @@ 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';
import { completeLevel, saveLevelPatch, getLevelPatch } from './gameState.js';
export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onNextLevel }) {
const [, forceUpdate] = useState(0);
@@ -20,8 +20,29 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
const [targetPlaying, setTargetPlaying] = useState(false);
useEffect(() => {
const unsub = subscribe(() => forceUpdate(n => n + 1));
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(() => {
@@ -32,15 +53,28 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
};
}, [level.id]);
const loadLevel = useCallback(() => {
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);
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);
setHintUsed(false);
setShowHint(false);
@@ -168,6 +202,22 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
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;
@@ -330,7 +380,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
</div>
)}
<button className="gm-btn danger" onClick={loadLevel} style={{ marginTop: 'auto' }}>
<button className="gm-btn danger" onClick={() => loadLevel(true)} style={{ marginTop: 'auto' }}>
Reiniciar Nivel
</button>
</div>
@@ -372,6 +422,15 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
</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