diff --git a/src/App.jsx b/src/App.jsx index 94861e0..2aa2c4e 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -174,6 +174,22 @@ export default function App({ onSwitchToGame }) { 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 handleToggleAudio = async () => { if (state.isRunning) { stopAudio(); @@ -279,6 +295,15 @@ export default function App({ onSwitchToGame }) { + {/* Zoom controls — top right of canvas */} +
+ + + +
+ {/* Module palette */} diff --git a/src/game/GameApp.jsx b/src/game/GameApp.jsx index 9e0f99e..f6dc772 100644 --- a/src/game/GameApp.jsx +++ b/src/game/GameApp.jsx @@ -2,43 +2,58 @@ import React, { useState, useCallback } from 'react'; import WorldMap from './WorldMap.jsx'; import PuzzleView from './PuzzleView.jsx'; import { WORLD_1 } from './levels/world1.js'; +import { WORLD_2 } from './levels/world2.js'; +import { WORLD_3 } from './levels/world3.js'; + +const allWorlds = [WORLD_1, WORLD_2, WORLD_3]; export default function GameApp({ onSwitchToSandbox }) { - const [view, setView] = useState('map'); // 'map' | 'puzzle' + const [view, setView] = useState('map'); const [currentLevel, setCurrentLevel] = useState(null); const [currentLevelIndex, setCurrentLevelIndex] = useState(0); + const [currentWorld, setCurrentWorld] = useState(null); - const worldLevels = WORLD_1.levels; - - const handleSelectLevel = useCallback((level) => { - const idx = worldLevels.findIndex(l => l.id === level.id); + const handleSelectLevel = useCallback((level, world) => { + const idx = world.levels.findIndex(l => l.id === level.id); setCurrentLevel(level); setCurrentLevelIndex(idx); + setCurrentWorld(world); setView('puzzle'); - }, [worldLevels]); + }, []); const handleBack = useCallback(() => { setView('map'); setCurrentLevel(null); + setCurrentWorld(null); }, []); const handleNextLevel = useCallback(() => { + if (!currentWorld) return; const nextIdx = currentLevelIndex + 1; - if (nextIdx < worldLevels.length) { - setCurrentLevel(worldLevels[nextIdx]); + if (nextIdx < currentWorld.levels.length) { + setCurrentLevel(currentWorld.levels[nextIdx]); setCurrentLevelIndex(nextIdx); } else { - setView('map'); + // Move to next world's first level if unlocked + const worldIdx = allWorlds.findIndex(w => w.id === currentWorld.id); + if (worldIdx < allWorlds.length - 1) { + const nextWorld = allWorlds[worldIdx + 1]; + setCurrentWorld(nextWorld); + setCurrentLevel(nextWorld.levels[0]); + setCurrentLevelIndex(0); + } else { + setView('map'); + } } - }, [currentLevelIndex, worldLevels]); + }, [currentLevelIndex, currentWorld]); - if (view === 'puzzle' && currentLevel) { + if (view === 'puzzle' && currentLevel && currentWorld) { return ( diff --git a/src/game/PuzzleView.jsx b/src/game/PuzzleView.jsx index 590265d..a062807 100644 --- a/src/game/PuzzleView.jsx +++ b/src/game/PuzzleView.jsx @@ -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 )} - @@ -372,6 +422,15 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN + {/* Zoom controls — top right */} +
+ + + +
+ {state.modules.length > 0 && state.connections.length === 0 && (
Arrastra de un puerto (circulo) a otro para conectar modulos diff --git a/src/game/WorldMap.jsx b/src/game/WorldMap.jsx index 99186c8..6f7c4c1 100644 --- a/src/game/WorldMap.jsx +++ b/src/game/WorldMap.jsx @@ -1,8 +1,10 @@ import React from 'react'; import { WORLD_1 } from './levels/world1.js'; -import { getLevelProgress, isLevelUnlocked } from './gameState.js'; +import { WORLD_2 } from './levels/world2.js'; +import { WORLD_3 } from './levels/world3.js'; +import { getLevelProgress, isLevelUnlocked, loadProgress } from './gameState.js'; -const worlds = [WORLD_1]; +const worlds = [WORLD_1, WORLD_2, WORLD_3]; function Stars({ count, max = 3 }) { return ( @@ -14,13 +16,23 @@ function Stars({ count, max = 3 }) { ); } +function getTotalStars() { + const p = loadProgress(); + return Object.values(p.completedLevels).reduce((s, l) => s + (l.stars || 0), 0); +} + +function getMaxStars() { + return worlds.reduce((s, w) => s + w.levels.length * 3, 0); +} + +function isWorldUnlocked(world) { + if (!world.unlockStars) return true; // World 1 always unlocked + return getTotalStars() >= world.unlockStars; +} + export default function WorldMap({ onSelectLevel, onSandbox }) { - const world = WORLD_1; - const totalStars = world.levels.reduce((s, l) => { - const p = getLevelProgress(l.id); - return s + (p?.stars || 0); - }, 0); - const maxStars = world.levels.length * 3; + const totalStars = getTotalStars(); + const maxStars = getMaxStars(); return (
@@ -43,69 +55,74 @@ export default function WorldMap({ onSelectLevel, onSandbox }) {
- {/* World section */} -
-
- {world.icon} -
-

Mundo 1: {world.name}

-

{world.subtitle}

-
-
+ {/* All worlds */} + {worlds.map((world, worldIdx) => { + const unlocked = isWorldUnlocked(world); + const worldStars = world.levels.reduce((s, l) => { + const p = getLevelProgress(l.id); + return s + (p?.stars || 0); + }, 0); + const worldMaxStars = world.levels.length * 3; - {/* Level grid */} -
- {world.levels.map((level, idx) => { - const progress = getLevelProgress(level.id); - const unlocked = isLevelUnlocked(level.id, world.levels); - const stars = progress?.stars || 0; - const isBoss = idx === world.levels.length - 1; - - return ( -
unlocked && onSelectLevel(level)} - > -
{idx + 1}
-
-

{level.title}

-

{level.subtitle}

+ if (!unlocked) { + return ( +
+
+ {world.icon} +
+

Mundo {worldIdx + 1}: {world.name}

+

Consigue {world.unlockStars} estrellas para desbloquear ({totalStars}/{world.unlockStars})

- {unlocked ? ( - - ) : ( - 🔒 - )} - {!unlocked &&
} + 🔒
- ); - })} -
-
+
+ ); + } - {/* Future worlds teaser */} -
-
- -
-

Mundo 2: Filtros

-

Proximamente... Consigue {Math.ceil(maxStars * 0.6)} estrellas para desbloquear

-
- 🔒 -
-
+ return ( +
+
+ {world.icon} +
+

Mundo {worldIdx + 1}: {world.name}

+

{world.subtitle}

+
+
+ {worldStars}/{worldMaxStars} +
+
-
-
- ⏤╲ -
-

Mundo 3: Envelopes

-

Proximamente...

+
+ {world.levels.map((level, idx) => { + const progress = getLevelProgress(level.id); + const levelUnlocked = isLevelUnlocked(level.id, world.levels); + const stars = progress?.stars || 0; + const isBoss = idx === world.levels.length - 1; + + return ( +
levelUnlocked && onSelectLevel(level, world)} + > +
{idx + 1}
+
+

{level.title}

+

{level.subtitle}

+
+ {levelUnlocked ? ( + + ) : ( + 🔒 + )} + {!levelUnlocked &&
} +
+ ); + })} +
- 🔒 -
-
+ ); + })}
); } diff --git a/src/game/gameState.js b/src/game/gameState.js index 9f2139a..8c09dba 100644 --- a/src/game/gameState.js +++ b/src/game/gameState.js @@ -1,18 +1,20 @@ /** * gameState.js — Game progress persistence - * Tracks completed levels, stars earned, and unlocks + * Tracks completed levels, stars earned, unlocks, and saved patches per level */ const STORAGE_KEY = 'synthquest-progress'; +const PATCHES_KEY = 'synthquest-patches'; const defaultProgress = { currentWorld: 'w1', - completedLevels: {}, // { levelId: { stars: 3, bestTime: 12.5 } } + completedLevels: {}, // { levelId: { stars: 3 } } unlockedWorlds: ['w1'], totalStars: 0, }; let _progress = null; +let _patches = null; // { levelId: { modules, connections } } export function loadProgress() { if (_progress) return _progress; @@ -50,15 +52,56 @@ export function getLevelProgress(levelId) { export function isLevelUnlocked(levelId, worldLevels) { const p = loadProgress(); - // First level is always unlocked const idx = worldLevels.findIndex(l => l.id === levelId); if (idx === 0) return true; - // Previous level must have at least 1 star const prevId = worldLevels[idx - 1]?.id; return prevId && p.completedLevels[prevId]?.stars >= 1; } export function resetProgress() { _progress = { ...defaultProgress }; + _patches = {}; saveProgress(); + savePatches(); +} + +// ==================== Level patch persistence ==================== + +function loadPatches() { + if (_patches) return _patches; + try { + const raw = localStorage.getItem(PATCHES_KEY); + _patches = raw ? JSON.parse(raw) : {}; + } catch { + _patches = {}; + } + return _patches; +} + +function savePatches() { + if (!_patches) return; + try { + localStorage.setItem(PATCHES_KEY, JSON.stringify(_patches)); + } catch {} +} + +export function saveLevelPatch(levelId, modules, connections) { + const patches = loadPatches(); + patches[levelId] = { + modules: modules.map(m => ({ id: m.id, type: m.type, x: m.x, y: m.y, params: { ...m.params } })), + connections: connections.map(c => ({ ...c })), + savedAt: Date.now(), + }; + savePatches(); +} + +export function getLevelPatch(levelId) { + const patches = loadPatches(); + return patches[levelId] || null; +} + +export function clearLevelPatch(levelId) { + const patches = loadPatches(); + delete patches[levelId]; + savePatches(); } diff --git a/src/game/levels/world2.js b/src/game/levels/world2.js new file mode 100644 index 0000000..90c38bd --- /dev/null +++ b/src/game/levels/world2.js @@ -0,0 +1,490 @@ +/** + * World 2 — "Filtros" (Filters) + * + * Teaches: lowpass, highpass, bandpass, resonance, cutoff modulation + * 8 levels, progressive difficulty + */ + +export const WORLD_2 = { + id: 'w2', + name: 'Filtros', + subtitle: 'Esculpe el timbre con filtros', + icon: '▽', + color: '#ff6644', + unlockStars: 12, // Need 12 stars from World 1 to unlock + levels: [ + // ─────────────── LEVEL 2.1 ─────────────── + { + id: 'w2-1', + title: 'El Paso Bajo', + subtitle: 'Quita los agudos', + description: 'Un filtro paso bajo (lowpass) deja pasar las frecuencias graves y elimina las agudas. Es el filtro más usado en síntesis — piensa en cómo suena la música debajo del agua. Conecta el oscilador a través del filtro.', + concept: 'Conecta: Oscillator → Filter → Output. El filtro ya está en modo lowpass. El knob "Cutoff" controla hasta qué frecuencia deja pasar. Bájalo para un sonido más oscuro.', + availableModules: [], + preplacedModules: [ + { id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: true }, + { id: 2, type: 'filter', x: 340, y: 80, params: { type: 'lowpass', frequency: 2000, Q: 1 }, locked: false }, + { id: 3, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true }, + ], + target: { + build: [ + { type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } }, + ], + filter: { type: 'lowpass', frequency: 800 }, + duration: 2.5, + }, + checks: [ + { + star: 1, + name: 'Señal filtrada', + desc: 'Conecta oscilador → filtro → salida', + test: (mods, conns) => { + const osc = mods.find(m => m.type === 'oscillator'); + const flt = mods.find(m => m.type === 'filter'); + const out = mods.find(m => m.type === 'output'); + if (!osc || !flt || !out) return false; + const oscToFlt = conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id); + const fltToOut = conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id); + return oscToFlt && fltToOut; + }, + }, + { + star: 2, + name: 'Cutoff bajo', + desc: 'Baja el cutoff por debajo de 1200 Hz', + test: (mods) => { + const flt = mods.find(m => m.type === 'filter'); + return flt && (flt.params.frequency ?? 2000) < 1200; + }, + }, + { + star: 3, + name: 'Sonido oscuro', + desc: 'Cutoff cercano a 800 Hz (±200 Hz)', + test: (mods) => { + const flt = mods.find(m => m.type === 'filter'); + if (!flt) return false; + return Math.abs((flt.params.frequency ?? 2000) - 800) <= 200; + }, + }, + ], + }, + + // ─────────────── LEVEL 2.2 ─────────────── + { + id: 'w2-2', + title: 'El Paso Alto', + subtitle: 'Solo los agudos', + description: 'El filtro paso alto (highpass) es lo opuesto: elimina los graves y deja pasar los agudos. Es perfecto para quitar el "barro" de un sonido o crear texturas etéreas y delgadas.', + concept: 'Cambia el tipo de filtro a "highpass". Sube el cutoff para que solo pasen las frecuencias altas. Un cutoff de ~2000 Hz eliminará todo lo grave.', + availableModules: ['filter'], + preplacedModules: [ + { id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: true }, + { id: 2, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true }, + ], + target: { + build: [ + { type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } }, + ], + filter: { type: 'highpass', frequency: 2000 }, + duration: 2.5, + }, + checks: [ + { + star: 1, + name: 'Filtro conectado', + desc: 'Coloca un filtro entre oscilador y salida', + test: (mods, conns) => { + const osc = mods.find(m => m.type === 'oscillator'); + const flt = mods.find(m => m.type === 'filter'); + const out = mods.find(m => m.type === 'output'); + if (!osc || !flt || !out) return false; + const oscToFlt = conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id); + const fltToOut = conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id); + return oscToFlt && fltToOut; + }, + }, + { + star: 2, + name: 'Modo highpass', + desc: 'Cambia el filtro a highpass', + test: (mods) => { + const flt = mods.find(m => m.type === 'filter'); + return flt && flt.params.type === 'highpass'; + }, + }, + { + star: 3, + name: 'Cutoff preciso', + desc: 'Cutoff cercano a 2000 Hz (±300 Hz)', + test: (mods) => { + const flt = mods.find(m => m.type === 'filter'); + if (!flt) return false; + return flt.params.type === 'highpass' && Math.abs((flt.params.frequency ?? 1000) - 2000) <= 300; + }, + }, + ], + }, + + // ─────────────── LEVEL 2.3 ─────────────── + { + id: 'w2-3', + title: 'Resonancia', + subtitle: 'El pico que canta', + description: 'La resonancia (Q) amplifica las frecuencias justo alrededor del punto de corte. Con poca resonancia el filtro es suave. Con mucha, el filtro "canta" — es el sonido ácido clásico del TB-303.', + concept: 'Sube el knob "Reso" (Q) del filtro a un valor alto (~8-12). Mantén el cutoff bajo (~600 Hz) con lowpass. Escucharás cómo el filtro enfatiza esa frecuencia.', + availableModules: [], + preplacedModules: [ + { id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sawtooth', frequency: 110, detune: 0 }, locked: true }, + { id: 2, type: 'filter', x: 340, y: 80, params: { type: 'lowpass', frequency: 1000, Q: 1 }, locked: false }, + { id: 3, type: 'output', x: 600, y: 100, params: { volume: -8 }, locked: true }, + ], + target: { + build: [ + { type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110 } }, + ], + filter: { type: 'lowpass', frequency: 600, Q: 10 }, + duration: 2.5, + }, + checks: [ + { + star: 1, + name: 'Señal filtrada', + desc: 'Conecta oscilador → filtro → salida', + test: (mods, conns) => { + const osc = mods.find(m => m.type === 'oscillator'); + const flt = mods.find(m => m.type === 'filter'); + const out = mods.find(m => m.type === 'output'); + if (!osc || !flt || !out) return false; + return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) && + conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id); + }, + }, + { + star: 2, + name: 'Resonancia alta', + desc: 'Sube la resonancia (Q) por encima de 5', + test: (mods) => { + const flt = mods.find(m => m.type === 'filter'); + return flt && (flt.params.Q ?? 1) > 5; + }, + }, + { + star: 3, + name: 'Sonido ácido', + desc: 'Q alto (~8-12) y cutoff bajo (~600 Hz ±200)', + test: (mods) => { + const flt = mods.find(m => m.type === 'filter'); + if (!flt) return false; + const q = flt.params.Q ?? 1; + const freq = flt.params.frequency ?? 1000; + return q >= 7 && q <= 15 && Math.abs(freq - 600) <= 200; + }, + }, + ], + }, + + // ─────────────── LEVEL 2.4 ─────────────── + { + id: 'w2-4', + title: 'Banda Pasante', + subtitle: 'Solo el medio', + description: 'El filtro bandpass deja pasar solo un rango estrecho de frecuencias alrededor del cutoff. Es como poner un lowpass y un highpass a la vez. Crea sonidos nasales, tipo telefono o walkie-talkie.', + concept: 'Cambia el tipo a "bandpass". El cutoff define el centro de la banda. Sube la Q para hacerla más estrecha (más nasal). Un cutoff de ~1000 Hz con Q alta suena como una voz por teléfono.', + availableModules: ['filter'], + preplacedModules: [ + { id: 1, type: 'noise', x: 80, y: 80, params: { type: 'white' }, locked: true }, + { id: 2, type: 'output', x: 600, y: 100, params: { volume: -10 }, locked: true }, + ], + target: { + build: [ + { type: 'noise', params: { type: 'white' } }, + ], + filter: { type: 'bandpass', frequency: 1000, Q: 8 }, + duration: 2, + }, + checks: [ + { + star: 1, + name: 'Ruido filtrado', + desc: 'Coloca filtro entre noise y salida', + test: (mods, conns) => { + const noise = mods.find(m => m.type === 'noise'); + const flt = mods.find(m => m.type === 'filter'); + const out = mods.find(m => m.type === 'output'); + if (!noise || !flt || !out) return false; + return conns.some(c => c.from.moduleId === noise.id && c.to.moduleId === flt.id) && + conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id); + }, + }, + { + star: 2, + name: 'Modo bandpass', + desc: 'Filtro en modo bandpass', + test: (mods) => { + const flt = mods.find(m => m.type === 'filter'); + return flt && flt.params.type === 'bandpass'; + }, + }, + { + star: 3, + name: 'Nasal perfecto', + desc: 'Bandpass a ~1000 Hz con Q alta (>5)', + test: (mods) => { + const flt = mods.find(m => m.type === 'filter'); + if (!flt) return false; + return flt.params.type === 'bandpass' && + Math.abs((flt.params.frequency ?? 1000) - 1000) <= 300 && + (flt.params.Q ?? 1) > 5; + }, + }, + ], + }, + + // ─────────────── LEVEL 2.5 ─────────────── + { + id: 'w2-5', + title: 'Filtro en Movimiento', + subtitle: 'LFO → Cutoff', + description: 'Los filtros estáticos son útiles, pero los filtros en movimiento son mágicos. Conectar un LFO al cutoff de un filtro crea un barrido cíclico — es el sonido "wah-wah" clásico del funk y la música electrónica.', + concept: 'Conecta un LFO a la entrada "Cutoff" del filtro. El LFO modulará el punto de corte automáticamente. Ajusta la velocidad del LFO (~2-4 Hz) para un wobble rítmico.', + availableModules: ['lfo'], + preplacedModules: [ + { id: 1, type: 'oscillator', x: 80, y: 60, params: { waveform: 'sawtooth', frequency: 110, detune: 0 }, locked: true }, + { id: 2, type: 'filter', x: 340, y: 60, params: { type: 'lowpass', frequency: 800, Q: 5 }, locked: false }, + { id: 3, type: 'output', x: 600, y: 80, params: { volume: -8 }, locked: true }, + ], + target: { + build: [ + { type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110 } }, + ], + filter: { type: 'lowpass', frequency: 800, Q: 5 }, + duration: 3, + }, + checks: [ + { + star: 1, + name: 'Cadena de audio', + desc: 'Oscilador → filtro → salida conectados', + test: (mods, conns) => { + const osc = mods.find(m => m.type === 'oscillator'); + const flt = mods.find(m => m.type === 'filter'); + const out = mods.find(m => m.type === 'output'); + if (!osc || !flt || !out) return false; + return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) && + conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id); + }, + }, + { + star: 2, + name: 'LFO conectado', + desc: 'Conecta un LFO a la entrada Cutoff del filtro', + test: (mods, conns) => { + const lfo = mods.find(m => m.type === 'lfo'); + const flt = mods.find(m => m.type === 'filter'); + if (!lfo || !flt) return false; + return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff'); + }, + }, + { + star: 3, + name: 'Wobble rítmico', + desc: 'LFO entre 1-6 Hz, resonancia > 3', + test: (mods) => { + const lfo = mods.find(m => m.type === 'lfo'); + const flt = mods.find(m => m.type === 'filter'); + if (!lfo || !flt) return false; + const rate = lfo.params.frequency ?? 2; + return rate >= 1 && rate <= 6 && (flt.params.Q ?? 1) > 3; + }, + }, + ], + }, + + // ─────────────── LEVEL 2.6 ─────────────── + { + id: 'w2-6', + title: 'Dos Filtros', + subtitle: 'Escultura sónica', + description: 'Los ingenieros de sonido encadenan filtros para obtener formas más complejas. Un highpass para quitar el subgrave seguido de un lowpass para suavizar los agudos es una técnica estándar de mezcla.', + concept: 'Conecta: Oscilador → Filtro 1 (highpass, ~200 Hz) → Filtro 2 (lowpass, ~3000 Hz) → Output. Esto deja solo las frecuencias medias — "limpia" el sonido.', + availableModules: ['filter'], + preplacedModules: [ + { id: 1, type: 'oscillator', x: 60, y: 80, params: { waveform: 'sawtooth', frequency: 110, detune: 0 }, locked: true }, + { id: 2, type: 'output', x: 720, y: 100, params: { volume: -6 }, locked: true }, + ], + target: { + build: [ + { type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110 } }, + ], + duration: 2.5, + }, + checks: [ + { + star: 1, + name: 'Cadena doble', + desc: 'Oscilador → filtro → filtro → salida', + test: (mods, conns) => { + const osc = mods.find(m => m.type === 'oscillator'); + const flts = mods.filter(m => m.type === 'filter'); + const out = mods.find(m => m.type === 'output'); + if (!osc || flts.length < 2 || !out) return false; + // Check chain exists + const oscToFlt = flts.some(f => conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === f.id)); + const fltToOut = flts.some(f => conns.some(c => c.from.moduleId === f.id && c.to.moduleId === out.id)); + const fltToFlt = flts.some(f1 => flts.some(f2 => + f1.id !== f2.id && conns.some(c => c.from.moduleId === f1.id && c.to.moduleId === f2.id) + )); + return oscToFlt && fltToOut && fltToFlt; + }, + }, + { + star: 2, + name: 'Highpass + Lowpass', + desc: 'Un filtro en highpass y otro en lowpass', + test: (mods) => { + const flts = mods.filter(m => m.type === 'filter'); + if (flts.length < 2) return false; + const types = flts.map(f => f.params.type); + return types.includes('highpass') && types.includes('lowpass'); + }, + }, + { + star: 3, + name: 'Banda limpia', + desc: 'HP ~200 Hz (±100) + LP ~3000 Hz (±1000)', + test: (mods) => { + const flts = mods.filter(m => m.type === 'filter'); + const hp = flts.find(f => f.params.type === 'highpass'); + const lp = flts.find(f => f.params.type === 'lowpass'); + if (!hp || !lp) return false; + return Math.abs((hp.params.frequency ?? 1000) - 200) <= 100 && + Math.abs((lp.params.frequency ?? 1000) - 3000) <= 1000; + }, + }, + ], + }, + + // ─────────────── LEVEL 2.7 ─────────────── + { + id: 'w2-7', + title: 'Filtro + Mezcla', + subtitle: 'Timbres paralelos', + description: 'Filtra dos osciladores de forma diferente y mézclalos. Es la base del diseño de sonido: capas con diferentes caracteres tímbricos que juntas crean algo más rico que la suma de sus partes.', + concept: 'Dos osciladores, cada uno con su propio filtro (diferentes cutoffs), ambos al mixer, mixer al output. Uno oscuro y gordo (LP bajo), otro brillante (LP alto o sin filtro).', + availableModules: ['oscillator', 'filter', 'mixer'], + preplacedModules: [ + { id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true }, + ], + target: { + build: [ + { type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110 } }, + { type: 'oscillator', params: { waveform: 'square', frequency: 220 } }, + ], + duration: 2.5, + }, + checks: [ + { + star: 1, + name: 'Dos cadenas', + desc: 'Dos osciladores, cada uno filtrado, al mixer', + test: (mods, conns) => { + const oscs = mods.filter(m => m.type === 'oscillator'); + const flts = mods.filter(m => m.type === 'filter'); + const mixer = mods.find(m => m.type === 'mixer'); + const out = mods.find(m => m.type === 'output'); + return oscs.length >= 2 && flts.length >= 2 && mixer && out && + conns.some(c => c.from.moduleId === mixer.id && c.to.moduleId === out.id); + }, + }, + { + star: 2, + name: 'Filtros diferentes', + desc: 'Los dos filtros tienen cutoffs distintos (diferencia > 500 Hz)', + test: (mods) => { + const flts = mods.filter(m => m.type === 'filter'); + if (flts.length < 2) return false; + const freqs = flts.map(f => f.params.frequency ?? 1000); + return Math.abs(freqs[0] - freqs[1]) > 500; + }, + }, + { + star: 3, + name: 'Capas contrastadas', + desc: 'Un filtro oscuro (<600 Hz) y otro brillante (>3000 Hz)', + test: (mods) => { + const flts = mods.filter(m => m.type === 'filter'); + if (flts.length < 2) return false; + const freqs = flts.map(f => f.params.frequency ?? 1000).sort((a, b) => a - b); + return freqs[0] < 600 && freqs[freqs.length - 1] > 3000; + }, + }, + ], + }, + + // ─────────────── LEVEL 2.8: BOSS ─────────────── + { + id: 'w2-8', + title: 'Acid Bass', + subtitle: 'BOSS: El sonido TB-303', + description: 'El Roland TB-303 definió el acid house. Su secreto: un oscilador cuadrado/sierra a frecuencia baja, un filtro lowpass con MUCHA resonancia, y modulación del cutoff. Recrea ese sonido legendario.', + concept: 'Oscilador saw a ~55-110 Hz → Filtro lowpass con Q alta (~12-15) y cutoff medio (~400-800 Hz) → Output. Añade un LFO lento (~0.5-2 Hz) modulando el cutoff para el movimiento ácido.', + availableModules: ['oscillator', 'filter', 'lfo'], + preplacedModules: [ + { id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true }, + ], + target: { + build: [ + { type: 'oscillator', params: { waveform: 'sawtooth', frequency: 82 } }, + ], + filter: { type: 'lowpass', frequency: 500, Q: 14 }, + duration: 3, + }, + checks: [ + { + star: 1, + name: 'Cadena ácida', + desc: 'Oscilador → filtro → salida con LFO al cutoff', + test: (mods, conns) => { + const osc = mods.find(m => m.type === 'oscillator'); + const flt = mods.find(m => m.type === 'filter'); + const lfo = mods.find(m => m.type === 'lfo'); + const out = mods.find(m => m.type === 'output'); + if (!osc || !flt || !out || !lfo) return false; + return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) && + conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id) && + conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff'); + }, + }, + { + star: 2, + name: 'Resonancia ácida', + desc: 'Filtro lowpass con Q > 10', + test: (mods) => { + const flt = mods.find(m => m.type === 'filter'); + return flt && flt.params.type === 'lowpass' && (flt.params.Q ?? 1) > 10; + }, + }, + { + star: 3, + name: '303 auténtico', + desc: 'Saw/square baja (<130Hz), Q>10, cutoff 300-900Hz, LFO lento (<3Hz)', + test: (mods) => { + const osc = mods.find(m => m.type === 'oscillator'); + const flt = mods.find(m => m.type === 'filter'); + const lfo = mods.find(m => m.type === 'lfo'); + if (!osc || !flt || !lfo) return false; + const freq = osc.params.frequency ?? 440; + const wave = osc.params.waveform; + const cutoff = flt.params.frequency ?? 1000; + const q = flt.params.Q ?? 1; + const rate = lfo.params.frequency ?? 2; + return freq < 130 && (wave === 'sawtooth' || wave === 'square') && + flt.params.type === 'lowpass' && q > 10 && + cutoff >= 300 && cutoff <= 900 && rate < 3; + }, + }, + ], + }, + ], +}; diff --git a/src/game/levels/world3.js b/src/game/levels/world3.js new file mode 100644 index 0000000..a026311 --- /dev/null +++ b/src/game/levels/world3.js @@ -0,0 +1,466 @@ +/** + * World 3 — "Envelopes" (ADSR) + * + * Teaches: attack, decay, sustain, release, VCA, amplitude shaping, sound design + * 8 levels, progressive difficulty + */ + +export const WORLD_3 = { + id: 'w3', + name: 'Envelopes', + subtitle: 'Dale forma al sonido en el tiempo', + icon: '⏤╲', + color: '#aa55ff', + unlockStars: 24, // Need 24 stars from World 1+2 to unlock + levels: [ + // ─────────────── LEVEL 3.1 ─────────────── + { + id: 'w3-1', + title: 'El VCA', + subtitle: 'Control de volumen', + description: 'Un VCA (Voltage Controlled Amplifier) es un amplificador cuyo volumen se puede controlar con una señal externa. Pasa el oscilador por un VCA para poder controlar su volumen.', + concept: 'Conecta: Oscilador → VCA (input "In") → Output. El knob "Gain" del VCA controla cuánto deja pasar. Es como un grifo para el sonido.', + availableModules: [], + preplacedModules: [ + { id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: true }, + { id: 2, type: 'vca', x: 340, y: 80, params: { gain: 0.5 }, locked: false }, + { id: 3, type: 'output', x: 580, y: 100, params: { volume: -6 }, locked: true }, + ], + target: { + build: [ + { type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } }, + ], + duration: 2, + }, + checks: [ + { + star: 1, + name: 'VCA conectado', + desc: 'Conecta oscilador → VCA → salida', + test: (mods, conns) => { + const osc = mods.find(m => m.type === 'oscillator'); + const vca = mods.find(m => m.type === 'vca'); + const out = mods.find(m => m.type === 'output'); + if (!osc || !vca || !out) return false; + return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === vca.id && c.to.port === 'in') && + conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id); + }, + }, + { + star: 2, + name: 'Volumen moderado', + desc: 'Gain del VCA por debajo de 0.7', + test: (mods) => { + const vca = mods.find(m => m.type === 'vca'); + return vca && (vca.params.gain ?? 0.8) < 0.7; + }, + }, + { + star: 3, + name: 'Medio volumen', + desc: 'Gain cercano a 0.5 (±0.1)', + test: (mods) => { + const vca = mods.find(m => m.type === 'vca'); + return vca && Math.abs((vca.params.gain ?? 0.8) - 0.5) <= 0.1; + }, + }, + ], + }, + + // ─────────────── LEVEL 3.2 ─────────────── + { + id: 'w3-2', + title: 'ADSR', + subtitle: 'Las 4 fases del sonido', + description: 'Todo sonido tiene una forma en el tiempo: el Attack (subida), Decay (bajada), Sustain (mantenimiento) y Release (apagado). Un Envelope genera esa curva ADSR que puedes usar para controlar el VCA.', + concept: 'Conecta el Envelope al VCA: la salida del Envelope → entrada CV del VCA. Conecta el Keyboard al Gate del Envelope para que se dispare al tocar. Toca notas y escucha cómo el Envelope da forma al volumen.', + availableModules: ['envelope', 'keyboard'], + preplacedModules: [ + { id: 1, type: 'oscillator', x: 80, y: 60, params: { waveform: 'square', frequency: 440, detune: 0 }, locked: true }, + { id: 2, type: 'vca', x: 400, y: 60, params: { gain: 0 }, locked: false }, + { id: 3, type: 'output', x: 620, y: 80, params: { volume: -6 }, locked: true }, + ], + target: { build: [], duration: 2 }, + checks: [ + { + star: 1, + name: 'Cadena con VCA', + desc: 'Oscilador → VCA → Salida', + test: (mods, conns) => { + const osc = mods.find(m => m.type === 'oscillator'); + const vca = mods.find(m => m.type === 'vca'); + const out = mods.find(m => m.type === 'output'); + if (!osc || !vca || !out) return false; + return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === vca.id) && + conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id); + }, + }, + { + star: 2, + name: 'Envelope al VCA', + desc: 'Conecta Envelope → VCA (CV) y Keyboard → Envelope (Gate)', + test: (mods, conns) => { + const env = mods.find(m => m.type === 'envelope'); + const vca = mods.find(m => m.type === 'vca'); + const kb = mods.find(m => m.type === 'keyboard'); + if (!env || !vca || !kb) return false; + return conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv') && + conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id && c.to.port === 'gate'); + }, + }, + { + star: 3, + name: 'Keyboard controla frecuencia', + desc: 'Keyboard → Osc (Freq) para tocar melodías', + test: (mods, conns) => { + const kb = mods.find(m => m.type === 'keyboard'); + const osc = mods.find(m => m.type === 'oscillator'); + if (!kb || !osc) return false; + return conns.some(c => c.from.moduleId === kb.id && c.from.port === 'freq' && c.to.moduleId === osc.id && c.to.port === 'freq'); + }, + }, + ], + }, + + // ─────────────── LEVEL 3.3 ─────────────── + { + id: 'w3-3', + title: 'Percusión', + subtitle: 'Attack rápido, decay corto', + description: 'Los sonidos percusivos tienen un attack instantáneo y un decay corto sin sustain. Piensa en un tambor, un clic, un bleep — el sonido aparece de golpe y muere rápido. Configura un envelope percusivo.', + concept: 'Attack muy bajo (~0.001s), Decay corto (~0.1-0.2s), Sustain a 0, Release corto. Esto crea un "blip" percusivo. Perfecto para hi-hats, kicks sintéticos, y bleeps 8-bit.', + availableModules: ['envelope', 'keyboard'], + preplacedModules: [ + { id: 1, type: 'oscillator', x: 80, y: 60, params: { waveform: 'sine', frequency: 440, detune: 0 }, locked: true }, + { id: 2, type: 'vca', x: 400, y: 60, params: { gain: 0 }, locked: false }, + { id: 3, type: 'output', x: 620, y: 80, params: { volume: -6 }, locked: true }, + ], + target: { build: [], duration: 2 }, + checks: [ + { + star: 1, + name: 'Señal con envelope', + desc: 'Osc → VCA → Out, con Envelope al CV y Keyboard al Gate', + test: (mods, conns) => { + const osc = mods.find(m => m.type === 'oscillator'); + const vca = mods.find(m => m.type === 'vca'); + const env = mods.find(m => m.type === 'envelope'); + const kb = mods.find(m => m.type === 'keyboard'); + const out = mods.find(m => m.type === 'output'); + if (!osc || !vca || !env || !kb || !out) return false; + return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === vca.id) && + conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id) && + conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv') && + conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id); + }, + }, + { + star: 2, + name: 'Sin sustain', + desc: 'Sustain a 0 (o casi)', + test: (mods) => { + const env = mods.find(m => m.type === 'envelope'); + return env && (env.params.sustain ?? 0.5) < 0.05; + }, + }, + { + star: 3, + name: 'Blip perfecto', + desc: 'Attack <0.01s, Decay 0.05-0.3s, Sustain ~0', + test: (mods) => { + const env = mods.find(m => m.type === 'envelope'); + if (!env) return false; + return (env.params.attack ?? 0.01) < 0.015 && + (env.params.decay ?? 0.2) >= 0.05 && (env.params.decay ?? 0.2) <= 0.3 && + (env.params.sustain ?? 0.5) < 0.05; + }, + }, + ], + }, + + // ─────────────── LEVEL 3.4 ─────────────── + { + id: 'w3-4', + title: 'Pad Atmosférico', + subtitle: 'Suave y envolvente', + description: 'Los pads son sonidos largos y suaves que rellenan el fondo de una mezcla. Se consiguen con un attack lento (el sonido entra gradualmente), sustain alto, y release largo (se desvanece lentamente).', + concept: 'Attack lento (~1-2s), Decay corto (~0.3s), Sustain alto (~0.7-0.9), Release largo (~2-4s). El sonido "respira" — entra suave y se queda flotando.', + availableModules: ['envelope', 'keyboard'], + preplacedModules: [ + { id: 1, type: 'oscillator', x: 80, y: 60, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: true }, + { id: 2, type: 'vca', x: 400, y: 60, params: { gain: 0 }, locked: false }, + { id: 3, type: 'output', x: 620, y: 80, params: { volume: -6 }, locked: true }, + ], + target: { build: [], duration: 2 }, + checks: [ + { + star: 1, + name: 'Señal con envelope', + desc: 'Osc → VCA → Out, Envelope al CV, Keyboard al Gate', + test: (mods, conns) => { + const env = mods.find(m => m.type === 'envelope'); + const vca = mods.find(m => m.type === 'vca'); + const kb = mods.find(m => m.type === 'keyboard'); + if (!env || !vca || !kb) return false; + return conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv') && + conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id); + }, + }, + { + star: 2, + name: 'Attack lento', + desc: 'Attack mayor de 0.5 segundos', + test: (mods) => { + const env = mods.find(m => m.type === 'envelope'); + return env && (env.params.attack ?? 0.01) > 0.5; + }, + }, + { + star: 3, + name: 'Pad perfecto', + desc: 'Attack >0.8s, Sustain >0.6, Release >1.5s', + test: (mods) => { + const env = mods.find(m => m.type === 'envelope'); + if (!env) return false; + return (env.params.attack ?? 0.01) > 0.8 && + (env.params.sustain ?? 0.5) > 0.6 && + (env.params.release ?? 0.5) > 1.5; + }, + }, + ], + }, + + // ─────────────── LEVEL 3.5 ─────────────── + { + id: 'w3-5', + title: 'Pluck', + subtitle: 'Cuerdas pulsadas', + description: 'El sonido de una cuerda pulsada (guitarra, arpa) tiene un attack rápido y un decay medio. No tiene sustain real — el sonido decrece naturalmente. El filtro ayuda a que suene más natural.', + concept: 'Envelope con Attack rápido (~0.001s), Decay medio (~0.4-0.8s), Sustain bajo (~0.1), Release ~0.3s. Usa una onda triangle o saw con un filtro lowpass para suavizar.', + availableModules: ['envelope', 'keyboard', 'filter'], + preplacedModules: [ + { id: 1, type: 'oscillator', x: 80, y: 60, params: { waveform: 'triangle', frequency: 440, detune: 0 }, locked: false }, + { id: 2, type: 'vca', x: 500, y: 60, params: { gain: 0 }, locked: false }, + { id: 3, type: 'output', x: 760, y: 80, params: { volume: -6 }, locked: true }, + ], + target: { build: [], duration: 2 }, + checks: [ + { + star: 1, + name: 'Cadena completa', + desc: 'Osc → (Filter →) VCA → Out con Envelope y Keyboard', + test: (mods, conns) => { + const env = mods.find(m => m.type === 'envelope'); + const vca = mods.find(m => m.type === 'vca'); + const kb = mods.find(m => m.type === 'keyboard'); + const out = mods.find(m => m.type === 'output'); + if (!env || !vca || !kb || !out) return false; + return conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id) && + conns.some(c => c.from.moduleId === env.id && c.to.port === 'cv') && + conns.some(c => c.from.moduleId === kb.id && c.to.port === 'gate'); + }, + }, + { + star: 2, + name: 'Forma pluck', + desc: 'Attack rápido (<0.02s), Sustain bajo (<0.2)', + test: (mods) => { + const env = mods.find(m => m.type === 'envelope'); + if (!env) return false; + return (env.params.attack ?? 0.01) < 0.02 && (env.params.sustain ?? 0.5) < 0.2; + }, + }, + { + star: 3, + name: 'Pluck natural', + desc: 'Pluck shape + filtro lowpass en la cadena', + test: (mods, conns) => { + const env = mods.find(m => m.type === 'envelope'); + const flt = mods.find(m => m.type === 'filter'); + if (!env || !flt) return false; + return (env.params.attack ?? 0.01) < 0.02 && + (env.params.sustain ?? 0.5) < 0.2 && + (env.params.decay ?? 0.2) >= 0.3 && + flt.params.type === 'lowpass'; + }, + }, + ], + }, + + // ─────────────── LEVEL 3.6 ─────────────── + { + id: 'w3-6', + title: 'Filtro Dinámico', + subtitle: 'Envelope → Cutoff', + description: 'Los envelopes no solo controlan volumen — ¡también pueden controlar el filtro! Conectar un envelope al cutoff crea sonidos que se "abren" y "cierran" con cada nota. Es la técnica más importante de síntesis sustractiva.', + concept: 'Conecta un segundo Envelope a la entrada Cutoff del filtro. Keyboard → Gate de ambos envelopes. Un envelope controla volumen (VCA), otro controla brillo (filtro cutoff).', + availableModules: ['envelope', 'keyboard', 'filter'], + preplacedModules: [ + { id: 1, type: 'oscillator', x: 60, y: 40, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: true }, + { id: 2, type: 'vca', x: 520, y: 40, params: { gain: 0 }, locked: false }, + { id: 3, type: 'output', x: 760, y: 80, params: { volume: -6 }, locked: true }, + ], + target: { build: [], duration: 2 }, + checks: [ + { + star: 1, + name: 'Doble envelope', + desc: 'Dos envelopes: uno al VCA, otro al filtro cutoff', + test: (mods, conns) => { + const envs = mods.filter(m => m.type === 'envelope'); + const vca = mods.find(m => m.type === 'vca'); + const flt = mods.find(m => m.type === 'filter'); + if (envs.length < 2 || !vca || !flt) return false; + const envToVca = envs.some(e => conns.some(c => c.from.moduleId === e.id && c.to.moduleId === vca.id && c.to.port === 'cv')); + const envToFlt = envs.some(e => conns.some(c => c.from.moduleId === e.id && c.to.moduleId === flt.id && c.to.port === 'cutoff')); + return envToVca && envToFlt; + }, + }, + { + star: 2, + name: 'Gates conectados', + desc: 'Keyboard → Gate de ambos envelopes', + test: (mods, conns) => { + const kb = mods.find(m => m.type === 'keyboard'); + const envs = mods.filter(m => m.type === 'envelope'); + if (!kb || envs.length < 2) return false; + const gatedEnvs = envs.filter(e => + conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === e.id && c.to.port === 'gate') + ); + return gatedEnvs.length >= 2; + }, + }, + { + star: 3, + name: 'Envelopes distintos', + desc: 'Los dos envelopes tienen decays diferentes (>0.1s diferencia)', + test: (mods) => { + const envs = mods.filter(m => m.type === 'envelope'); + if (envs.length < 2) return false; + const decays = envs.map(e => e.params.decay ?? 0.2); + return Math.abs(decays[0] - decays[1]) > 0.1; + }, + }, + ], + }, + + // ─────────────── LEVEL 3.7 ─────────────── + { + id: 'w3-7', + title: 'Tremolo', + subtitle: 'LFO → Volumen', + description: 'El tremolo es una variación rítmica del volumen. Se consigue conectando un LFO a la ganancia del VCA. Es un efecto clásico de guitarras, órganos y sintetizadores vintage.', + concept: 'Conecta un LFO a la entrada CV del VCA (no del filtro). Un LFO a ~4-8 Hz con amplitud moderada crea un tremolo clásico. Más lento (~1-2 Hz) suena como un "pulso".', + availableModules: ['lfo'], + preplacedModules: [ + { id: 1, type: 'oscillator', x: 80, y: 60, params: { waveform: 'sine', frequency: 440, detune: 0 }, locked: true }, + { id: 2, type: 'vca', x: 340, y: 60, params: { gain: 0.7 }, locked: false }, + { id: 3, type: 'output', x: 580, y: 80, params: { volume: -6 }, locked: true }, + ], + target: { build: [], duration: 3 }, + checks: [ + { + star: 1, + name: 'Cadena básica', + desc: 'Oscilador → VCA → Salida', + test: (mods, conns) => { + const osc = mods.find(m => m.type === 'oscillator'); + const vca = mods.find(m => m.type === 'vca'); + const out = mods.find(m => m.type === 'output'); + if (!osc || !vca || !out) return false; + return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === vca.id) && + conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id); + }, + }, + { + star: 2, + name: 'LFO al VCA', + desc: 'Conecta LFO → VCA (CV)', + test: (mods, conns) => { + const lfo = mods.find(m => m.type === 'lfo'); + const vca = mods.find(m => m.type === 'vca'); + if (!lfo || !vca) return false; + return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === vca.id && c.to.port === 'cv'); + }, + }, + { + star: 3, + name: 'Tremolo rítmico', + desc: 'LFO entre 3-10 Hz (tremolo audible)', + test: (mods) => { + const lfo = mods.find(m => m.type === 'lfo'); + if (!lfo) return false; + const rate = lfo.params.frequency ?? 2; + return rate >= 3 && rate <= 10; + }, + }, + ], + }, + + // ─────────────── LEVEL 3.8: BOSS ─────────────── + { + id: 'w3-8', + title: 'Synth Lead Completo', + subtitle: 'BOSS: Ponlo todo junto', + description: 'Es hora de construir un sonido de lead completo desde cero. Combina todo lo que has aprendido: oscilador, filtro con envelope, VCA con envelope, y keyboard para tocar. Es el patch clásico de síntesis sustractiva.', + concept: 'Keyboard → Osc (freq) + Env1 (gate) + Env2 (gate). Osc → Filter → VCA → Output. Env1 → Filter cutoff (decay medio para "apertura"). Env2 → VCA cv (sustain para mantener). Ajusta para un lead expresivo.', + availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'keyboard'], + preplacedModules: [ + { id: 1, type: 'output', x: 800, y: 120, params: { volume: -6 }, locked: true }, + ], + target: { build: [], duration: 3 }, + checks: [ + { + star: 1, + name: 'Cadena sustractiva', + desc: 'Osc → Filter → VCA → Output', + test: (mods, conns) => { + const osc = mods.find(m => m.type === 'oscillator'); + const flt = mods.find(m => m.type === 'filter'); + const vca = mods.find(m => m.type === 'vca'); + const out = mods.find(m => m.type === 'output'); + if (!osc || !flt || !vca || !out) return false; + return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) && + conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === vca.id) && + conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id); + }, + }, + { + star: 2, + name: 'Doble modulación', + desc: 'Envelope al filtro cutoff Y envelope al VCA cv', + test: (mods, conns) => { + const envs = mods.filter(m => m.type === 'envelope'); + const flt = mods.find(m => m.type === 'filter'); + const vca = mods.find(m => m.type === 'vca'); + if (envs.length < 2 || !flt || !vca) return false; + const envToFlt = envs.some(e => conns.some(c => c.from.moduleId === e.id && c.to.moduleId === flt.id && c.to.port === 'cutoff')); + const envToVca = envs.some(e => conns.some(c => c.from.moduleId === e.id && c.to.moduleId === vca.id && c.to.port === 'cv')); + return envToFlt && envToVca; + }, + }, + { + star: 3, + name: 'Lead expresivo', + desc: 'Keyboard controla freq + gates, envelopes distintos', + test: (mods, conns) => { + const kb = mods.find(m => m.type === 'keyboard'); + const osc = mods.find(m => m.type === 'oscillator'); + const envs = mods.filter(m => m.type === 'envelope'); + if (!kb || !osc || envs.length < 2) return false; + // KB → osc freq + const kbFreq = conns.some(c => c.from.moduleId === kb.id && c.from.port === 'freq' && c.to.moduleId === osc.id); + // KB → both env gates + const gated = envs.filter(e => + conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === e.id && c.to.port === 'gate') + ); + // Envelopes have different settings + const decays = envs.map(e => e.params.decay ?? 0.2); + const diffDecay = Math.abs(decays[0] - decays[1]) > 0.05; + return kbFreq && gated.length >= 2 && diffDecay; + }, + }, + ], + }, + ], +}; diff --git a/src/game/targetAudio.js b/src/game/targetAudio.js index 2c3c3a6..62cdd6a 100644 --- a/src/game/targetAudio.js +++ b/src/game/targetAudio.js @@ -25,7 +25,20 @@ export async function playTarget(target) { const output = new Tone.Gain(0.5).toDestination(); nodes.push(output); - // Build oscillators from target.build + // Optional filter in the chain + let destination = output; + if (target.filter) { + const filter = new Tone.Filter({ + type: target.filter.type || 'lowpass', + frequency: target.filter.frequency || 1000, + Q: target.filter.Q || 1, + }); + filter.connect(output); + destination = filter; + nodes.push(filter); + } + + // Build oscillators / noise from target.build for (const spec of target.build) { if (spec.type === 'oscillator') { const osc = new Tone.Oscillator({ @@ -33,9 +46,14 @@ export async function playTarget(target) { frequency: spec.params.frequency || 440, detune: spec.params.detune || 0, }); - osc.connect(output); + osc.connect(destination); osc.start(); nodes.push(osc); + } else if (spec.type === 'noise') { + const noise = new Tone.Noise(spec.params.type || 'white'); + noise.connect(destination); + noise.start(); + nodes.push(noise); } } diff --git a/src/index.css b/src/index.css index 7507493..052fefb 100644 --- a/src/index.css +++ b/src/index.css @@ -566,3 +566,67 @@ html, body, #root { .gm-check-star { color: var(--yellow); } .gm-complete-actions { display: flex; gap: 8px; justify-content: center; } + +/* World stars counter */ +.gm-world-stars { + display: flex; align-items: center; gap: 4px; + font-size: 14px; color: var(--text2); + background: var(--surface); border-radius: 12px; padding: 4px 10px; +} +.gm-world-stars .star.filled { color: var(--yellow); } + +/* ===== Zoom Controls (Google Maps style) ===== */ +.zoom-controls { + position: absolute; + top: 12px; + right: 12px; + z-index: 50; + display: flex; + flex-direction: column; + background: var(--surface); + border: 1px solid var(--border); + border-radius: 8px; + overflow: hidden; + box-shadow: 0 2px 8px rgba(0,0,0,0.4); +} +.zoom-btn { + width: 36px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + background: transparent; + color: var(--text); + border: none; + cursor: pointer; + font-size: 18px; + font-weight: 600; + transition: background 0.15s; +} +.zoom-btn:hover { + background: var(--surface2); +} +.zoom-btn:active { + background: var(--border); +} +.zoom-btn.zoom-label { + font-size: 10px; + font-weight: 500; + color: var(--text2); + border-top: 1px solid var(--border); + border-bottom: 1px solid var(--border); + height: 26px; + width: 36px; +} + +/* Zoom positioning inside puzzle canvas */ +.gm-puzzle-canvas-wrap .zoom-controls { + top: 12px; + right: 12px; +} + +/* Position zoom inside sandbox main-area (offset for palette sidebar) */ +.main-area .zoom-controls { + top: 12px; + right: 220px; +}