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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user