feat: admin mode, worlds 4-6, and stereo output fix
- Admin panel: add/remove stars, unlock worlds, reset progress (🛠 button) - World 4 "Modulación" (8 levels): vibrato, sirena, wah-wah, auto-pan, FM, wobble bass - World 5 "Efectos" (8 levels): delay, slapback, reverb, distortion, dub echo, shoegaze, ambient - World 6 "Diseño Sonoro" (8 levels): kick, hi-hat, snare, pad, reese bass, laser, trance arp, final boss - Star unlock progression: W4=36★, W5=48★, W6=60★ (total 48 levels, 144 stars) - Fix stereo output: left/right channels now route through Tone.Merge for true stereo separation Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -136,13 +136,28 @@ function createNode(mod) {
|
||||
};
|
||||
}
|
||||
case 'output': {
|
||||
const gain = new Tone.Gain(Tone.dbToGain(p.volume));
|
||||
gain.toDestination();
|
||||
// True stereo output: separate left/right channels → merge → master gain → destination
|
||||
const leftGain = new Tone.Gain(1);
|
||||
const rightGain = new Tone.Gain(1);
|
||||
const merge = new Tone.Merge();
|
||||
const master = new Tone.Gain(Tone.dbToGain(p.volume));
|
||||
leftGain.connect(merge, 0, 0);
|
||||
rightGain.connect(merge, 0, 1);
|
||||
merge.connect(master);
|
||||
master.toDestination();
|
||||
return {
|
||||
node: gain,
|
||||
inputs: { left: gain, right: gain },
|
||||
node: master,
|
||||
_merge: merge,
|
||||
_leftGain: leftGain,
|
||||
_rightGain: rightGain,
|
||||
inputs: { left: leftGain, right: rightGain },
|
||||
outputs: {},
|
||||
dispose: () => { gain.disconnect(); gain.dispose(); },
|
||||
dispose: () => {
|
||||
leftGain.disconnect(); leftGain.dispose();
|
||||
rightGain.disconnect(); rightGain.dispose();
|
||||
merge.disconnect(); merge.dispose();
|
||||
master.disconnect(); master.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'keyboard': {
|
||||
|
||||
118
src/game/AdminPanel.jsx
Normal file
118
src/game/AdminPanel.jsx
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* AdminPanel.jsx — Debug/admin panel for SynthQuest
|
||||
* Allows adding/removing stars and unlocking levels for testing
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { loadProgress, saveProgress, resetProgress } from './gameState.js';
|
||||
|
||||
export default function AdminPanel({ worlds, onClose }) {
|
||||
const [, refresh] = useState(0);
|
||||
const p = loadProgress();
|
||||
const totalStars = Object.values(p.completedLevels).reduce((s, l) => s + (l.stars || 0), 0);
|
||||
|
||||
const setStars = (levelId, stars) => {
|
||||
if (stars <= 0) {
|
||||
delete p.completedLevels[levelId];
|
||||
} else {
|
||||
p.completedLevels[levelId] = { stars: Math.min(3, stars), completedAt: Date.now() };
|
||||
}
|
||||
saveProgress();
|
||||
refresh(n => n + 1);
|
||||
};
|
||||
|
||||
const unlockWorld = (world) => {
|
||||
// Give 1 star to each level in all previous worlds up to the requirement
|
||||
let needed = world.unlockStars || 0;
|
||||
for (const w of worlds) {
|
||||
if (w.id === world.id) break;
|
||||
for (const level of w.levels) {
|
||||
if (needed <= 0) break;
|
||||
const existing = p.completedLevels[level.id]?.stars || 0;
|
||||
if (existing < 1) {
|
||||
p.completedLevels[level.id] = { stars: 1, completedAt: Date.now() };
|
||||
needed -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
saveProgress();
|
||||
refresh(n => n + 1);
|
||||
};
|
||||
|
||||
const giveAllStars = () => {
|
||||
for (const w of worlds) {
|
||||
for (const level of w.levels) {
|
||||
p.completedLevels[level.id] = { stars: 3, completedAt: Date.now() };
|
||||
}
|
||||
}
|
||||
saveProgress();
|
||||
refresh(n => n + 1);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
resetProgress();
|
||||
refresh(n => n + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-overlay" onClick={onClose}>
|
||||
<div className="admin-panel" onClick={e => e.stopPropagation()}>
|
||||
<div className="admin-header">
|
||||
<h2>🛠 Admin Mode</h2>
|
||||
<span className="admin-total">Total: ★ {totalStars}</span>
|
||||
<button className="admin-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-actions">
|
||||
<button className="admin-action-btn gold" onClick={giveAllStars}>★★★ Todo</button>
|
||||
<button className="admin-action-btn danger" onClick={handleReset}>Reset Progreso</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-worlds">
|
||||
{worlds.map((world, wi) => {
|
||||
const worldStars = world.levels.reduce((s, l) => {
|
||||
return s + (p.completedLevels[l.id]?.stars || 0);
|
||||
}, 0);
|
||||
const isUnlocked = !world.unlockStars || totalStars >= world.unlockStars;
|
||||
|
||||
return (
|
||||
<div key={world.id} className="admin-world">
|
||||
<div className="admin-world-header">
|
||||
<span className="admin-world-icon" style={{ color: world.color }}>{world.icon}</span>
|
||||
<span className="admin-world-name">M{wi + 1}: {world.name}</span>
|
||||
<span className="admin-world-stars">★ {worldStars}/{world.levels.length * 3}</span>
|
||||
{!isUnlocked && (
|
||||
<button className="admin-unlock-btn" onClick={() => unlockWorld(world)}>
|
||||
🔓 Desbloquear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-levels">
|
||||
{world.levels.map((level, li) => {
|
||||
const stars = p.completedLevels[level.id]?.stars || 0;
|
||||
return (
|
||||
<div key={level.id} className="admin-level">
|
||||
<span className="admin-level-num">{wi + 1}.{li + 1}</span>
|
||||
<span className="admin-level-name">{level.title}</span>
|
||||
<div className="admin-star-btns">
|
||||
{[0, 1, 2, 3].map(s => (
|
||||
<button
|
||||
key={s}
|
||||
className={`admin-star-btn ${stars >= s && s > 0 ? 'active' : ''} ${s === 0 ? 'zero' : ''}`}
|
||||
onClick={() => setStars(level.id, s)}
|
||||
>
|
||||
{s === 0 ? '✕' : '★'.repeat(s)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,17 +1,22 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import WorldMap from './WorldMap.jsx';
|
||||
import PuzzleView from './PuzzleView.jsx';
|
||||
import AdminPanel from './AdminPanel.jsx';
|
||||
import { WORLD_1 } from './levels/world1.js';
|
||||
import { WORLD_2 } from './levels/world2.js';
|
||||
import { WORLD_3 } from './levels/world3.js';
|
||||
import { WORLD_4 } from './levels/world4.js';
|
||||
import { WORLD_5 } from './levels/world5.js';
|
||||
import { WORLD_6 } from './levels/world6.js';
|
||||
|
||||
const allWorlds = [WORLD_1, WORLD_2, WORLD_3];
|
||||
const allWorlds = [WORLD_1, WORLD_2, WORLD_3, WORLD_4, WORLD_5, WORLD_6];
|
||||
|
||||
export default function GameApp({ onSwitchToSandbox }) {
|
||||
const [view, setView] = useState('map');
|
||||
const [currentLevel, setCurrentLevel] = useState(null);
|
||||
const [currentLevelIndex, setCurrentLevelIndex] = useState(0);
|
||||
const [currentWorld, setCurrentWorld] = useState(null);
|
||||
const [showAdmin, setShowAdmin] = useState(false);
|
||||
|
||||
const handleSelectLevel = useCallback((level, world) => {
|
||||
const idx = world.levels.findIndex(l => l.id === level.id);
|
||||
@@ -61,9 +66,18 @@ export default function GameApp({ onSwitchToSandbox }) {
|
||||
}
|
||||
|
||||
return (
|
||||
<WorldMap
|
||||
onSelectLevel={handleSelectLevel}
|
||||
onSandbox={onSwitchToSandbox}
|
||||
/>
|
||||
<>
|
||||
<WorldMap
|
||||
onSelectLevel={handleSelectLevel}
|
||||
onSandbox={onSwitchToSandbox}
|
||||
onAdmin={() => setShowAdmin(true)}
|
||||
/>
|
||||
{showAdmin && (
|
||||
<AdminPanel
|
||||
worlds={allWorlds}
|
||||
onClose={() => setShowAdmin(false)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,12 @@ import React from 'react';
|
||||
import { WORLD_1 } from './levels/world1.js';
|
||||
import { WORLD_2 } from './levels/world2.js';
|
||||
import { WORLD_3 } from './levels/world3.js';
|
||||
import { WORLD_4 } from './levels/world4.js';
|
||||
import { WORLD_5 } from './levels/world5.js';
|
||||
import { WORLD_6 } from './levels/world6.js';
|
||||
import { getLevelProgress, isLevelUnlocked, loadProgress } from './gameState.js';
|
||||
|
||||
const worlds = [WORLD_1, WORLD_2, WORLD_3];
|
||||
const worlds = [WORLD_1, WORLD_2, WORLD_3, WORLD_4, WORLD_5, WORLD_6];
|
||||
|
||||
function Stars({ count, max = 3 }) {
|
||||
return (
|
||||
@@ -30,7 +33,7 @@ function isWorldUnlocked(world) {
|
||||
return getTotalStars() >= world.unlockStars;
|
||||
}
|
||||
|
||||
export default function WorldMap({ onSelectLevel, onSandbox }) {
|
||||
export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
|
||||
const totalStars = getTotalStars();
|
||||
const maxStars = getMaxStars();
|
||||
|
||||
@@ -52,6 +55,11 @@ export default function WorldMap({ onSelectLevel, onSandbox }) {
|
||||
<button className="gm-sandbox-btn" onClick={onSandbox}>
|
||||
🎛 Sandbox
|
||||
</button>
|
||||
{onAdmin && (
|
||||
<button className="gm-admin-btn" onClick={onAdmin} title="Admin Mode">
|
||||
🛠
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
475
src/game/levels/world4.js
Normal file
475
src/game/levels/world4.js
Normal file
@@ -0,0 +1,475 @@
|
||||
/**
|
||||
* World 4 — "Modulación" (Modulation)
|
||||
*
|
||||
* Teaches: LFO routing, vibrato, PWM, FM synthesis, ring modulation, complex patches
|
||||
* 8 levels, progressive difficulty
|
||||
*/
|
||||
|
||||
export const WORLD_4 = {
|
||||
id: 'w4',
|
||||
name: 'Modulación',
|
||||
subtitle: 'Haz que el sonido viva y respire',
|
||||
icon: '∿',
|
||||
color: '#ffcc00',
|
||||
unlockStars: 36,
|
||||
levels: [
|
||||
// ─────────────── LEVEL 4.1 ───────────────
|
||||
{
|
||||
id: 'w4-1',
|
||||
title: 'Vibrato',
|
||||
subtitle: 'LFO → Frecuencia',
|
||||
description: 'El vibrato es una oscilación sutil de la frecuencia. Cantantes, violinistas y sintetizadores lo usan para dar expresividad. Se consigue conectando un LFO lento a la frecuencia del oscilador.',
|
||||
concept: 'Conecta un LFO a la entrada "Freq" del oscilador. Un LFO a ~5-7 Hz con amplitud pequeña crea un vibrato natural. Demasiado rápido o amplio suena a sirena.',
|
||||
availableModules: ['lfo'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sine', frequency: 440, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 500, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Señal básica',
|
||||
desc: 'Oscilador conectado a la salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
return osc && out && conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'LFO a frecuencia',
|
||||
desc: 'Conecta LFO → Osc (Freq)',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
if (!lfo || !osc) return false;
|
||||
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === osc.id && c.to.port === 'freq');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Vibrato musical',
|
||||
desc: 'LFO entre 4-8 Hz (vibrato natural)',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!lfo) return false;
|
||||
const rate = lfo.params.frequency ?? 2;
|
||||
return rate >= 4 && rate <= 8;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 4.2 ───────────────
|
||||
{
|
||||
id: 'w4-2',
|
||||
title: 'Sirena',
|
||||
subtitle: 'LFO lento = pitch sweep',
|
||||
description: 'Cuando el LFO es muy lento y con mucha amplitud, el vibrato se convierte en un barrido de frecuencia — como una sirena. Los DJs y productores usan este efecto para crear tensión y transiciones.',
|
||||
concept: 'Usa un LFO muy lento (~0.2-0.5 Hz) con forma de onda sine o triangle conectado a la frecuencia del oscilador. La velocidad lenta crea un sweep dramático arriba y abajo.',
|
||||
availableModules: ['lfo'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'square', frequency: 440, detune: 0 }, locked: false },
|
||||
{ id: 2, type: 'output', x: 500, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 4 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'LFO conectado',
|
||||
desc: 'LFO → Osc (Freq) → Output',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
if (!lfo || !osc) return false;
|
||||
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === osc.id && c.to.port === 'freq');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sweep lento',
|
||||
desc: 'LFO por debajo de 1 Hz',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
return lfo && (lfo.params.frequency ?? 2) < 1;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Sirena perfecta',
|
||||
desc: 'LFO 0.1-0.5 Hz, onda sine o triangle',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!lfo) return false;
|
||||
const rate = lfo.params.frequency ?? 2;
|
||||
const wave = lfo.params.waveform ?? 'sine';
|
||||
return rate >= 0.1 && rate <= 0.5 && (wave === 'sine' || wave === 'triangle');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 4.3 ───────────────
|
||||
{
|
||||
id: 'w4-3',
|
||||
title: 'Wah-Wah Rítmico',
|
||||
subtitle: 'LFO square → Cutoff',
|
||||
description: 'Un LFO con onda cuadrada crea cambios bruscos en el cutoff del filtro — el filtro salta entre abierto y cerrado. Es un efecto rítmico perfecto para música electrónica y funk.',
|
||||
concept: 'LFO square a ~2-4 Hz conectado al cutoff del filtro. La onda cuadrada crea un on/off rítmico. Ajusta el cutoff base del filtro y la resonancia para darle más carácter.',
|
||||
availableModules: ['lfo'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 60, y: 60, params: { waveform: 'sawtooth', frequency: 110, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'filter', x: 300, y: 60, params: { type: 'lowpass', frequency: 600, Q: 4 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 560, y: 80, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena con LFO',
|
||||
desc: 'Osc → Filter → Out, 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 || !lfo || !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) &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'LFO cuadrado',
|
||||
desc: 'LFO con onda square',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
return lfo && (lfo.params.waveform ?? 'sine') === 'square';
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Wah rítmico',
|
||||
desc: 'LFO square a 2-4 Hz, filtro con Q > 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 (lfo.params.waveform ?? 'sine') === 'square' &&
|
||||
rate >= 2 && rate <= 4 && (flt.params.Q ?? 1) > 3;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 4.4 ───────────────
|
||||
{
|
||||
id: 'w4-4',
|
||||
title: 'Auto-Pan',
|
||||
subtitle: 'Sonido en movimiento',
|
||||
description: 'Conectar LFOs a los niveles de un mixer permite mover el sonido entre canales. Si envías el mismo oscilador al Left y Right con LFOs invertidos, el sonido viaja de un altavoz al otro.',
|
||||
concept: 'Conecta el oscilador al output con dos cables (Left y Right). Añade un LFO que module algo para crear movimiento estéreo. El efecto auto-pan crea una sensación de espacio.',
|
||||
availableModules: ['lfo', 'vca', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sine', frequency: 440, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Estéreo',
|
||||
desc: 'Oscilador conectado a ambos canales (Left + Right)',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !out) return false;
|
||||
// Direct or through other modules to both channels
|
||||
const toLeft = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
|
||||
const toRight = conns.some(c => c.to.moduleId === out.id && c.to.port === 'right');
|
||||
return toLeft && toRight;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'LFO presente',
|
||||
desc: 'Hay al menos un LFO conectado',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!lfo) return false;
|
||||
return conns.some(c => c.from.moduleId === lfo.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Modulación estéreo',
|
||||
desc: 'LFO modula VCA(s) en la cadena estéreo',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const vcas = mods.filter(m => m.type === 'vca');
|
||||
if (!lfo || vcas.length < 1) return false;
|
||||
return vcas.some(v => conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === v.id));
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 4.5 ───────────────
|
||||
{
|
||||
id: 'w4-5',
|
||||
title: 'Doble Modulación',
|
||||
subtitle: 'LFO al filter + LFO al VCA',
|
||||
description: 'Un solo LFO puede modular múltiples destinos a la vez. Conectar el mismo LFO al cutoff del filtro y al gain del VCA crea un sonido que se abre y se hace más fuerte simultáneamente — un efecto muy dinámico.',
|
||||
concept: 'Usa un LFO y conéctalo tanto al Cutoff del filtro como al CV del VCA. El mismo movimiento cíclico afecta brillo y volumen a la vez. Ajusta ~2-3 Hz.',
|
||||
availableModules: ['lfo'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 60, y: 40, params: { waveform: 'sawtooth', frequency: 110, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'filter', x: 280, y: 40, params: { type: 'lowpass', frequency: 800, Q: 5 }, locked: false },
|
||||
{ id: 3, type: 'vca', x: 480, y: 40, params: { gain: 0.6 }, locked: false },
|
||||
{ id: 4, type: 'output', x: 680, y: 60, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena completa',
|
||||
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: 'LFO a dos destinos',
|
||||
desc: 'Un LFO conectado al Cutoff Y al CV',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (!lfo || !flt || !vca) return false;
|
||||
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff') &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Pulso rítmico',
|
||||
desc: 'LFO a 1-4 Hz, filtro resonante (Q > 4)',
|
||||
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 <= 4 && (flt.params.Q ?? 1) > 4;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 4.6 ───────────────
|
||||
{
|
||||
id: 'w4-6',
|
||||
title: 'Cross-Modulation',
|
||||
subtitle: 'Oscilador modula oscilador',
|
||||
description: 'Cuando un oscilador modula la frecuencia de otro oscilador a velocidades audibles (>20 Hz), se crea FM synthesis — timbres metálicos, campanas, y texturas inarmónicas que no puedes conseguir de otra forma.',
|
||||
concept: 'Conecta la salida de un oscilador a la entrada "Freq" de otro. Si el modulador está a frecuencia audible (>50 Hz), se crea FM. Frequencies bajas = vibrato, altas = nuevos timbres.',
|
||||
availableModules: ['oscillator', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 600, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dos osciladores',
|
||||
desc: 'Al menos 2 osciladores con uno modulando al otro',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
if (oscs.length < 2) return false;
|
||||
// One osc connected to another osc's freq
|
||||
return oscs.some(o1 => oscs.some(o2 =>
|
||||
o1.id !== o2.id && conns.some(c =>
|
||||
c.from.moduleId === o1.id && c.to.moduleId === o2.id && c.to.port === 'freq'
|
||||
)
|
||||
));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sonido audible',
|
||||
desc: 'El oscilador portador está conectado a la salida',
|
||||
test: (mods, conns) => {
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!out) return false;
|
||||
// Something reaches the output
|
||||
return conns.some(c => c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'FM metálico',
|
||||
desc: 'Modulador > 50 Hz (crea timbres FM reales)',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
if (oscs.length < 2) return false;
|
||||
// Find modulator: osc that connects to another osc's freq
|
||||
for (const o1 of oscs) {
|
||||
for (const o2 of oscs) {
|
||||
if (o1.id !== o2.id) {
|
||||
const isModulating = conns.some(c =>
|
||||
c.from.moduleId === o1.id && c.to.moduleId === o2.id && c.to.port === 'freq'
|
||||
);
|
||||
if (isModulating && (o1.params.frequency ?? 440) > 50) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 4.7 ───────────────
|
||||
{
|
||||
id: 'w4-7',
|
||||
title: 'Modulación Compleja',
|
||||
subtitle: 'Multi-destino',
|
||||
description: 'Los sintetizadores modulares brillan cuando conectas múltiples fuentes de modulación a múltiples destinos. Un LFO al filtro, un envelope al VCA, el keyboard a la frecuencia — cada conexión añade expresividad.',
|
||||
concept: 'Construye un patch con: Keyboard → Osc freq + Env gate. LFO → Filter cutoff. Envelope → VCA cv. Cada fuente de modulación controla un aspecto diferente del sonido.',
|
||||
availableModules: ['lfo', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 60, y: 40, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: false },
|
||||
{ id: 2, type: 'filter', x: 280, y: 40, params: { type: 'lowpass', frequency: 1000, Q: 4 }, locked: false },
|
||||
{ id: 3, type: 'vca', x: 480, y: 40, params: { gain: 0 }, locked: false },
|
||||
{ id: 4, type: 'output', x: 680, y: 60, 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: 'Tres moduladores',
|
||||
desc: 'LFO, Envelope y Keyboard todos conectados',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!lfo || !env || !kb) return false;
|
||||
const lfoConn = conns.some(c => c.from.moduleId === lfo.id);
|
||||
const envConn = conns.some(c => c.from.moduleId === env.id);
|
||||
const kbConn = conns.some(c => c.from.moduleId === kb.id);
|
||||
return lfoConn && envConn && kbConn;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Routing correcto',
|
||||
desc: 'KB→freq, LFO→cutoff, Env→VCA cv, KB→gate',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (!lfo || !env || !kb || !osc || !flt || !vca) return false;
|
||||
return conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === osc.id && c.to.port === 'freq') &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff') &&
|
||||
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');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 4.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w4-8',
|
||||
title: 'Dubstep Wobble',
|
||||
subtitle: 'BOSS: El bajo que wobbles',
|
||||
description: 'El wobble bass de dubstep es modulación pura: un oscilador grave con un filtro lowpass resonante modulado por un LFO. Añade un envelope para el ataque y tienes el sonido que definió un género.',
|
||||
concept: 'Osc saw grave (~55 Hz) → Filter LP resonante → VCA → Output. LFO (~1-3 Hz) → Filter cutoff. Envelope → VCA cv. Keyboard → gate + freq. Q alta (~10+) para ese sonido agresivo.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 4 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena con modulación',
|
||||
desc: 'Osc → Filter → VCA → Output con LFO al cutoff',
|
||||
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 lfo = mods.find(m => m.type === 'lfo');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !vca || !lfo || !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) &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Wobble bass',
|
||||
desc: 'Osc grave (<130 Hz), LFO lento (1-3 Hz), Q > 8',
|
||||
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;
|
||||
return (osc.params.frequency ?? 440) < 130 &&
|
||||
(lfo.params.frequency ?? 2) >= 1 && (lfo.params.frequency ?? 2) <= 3 &&
|
||||
(flt.params.Q ?? 1) > 8;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Wobble completo',
|
||||
desc: 'Todo lo anterior + Envelope al VCA + Keyboard al gate',
|
||||
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 env = mods.find(m => m.type === 'envelope');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!osc || !flt || !lfo || !env || !vca || !kb) return false;
|
||||
return (osc.params.frequency ?? 440) < 130 &&
|
||||
(flt.params.Q ?? 1) > 8 &&
|
||||
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');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
448
src/game/levels/world5.js
Normal file
448
src/game/levels/world5.js
Normal file
@@ -0,0 +1,448 @@
|
||||
/**
|
||||
* World 5 — "Efectos" (Effects)
|
||||
*
|
||||
* Teaches: delay, reverb, distortion, effect chains, wet/dry mixing
|
||||
* 8 levels, progressive difficulty
|
||||
*/
|
||||
|
||||
export const WORLD_5 = {
|
||||
id: 'w5',
|
||||
name: 'Efectos',
|
||||
subtitle: 'Transforma el sonido con efectos',
|
||||
icon: '◈',
|
||||
color: '#44ff88',
|
||||
unlockStars: 48,
|
||||
levels: [
|
||||
// ─────────────── LEVEL 5.1 ───────────────
|
||||
{
|
||||
id: 'w5-1',
|
||||
title: 'El Eco',
|
||||
subtitle: 'Delay básico',
|
||||
description: 'El delay repite el sonido después de un tiempo. Es como gritar en un cañón y escuchar tu voz rebotando. El delay más simple tiene un tiempo de repetición y un feedback que controla cuántas veces se repite.',
|
||||
concept: 'Conecta: Oscilador → Delay → Output. El knob "Time" controla el tiempo entre repeticiones. El "Feedback" controla cuántas repeticiones. Empieza con un feedback bajo (~0.3).',
|
||||
availableModules: [],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sine', frequency: 440, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'delay', x: 340, y: 80, params: { time: 0.3, feedback: 0.3, mix: 0.5 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Delay conectado',
|
||||
desc: 'Oscilador → Delay → Salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !del || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === del.id) &&
|
||||
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Repeticiones',
|
||||
desc: 'Feedback por encima de 0.2',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
return del && (del.params.feedback ?? 0) > 0.2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Eco rítmico',
|
||||
desc: 'Delay time 0.2-0.5s, feedback 0.3-0.6',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
if (!del) return false;
|
||||
const t = del.params.time ?? 0.3;
|
||||
const fb = del.params.feedback ?? 0;
|
||||
return t >= 0.2 && t <= 0.5 && fb >= 0.3 && fb <= 0.6;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 5.2 ───────────────
|
||||
{
|
||||
id: 'w5-2',
|
||||
title: 'Slapback',
|
||||
subtitle: 'El delay rockabilly',
|
||||
description: 'El slapback es un delay muy corto (50-120ms) con una sola repetición. Es el efecto clásico de las voces de Elvis y el rockabilly — da presencia sin crear un eco largo.',
|
||||
concept: 'Delay con tiempo corto (~0.05-0.12s) y feedback muy bajo (~0.1 o menos). Una sola repetición rápida. El mix controla cuánto delay se mezcla con la señal original.',
|
||||
availableModules: ['delay'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'square', frequency: 330, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Delay en la cadena',
|
||||
desc: 'Osc → Delay → Salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !del || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === del.id) &&
|
||||
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Tiempo corto',
|
||||
desc: 'Delay time menor de 0.15s',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
return del && (del.params.time ?? 0.3) < 0.15;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Slapback perfecto',
|
||||
desc: 'Time 0.05-0.12s, feedback < 0.15',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
if (!del) return false;
|
||||
const t = del.params.time ?? 0.3;
|
||||
const fb = del.params.feedback ?? 0.3;
|
||||
return t >= 0.05 && t <= 0.12 && fb < 0.15;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 5.3 ───────────────
|
||||
{
|
||||
id: 'w5-3',
|
||||
title: 'Reverb Espacial',
|
||||
subtitle: 'El sonido del espacio',
|
||||
description: 'La reverb simula el sonido de un espacio acústico — desde una habitación pequeña hasta una catedral enorme. Es quizás el efecto más usado en toda la producción musical.',
|
||||
concept: 'Conecta: Oscilador → Reverb → Output. El knob de "decay" (o room size) controla el tamaño del espacio. Más largo = catedral. Más corto = habitación pequeña.',
|
||||
availableModules: [],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'triangle', frequency: 440, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'reverb', x: 340, y: 80, params: { decay: 2, mix: 0.4 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Reverb conectada',
|
||||
desc: 'Oscilador → Reverb → Salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !rev || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === rev.id) &&
|
||||
conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Espacio grande',
|
||||
desc: 'Decay mayor de 3 segundos',
|
||||
test: (mods) => {
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
return rev && (rev.params.decay ?? 2) > 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Catedral',
|
||||
desc: 'Decay > 5s, mix 0.3-0.6 (no demasiado)',
|
||||
test: (mods) => {
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (!rev) return false;
|
||||
return (rev.params.decay ?? 2) > 5 &&
|
||||
(rev.params.mix ?? 0.5) >= 0.3 && (rev.params.mix ?? 0.5) <= 0.6;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 5.4 ───────────────
|
||||
{
|
||||
id: 'w5-4',
|
||||
title: 'Distorsión',
|
||||
subtitle: 'Rompe la señal',
|
||||
description: 'La distorsión amplifica la señal hasta que se "rompe", creando armónicos nuevos. Desde el overdrive suave de un amplificador de guitarra hasta el fuzz salvaje — la distorsión añade agresividad y presencia.',
|
||||
concept: 'Conecta: Oscilador → Distortion → Output. Sube el "Drive" para más distorsión. Con una onda sine pura, escucharás cómo aparecen armónicos que no estaban antes.',
|
||||
availableModules: ['distortion'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sine', frequency: 220, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 600, y: 100, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2.5 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Distorsión conectada',
|
||||
desc: 'Osc → Distortion → Salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !dist || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === dist.id) &&
|
||||
conns.some(c => c.from.moduleId === dist.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Drive alto',
|
||||
desc: 'Distorsión con drive moderado-alto',
|
||||
test: (mods) => {
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
return dist && (dist.params.drive ?? 1) > 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Fuzz sine',
|
||||
desc: 'Onda sine con drive > 5 (máxima transformación)',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
if (!osc || !dist) return false;
|
||||
return osc.params.waveform === 'sine' && (dist.params.drive ?? 1) > 5;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 5.5 ───────────────
|
||||
{
|
||||
id: 'w5-5',
|
||||
title: 'Cadena de Efectos',
|
||||
subtitle: 'Orden importa',
|
||||
description: 'El orden de los efectos cambia radicalmente el resultado. Distorsión antes de delay suena diferente a delay antes de distorsión. Experimenta encadenando efectos en diferente orden.',
|
||||
concept: 'Prueba: Osc → Distortion → Delay → Output (la distorsión se repite limpia). El orden crea caracteres distintos. Encadena al menos 2 efectos diferentes.',
|
||||
availableModules: ['delay', 'distortion'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 60, y: 80, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 740, y: 100, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dos efectos',
|
||||
desc: 'Al menos 2 módulos de efecto en la cadena',
|
||||
test: (mods, conns) => {
|
||||
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
return effects.length >= 2 && out && conns.some(c => c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Efectos encadenados',
|
||||
desc: 'Los efectos están conectados en serie (uno al otro)',
|
||||
test: (mods, conns) => {
|
||||
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
|
||||
if (effects.length < 2) return false;
|
||||
// Check if any effect connects to another effect
|
||||
return effects.some(e1 => effects.some(e2 =>
|
||||
e1.id !== e2.id && conns.some(c => c.from.moduleId === e1.id && c.to.moduleId === e2.id)
|
||||
));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Cadena completa',
|
||||
desc: 'Osc → efecto1 → efecto2 → Output (cadena lineal)',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || effects.length < 2 || !out) return false;
|
||||
// Osc → some effect
|
||||
const oscToFx = effects.find(e => conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === e.id));
|
||||
if (!oscToFx) return false;
|
||||
// That effect → another effect
|
||||
const fxToFx = effects.find(e => e.id !== oscToFx.id &&
|
||||
conns.some(c => c.from.moduleId === oscToFx.id && c.to.moduleId === e.id));
|
||||
if (!fxToFx) return false;
|
||||
// Second effect → output
|
||||
return conns.some(c => c.from.moduleId === fxToFx.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 5.6 ───────────────
|
||||
{
|
||||
id: 'w5-6',
|
||||
title: 'Delay + Filtro',
|
||||
subtitle: 'Dub echo',
|
||||
description: 'El sonido dub es delay con feedback alto pasado por un filtro que va quitando agudos. Cada repetición suena más oscura y lejana — es el efecto que definió el reggae dub en los 70.',
|
||||
concept: 'Osc → Delay (feedback alto ~0.5-0.7) → Filter (lowpass, cutoff bajo ~800 Hz) → Output. El filtro después del delay oscurece las repeticiones, creando profundidad.',
|
||||
availableModules: ['delay', 'filter'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 60, y: 80, params: { waveform: 'square', frequency: 330, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 720, y: 100, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Delay + Filter',
|
||||
desc: 'Osc → Delay → Filter → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !del || !flt || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === del.id) &&
|
||||
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Echo largo',
|
||||
desc: 'Delay feedback > 0.4, time > 0.2s',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
return del && (del.params.feedback ?? 0) > 0.4 && (del.params.time ?? 0.3) > 0.2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Dub echo',
|
||||
desc: 'Feedback 0.5-0.7, filtro lowpass cutoff < 1000 Hz',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!del || !flt) return false;
|
||||
const fb = del.params.feedback ?? 0;
|
||||
return fb >= 0.5 && fb <= 0.7 &&
|
||||
flt.params.type === 'lowpass' && (flt.params.frequency ?? 2000) < 1000;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 5.7 ───────────────
|
||||
{
|
||||
id: 'w5-7',
|
||||
title: 'Shoegaze Wall',
|
||||
subtitle: 'Reverb + Distorsión',
|
||||
description: 'El sonido shoegaze (My Bloody Valentine, Slowdive) es una pared de sonido creada con distorsión y reverb masiva. La distorsión aplasta la señal y la reverb la convierte en una nube etérea.',
|
||||
concept: 'Osc → Distortion (drive medio) → Reverb (decay largo, mix alto) → Output. La combinación de distorsión y reverb crea una textura densa y atmosférica.',
|
||||
availableModules: ['distortion', 'reverb'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 60, y: 80, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: false },
|
||||
{ id: 2, type: 'output', x: 720, y: 100, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 4 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dist + Reverb',
|
||||
desc: 'Osc → Distortion → Reverb → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !dist || !rev || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === dist.id) &&
|
||||
conns.some(c => c.from.moduleId === dist.id && c.to.moduleId === rev.id) &&
|
||||
conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Pared de sonido',
|
||||
desc: 'Drive > 3, reverb decay > 4s',
|
||||
test: (mods) => {
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (!dist || !rev) return false;
|
||||
return (dist.params.drive ?? 1) > 3 && (rev.params.decay ?? 2) > 4;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Shoegaze perfecto',
|
||||
desc: 'Drive 4-8, decay > 6s, reverb mix > 0.5',
|
||||
test: (mods) => {
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (!dist || !rev) return false;
|
||||
const drive = dist.params.drive ?? 1;
|
||||
return drive >= 4 && drive <= 8 &&
|
||||
(rev.params.decay ?? 2) > 6 && (rev.params.mix ?? 0.5) > 0.5;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 5.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w5-8',
|
||||
title: 'Ambient Scape',
|
||||
subtitle: 'BOSS: Paisaje sonoro',
|
||||
description: 'Crea un paisaje sonoro ambient completo: un sonido que evoluciona lentamente, envuelto en efectos. Combina osciladores, filtros, modulación y efectos para crear una textura atmosférica.',
|
||||
concept: 'Osc → Filter (LFO al cutoff) → Delay → Reverb → Output. Envelope al VCA para control. Experimenta con tiempos largos, feedback alto, y modulación lenta para un sonido que "flota".',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'delay', 'reverb', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 5 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena con efectos',
|
||||
desc: 'Al menos un efecto (delay/reverb) conectado a la salida',
|
||||
test: (mods, conns) => {
|
||||
const effects = mods.filter(m => ['delay', 'reverb'].includes(m.type));
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (effects.length === 0 || !out) return false;
|
||||
return effects.some(e => conns.some(c => c.from.moduleId === e.id && c.to.moduleId === out.id)) ||
|
||||
conns.some(c => c.to.moduleId === out.id && effects.some(e => c.from.moduleId === e.id));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Modulación + Efectos',
|
||||
desc: 'Tiene oscilador, filtro, y al menos 2 efectos conectados',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
|
||||
if (!osc || !flt || effects.length < 2) return false;
|
||||
// Check osc is connected to something
|
||||
return conns.some(c => c.from.moduleId === osc.id) && effects.length >= 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Paisaje completo',
|
||||
desc: 'Osc+Filter+LFO(cutoff)+Delay+Reverb, todo conectado',
|
||||
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 del = mods.find(m => m.type === 'delay');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !lfo || !del || !rev || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff') &&
|
||||
conns.some(c => c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
479
src/game/levels/world6.js
Normal file
479
src/game/levels/world6.js
Normal file
@@ -0,0 +1,479 @@
|
||||
/**
|
||||
* World 6 — "Diseño Sonoro" (Sound Design Mastery)
|
||||
*
|
||||
* Teaches: putting it ALL together, real-world sound recreation
|
||||
* 8 levels, boss challenges combining everything learned
|
||||
*/
|
||||
|
||||
export const WORLD_6 = {
|
||||
id: 'w6',
|
||||
name: 'Diseño Sonoro',
|
||||
subtitle: 'Combina todo para crear sonidos reales',
|
||||
icon: '◉',
|
||||
color: '#ff44aa',
|
||||
unlockStars: 60,
|
||||
levels: [
|
||||
// ─────────────── LEVEL 6.1 ───────────────
|
||||
{
|
||||
id: 'w6-1',
|
||||
title: 'Kick Drum',
|
||||
subtitle: 'El latido del beat',
|
||||
description: 'Un kick sintético se crea con un oscilador sine a frecuencia baja + un envelope muy rápido en el VCA para el golpe. Algunos añaden un pitch envelope para el "click" del ataque.',
|
||||
concept: 'Osc sine a ~55 Hz → VCA → Output. Envelope con attack 0, decay ~0.2s, sustain 0. El envelope al VCA crea el golpe. Para el click: un segundo osc más agudo con decay ultra-corto.',
|
||||
availableModules: ['oscillator', 'vca', 'envelope', 'keyboard', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Kick básico',
|
||||
desc: 'Osc sine grave + VCA + Envelope → Output',
|
||||
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 out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !vca || !env || !out) return false;
|
||||
return (osc.params.frequency ?? 440) < 100 &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Punch',
|
||||
desc: 'Sine < 80 Hz, envelope rápido (attack < 0.01, decay < 0.3)',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!osc || !env) return false;
|
||||
return (osc.params.frequency ?? 440) < 80 &&
|
||||
osc.params.waveform === 'sine' &&
|
||||
(env.params.attack ?? 0.01) < 0.01 &&
|
||||
(env.params.decay ?? 0.2) < 0.3 &&
|
||||
(env.params.sustain ?? 0.5) < 0.1;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: '808 Kick',
|
||||
desc: 'Frecuencia 40-60 Hz, decay 0.15-0.4s, keyboard conectado',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!osc || !env || !kb) return false;
|
||||
const freq = osc.params.frequency ?? 440;
|
||||
const decay = env.params.decay ?? 0.2;
|
||||
return freq >= 40 && freq <= 60 && decay >= 0.15 && decay <= 0.4 &&
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id && c.to.port === 'gate');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 6.2 ───────────────
|
||||
{
|
||||
id: 'w6-2',
|
||||
title: 'Hi-Hat',
|
||||
subtitle: 'Noise + Filtro + Envelope',
|
||||
description: 'Los hi-hats son ruido blanco filtrado con un envelope corto. El ruido proporciona la textura metálica, el filtro highpass quita los graves, y el envelope corto le da el "tss".',
|
||||
concept: 'Noise → Filter (highpass, cutoff alto ~6000+ Hz) → VCA → Output. Envelope corto (attack 0, decay ~0.05-0.15s, sustain 0) al VCA. Keyboard al gate del envelope.',
|
||||
availableModules: ['noise', 'filter', 'vca', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Ruido filtrado',
|
||||
desc: 'Noise → Filter → VCA → Output con envelope',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!noise || !flt || !vca || !env) return false;
|
||||
return conns.some(c => c.from.moduleId === noise.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sonido metálico',
|
||||
desc: 'Filtro highpass, cutoff > 4000 Hz',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
return flt && flt.params.type === 'highpass' && (flt.params.frequency ?? 1000) > 4000;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Hi-hat cerrado',
|
||||
desc: 'HP > 6000 Hz, envelope ultra-corto (decay < 0.1s)',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!flt || !env) return false;
|
||||
return flt.params.type === 'highpass' && (flt.params.frequency ?? 1000) > 6000 &&
|
||||
(env.params.decay ?? 0.2) < 0.1 && (env.params.sustain ?? 0.5) < 0.05;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 6.3 ───────────────
|
||||
{
|
||||
id: 'w6-3',
|
||||
title: 'Snare',
|
||||
subtitle: 'Tono + Ruido',
|
||||
description: 'Un snare es la combinación de un cuerpo tonal (oscilador) y una cola de ruido (noise). Se mezclan juntos con envelopes diferentes — el tono muere rápido y el ruido un poco después.',
|
||||
concept: 'Dos cadenas: 1) Osc sine (~200 Hz) → VCA1 → Mixer. 2) Noise → Filter HP → VCA2 → Mixer. Mixer → Output. Envelopes diferentes: el tono más corto que el ruido.',
|
||||
availableModules: ['oscillator', 'noise', 'filter', 'vca', 'envelope', 'keyboard', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dos fuentes',
|
||||
desc: 'Oscilador Y Noise, ambos al mixer → output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
return osc && noise && mixer && out &&
|
||||
conns.some(c => c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Envelopes',
|
||||
desc: 'Al menos 2 envelopes controlando VCAs',
|
||||
test: (mods, conns) => {
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const vcas = mods.filter(m => m.type === 'vca');
|
||||
if (envs.length < 2 || vcas.length < 2) return false;
|
||||
const envToVca = envs.filter(e =>
|
||||
vcas.some(v => conns.some(c => c.from.moduleId === e.id && c.to.moduleId === v.id && c.to.port === 'cv'))
|
||||
);
|
||||
return envToVca.length >= 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Snare realista',
|
||||
desc: 'Osc ~150-250 Hz, noise filtrado HP, decays diferentes',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
if (!osc || !flt || envs.length < 2) return false;
|
||||
const freq = osc.params.frequency ?? 440;
|
||||
const decays = envs.map(e => e.params.decay ?? 0.2);
|
||||
return freq >= 150 && freq <= 250 &&
|
||||
flt.params.type === 'highpass' &&
|
||||
Math.abs(decays[0] - decays[1]) > 0.03;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 6.4 ───────────────
|
||||
{
|
||||
id: 'w6-4',
|
||||
title: 'Pad Espacial',
|
||||
subtitle: 'Capas + Efectos',
|
||||
description: 'Un pad espacial combina múltiples osciladores detuned, un filtro suave, un envelope lento, y efectos de reverb/delay para crear una textura inmersiva que rellena todo el espectro.',
|
||||
concept: 'Dos oscs saw detuned → Mixer → Filter LP → VCA → Reverb → Output. Envelope lento al VCA. LFO lento al cutoff. Reverb con decay largo. El resultado: un colchón de sonido etéreo.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'reverb', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 5 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Múltiples osciladores',
|
||||
desc: 'Al menos 2 osciladores mezclados',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
return oscs.length >= 2 && mixer;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Con efectos',
|
||||
desc: 'Reverb en la cadena con decay > 3s',
|
||||
test: (mods) => {
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
return rev && (rev.params.decay ?? 2) > 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Pad completo',
|
||||
desc: '2+ oscs detuned, filtro, LFO al cutoff, envelope al VCA, reverb',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (oscs.length < 2 || !flt || !lfo || !env || !vca || !rev) return false;
|
||||
// Check detune
|
||||
const hasDetune = oscs.some(o => Math.abs(o.params.detune ?? 0) > 2);
|
||||
// Check LFO to cutoff
|
||||
const lfoToCutoff = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
// Check env to VCA
|
||||
const envToVca = conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
return hasDetune && lfoToCutoff && envToVca;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 6.5 ───────────────
|
||||
{
|
||||
id: 'w6-5',
|
||||
title: 'Bajo Reese',
|
||||
subtitle: 'El bajo de Drum & Bass',
|
||||
description: 'El Reese bass es un bajo icónico del Drum & Bass: dos osciladores sawtooth detuned a frecuencia grave, pasados por un filtro lowpass que se abre y cierra. Es gordo, agresivo y hipnótico.',
|
||||
concept: 'Dos oscs sawtooth a ~55 Hz, uno con detune +7-12. Mixer → Filter LP resonante → VCA → Output. LFO lento (~0.3-1 Hz) al cutoff del filtro. El "movimiento" del filtro es lo que le da vida.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'mixer', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 4 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dos sierras graves',
|
||||
desc: '2 osciladores saw < 100 Hz mezclados',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
if (oscs.length < 2) return false;
|
||||
return oscs.filter(o => o.params.waveform === 'sawtooth' && (o.params.frequency ?? 440) < 100).length >= 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Detune + Filtro',
|
||||
desc: 'Osciladores detuned, filtro LP en la cadena',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sawtooth');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (oscs.length < 2 || !flt) return false;
|
||||
const hasDetune = oscs.some(o => Math.abs(o.params.detune ?? 0) > 3);
|
||||
return hasDetune && flt.params.type === 'lowpass';
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Reese Bass',
|
||||
desc: 'Detuned saws + LP resonante + LFO al cutoff',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sawtooth');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (oscs.length < 2 || !flt || !lfo) return false;
|
||||
const hasDetune = oscs.some(o => Math.abs(o.params.detune ?? 0) > 3);
|
||||
const isLPres = flt.params.type === 'lowpass' && (flt.params.Q ?? 1) > 3;
|
||||
const lfoToCut = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
return hasDetune && isLPres && lfoToCut;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 6.6 ───────────────
|
||||
{
|
||||
id: 'w6-6',
|
||||
title: 'Efecto Laser',
|
||||
subtitle: 'Pew pew!',
|
||||
description: 'El sonido laser clásico de los juegos retro es un oscilador cuya frecuencia baja rápidamente — un pitch sweep descendente. Se consigue con un envelope que modula la frecuencia del oscilador.',
|
||||
concept: 'Osc square/saw → VCA → Output. Envelope al VCA (ataque rápido, decay corto). Un SEGUNDO envelope a la frecuencia del osc (empieza agudo y baja rápido). Keyboard dispara ambos.',
|
||||
availableModules: ['oscillator', 'vca', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Sonido con envelope',
|
||||
desc: 'Osc → VCA → Output con envelope y keyboard',
|
||||
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');
|
||||
return osc && vca && env && kb && out &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Pitch envelope',
|
||||
desc: 'Un envelope conectado a la frecuencia del oscilador',
|
||||
test: (mods, conns) => {
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
if (!osc || envs.length < 2) return false;
|
||||
return envs.some(e => conns.some(c =>
|
||||
c.from.moduleId === e.id && c.to.moduleId === osc.id && c.to.port === 'freq'
|
||||
));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Pew pew!',
|
||||
desc: 'Osc square/saw, pitch env corto (decay < 0.2s), keyboard a ambos gates',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!osc || envs.length < 2 || !kb) return false;
|
||||
const wave = osc.params.waveform;
|
||||
const pitchEnv = envs.find(e => conns.some(c =>
|
||||
c.from.moduleId === e.id && c.to.moduleId === osc.id && c.to.port === 'freq'
|
||||
));
|
||||
if (!pitchEnv) return false;
|
||||
const gated = envs.filter(e =>
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === e.id && c.to.port === 'gate')
|
||||
);
|
||||
return (wave === 'square' || wave === 'sawtooth') &&
|
||||
(pitchEnv.params.decay ?? 0.2) < 0.2 &&
|
||||
gated.length >= 2;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 6.7 ───────────────
|
||||
{
|
||||
id: 'w6-7',
|
||||
title: 'Arpegio Trance',
|
||||
subtitle: 'Secuenciador + Synth',
|
||||
description: 'Los arpegios de trance son notas rápidas que crean patrones hipnóticos. Usa el secuenciador para disparar notas en el sintetizador con un envelope corto y un filtro que sube y baja.',
|
||||
concept: 'Sequencer → Osc freq + Envelope gate. Osc → Filter → VCA → Delay → Output. Envelope corto al VCA (pluck). LFO lento al cutoff del filtro. El delay repite el patrón.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'delay', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 4 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Secuenciador activo',
|
||||
desc: 'Sequencer conectado al oscilador',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
if (!seq || !osc) return false;
|
||||
return conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === osc.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Synth con envolvente',
|
||||
desc: 'Osc → Filter → VCA → Output con envelope al VCA',
|
||||
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 env = mods.find(m => m.type === 'envelope');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !vca || !env || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Trance completo',
|
||||
desc: 'Sequencer + synth sustractivo completo + delay',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
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 del = mods.find(m => m.type === 'delay');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!seq || !osc || !flt || !vca || !del || !env) return false;
|
||||
return conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === osc.id) &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 6.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w6-8',
|
||||
title: 'Tu Sintetizador',
|
||||
subtitle: 'BOSS FINAL: Diseña tu propio sonido',
|
||||
description: 'Has aprendido osciladores, filtros, envelopes, modulación y efectos. Ahora construye el sintetizador más completo que puedas. Sin restricciones. Sin guía. Solo tu creatividad y todo lo que has aprendido.',
|
||||
concept: 'Construye un patch completo con al menos: 2 osciladores, 1 filtro, 1 VCA, 2 envelopes, 1 LFO, 1 efecto, y un keyboard. ¡Hazlo sonar increíble!',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'noise', 'keyboard', 'delay', 'reverb', 'distortion', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 950, y: 160, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 5 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Patch funcional',
|
||||
desc: 'Al menos 5 módulos conectados con sonido a la salida',
|
||||
test: (mods, conns) => {
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!out) return false;
|
||||
// Count non-output modules
|
||||
const modCount = mods.filter(m => m.type !== 'output').length;
|
||||
// Something reaches output
|
||||
const hasOutput = conns.some(c => c.to.moduleId === out.id);
|
||||
return modCount >= 5 && hasOutput && conns.length >= 5;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Síntesis completa',
|
||||
desc: 'Tiene osc + filtro + VCA + envelope + efecto, todos conectados',
|
||||
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 env = mods.find(m => m.type === 'envelope');
|
||||
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
|
||||
if (!osc || !flt || !vca || !env || effects.length === 0) return false;
|
||||
// All main pieces should have connections
|
||||
const oscConn = conns.some(c => c.from.moduleId === osc.id);
|
||||
const envConn = conns.some(c => c.from.moduleId === env.id);
|
||||
return oscConn && envConn && conns.length >= 7;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Maestro del Sonido',
|
||||
desc: '8+ módulos, 2+ oscs, 2+ envelopes, LFO, efecto, keyboard — ¡todo!',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
|
||||
const nonOutput = mods.filter(m => m.type !== 'output');
|
||||
return nonOutput.length >= 8 && oscs.length >= 2 && envs.length >= 2 &&
|
||||
lfo && kb && effects.length >= 1 && conns.length >= 10;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -630,3 +630,92 @@ html, body, #root {
|
||||
top: 12px;
|
||||
right: 220px;
|
||||
}
|
||||
|
||||
/* ===== Admin Panel ===== */
|
||||
.gm-admin-btn {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text2);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.gm-admin-btn:hover { background: var(--surface2); color: var(--text); }
|
||||
|
||||
.admin-overlay {
|
||||
position: fixed; inset: 0; z-index: 1000;
|
||||
background: rgba(0,0,0,0.7);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.admin-panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
width: 90%; max-width: 700px; max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.admin-header {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
margin-bottom: 16px; padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.admin-header h2 { font-size: 18px; color: var(--yellow); flex: 1; }
|
||||
.admin-total { color: var(--yellow); font-size: 14px; font-weight: 600; }
|
||||
.admin-close {
|
||||
background: none; border: none; color: var(--text2);
|
||||
cursor: pointer; font-size: 18px; padding: 4px 8px;
|
||||
}
|
||||
.admin-close:hover { color: var(--text); }
|
||||
|
||||
.admin-actions {
|
||||
display: flex; gap: 8px; margin-bottom: 16px;
|
||||
}
|
||||
.admin-action-btn {
|
||||
padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border);
|
||||
background: var(--surface); color: var(--text); cursor: pointer;
|
||||
font-size: 12px; font-weight: 500; transition: all 0.15s;
|
||||
}
|
||||
.admin-action-btn.gold { border-color: var(--yellow); color: var(--yellow); }
|
||||
.admin-action-btn.gold:hover { background: var(--yellow); color: var(--bg); }
|
||||
.admin-action-btn.danger { border-color: var(--red); color: var(--red); }
|
||||
.admin-action-btn.danger:hover { background: var(--red); color: #fff; }
|
||||
|
||||
.admin-world { margin-bottom: 16px; }
|
||||
.admin-world-header {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 0; border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.admin-world-icon { font-size: 18px; }
|
||||
.admin-world-name { flex: 1; font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
.admin-world-stars { font-size: 12px; color: var(--yellow); }
|
||||
.admin-unlock-btn {
|
||||
padding: 3px 10px; border-radius: 4px; border: 1px solid var(--green);
|
||||
background: transparent; color: var(--green); cursor: pointer;
|
||||
font-size: 11px; transition: all 0.15s;
|
||||
}
|
||||
.admin-unlock-btn:hover { background: var(--green); color: var(--bg); }
|
||||
|
||||
.admin-levels { display: flex; flex-direction: column; gap: 2px; }
|
||||
.admin-level {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 4px 8px; border-radius: 4px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.admin-level:hover { background: var(--surface); }
|
||||
.admin-level-num { font-size: 11px; color: var(--text2); width: 24px; }
|
||||
.admin-level-name { flex: 1; font-size: 12px; color: var(--text); }
|
||||
.admin-star-btns { display: flex; gap: 3px; }
|
||||
.admin-star-btn {
|
||||
padding: 2px 6px; border-radius: 3px; border: 1px solid var(--border);
|
||||
background: transparent; cursor: pointer; font-size: 11px;
|
||||
color: var(--text2); transition: all 0.1s;
|
||||
}
|
||||
.admin-star-btn:hover { border-color: var(--yellow); color: var(--yellow); }
|
||||
.admin-star-btn.active { background: var(--yellow); color: var(--bg); border-color: var(--yellow); }
|
||||
.admin-star-btn.zero { color: var(--red); }
|
||||
.admin-star-btn.zero:hover { border-color: var(--red); color: var(--red); }
|
||||
|
||||
Reference in New Issue
Block a user