feat: UI sounds, live LFO visualization, wire fix, worlds 7-12, bug fixes
- Add procedural UI sound effects (connect/disconnect, engine start/stop, level complete/fail, star earned, hint, navigation) via Tone.js - Live LFO modulation visualization: knobs animate in real-time showing modulated value, ghost dot shows base value, number glows cyan - Fix wire recalculation on zoom/pan/level re-entry (post-layout refresh) - Fix retry button to keep current patch instead of reloading level - Fix default param detection: newly added modules now populate all default params so level checkers work without manual param changes - Add worlds 7-12: Secuencias y Ritmos, Texturas de Ruido, Síntesis Sustractiva, Espacio y Stereo, Técnicas Avanzadas, Gran Final (48 new levels, 144 new possible stars, 288 total stars) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,6 +3,7 @@ import { state, subscribe, addModule, emit, addConnection, updateModulePosition,
|
||||
import { startAudio, stopAudio, connectWire, rebuildGraph } from './engine/audioEngine.js';
|
||||
import { getModuleDef } from './engine/moduleRegistry.js';
|
||||
import { autoSave, autoLoad, exportPatch, importPatch } from './engine/presets.js';
|
||||
import { playEngineStart, playEngineStop } from './engine/uiSounds.js';
|
||||
import ModuleNode from './components/ModuleNode.jsx';
|
||||
import WireLayer from './components/WireLayer.jsx';
|
||||
import ModulePalette from './components/ModulePalette.jsx';
|
||||
@@ -193,8 +194,10 @@ export default function App({ onSwitchToGame }) {
|
||||
const handleToggleAudio = async () => {
|
||||
if (state.isRunning) {
|
||||
stopAudio();
|
||||
playEngineStop();
|
||||
} else {
|
||||
await startAudio();
|
||||
playEngineStart();
|
||||
}
|
||||
emit();
|
||||
};
|
||||
|
||||
@@ -19,14 +19,17 @@ function describeArc(cx, cy, r, startDeg, endDeg) {
|
||||
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${large} 0 ${end.x} ${end.y}`;
|
||||
}
|
||||
|
||||
export default function Knob({ value, min, max, onChange, color = 'var(--accent)', label, unit, formatValue, modulated = false }) {
|
||||
export default function Knob({ value, min, max, onChange, color = 'var(--accent)', label, unit, formatValue, modulated = false, liveValue }) {
|
||||
const ref = useRef(null);
|
||||
const dragRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editText, setEditText] = useState('');
|
||||
|
||||
const norm = Math.max(0, Math.min(1, (value - min) / (max - min)));
|
||||
// Use liveValue for visual display when being modulated, base value for interaction
|
||||
const displayNum = liveValue !== undefined ? liveValue : value;
|
||||
const clampedDisplay = Math.max(min, Math.min(max, displayNum));
|
||||
const norm = Math.max(0, Math.min(1, (clampedDisplay - min) / (max - min)));
|
||||
const angleDeg = START_ANGLE - norm * RANGE;
|
||||
|
||||
const cx = SIZE / 2, cy = SIZE / 2;
|
||||
@@ -36,11 +39,16 @@ export default function Knob({ value, min, max, onChange, color = 'var(--accent)
|
||||
|
||||
const dotPos = polarToCart(cx, cy, RADIUS - 4, angleDeg);
|
||||
|
||||
const displayVal = formatValue ? formatValue(value) :
|
||||
value >= 1000 ? `${(value / 1000).toFixed(1)}k` :
|
||||
value >= 100 ? Math.round(value) :
|
||||
value >= 1 ? value.toFixed(1) :
|
||||
value.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
|
||||
// Also show base value indicator when modulated
|
||||
const baseNorm = Math.max(0, Math.min(1, (value - min) / (max - min)));
|
||||
const baseAngle = START_ANGLE - baseNorm * RANGE;
|
||||
const baseDotPos = polarToCart(cx, cy, RADIUS - 4, baseAngle);
|
||||
|
||||
const displayVal = formatValue ? formatValue(displayNum) :
|
||||
displayNum >= 1000 ? `${(displayNum / 1000).toFixed(1)}k` :
|
||||
displayNum >= 100 ? Math.round(displayNum) :
|
||||
displayNum >= 1 ? displayNum.toFixed(1) :
|
||||
displayNum.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
|
||||
|
||||
const handlePointerDown = useCallback((e) => {
|
||||
if (editing) return;
|
||||
@@ -131,6 +139,10 @@ export default function Knob({ value, min, max, onChange, color = 'var(--accent)
|
||||
)}
|
||||
<path className="knob-track" d={trackPath} />
|
||||
{fillPath && <path className="knob-fill" d={fillPath} style={{ stroke: color }} />}
|
||||
{/* Ghost dot at base value position when modulated */}
|
||||
{liveValue !== undefined && (
|
||||
<circle className="knob-base-dot" cx={baseDotPos.x} cy={baseDotPos.y} r={1.5} />
|
||||
)}
|
||||
<circle className="knob-dot" cx={dotPos.x} cy={dotPos.y} r={2} />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { getModuleDef } from '../engine/moduleRegistry.js';
|
||||
import { state, removeModule, updateModuleParam, isPortConnected, emit } from '../engine/state.js';
|
||||
import { updateParam } from '../engine/audioEngine.js';
|
||||
@@ -15,6 +15,17 @@ const PORT_TO_PARAM = {
|
||||
vca: { cv: 'gain' },
|
||||
};
|
||||
|
||||
// Compute a simulated LFO waveform value at time t (seconds)
|
||||
function simulateLFO(waveform, phase) {
|
||||
switch (waveform) {
|
||||
case 'sine': return Math.sin(2 * Math.PI * phase);
|
||||
case 'triangle': return 1 - 4 * Math.abs((phase % 1) - 0.5);
|
||||
case 'sawtooth': return 2 * (phase % 1) - 1;
|
||||
case 'square': return (phase % 1) < 0.5 ? 1 : -1;
|
||||
default: return Math.sin(2 * Math.PI * phase);
|
||||
}
|
||||
}
|
||||
|
||||
export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }) {
|
||||
const def = getModuleDef(mod.type);
|
||||
if (!def) return null;
|
||||
@@ -33,6 +44,59 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Live LFO modulation visualization ====================
|
||||
const [liveValues, setLiveValues] = useState({});
|
||||
const rafRef = useRef(null);
|
||||
const startTimeRef = useRef(performance.now() / 1000);
|
||||
|
||||
useEffect(() => {
|
||||
if (modulatedParams.size === 0) {
|
||||
setLiveValues({});
|
||||
return;
|
||||
}
|
||||
|
||||
const tick = () => {
|
||||
const t = performance.now() / 1000 - startTimeRef.current;
|
||||
const newValues = {};
|
||||
|
||||
for (const conn of state.connections) {
|
||||
if (conn.to.moduleId !== mod.id) continue;
|
||||
const paramName = portMap[conn.to.port];
|
||||
if (!paramName) continue;
|
||||
|
||||
const srcMod = state.modules.find(m => m.id === conn.from.moduleId);
|
||||
if (!srcMod || srcMod.type !== 'lfo') continue;
|
||||
|
||||
// Read LFO params from state
|
||||
const lfoDef = getModuleDef('lfo');
|
||||
const lfoP = { ...Object.fromEntries(Object.entries(lfoDef.params).map(([k, v]) => [k, v.default])), ...srcMod.params };
|
||||
const freq = lfoP.frequency;
|
||||
const amp = lfoP.amplitude;
|
||||
const waveform = lfoP.waveform;
|
||||
const phase = (t * freq) % 1;
|
||||
const lfoVal = simulateLFO(waveform, phase) * amp;
|
||||
|
||||
// Compute modulated value (same scaling as audioEngine)
|
||||
const baseValue = params[paramName];
|
||||
let scale;
|
||||
if (mod.type === 'oscillator' && paramName === 'frequency') scale = baseValue * 0.5;
|
||||
else if (mod.type === 'filter' && paramName === 'frequency') scale = baseValue;
|
||||
else if (mod.type === 'vca' && paramName === 'gain') scale = 1;
|
||||
else scale = baseValue || 1;
|
||||
|
||||
newValues[paramName] = baseValue + lfoVal * scale;
|
||||
}
|
||||
|
||||
setLiveValues(newValues);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [mod.id, mod.type, modulatedParams.size]);
|
||||
|
||||
const handleParamChange = useCallback((name, value) => {
|
||||
updateModuleParam(mod.id, name, value);
|
||||
updateParam(mod.id, name, value);
|
||||
@@ -134,12 +198,17 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
|
||||
onChange={v => handleParamChange(name, v)}
|
||||
color={color}
|
||||
modulated={modulatedParams.has(name)}
|
||||
liveValue={liveValues[name]}
|
||||
/>
|
||||
<span className="param-value">
|
||||
{params[name] >= 1000 ? `${(params[name] / 1000).toFixed(1)}k` :
|
||||
params[name] >= 100 ? Math.round(params[name]) :
|
||||
params[name] >= 1 ? Number(params[name]).toFixed(1) :
|
||||
Number(params[name]).toFixed(3).replace(/0+$/, '').replace(/\.$/, '')}
|
||||
<span className={`param-value ${liveValues[name] !== undefined ? 'param-value-live' : ''}`}>
|
||||
{(() => {
|
||||
const v = liveValues[name] !== undefined ? liveValues[name] : params[name];
|
||||
const s = v >= 1000 ? `${(v / 1000).toFixed(1)}k` :
|
||||
v >= 100 ? Math.round(v) :
|
||||
v >= 1 ? Number(v).toFixed(1) :
|
||||
Number(v).toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
|
||||
return s;
|
||||
})()}
|
||||
{paramDef.unit ? ` ${paramDef.unit}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { wirePath } from '../utils/bezier.js';
|
||||
import { state, removeConnection } from '../engine/state.js';
|
||||
import { disconnectWire } from '../engine/audioEngine.js';
|
||||
import { getModuleDef, PORT_TYPE } from '../engine/moduleRegistry.js';
|
||||
|
||||
export default function WireLayer({ portPositions, tempWire, containerRef, zoom, camX, camY }) {
|
||||
// Force a second render after DOM commit so getBoundingClientRect reads correct positions
|
||||
// This fixes wires lagging behind after zoom, pan, or level re-entry
|
||||
const [, refreshWires] = useState(0);
|
||||
const connCount = state.connections.length;
|
||||
const modCount = state.modules.length;
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => refreshWires(n => n + 1));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [zoom, camX, camY, connCount, modCount]);
|
||||
const getPortPos = (moduleId, portName, direction) => {
|
||||
const key = `${moduleId}-${portName}-${direction}`;
|
||||
const el = portPositions.current[key];
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
* state.js — Centralized reactive state for the modular synth
|
||||
* Uses a simple pub/sub pattern for React integration
|
||||
*/
|
||||
import { playConnect, playDisconnect, playModuleAdd, playModuleDelete } from './uiSounds.js';
|
||||
import { getModuleDef } from './moduleRegistry.js';
|
||||
|
||||
let _listeners = new Set();
|
||||
let _nextModuleId = 1;
|
||||
@@ -40,9 +42,15 @@ export function emit() {
|
||||
|
||||
export function addModule(type, x, y) {
|
||||
const id = _nextModuleId++;
|
||||
state.modules.push({ id, type, x, y, params: {}, collapsed: false });
|
||||
// Populate ALL default params so level checkers can read them immediately
|
||||
const def = getModuleDef(type);
|
||||
const defaults = def
|
||||
? Object.fromEntries(Object.entries(def.params).map(([k, v]) => [k, v.default]))
|
||||
: {};
|
||||
state.modules.push({ id, type, x, y, params: defaults, collapsed: false });
|
||||
state.selectedModuleId = id;
|
||||
emit();
|
||||
playModuleAdd();
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -53,6 +61,7 @@ export function removeModule(id) {
|
||||
);
|
||||
if (state.selectedModuleId === id) state.selectedModuleId = null;
|
||||
emit();
|
||||
playModuleDelete();
|
||||
}
|
||||
|
||||
export function updateModulePosition(id, x, y) {
|
||||
@@ -78,19 +87,21 @@ export function addConnection(fromModuleId, fromPort, toModuleId, toPort) {
|
||||
c.to.moduleId === toModuleId && c.to.port === toPort
|
||||
);
|
||||
if (inputTaken) {
|
||||
// Remove old connection to this input
|
||||
removeConnection(inputTaken.id);
|
||||
// Remove old connection to this input (silent — connect sound will play)
|
||||
removeConnection(inputTaken.id, true);
|
||||
}
|
||||
|
||||
const id = _nextConnectionId++;
|
||||
state.connections.push({ id, from: { moduleId: fromModuleId, port: fromPort }, to: { moduleId: toModuleId, port: toPort } });
|
||||
emit();
|
||||
playConnect();
|
||||
return id;
|
||||
}
|
||||
|
||||
export function removeConnection(id) {
|
||||
export function removeConnection(id, _silent = false) {
|
||||
state.connections = state.connections.filter(c => c.id !== id);
|
||||
emit();
|
||||
if (!_silent) playDisconnect();
|
||||
}
|
||||
|
||||
export function getModule(id) {
|
||||
|
||||
223
src/engine/uiSounds.js
Normal file
223
src/engine/uiSounds.js
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* uiSounds.js — Procedural UI sound effects using Tone.js
|
||||
* All sounds are synthesized on-the-fly — no audio files needed.
|
||||
* Sounds are short, subtle, and "synth-themed" to match the app.
|
||||
*/
|
||||
import * as Tone from 'tone';
|
||||
|
||||
let _enabled = true;
|
||||
let _volume = -18; // dB, subtle
|
||||
let _initialized = false;
|
||||
let _masterGain = null;
|
||||
|
||||
// Lazy init — only create audio nodes after user interaction (Tone.start)
|
||||
function ensureInit() {
|
||||
if (_initialized) return true;
|
||||
if (Tone.context.state !== 'running') return false;
|
||||
_masterGain = new Tone.Gain(Tone.dbToGain(_volume)).toDestination();
|
||||
_initialized = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function setUISoundsEnabled(enabled) { _enabled = enabled; }
|
||||
export function isUISoundsEnabled() { return _enabled; }
|
||||
export function setUIVolume(db) {
|
||||
_volume = db;
|
||||
if (_masterGain) _masterGain.gain.value = Tone.dbToGain(db);
|
||||
}
|
||||
|
||||
// ==================== Sound definitions ====================
|
||||
|
||||
/** Cable connected — short bright "click" with rising pitch */
|
||||
export function playConnect() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.001, decay: 0.08, sustain: 0, release: 0.05 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('C6', 0.06);
|
||||
setTimeout(() => {
|
||||
const synth2 = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.001, decay: 0.06, sustain: 0, release: 0.04 },
|
||||
}).connect(_masterGain);
|
||||
synth2.triggerAttackRelease('E6', 0.05);
|
||||
setTimeout(() => synth2.dispose(), 200);
|
||||
}, 40);
|
||||
setTimeout(() => synth.dispose(), 300);
|
||||
}
|
||||
|
||||
/** Cable disconnected — short descending blip */
|
||||
export function playDisconnect() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'triangle' },
|
||||
envelope: { attack: 0.001, decay: 0.1, sustain: 0, release: 0.05 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('E5', 0.06);
|
||||
setTimeout(() => {
|
||||
const synth2 = new Tone.Synth({
|
||||
oscillator: { type: 'triangle' },
|
||||
envelope: { attack: 0.001, decay: 0.08, sustain: 0, release: 0.04 },
|
||||
}).connect(_masterGain);
|
||||
synth2.triggerAttackRelease('C5', 0.05);
|
||||
setTimeout(() => synth2.dispose(), 200);
|
||||
}, 50);
|
||||
setTimeout(() => synth.dispose(), 300);
|
||||
}
|
||||
|
||||
/** Module added — soft metallic "pop" */
|
||||
export function playModuleAdd() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.MembraneSynth({
|
||||
pitchDecay: 0.01,
|
||||
octaves: 4,
|
||||
envelope: { attack: 0.001, decay: 0.15, sustain: 0, release: 0.1 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('C4', 0.08);
|
||||
setTimeout(() => synth.dispose(), 400);
|
||||
}
|
||||
|
||||
/** Module deleted — reverse "zap" */
|
||||
export function playModuleDelete() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sawtooth' },
|
||||
envelope: { attack: 0.001, decay: 0.12, sustain: 0, release: 0.05 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('A3', 0.08);
|
||||
setTimeout(() => synth.dispose(), 300);
|
||||
}
|
||||
|
||||
/** Button click — tiny tick */
|
||||
export function playClick() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.001, decay: 0.03, sustain: 0, release: 0.02 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('A5', 0.02);
|
||||
setTimeout(() => synth.dispose(), 150);
|
||||
}
|
||||
|
||||
/** Star earned — bright ascending arpeggio */
|
||||
export function playStar(starNumber = 1) {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const notes = ['C5', 'E5', 'G5'];
|
||||
const note = notes[Math.min(starNumber - 1, 2)];
|
||||
const delay = (starNumber - 1) * 300;
|
||||
setTimeout(() => {
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.005, decay: 0.3, sustain: 0.1, release: 0.3 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease(note, 0.25);
|
||||
// Shimmer harmonic
|
||||
const shimmer = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.01, decay: 0.2, sustain: 0, release: 0.2 },
|
||||
volume: -6,
|
||||
}).connect(_masterGain);
|
||||
shimmer.triggerAttackRelease(
|
||||
Tone.Frequency(note).transpose(12).toNote(), 0.15
|
||||
);
|
||||
setTimeout(() => { synth.dispose(); shimmer.dispose(); }, 800);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/** Level complete — triumphant chord */
|
||||
export function playLevelComplete() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const chord = ['C4', 'E4', 'G4', 'C5'];
|
||||
chord.forEach((note, i) => {
|
||||
setTimeout(() => {
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'triangle' },
|
||||
envelope: { attack: 0.01, decay: 0.5, sustain: 0.2, release: 0.5 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease(note, 0.4);
|
||||
setTimeout(() => synth.dispose(), 1200);
|
||||
}, i * 60);
|
||||
});
|
||||
}
|
||||
|
||||
/** Level failed / check failed — low "bonk" */
|
||||
export function playFail() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'square' },
|
||||
envelope: { attack: 0.001, decay: 0.15, sustain: 0, release: 0.1 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('D#3', 0.1);
|
||||
setTimeout(() => {
|
||||
const synth2 = new Tone.Synth({
|
||||
oscillator: { type: 'square' },
|
||||
envelope: { attack: 0.001, decay: 0.2, sustain: 0, release: 0.1 },
|
||||
}).connect(_masterGain);
|
||||
synth2.triggerAttackRelease('C3', 0.12);
|
||||
setTimeout(() => synth2.dispose(), 400);
|
||||
}, 100);
|
||||
setTimeout(() => synth.dispose(), 400);
|
||||
}
|
||||
|
||||
/** Hint revealed — mysterious "whoosh" */
|
||||
export function playHint() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const noise = new Tone.Noise('pink');
|
||||
const filter = new Tone.Filter({ type: 'bandpass', frequency: 2000, Q: 2 });
|
||||
const env = new Tone.AmplitudeEnvelope({ attack: 0.05, decay: 0.2, sustain: 0, release: 0.1 });
|
||||
noise.connect(filter).connect(env).connect(_masterGain);
|
||||
noise.start();
|
||||
env.triggerAttack();
|
||||
setTimeout(() => { env.triggerRelease(); }, 150);
|
||||
setTimeout(() => { noise.stop(); noise.dispose(); filter.dispose(); env.dispose(); }, 600);
|
||||
}
|
||||
|
||||
/** Audio engine start — power-on sweep */
|
||||
export function playEngineStart() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.1, decay: 0.2, sustain: 0, release: 0.1 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('C4', 0.15);
|
||||
setTimeout(() => {
|
||||
const synth2 = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.05, decay: 0.15, sustain: 0, release: 0.1 },
|
||||
}).connect(_masterGain);
|
||||
synth2.triggerAttackRelease('G4', 0.12);
|
||||
setTimeout(() => synth2.dispose(), 400);
|
||||
}, 100);
|
||||
setTimeout(() => synth.dispose(), 400);
|
||||
}
|
||||
|
||||
/** Audio engine stop — power-down */
|
||||
export function playEngineStop() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.01, decay: 0.2, sustain: 0, release: 0.1 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('G4', 0.1);
|
||||
setTimeout(() => {
|
||||
const synth2 = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.01, decay: 0.25, sustain: 0, release: 0.15 },
|
||||
}).connect(_masterGain);
|
||||
synth2.triggerAttackRelease('C4', 0.15);
|
||||
setTimeout(() => synth2.dispose(), 500);
|
||||
}, 80);
|
||||
setTimeout(() => synth.dispose(), 500);
|
||||
}
|
||||
|
||||
/** Navigation click (map, back buttons) — soft "tick" */
|
||||
export function playNav() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.001, decay: 0.04, sustain: 0, release: 0.03 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('E5', 0.03);
|
||||
setTimeout(() => synth.dispose(), 150);
|
||||
}
|
||||
@@ -8,8 +8,14 @@ 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 { WORLD_7 } from './levels/world7.js';
|
||||
import { WORLD_8 } from './levels/world8.js';
|
||||
import { WORLD_9 } from './levels/world9.js';
|
||||
import { WORLD_10 } from './levels/world10.js';
|
||||
import { WORLD_11 } from './levels/world11.js';
|
||||
import { WORLD_12 } from './levels/world12.js';
|
||||
|
||||
const allWorlds = [WORLD_1, WORLD_2, WORLD_3, WORLD_4, WORLD_5, WORLD_6];
|
||||
const allWorlds = [WORLD_1, WORLD_2, WORLD_3, WORLD_4, WORLD_5, WORLD_6, WORLD_7, WORLD_8, WORLD_9, WORLD_10, WORLD_11, WORLD_12];
|
||||
|
||||
export default function GameApp({ onSwitchToSandbox }) {
|
||||
const [view, setView] = useState('map');
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { playStar, playNav } from '../engine/uiSounds.js';
|
||||
|
||||
export default function LevelComplete({ stars, checks, levelTitle, onNext, onRetry, onMap, isLastLevel, hintPenalty }) {
|
||||
const [showStars, setShowStars] = useState(0);
|
||||
@@ -6,7 +7,10 @@ export default function LevelComplete({ stars, checks, levelTitle, onNext, onRet
|
||||
useEffect(() => {
|
||||
const timers = [];
|
||||
for (let i = 1; i <= stars; i++) {
|
||||
timers.push(setTimeout(() => setShowStars(i), i * 400));
|
||||
timers.push(setTimeout(() => {
|
||||
setShowStars(i);
|
||||
playStar(i);
|
||||
}, i * 400));
|
||||
}
|
||||
return () => timers.forEach(clearTimeout);
|
||||
}, [stars]);
|
||||
@@ -56,7 +60,7 @@ export default function LevelComplete({ stars, checks, levelTitle, onNext, onRet
|
||||
</div>
|
||||
|
||||
<div className="gm-complete-actions">
|
||||
<button className="gm-btn secondary" onClick={onMap}>Mapa</button>
|
||||
<button className="gm-btn secondary" onClick={() => { playNav(); onMap(); }}>Mapa</button>
|
||||
<button className="gm-btn secondary" onClick={onRetry}>
|
||||
Reintentar
|
||||
</button>
|
||||
|
||||
@@ -7,6 +7,7 @@ import WireLayer from '../components/WireLayer.jsx';
|
||||
import { playTarget, stopTarget, isTargetPlaying } from './targetAudio.js';
|
||||
import LevelComplete from './LevelComplete.jsx';
|
||||
import { completeLevel, saveLevelPatch, getLevelPatch, markHintUsed, wasHintUsed } from './gameState.js';
|
||||
import { playLevelComplete, playFail, playHint, playEngineStart, playEngineStop, playNav } from '../engine/uiSounds.js';
|
||||
|
||||
export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onNextLevel }) {
|
||||
const [, forceUpdate] = useState(0);
|
||||
@@ -230,8 +231,10 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
||||
const handleToggleAudio = async () => {
|
||||
if (state.isRunning) {
|
||||
stopAudio();
|
||||
playEngineStop();
|
||||
} else {
|
||||
await startAudio();
|
||||
playEngineStart();
|
||||
}
|
||||
emit();
|
||||
};
|
||||
@@ -252,6 +255,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
||||
setHintUsed(true);
|
||||
setShowHint(true);
|
||||
markHintUsed(level.id);
|
||||
playHint();
|
||||
};
|
||||
|
||||
const handleCheck = () => {
|
||||
@@ -275,6 +279,9 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
||||
|
||||
if (stars >= 1) {
|
||||
completeLevel(level.id, stars);
|
||||
playLevelComplete();
|
||||
} else {
|
||||
playFail();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -284,7 +291,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
||||
<div className="gm-puzzle">
|
||||
{/* Top bar */}
|
||||
<div className="gm-puzzle-bar">
|
||||
<button className="gm-btn icon" onClick={onBack}>← Mapa</button>
|
||||
<button className="gm-btn icon" onClick={() => { playNav(); onBack(); }}>← Mapa</button>
|
||||
<div className="gm-puzzle-title">
|
||||
<span className="gm-puzzle-num">{levelIndex + 1}/{worldLevels.length}</span>
|
||||
<span className="gm-puzzle-name">{level.title}</span>
|
||||
@@ -450,7 +457,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
||||
levelTitle={level.title}
|
||||
isLastLevel={isLastLevel}
|
||||
hintPenalty={result.hintPenalty}
|
||||
onRetry={loadLevel}
|
||||
onRetry={() => setResult(null)}
|
||||
onMap={onBack}
|
||||
onNext={onNextLevel}
|
||||
/>
|
||||
|
||||
@@ -5,9 +5,15 @@ 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 { WORLD_7 } from './levels/world7.js';
|
||||
import { WORLD_8 } from './levels/world8.js';
|
||||
import { WORLD_9 } from './levels/world9.js';
|
||||
import { WORLD_10 } from './levels/world10.js';
|
||||
import { WORLD_11 } from './levels/world11.js';
|
||||
import { WORLD_12 } from './levels/world12.js';
|
||||
import { getLevelProgress, isLevelUnlocked, loadProgress } from './gameState.js';
|
||||
|
||||
const worlds = [WORLD_1, WORLD_2, WORLD_3, WORLD_4, WORLD_5, WORLD_6];
|
||||
const worlds = [WORLD_1, WORLD_2, WORLD_3, WORLD_4, WORLD_5, WORLD_6, WORLD_7, WORLD_8, WORLD_9, WORLD_10, WORLD_11, WORLD_12];
|
||||
|
||||
function Stars({ count, max = 3 }) {
|
||||
return (
|
||||
|
||||
500
src/game/levels/world10.js
Normal file
500
src/game/levels/world10.js
Normal file
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* World 10 — "Espacio y Stereo" (Space and Stereo)
|
||||
*
|
||||
* Teaches: Stereo imaging, spatial effects, delay for width, reverb placement
|
||||
* 8 levels, boss challenges with complete stereo mix
|
||||
*/
|
||||
|
||||
export const WORLD_10 = {
|
||||
id: 'w10',
|
||||
name: 'Espacio y Stereo',
|
||||
subtitle: 'Profundidad y dimensión',
|
||||
icon: '◉◉',
|
||||
color: '#44ddaa',
|
||||
unlockStars: 108,
|
||||
levels: [
|
||||
// ─────────────── LEVEL 10.1 ───────────────
|
||||
{
|
||||
id: 'w10-1',
|
||||
title: 'Pan Left-Right',
|
||||
subtitle: 'Los canales estéreo básicos',
|
||||
description: 'La estéreo más simple: coloca una fuente en el canal izquierdo y otra en el derecho. El output tiene dos entradas: "left" y "right". Conecta diferentes osciladores a cada uno.',
|
||||
concept: 'Osc 1 → Output (left). Osc 2 → Output (right). El output tiene dos canales separados. Juntos crean la ilusión de width — como si el sonido viniera de dos lugares diferentes.',
|
||||
availableModules: ['oscillator', 'vca', 'envelope', 'keyboard', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Estéreo básica',
|
||||
desc: 'Dos osciladores, uno al left, uno al right',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 2 || !out) return false;
|
||||
const leftConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
|
||||
const rightConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'right');
|
||||
return leftConn && rightConn;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Estéreo con VCA',
|
||||
desc: 'Cada oscilador con su VCA antes de output',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const vcas = mods.filter(m => m.type === 'vca');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 2 || vcas.length < 2 || !out) return false;
|
||||
const leftConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
|
||||
const rightConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'right');
|
||||
return leftConn && rightConn &&
|
||||
oscs.some(o => conns.some(c => c.from.moduleId === o.id && c.to.moduleId === vcas[0].id));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Estéreo Controlada',
|
||||
desc: 'Oscs left/right con envelopes separados gateados por keyboard',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const vcas = mods.filter(m => m.type === 'vca');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 2 || vcas.length < 2 || envs.length < 2 || !kb || !out) return false;
|
||||
const leftConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
|
||||
const rightConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'right');
|
||||
const gated = envs.filter(e =>
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === e.id && c.to.port === 'gate')
|
||||
);
|
||||
return leftConn && rightConn && gated.length >= 2;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 10.2 ───────────────
|
||||
{
|
||||
id: 'w10-2',
|
||||
title: 'Stereo Detune',
|
||||
subtitle: 'Ancho con osciladores diferentes',
|
||||
description: 'Coloca el mismo oscilador en ambos canales pero detuned: izquierda a la frecuencia exacta, derecha con un pequeño detune (+5 a +15 cents). Crea un "chorus" natural que te envuelve.',
|
||||
concept: 'Osc 1 (detune 0) → Left. Osc 2 (detune +7) a misma nota → Right. Cuando están cerca pero no iguales, el beating crea width. Es como tener dos cantantes cantando casi al unísono.',
|
||||
availableModules: ['oscillator', 'vca', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dos osciladores detuned',
|
||||
desc: 'Oscs a misma frecuencia pero con detune diferente',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 2 || !out) return false;
|
||||
const detunes = oscs.map(o => o.params.detune ?? 0);
|
||||
const freqs = oscs.map(o => o.params.frequency ?? 440);
|
||||
const sameFreq = Math.abs(freqs[0] - freqs[1]) < 10;
|
||||
const differentDetune = Math.abs(detunes[0] - detunes[1]) > 3;
|
||||
return sameFreq && differentDetune;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Stereo width audible',
|
||||
desc: 'Detune entre oscs > 5 cents para efecto chorus',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
if (oscs.length < 2) return false;
|
||||
const detunes = oscs.map(o => o.params.detune ?? 0);
|
||||
return Math.abs(detunes[0] - detunes[1]) > 5;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Chorus Estéreo',
|
||||
desc: 'Detuned oscs left/right con VCAs y envelopes',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const vcas = mods.filter(m => m.type === 'vca');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 2 || vcas.length < 2 || envs.length < 1 || !out) return false;
|
||||
const detunes = oscs.map(o => o.params.detune ?? 0);
|
||||
const freqs = oscs.map(o => o.params.frequency ?? 440);
|
||||
const leftConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
|
||||
const rightConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'right');
|
||||
return Math.abs(freqs[0] - freqs[1]) < 10 &&
|
||||
Math.abs(detunes[0] - detunes[1]) > 5 &&
|
||||
leftConn && rightConn;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 10.3 ───────────────
|
||||
{
|
||||
id: 'w10-3',
|
||||
title: 'Delay para Ancho',
|
||||
subtitle: 'La profundidad del eco',
|
||||
description: 'El delay es uno de los mejores trucos para width: copia la señal, la envía al otro canal con un pequeño delay (20-80ms). El cerebro interpreta esto como "la misma fuente reflejada en espacio".',
|
||||
concept: 'Osc → Left (seco). Osc → Delay (15-50ms) → Right. El delay crea la ilusión de distancia. Cuanto más delay, más separación. Mantén el feedback bajo para evitar caos.',
|
||||
availableModules: ['oscillator', 'vca', 'delay', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Delay en señal',
|
||||
desc: 'Oscilador → Delay → Output',
|
||||
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: 'Delay corto',
|
||||
desc: 'Delay con tiempo entre 20-80ms',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
if (!del) return false;
|
||||
const time = del.params.delayTime ?? 0.3;
|
||||
return time >= 0.02 && time <= 0.08;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Delay Estéreo',
|
||||
desc: 'Osc left + Osc/Delay right con envelopes',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 1 || !del || !out) return false;
|
||||
const time = del.params.delayTime ?? 0.3;
|
||||
const leftConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
|
||||
const rightConn = conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out.id && c.to.port === 'right');
|
||||
return time >= 0.015 && time <= 0.1 &&
|
||||
(del.params.feedback ?? 0.4) < 0.5 &&
|
||||
leftConn && rightConn;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 10.4 ───────────────
|
||||
{
|
||||
id: 'w10-4',
|
||||
title: 'Reverb Corta',
|
||||
subtitle: 'La sala pequeña',
|
||||
description: 'Una reverb corta (decay 1-2s) simula una habitación pequeña. No es mucha cola, solo lo suficiente para darle "espacio" al sonido sin que desaparezca en la distancia. Perfecto para síntesis.',
|
||||
concept: 'Osc → VCA → Reverb (decay 1-2s, wet 0.3-0.5) → Output. La reverb enturbia ligeramente el sonido y lo coloca "en una sala". Mantén wet bajo para que no sea un sonido amortiguado.',
|
||||
availableModules: ['oscillator', 'vca', 'reverb', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Reverb en la cadena',
|
||||
desc: 'Osc → Reverb → Output',
|
||||
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: 'Decay corta',
|
||||
desc: 'Reverb con decay entre 1-2 segundos',
|
||||
test: (mods) => {
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (!rev) return false;
|
||||
const decay = rev.params.decay ?? 3;
|
||||
return decay >= 1 && decay <= 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Sala Perfecta',
|
||||
desc: 'Reverb (decay 1-2s, wet 0.3-0.5) + envelope al VCA',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!osc || !vca || !rev || !env) return false;
|
||||
const decay = rev.params.decay ?? 3;
|
||||
const wet = rev.params.wet ?? 0.4;
|
||||
return decay >= 1 && decay <= 2 &&
|
||||
wet >= 0.25 && wet <= 0.6 &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 10.5 ───────────────
|
||||
{
|
||||
id: 'w10-5',
|
||||
title: 'Catedral Reverb',
|
||||
subtitle: 'Los espacios enormes',
|
||||
description: 'Una catedral reverb es lo opuesto: decay largo (3+ segundos), wet alto. El sonido se desvanece lentamente, como si estuvieras en una basílica gigante. Crea atmósfera épica.',
|
||||
concept: 'Osc → VCA → Reverb (decay > 3s, wet > 0.5) → Output. El sonido se desmorona lentamente en el aire. Usa notas largas para aprovechar la cola reverb. ¡Es mágico!',
|
||||
availableModules: ['oscillator', 'vca', 'reverb', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 4 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Reverb larga',
|
||||
desc: 'Reverb con decay > 3 segundos',
|
||||
test: (mods) => {
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (!rev) return false;
|
||||
return (rev.params.decay ?? 3) > 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Reverb mojada',
|
||||
desc: 'Reverb con wet > 0.5 para efecto dramático',
|
||||
test: (mods) => {
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (!rev) return false;
|
||||
return (rev.params.decay ?? 3) > 3 &&
|
||||
(rev.params.wet ?? 0.4) > 0.5;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Catedral Épica',
|
||||
desc: 'Reverb (decay > 4s, wet > 0.6) con envelope lento al VCA',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!osc || !vca || !rev || !env || !kb) return false;
|
||||
return (rev.params.decay ?? 3) > 4 &&
|
||||
(rev.params.wet ?? 0.4) > 0.6 &&
|
||||
(env.params.attack ?? 0.01) < 0.1 &&
|
||||
(env.params.decay ?? 0.2) > 0.5 &&
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id && c.to.port === 'gate');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 10.6 ───────────────
|
||||
{
|
||||
id: 'w10-6',
|
||||
title: 'Slapback Echo',
|
||||
subtitle: 'Doblado rítmico',
|
||||
description: 'El slapback echo es un delay muy corto (100-200ms) sin feedback, que crea un efecto de "doblado" — como si hubiera una copia del sonido muy cerca. Popular en rockabilly y sintetizadores.',
|
||||
concept: 'Osc → Left (seco). Osc → Delay (100-200ms, feedback bajo) → Right. El delay corto mantiene la segunda "voz" identificable pero cercana. Es como tener un doblante.',
|
||||
availableModules: ['oscillator', 'vca', 'delay', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Delay rítmico',
|
||||
desc: 'Delay entre 80-250ms',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
if (!del) return false;
|
||||
const time = del.params.delayTime ?? 0.3;
|
||||
return time >= 0.08 && time <= 0.25;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sin feedback',
|
||||
desc: 'Delay con feedback < 0.2 para no crear repeticiones caóticas',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
if (!del) return false;
|
||||
const time = del.params.delayTime ?? 0.3;
|
||||
const fb = del.params.feedback ?? 0.4;
|
||||
return time >= 0.08 && time <= 0.25 && fb < 0.2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Doblante Perfecto',
|
||||
desc: 'Delay (100-200ms, feedback < 0.1) en stereo left/right',
|
||||
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;
|
||||
const time = del.params.delayTime ?? 0.3;
|
||||
const fb = del.params.feedback ?? 0.4;
|
||||
const leftConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
|
||||
const rightConn = conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out.id && c.to.port === 'right');
|
||||
return time >= 0.1 && time <= 0.2 &&
|
||||
fb < 0.1 &&
|
||||
leftConn && rightConn;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 10.7 ───────────────
|
||||
{
|
||||
id: 'w10-7',
|
||||
title: 'Orden de Efectos',
|
||||
subtitle: 'La cadena de procesamiento',
|
||||
description: 'El orden de los efectos es crítico: ¿delay antes o después de reverb? ¿Filtro antes que distortion? Aquí aprendes a construir cadenas de efectos que suenen coherentes y profesionales.',
|
||||
concept: 'Construye: Osc → Filter → Distortion → Delay → Reverb → Output. Cada efecto transforma el anterior. El filtro quita brillo, distortion añade armónicos, delay añade movimiento, reverb añade espacio.',
|
||||
availableModules: ['oscillator', 'filter', 'distortion', 'delay', 'reverb', 'vca', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 4 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena básica',
|
||||
desc: 'Osc → Filter → Delay → Reverb → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
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 || !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 === flt.id && c.to.moduleId === del.id) &&
|
||||
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === rev.id) &&
|
||||
conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Con distortion',
|
||||
desc: 'Cadena con filtro + distortion + delay + reverb',
|
||||
test: (mods, conns) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (!flt || !dist || !del || !rev) return false;
|
||||
return conns.some(c => c.from.moduleId === dist.id && c.to.moduleId === del.id) ||
|
||||
conns.some(c => c.from.moduleId === dist.id && c.to.moduleId === rev.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Cadena Profesional',
|
||||
desc: 'Osc → Filter → Distortion → Delay → Reverb con envelope',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !dist || !del || !rev || !env || !out) return false;
|
||||
const fltOsc = conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id);
|
||||
const distFlt = conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === dist.id);
|
||||
const delDist = conns.some(c => c.from.moduleId === dist.id && c.to.moduleId === del.id);
|
||||
const revDel = conns.some(c => c.from.moduleId === del.id && c.to.moduleId === rev.id);
|
||||
const outRev = conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === out.id);
|
||||
return fltOsc && distFlt && delDist && revDel && outRev;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 10.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w10-8',
|
||||
title: 'Mix Espacial',
|
||||
subtitle: 'BOSS FINAL: Orquesta Estéreo',
|
||||
description: 'Construye una mezcla estéreo completa con múltiples fuentes, cada una con su propia posición en el espacio. Usa delay, reverb, y pan para colocar cada instrumento. Crea una orquesta de sintetizadores.',
|
||||
concept: 'Múltiples osciladores/fuentes, algunos en left/right, algunos con delay, algunos con reverb, todos controlados por keyboard/sequencer. La mezcla final debe sonar amplia, profunda, y multidimensional.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'mixer', 'lfo', 'envelope', 'keyboard', 'sequencer', 'delay', 'reverb', 'distortion'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 950, y: 160, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 5 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Mezcla funcional',
|
||||
desc: 'Múltiples fuentes en left y right del output',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 2 || !out) return false;
|
||||
const leftConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
|
||||
const rightConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'right');
|
||||
return leftConn && rightConn && conns.length >= 8;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Con efectos espaciales',
|
||||
desc: 'Delay y Reverb en la mezcla creando profundidad',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
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 (oscs.length < 2 || !del || !rev || !out) return false;
|
||||
const delToOut = conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out.id);
|
||||
const revToOut = conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === out.id);
|
||||
return delToOut && revToOut;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Orquesta Completa',
|
||||
desc: '3+ oscs, stereo pan, delay + reverb, filter, envelope, keyboard/sequencer',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flts = mods.filter(m => m.type === 'filter');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 3 || flts.length < 1 || envs.length < 1 || !del || !rev || !out) return false;
|
||||
if (!kb && !seq) return false;
|
||||
const leftConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
|
||||
const rightConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'right');
|
||||
const delToOut = conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out.id);
|
||||
const revToOut = conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === out.id);
|
||||
return leftConn && rightConn && delToOut && revToOut && conns.length >= 15;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
508
src/game/levels/world11.js
Normal file
508
src/game/levels/world11.js
Normal file
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* World 11 — "Técnicas Avanzadas" (Advanced Techniques)
|
||||
*
|
||||
* Teaches: filter self-oscillation, ring modulation, drone textures,
|
||||
* polysynth, sidechain, feedback loops, cross-modulation
|
||||
* 8 levels, boss challenge with experimental patching
|
||||
*/
|
||||
|
||||
export const WORLD_11 = {
|
||||
id: 'w11',
|
||||
name: 'Técnicas Avanzadas',
|
||||
subtitle: 'Dominando el sintetizador',
|
||||
icon: '⚙',
|
||||
color: '#aa55ff',
|
||||
unlockStars: 120,
|
||||
levels: [
|
||||
// ─────────────── LEVEL 11.1 ───────────────
|
||||
{
|
||||
id: 'w11-1',
|
||||
title: 'Oscilación del Filtro',
|
||||
subtitle: 'El filtro se vuelve oscilador',
|
||||
description: 'Cuando subes la resonancia (Q) de un filtro lowpass al máximo, el filtro se auto-oscila y produce un tono puro. Es como un oscilador oculto dentro del filtro. Al modular la frecuencia de corte, obtienes un sintetizador completamente nuevo.',
|
||||
concept: 'Noise → Filter LP con Q muy alto (>8) → VCA → Output. Envelope al VCA. LFO o Keyboard al cutoff del filtro. La oscilación del filtro crea tonos puros sin necesidad de oscilador.',
|
||||
availableModules: ['noise', 'filter', 'vca', 'envelope', 'lfo', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Filtro resonante',
|
||||
desc: 'Noise → Filter LP con Q alto (>5) → VCA → Output',
|
||||
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 out = mods.find(m => m.type === 'output');
|
||||
if (!noise || !flt || !vca || !out) return false;
|
||||
return flt.params.type === 'lowpass' && (flt.params.Q ?? 1) > 5 &&
|
||||
conns.some(c => c.from.moduleId === noise.id && c.to.moduleId === flt.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Auto-oscilación',
|
||||
desc: 'Filtro con Q > 8 para oscilación clara',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
return flt && flt.params.type === 'lowpass' && (flt.params.Q ?? 1) > 8;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Sintetizador por Filtro',
|
||||
desc: 'Q > 9, LFO o Keyboard al cutoff, envelope al VCA',
|
||||
test: (mods, conns) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!flt || !env) return false;
|
||||
const hasModulation = (lfo && conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff')) ||
|
||||
(kb && conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === flt.id && c.to.port === 'cutoff'));
|
||||
return (flt.params.Q ?? 1) > 9 && hasModulation &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 11.2 ───────────────
|
||||
{
|
||||
id: 'w11-2',
|
||||
title: 'Modulación en Anillo',
|
||||
subtitle: 'VCA como multiplicador',
|
||||
description: 'La modulación en anillo es un efecto clásico que surge de multiplicar dos señales de audio. Se simula aquí usando un VCA: una señal en "in" y un LFO/oscilador rápido en "cv". El resultado son frecuencias de suma y resta (sidebands).',
|
||||
concept: 'Osc1 → VCA. Osc2 rápido o LFO → cv del VCA. VCA → Mixer o directamente a Output. El VCA actúa como "multiplicador" creando tonos nuevos inarmónicos.',
|
||||
availableModules: ['oscillator', 'lfo', 'vca', 'mixer', 'filter', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dos osciladores',
|
||||
desc: 'Osc1 al in del VCA, Osc2/LFO al cv',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (oscs.length < 1 || !vca || (!lfo && oscs.length < 2)) return false;
|
||||
const hasInput = oscs.some(o => conns.some(c => c.from.moduleId === o.id && c.to.moduleId === vca.id && c.to.port === 'in'));
|
||||
const hasCV = (lfo && conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === vca.id && c.to.port === 'cv')) ||
|
||||
(oscs.length >= 2 && oscs.some(o => conns.some(c => c.from.moduleId === o.id && c.to.moduleId === vca.id && c.to.port === 'cv')));
|
||||
return hasInput && hasCV;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sonido ruidoso',
|
||||
desc: 'LFO/Osc rápido modulando el VCA (frecuencias inarmónicas)',
|
||||
test: (mods, conns) => {
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
if (!vca) return false;
|
||||
const hasRingMod = (lfo && conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === vca.id && c.to.port === 'cv')) ||
|
||||
(oscs.length >= 2);
|
||||
return hasRingMod && conns.some(c => c.to.moduleId === vca.id && c.to.port === 'in');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Modulación en Anillo completa',
|
||||
desc: 'Dos oscs con frecuencias diferentes, VCA como ring mod, sonidos inarmónicos claros',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (oscs.length < 2 || !vca) return false;
|
||||
const hasInput = oscs.some(o => conns.some(c => c.from.moduleId === o.id && c.to.moduleId === vca.id && c.to.port === 'in'));
|
||||
const hasCV = oscs.some(o => conns.some(c => c.from.moduleId === o.id && c.to.moduleId === vca.id && c.to.port === 'cv'));
|
||||
const freq1 = oscs[0].params.frequency ?? 440;
|
||||
const freq2 = oscs[1].params.frequency ?? 440;
|
||||
return hasInput && hasCV && Math.abs(freq1 - freq2) > 50;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 11.3 ───────────────
|
||||
{
|
||||
id: 'w11-3',
|
||||
title: 'Texturas de Drone',
|
||||
subtitle: 'Sonido que evoluciona lentamente',
|
||||
description: 'Un drone es un sonido constante que evoluciona gradualmente. Se crea con osciladores a tonos bajos, múltiples LFOs muy lentos modulando filtros y amplitud, creando texturas hipnóticas que cambian imperceptiblemente.',
|
||||
concept: 'Dos oscs sine bajos (~50-100 Hz) detuned. Mixer → Filter LP. LFOs muy lentos (~0.1-0.5 Hz) al cutoff, amplitud. Reverb largo. Sin gates ni envelopes percusivos — todo fluye continuamente.',
|
||||
availableModules: ['oscillator', 'filter', 'lfo', 'mixer', 'reverb', 'vca'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 5 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Osciladores graves',
|
||||
desc: 'Dos oscs sine < 120 Hz mezclados',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sine');
|
||||
if (oscs.length < 2) return false;
|
||||
return oscs.filter(o => (o.params.frequency ?? 440) < 120).length >= 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Evolución lenta',
|
||||
desc: 'LFO lento (<1 Hz) modulando el 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;
|
||||
const isLowFreq = (lfo.params.frequency ?? 2) < 1;
|
||||
const toFilter = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
return isLowFreq && toFilter;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Drone hipnótico',
|
||||
desc: '2 oscs sine detuned bajos, filtro LP, 2+ LFOs muy lentos, reverb largo',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sine');
|
||||
const lfos = mods.filter(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (oscs.length < 2 || lfos.length < 2 || !flt || !rev) return false;
|
||||
const graveBoth = oscs.filter(o => (o.params.frequency ?? 440) < 120).length >= 2;
|
||||
const hasDetune = oscs.some(o => Math.abs(o.params.detune ?? 0) > 2);
|
||||
const slowLfos = lfos.filter(l => (l.params.frequency ?? 2) < 1).length >= 2;
|
||||
const reverbLong = (rev.params.decay ?? 2) > 3;
|
||||
return graveBoth && hasDetune && slowLfos && reverbLong;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 11.4 ───────────────
|
||||
{
|
||||
id: 'w11-4',
|
||||
title: 'Polifonía',
|
||||
subtitle: 'Múltiples voces simultáneamente',
|
||||
description: 'La polifonía significa tocar múltiples notas simultáneamente. En sintética, usas el keyboard con múltiples canales (oscs + envelopes) para que cada nota presionada active una "voz". Cada voz tiene su propio envelope y filtro.',
|
||||
concept: 'Cuatro "voces": cada una es Osc → Filter → VCA. Todas conectan a un Mixer → Output. Keyboard conectado a la freq de todos los oscs Y al gate de todos los envelopes. Así toca 4 notas a la vez.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'mixer', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 4 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Múltiples voces',
|
||||
desc: 'Al menos 3 oscs conectados al keyboard',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (oscs.length < 3 || !kb) return false;
|
||||
const connectedToKb = oscs.filter(o =>
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === o.id && c.to.port === 'freq')
|
||||
).length;
|
||||
return connectedToKb >= 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Voces completas',
|
||||
desc: '3+ oscs, cada uno pasa por filter + VCA, todos al mixer',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flts = mods.filter(m => m.type === 'filter');
|
||||
const vcas = mods.filter(m => m.type === 'vca');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
if (oscs.length < 3 || flts.length < 3 || vcas.length < 3 || !mixer) return false;
|
||||
// Each osc should go through a filter and VCA
|
||||
let voiceCount = 0;
|
||||
oscs.forEach(o => {
|
||||
const hasFilter = conns.some(c => c.from.moduleId === o.id && c.to.moduleId === flts.find(f => true)?.id);
|
||||
if (hasFilter) voiceCount++;
|
||||
});
|
||||
return voiceCount >= 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Polisintetizador',
|
||||
desc: '4+ voces (osc+filter+vca), keyboard a freq Y gates, todos mezclados, envelopes',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
if (oscs.length < 4 || envs.length < 3 || !kb || !mixer) return false;
|
||||
// Keyboard controls freq of oscs
|
||||
const kbFreq = oscs.filter(o =>
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === o.id && c.to.port === 'freq')
|
||||
).length;
|
||||
// Keyboard controls gates
|
||||
const kbGates = envs.filter(e =>
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === e.id && c.to.port === 'gate')
|
||||
).length;
|
||||
return kbFreq >= 3 && kbGates >= 3 && conns.length >= 12;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 11.5 ───────────────
|
||||
{
|
||||
id: 'w11-5',
|
||||
title: 'Sidechain Simulation',
|
||||
subtitle: 'Bajar el volumen al ritmo',
|
||||
description: 'El sidechain es un efecto donde el volumen (amplitud) baja en ritmo con algo — típicamente un beat. Se simula aquí con un envelope o LFO de ritmo rápido que controla un VCA, creando "ducks" de volumen.',
|
||||
concept: 'Osc → Filter → VCA. Un segundo envelope (o sequencer) con ataque/decay rápidos controla la amplitud del VCA. Cada tiempo que el sidechain se "abre", suena; cuando "cierra", se silencia. Efecto de "bomba".',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'sequencer', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'VCA modulado',
|
||||
desc: 'Envelope o Sequencer al cv del VCA',
|
||||
test: (mods, conns) => {
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
if (!vca || (!env && !seq)) return false;
|
||||
return (env && conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv')) ||
|
||||
(seq && conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === vca.id && c.to.port === 'cv'));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Ritmo percibible',
|
||||
desc: 'Envelope decay rápido (< 0.3s) para efecto "pump"',
|
||||
test: (mods) => {
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
return envs.some(e => (e.params.decay ?? 0.2) < 0.3);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Sidechain completo',
|
||||
desc: 'Osc → Filter → VCA. Envelope rápido (< 0.3s) al cv, efecto pump clara',
|
||||
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');
|
||||
if (!osc || !flt || !vca || !env) return false;
|
||||
const pump = (env.params.decay ?? 0.2) < 0.3 && (env.params.attack ?? 0.01) < 0.05;
|
||||
const toVca = conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
return pump && toVca && conns.length >= 4;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 11.6 ───────────────
|
||||
{
|
||||
id: 'w11-6',
|
||||
title: 'Bucles de Retroalimentación',
|
||||
subtitle: 'Caos controlado con feedback',
|
||||
description: 'Al conectar la salida de un efecto (delay, reverb) de vuelta a su entrada, creas retroalimentación. Con los parámetros justos, genera texturas evolucionando lentamente. Con los parámetros equivocados, ¡explosión sónica!',
|
||||
concept: 'Osc → Filter → Delay. Salida del delay vuelve a su propia entrada (feedback alto 0.7-0.9). Reverb después del delay. Envelope muy largo para dejar que el feedback crezca. Los sonidos se multiplican y transforman constantemente.',
|
||||
availableModules: ['oscillator', 'filter', 'delay', 'reverb', 'vca', 'envelope'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 4 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Delay en la cadena',
|
||||
desc: 'Osc → Filter → Delay → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !del || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === del.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Feedback observable',
|
||||
desc: 'Delay con feedback > 0.5 para retroalimentación clara',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
return del && (del.params.feedback ?? 0.4) > 0.5;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Texturas evolucionando',
|
||||
desc: 'Osc → Filtro → Delay (fb > 0.7) → Reverb, envelope largo, sonido crece y cambia',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!osc || !flt || !del || !rev || !env) return false;
|
||||
const highFb = (del.params.feedback ?? 0.4) > 0.7;
|
||||
const longEnv = (env.params.decay ?? 0.2) > 0.5;
|
||||
const chainOk = conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === del.id) &&
|
||||
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === rev.id);
|
||||
return highFb && longEnv && chainOk;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 11.7 ───────────────
|
||||
{
|
||||
id: 'w11-7',
|
||||
title: 'Cross-Modulación',
|
||||
subtitle: 'LFOs modulándose entre sí',
|
||||
description: 'Cuando un LFO modula la frecuencia de otro LFO, creas patrones dinámicos impredecibles. Cuando un LFO modula la amplitud de otro, creas "breathing" de amplitud. Combines esto con osciladores para sonar experimental y alienígena.',
|
||||
concept: 'LFO1 lento (0.5 Hz) → modula freq del LFO2. LFO2 más rápido (4 Hz) → modula cutoff del filtro. Osc grave → Filter → Output. El patrón del filtro cambia constantemente porque LFO2 está siendo modulado.',
|
||||
availableModules: ['oscillator', 'filter', 'lfo', 'vca'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'LFO al LFO',
|
||||
desc: 'Un LFO modulando la frecuencia de otro',
|
||||
test: (mods, conns) => {
|
||||
const lfos = mods.filter(m => m.type === 'lfo');
|
||||
if (lfos.length < 2) return false;
|
||||
return lfos.some(l => conns.some(c =>
|
||||
c.from.moduleId === l.id && c.to.moduleId === lfos.find(x => x.id !== l.id)?.id && c.to.port === 'frequency'
|
||||
));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Modulación en cascada',
|
||||
desc: 'LFO modulado a otro LFO, ese LFO modula filter cutoff',
|
||||
test: (mods, conns) => {
|
||||
const lfos = mods.filter(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (lfos.length < 2 || !flt) return false;
|
||||
const hasLfoToLfo = conns.some(c =>
|
||||
c.from.moduleId === lfos[0].id && c.to.moduleId === lfos[1].id
|
||||
);
|
||||
const hasLfoToFilter = conns.some(c =>
|
||||
c.from.moduleId === lfos[1].id && c.to.moduleId === flt.id && c.to.port === 'cutoff'
|
||||
);
|
||||
return hasLfoToLfo && hasLfoToFilter;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Cross-Mod experimental',
|
||||
desc: 'LFO lento (< 1 Hz) modula freq de LFO rápido (> 3 Hz), cutoff oscila dinámicamente',
|
||||
test: (mods, conns) => {
|
||||
const lfos = mods.filter(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (lfos.length < 2 || !flt) return false;
|
||||
const slowLfo = lfos.find(l => (l.params.frequency ?? 2) < 1);
|
||||
const fastLfo = lfos.find(l => (l.params.frequency ?? 2) > 3);
|
||||
if (!slowLfo || !fastLfo) return false;
|
||||
const crossMod = conns.some(c =>
|
||||
c.from.moduleId === slowLfo.id && c.to.moduleId === fastLfo.id && c.to.port === 'frequency'
|
||||
);
|
||||
const toFilter = conns.some(c =>
|
||||
c.from.moduleId === fastLfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff'
|
||||
);
|
||||
return crossMod && toFilter;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 11.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w11-8',
|
||||
title: 'Patch Experimental',
|
||||
subtitle: 'BOSS FINAL: Sin límites de creatividad',
|
||||
description: 'Has dominado las técnicas avanzadas. Ahora construye el patch más experimental, raro y creativo que puedas. Combina oscilaciones de filtro, modulación en anillo, feedback caótico, modulación cruzada... ¡Sin restricciones!',
|
||||
concept: 'Toma todo lo aprendido: self-oscillation, ring mod, drones, polifonía, sidechain, feedback, cross-mod. Combina al menos 3 técnicas avanzadas diferentes en un solo patch. 10+ módulos, 15+ conexiones. ¡Sorpréndete a ti mismo!',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'noise', 'keyboard', 'delay', 'reverb', 'distortion', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 950, y: 160, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 5 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Patch complejo',
|
||||
desc: 'Al menos 8 módulos, 10+ conexiones, sonido sólido',
|
||||
test: (mods, conns) => {
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!out) return false;
|
||||
const nonOutput = mods.filter(m => m.type !== 'output');
|
||||
const hasOutput = conns.some(c => c.to.moduleId === out.id);
|
||||
return nonOutput.length >= 8 && conns.length >= 10 && hasOutput;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Técnicas avanzadas',
|
||||
desc: 'Al menos 2 técnicas avanzadas reconocibles (self-osc, ring mod, feedback, cross-mod, etc)',
|
||||
test: (mods, conns) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const lfos = mods.filter(m => m.type === 'lfo');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
|
||||
let techCount = 0;
|
||||
// Self-oscillation check
|
||||
if (flt && (flt.params.Q ?? 1) > 7) techCount++;
|
||||
// Feedback loop
|
||||
if (del && (del.params.feedback ?? 0.4) > 0.6) techCount++;
|
||||
// Cross-mod (LFO to LFO)
|
||||
if (lfos.length >= 2 && conns.some(c =>
|
||||
lfos.some(l1 => lfos.some(l2 => l1.id !== l2.id && c.from.moduleId === l1.id && c.to.moduleId === l2.id))
|
||||
)) techCount++;
|
||||
// Ring mod (VCA as ring mod)
|
||||
if (vca && conns.some(c => c.to.moduleId === vca.id && c.to.port === 'cv') &&
|
||||
conns.some(c => c.to.moduleId === vca.id && c.to.port === 'in')) techCount++;
|
||||
|
||||
return techCount >= 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Maestro Avanzado',
|
||||
desc: '10+ módulos, 15+ conexiones, 3+ técnicas avanzadas, mixer, efectos, sonido único',
|
||||
test: (mods, conns) => {
|
||||
const nonOutput = mods.filter(m => m.type !== 'output');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const lfos = mods.filter(m => m.type === 'lfo');
|
||||
|
||||
if (nonOutput.length < 10 || !mixer || effects.length === 0 || conns.length < 15) return false;
|
||||
|
||||
let techCount = 0;
|
||||
if (flt && (flt.params.Q ?? 1) > 7) techCount++;
|
||||
if (del && (del.params.feedback ?? 0.4) > 0.6) techCount++;
|
||||
if (lfos.length >= 2) techCount++;
|
||||
|
||||
return techCount >= 3;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
500
src/game/levels/world12.js
Normal file
500
src/game/levels/world12.js
Normal file
@@ -0,0 +1,500 @@
|
||||
/**
|
||||
* World 12 — "Gran Final" (Grand Finale)
|
||||
*
|
||||
* Teaches: building a complete track from start to finish
|
||||
* 8 levels creating a full production: intro, drop, lead, breakdown, build-up, mix, outro
|
||||
* boss challenge: create a complete musical piece with scope visualization
|
||||
*/
|
||||
|
||||
export const WORLD_12 = {
|
||||
id: 'w12',
|
||||
name: 'Gran Final',
|
||||
subtitle: 'Tu obra maestra',
|
||||
icon: '♛',
|
||||
color: '#ffd700',
|
||||
unlockStars: 132,
|
||||
levels: [
|
||||
// ─────────────── LEVEL 12.1 ───────────────
|
||||
{
|
||||
id: 'w12-1',
|
||||
title: 'Intro Ambiental',
|
||||
subtitle: 'Comenzando suavemente',
|
||||
description: 'Toda gran pista comienza con una introducción ambiental. Crea una atmósfera con pads, sonidos largos y efectos de reverb/delay. Sin ritmo fuerte, solo texturas flotantes.',
|
||||
concept: 'Dos oscs sine graves detuned + Mixer → Filter LP → VCA con envelope muy largo → Reverb → Output. LFO lento al cutoff. Sin percusión, puro ambiente. Cero attack, máximo sustain.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'reverb'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 5 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Pad ambiental',
|
||||
desc: '2 oscs sine grave + reverb largo',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sine');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (oscs.length < 2 || !rev) return false;
|
||||
return oscs.filter(o => (o.params.frequency ?? 440) < 150).length >= 2 &&
|
||||
(rev.params.decay ?? 2) > 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Evolución lenta',
|
||||
desc: 'LFO < 1 Hz modulando cutoff, envelope muy largo (decay > 1s)',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!lfo || !env) return false;
|
||||
return (lfo.params.frequency ?? 2) < 1 &&
|
||||
(env.params.decay ?? 0.2) > 1 &&
|
||||
(env.params.sustain ?? 0.5) > 0.4;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Intro hipnótica',
|
||||
desc: '2+ oscs detuned, filter LP, LFO lento al cutoff, reverb > 4s, envelope attack 0',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sine');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (oscs.length < 2 || !flt || !lfo || !rev || !env) return false;
|
||||
const graveLong = oscs.filter(o => (o.params.frequency ?? 440) < 150).length >= 2;
|
||||
const hasDetune = oscs.some(o => Math.abs(o.params.detune ?? 0) > 2);
|
||||
const slowLfo = (lfo.params.frequency ?? 2) < 1;
|
||||
const longRev = (rev.params.decay ?? 2) > 4;
|
||||
const niceEnv = (env.params.attack ?? 0.01) < 0.05 && (env.params.decay ?? 0.2) > 1;
|
||||
const lfoToFilter = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
return graveLong && hasDetune && slowLfo && longRev && niceEnv && lfoToFilter;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 12.2 ───────────────
|
||||
{
|
||||
id: 'w12-2',
|
||||
title: 'El Drop',
|
||||
subtitle: 'Entra el beat con fuerza',
|
||||
description: 'Después de la intro, llega el drop: un cambio dramático donde entra el kick, snare y bass graves. Es el momento de tensión y energía. Combina un bass grave con un beat de síntesis.',
|
||||
concept: 'Dos elementos: 1) Drum: Osc sine grave (~55 Hz) con envelope rápido (attack 0, decay 0.2). 2) Bass: Oscs sawtooth detuned, filtro LP abierto, sonido gordo y agresivo. Sequencer para el ritmo.',
|
||||
availableModules: ['oscillator', 'vca', 'envelope', 'mixer', 'filter', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 4 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Kick + Bass',
|
||||
desc: 'Osc grave con envelope corto (kick) + osc grave para bass',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (oscs.length < 2 || !vca || !env) return false;
|
||||
const graveOscs = oscs.filter(o => (o.params.frequency ?? 440) < 100);
|
||||
return graveOscs.length >= 2 && (env.params.decay ?? 0.2) < 0.3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Ritmo percibible',
|
||||
desc: 'Sequencer conectado, beat claro con kick percusivo',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!seq || !osc || !env) return false;
|
||||
return conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === osc.id) &&
|
||||
(env.params.decay ?? 0.2) < 0.25;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Drop potente',
|
||||
desc: 'Kick < 80 Hz decay < 0.2s, bass sawtooth detuned, sequencer, sonido gordo y fuerte',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (oscs.length < 2 || !seq || !env) return false;
|
||||
const kickOsc = oscs.find(o => (o.params.frequency ?? 440) < 80);
|
||||
const sawOscs = oscs.filter(o => o.params.waveform === 'sawtooth');
|
||||
const hasDetune = sawOscs.some(o => Math.abs(o.params.detune ?? 0) > 3);
|
||||
const fastKick = (env.params.decay ?? 0.2) < 0.2;
|
||||
const seqConnected = conns.some(c => c.from.moduleId === seq.id);
|
||||
return kickOsc && sawOscs.length > 0 && hasDetune && fastKick && seqConnected;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 12.3 ───────────────
|
||||
{
|
||||
id: 'w12-3',
|
||||
title: 'Lead Melódico',
|
||||
subtitle: 'Melodía protagonista',
|
||||
description: 'Usa el piano roll para crear una melodía líder que brille sobre el bass. El lead es típicamente un solo sintetizado con oscilador brillante, filtro modulado y reverb para espaciosidad.',
|
||||
concept: 'Piano roll → Osc square/bright → Filter LP con resonancia → VCA → Reverb → Mixer. Envelope para notas definidas (attack corto, decay/sustain para "peso"). LFO lento al cutoff para movimiento.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'lfo', 'pianoroll', 'reverb', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 850, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 5 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Melodía activa',
|
||||
desc: 'Piano roll conectado a osc, notas reproducidas',
|
||||
test: (mods, conns) => {
|
||||
const pr = mods.find(m => m.type === 'pianoroll');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
if (!pr || !osc) return false;
|
||||
return conns.some(c => c.from.moduleId === pr.id && c.to.moduleId === osc.id && c.to.port === 'freq');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Lead con carácter',
|
||||
desc: 'Osc square/bright, filter resonante, envelope con ataque corto',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!osc || !flt || !env) return false;
|
||||
const isBright = osc.params.waveform === 'square' || osc.params.waveform === 'sawtooth';
|
||||
const hasResonance = (flt.params.Q ?? 1) > 2;
|
||||
const quickAttack = (env.params.attack ?? 0.01) < 0.05;
|
||||
return isBright && hasResonance && quickAttack;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Lead melódico',
|
||||
desc: 'Piano roll + osc square con filter resonante + LFO al cutoff + reverb, notas claramente escuchables',
|
||||
test: (mods, conns) => {
|
||||
const pr = mods.find(m => m.type === 'pianoroll');
|
||||
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 rev = mods.find(m => m.type === 'reverb');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!pr || !osc || !flt || !lfo || !rev || !env) return false;
|
||||
const prConnected = conns.some(c => c.from.moduleId === pr.id && c.to.moduleId === osc.id && c.to.port === 'freq');
|
||||
const gateConnected = conns.some(c => c.from.moduleId === pr.id && c.to.moduleId === env.id && c.to.port === 'gate');
|
||||
const lfoToFilter = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
return prConnected && gateConnected && lfoToFilter && (rev.params.decay ?? 2) > 2;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 12.4 ───────────────
|
||||
{
|
||||
id: 'w12-4',
|
||||
title: 'Breakdown',
|
||||
subtitle: 'Menos es más',
|
||||
description: 'El breakdown es una sección donde quitas elementos clave para crear contraste. Quitas el kick, quitas el bass pesado, dejas solo los pads suaves o un synth secundario. Construye anticipación para el regreso.',
|
||||
concept: 'Calla el kick y bass de secciones previas. Deja solo pads suaves, lead melódico suave, y efectos. Opcional: introduce un elemento nuevo y suave (strings sintéticos, pad etéreo). Todo con reverb abundante.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'lfo', 'mixer', 'reverb', 'pianoroll'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 850, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 5 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Sonido suave',
|
||||
desc: 'Oscs sine/pads, sin percusión aguda, reverb presente',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (!rev || oscs.length < 1) return false;
|
||||
const sines = oscs.filter(o => o.params.waveform === 'sine');
|
||||
return sines.length >= 1 && (rev.params.decay ?? 2) > 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Atmósfera ambiental',
|
||||
desc: 'Múltiples layers suaves, LFO modulando filtro, no hay kicks agudos',
|
||||
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');
|
||||
if (oscs.length < 2 || !flt || !lfo) return false;
|
||||
const softOscs = oscs.filter(o => (o.params.frequency ?? 440) < 200);
|
||||
return softOscs.length >= 1 &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Breakdown perfecto',
|
||||
desc: '2+ oscs suaves, filtro con LFO, envelope largo, reverb > 3s, sonido flotante y aéreo',
|
||||
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 rev = mods.find(m => m.type === 'reverb');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (oscs.length < 2 || !flt || !lfo || !rev || !env) return false;
|
||||
const softOscs = oscs.filter(o => (o.params.frequency ?? 440) < 200);
|
||||
const longEnv = (env.params.decay ?? 0.2) > 1 && (env.params.sustain ?? 0.5) > 0.3;
|
||||
const lfoToFilter = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
return softOscs.length >= 2 && longEnv && lfoToFilter && (rev.params.decay ?? 2) > 3;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 12.5 ───────────────
|
||||
{
|
||||
id: 'w12-5',
|
||||
title: 'Build-Up',
|
||||
subtitle: 'La tensión sube',
|
||||
description: 'El build-up es donde añades elementos gradualmente para construir tensión. Comienzas minimal, y lentamente añades más capas: pads, bass, efectos, filtros abriendo. La audiencia siente que algo grande viene.',
|
||||
concept: 'Empieza con un LFO lento abriendo un filtro sobre un oscilador suave. Gradualmente: añade un segundo osc, un tercer osc, baja el cutoff, suena más agresivo. El sequencer acelera. La reverb se vuelve más agresiva (menos decay).',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'reverb', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 850, y: 140, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 5 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Tensión creciente',
|
||||
desc: 'LFO modulando filter cutoff, sonido evoluciona',
|
||||
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: 2,
|
||||
name: 'Múltiples layers',
|
||||
desc: '3+ oscs, filtro con LFO, sonido más agresivo que intro',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
if (oscs.length < 3 || !flt) return false;
|
||||
const hasSeq = seq && conns.some(c => c.from.moduleId === seq.id);
|
||||
return hasSeq;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Build-Up intenso',
|
||||
desc: '3+ oscs, LFO lento al cutoff, sequencer activo, reverb < 2s (más seco), sonido cresce',
|
||||
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 seq = mods.find(m => m.type === 'sequencer');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (oscs.length < 3 || !flt || !lfo || !seq) return false;
|
||||
const slowLfo = (lfo.params.frequency ?? 2) < 1;
|
||||
const dryReverb = rev && (rev.params.decay ?? 2) < 2.5;
|
||||
const seqConnected = conns.some(c => c.from.moduleId === seq.id);
|
||||
const lfoToFilter = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
return slowLfo && seqConnected && lfoToFilter && conns.length >= 10;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 12.6 ───────────────
|
||||
{
|
||||
id: 'w12-6',
|
||||
title: 'Mix Completo',
|
||||
subtitle: 'Todos los elementos unidos',
|
||||
description: 'Ahora mezcla todo: intro, drop, lead, breakdown, build-up. Todos los elementos están presentes. El desafío es balancear los volúmenes para que nada se ahogue. Usa un mixer y output con gain correcto.',
|
||||
concept: 'Enruta todos los elementos de secciones anteriores a un único mixer. Todos los canales del mixer contribuyen al sonido final. Ajusta los gains del mixer y output para balance: nada clipeado, nada muy suave. Sonido cohesivo.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'reverb', 'sequencer', 'pianoroll'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 950, y: 160, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 6 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Mixer activo',
|
||||
desc: 'Mixer con múltiples entradas, output rellenado',
|
||||
test: (mods, conns) => {
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!mixer || !out) return false;
|
||||
const inputsToMixer = conns.filter(c => c.to.moduleId === mixer.id).length;
|
||||
const mixerToOut = conns.some(c => c.from.moduleId === mixer.id && c.to.moduleId === out.id);
|
||||
return inputsToMixer >= 2 && mixerToOut;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Balance de sonido',
|
||||
desc: 'Múltiples elementos (oscs, reverb, seq, pianoroll) todos en mixer',
|
||||
test: (mods, conns) => {
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const pr = mods.find(m => m.type === 'pianoroll');
|
||||
if (!mixer) return false;
|
||||
const inputCount = conns.filter(c => c.to.moduleId === mixer.id).length;
|
||||
return oscs.length >= 3 && inputCount >= 4;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Mix profesional',
|
||||
desc: '8+ elementos en mixer, sonido balanceado, output -10 a -6dB, 15+ conexiones totales',
|
||||
test: (mods, conns) => {
|
||||
const nonOut = mods.filter(m => m.type !== 'output');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (nonOut.length < 8 || !mixer || !out) return false;
|
||||
const inputsToMixer = conns.filter(c => c.to.moduleId === mixer.id).length;
|
||||
const outVolume = out.params.volume ?? -6;
|
||||
return inputsToMixer >= 5 && outVolume >= -12 && outVolume <= -4 && conns.length >= 15;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 12.7 ───────────────
|
||||
{
|
||||
id: 'w12-7',
|
||||
title: 'Outro Etéreo',
|
||||
subtitle: 'Despedida musical',
|
||||
description: 'El outro es donde se desvanece todo. Quitas elementos poco a poco, quizás repites la intro ambiental, y añades mucha reverb para crear una sensación de distancia y cierre. El sonido debe desvanecer suavemente.',
|
||||
concept: 'Repite elementos de la intro: oscs sine graves detuned, filtro suave, LFO muy lento al cutoff, reverb LARGO (5+ segundos). Envelope con sustain muy bajo para fade suave. Opcional: distorsión suave o delay con feedback para movimiento final.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'reverb', 'delay'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 850, y: 140, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 5 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Reverb largo',
|
||||
desc: 'Reverb con decay > 4s para fade etéreo',
|
||||
test: (mods) => {
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
return rev && (rev.params.decay ?? 2) > 4;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sonido desvanecido',
|
||||
desc: 'Oscs graves, LFO lento, reverb largo, envelope largo sin gates',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (oscs.length < 1 || !lfo || !rev || !env) return false;
|
||||
const softOscs = oscs.filter(o => (o.params.frequency ?? 440) < 150);
|
||||
const slowLfo = (lfo.params.frequency ?? 2) < 0.5;
|
||||
const veryLongRev = (rev.params.decay ?? 2) > 4;
|
||||
return softOscs.length >= 1 && slowLfo && veryLongRev;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Outro perfecto',
|
||||
subtitle: '2+ oscs graves detuned, LFO < 0.5 Hz, reverb > 5s, delay con feedback, sonido flota al silencio',
|
||||
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 rev = mods.find(m => m.type === 'reverb');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (oscs.length < 2 || !flt || !lfo || !rev || !env) return false;
|
||||
const graveDetuned = oscs.filter(o => (o.params.frequency ?? 440) < 150).length >= 2 &&
|
||||
oscs.some(o => Math.abs(o.params.detune ?? 0) > 2);
|
||||
const verySlowLfo = (lfo.params.frequency ?? 2) < 0.5;
|
||||
const veryLongRev = (rev.params.decay ?? 2) > 5;
|
||||
const lfoToFilter = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
return graveDetuned && verySlowLfo && veryLongRev && lfoToFilter;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 12.8: BOSS FINAL ───────────────
|
||||
{
|
||||
id: 'w12-8',
|
||||
title: 'Tu Obra Maestra',
|
||||
subtitle: 'BOSS FINAL: Tu track completa',
|
||||
description: 'Eres un sintetista maestro. Construye una obra musical completa: una pista de principio a fin. Intro, drop, lead, breakdown, build-up, mezcla y outro. Usa el módulo scope para visualizar tu sonido. Sin límites. Solo tu visión.',
|
||||
concept: 'Crea un track de 10+ módulos y 12+ conexiones. Debe tener: keyboard O sequencer, pianoroll para lead, múltiples osciladores, filtros modulados, reverb/delay, y OBLIGATORIO: scope module para visualización. Mixer para balance. Sonido profesional, único y musical.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'reverb', 'delay', 'sequencer', 'pianoroll', 'keyboard', 'scope'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 1000, y: 160, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 8 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Track básica',
|
||||
desc: '10+ módulos, 12+ conexiones, scope presente, sonido a través de output',
|
||||
test: (mods, conns) => {
|
||||
const nonOut = mods.filter(m => m.type !== 'output');
|
||||
const scope = mods.find(m => m.type === 'scope');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (nonOut.length < 10 || !scope || !out) return false;
|
||||
const hasOutput = conns.some(c => c.to.moduleId === out.id);
|
||||
return conns.length >= 12 && hasOutput;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Estructura musical',
|
||||
desc: '4+ secciones reconocibles: lead, bass, pads, efectos. Scope visualiza.',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const pr = mods.find(m => m.type === 'pianoroll');
|
||||
const flt = mods.filter(m => m.type === 'filter');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const scope = mods.find(m => m.type === 'scope');
|
||||
if (oscs.length < 4 || !scope) return false;
|
||||
const hasSequencing = seq || pr;
|
||||
const hasMelody = (pr && conns.some(c => c.from.moduleId === pr.id)) ||
|
||||
(seq && conns.some(c => c.from.moduleId === seq.id));
|
||||
return hasSequencing && flt.length >= 2 && rev && hasMelody;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Masterpiece',
|
||||
desc: '10+ módulos, keyboard/sequencer/pianoroll, 4+ oscs, mixer, 3+ efectos, scope, 15+ conexiones, música profesional',
|
||||
test: (mods, conns) => {
|
||||
const nonOut = mods.filter(m => m.type !== 'output');
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const pr = mods.find(m => m.type === 'pianoroll');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
|
||||
const scope = mods.find(m => m.type === 'scope');
|
||||
if (nonOut.length < 10 || oscs.length < 4 || !mixer || !scope || conns.length < 15) return false;
|
||||
const hasControl = (seq && conns.some(c => c.from.moduleId === seq.id)) ||
|
||||
(pr && conns.some(c => c.from.moduleId === pr.id)) ||
|
||||
(kb && conns.some(c => c.from.moduleId === kb.id));
|
||||
return hasControl && effects.length >= 3 && conns.length >= 15;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
471
src/game/levels/world7.js
Normal file
471
src/game/levels/world7.js
Normal file
@@ -0,0 +1,471 @@
|
||||
/**
|
||||
* World 7 — "Secuencias y Ritmos" (Sequences and Rhythms)
|
||||
*
|
||||
* Teaches: sequencer basics, bass sequences, pluck sequences, filtered sequences,
|
||||
* basic drum machines, swing, effects on sequences
|
||||
* 8 levels + boss challenge: "Beat Completo" (Full beat with bass + drums + effects)
|
||||
*/
|
||||
|
||||
export const WORLD_7 = {
|
||||
id: 'w7',
|
||||
name: 'Secuencias y Ritmos',
|
||||
subtitle: 'Programando patrones',
|
||||
icon: '▦',
|
||||
color: '#ff8800',
|
||||
unlockStars: 72,
|
||||
levels: [
|
||||
// ─────────────── LEVEL 7.1 ───────────────
|
||||
{
|
||||
id: 'w7-1',
|
||||
title: 'Primer Secuenciador',
|
||||
subtitle: 'Notas en secuencia',
|
||||
description: 'El secuenciador es como un metrónomo que dispara notas en un patrón. Cada paso tiene una nota. Conéctalo a un oscilador y tendrás una melodía que se repite.',
|
||||
concept: 'Sequencer → Osc freq. Osc → VCA → Output. Envelope dispara el VCA. El resultado: una melodía secuenciada.',
|
||||
availableModules: ['oscillator', 'vca', 'envelope', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Secuenciador conectado',
|
||||
desc: 'Sequencer → Osc → VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
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 (!seq || !osc || !vca || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === osc.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sonido rítmico',
|
||||
desc: 'Envelope dispara el VCA en cadencia',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (!seq || !env || !vca) return false;
|
||||
return conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === env.id && c.to.port === 'gate') &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Secuencia limpia',
|
||||
desc: 'Sequencer con BPM 140, oscilador sine, envelope corto (decay < 0.2s)',
|
||||
test: (mods) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!seq || !osc || !env) return false;
|
||||
return (seq.params.bpm ?? 140) === 140 &&
|
||||
osc.params.waveform === 'sine' &&
|
||||
(env.params.decay ?? 0.2) < 0.2;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 7.2 ───────────────
|
||||
{
|
||||
id: 'w7-2',
|
||||
title: 'Bajo Secuenciado',
|
||||
subtitle: 'Riffs graves y hipnóticos',
|
||||
description: 'Un riff de bajo es una frase corta repetida. Usa el secuenciador con un oscilador grave para crear un riff clásico — sawtooth detuned, filtro animado, sonido gordo.',
|
||||
concept: 'Secuenciador → Dos oscs saw (~55 Hz) detuned → Filter LP → VCA → Output. Envelope al VCA. LFO lento al cutoff. Hipnótico y gordo.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'sequencer', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Bajo grave',
|
||||
desc: 'Sequencer a oscilador < 100 Hz',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
if (!seq || oscs.length === 0) return false;
|
||||
return oscs.some(o => (o.params.frequency ?? 440) < 100) &&
|
||||
conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === oscs[0].id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Detuned y filtrado',
|
||||
desc: '2 oscs sawtooth detuned, filtro lowpass',
|
||||
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) > 2);
|
||||
return hasDetune && flt.params.type === 'lowpass';
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Riff completo',
|
||||
desc: 'Detuned saws < 70 Hz + LP + LFO al cutoff + envelope corto (decay < 0.3s)',
|
||||
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');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (oscs.length < 2 || !flt || !lfo || !env) return false;
|
||||
const allGrave = oscs.every(o => (o.params.frequency ?? 440) < 70);
|
||||
const hasDetune = oscs.some(o => Math.abs(o.params.detune ?? 0) > 2);
|
||||
const lfoToFlt = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
return allGrave && hasDetune && lfoToFlt && (env.params.decay ?? 0.2) < 0.3;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 7.3 ───────────────
|
||||
{
|
||||
id: 'w7-3',
|
||||
title: 'Sonido Pluck',
|
||||
subtitle: 'Notas percusivas secuenciadas',
|
||||
description: 'Un pluck es una nota corta y percusiva que decae rápido — como una gota de agua. Muy usado en lo que se llama "pluck bass" o "pluck lead". El secuenciador lo lanza en cadencia.',
|
||||
concept: 'Sequencer → Osc freq + Envelope gate. Osc square → Filter LP → VCA → Output. Envelope corto (attack 0, decay ~0.15s). LFO moderado al cutoff. El resultado: un sonido de gota de agua que repica.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 750, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2.5 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Pluck básico',
|
||||
desc: 'Sequencer → Osc → Filter → VCA → Output',
|
||||
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');
|
||||
if (!seq || !osc || !flt || !vca) return false;
|
||||
return conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === osc.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === vca.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Percusivo',
|
||||
desc: 'Envelope muy corto (decay < 0.2s, sustain < 0.1)',
|
||||
test: (mods) => {
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!env) return false;
|
||||
return (env.params.decay ?? 0.2) < 0.2 && (env.params.sustain ?? 0.5) < 0.1;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Pluck líquido',
|
||||
desc: 'Square osc, filtro LP + LFO al cutoff, envelope (attack 0, decay 0.1-0.2s)',
|
||||
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 lfo = mods.find(m => m.type === 'lfo');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!seq || !osc || !flt || !lfo || !env) return false;
|
||||
const decay = env.params.decay ?? 0.2;
|
||||
const lfoToFlt = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
return osc.params.waveform === 'square' && flt.params.type === 'lowpass' &&
|
||||
(env.params.attack ?? 0.01) <= 0.01 && decay >= 0.1 && decay <= 0.2 && lfoToFlt;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 7.4 ───────────────
|
||||
{
|
||||
id: 'w7-4',
|
||||
title: 'Secuencia Filtrada',
|
||||
subtitle: 'Caja de ritmo sintética',
|
||||
description: 'Una variación del secuenciador: envía frecuencias a un filtro en lugar de (o además de) un oscilador. Esto crea sonidos únicos — casi como un sintetizador de ritmos donde el sonido source es fijo pero el filtro lo transforma.',
|
||||
concept: 'Noise → Filter LP → VCA → Output. Sequencer al cutoff del filtro (modula en tiempo real). Envelope al VCA. El resultado: un instrumento de ritmo completamente nuevo.',
|
||||
availableModules: ['noise', 'filter', 'vca', 'envelope', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Noise secuenciado',
|
||||
desc: 'Sequencer modula el cutoff del filtro',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!seq || !noise || !flt) return false;
|
||||
return conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Con envelope',
|
||||
desc: 'Noise → Filter → VCA con envelope al VCA',
|
||||
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 === flt.id && c.to.moduleId === vca.id) &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Caja de ritmo',
|
||||
desc: 'Sequencer 16 steps, filtro con resonancia (Q > 2), envelope corto',
|
||||
test: (mods) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!seq || !flt || !env) return false;
|
||||
return (seq.params.steps ?? '16') === '16' &&
|
||||
(flt.params.Q ?? 1) > 2 &&
|
||||
(env.params.decay ?? 0.2) < 0.15;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 7.5 ───────────────
|
||||
{
|
||||
id: 'w7-5',
|
||||
title: 'Kick Secuenciado',
|
||||
subtitle: 'El corazón del beat',
|
||||
description: 'Ahora combina lo aprendido: usa el secuenciador para disparar un kick drum completo. El kick es simple: oscilador sine grave + envelope rápido. El secuenciador lo mantiene en ritmo.',
|
||||
concept: 'Sequencer gate → Envelope → Osc sine (40-60 Hz) + VCA → Output. El envelope dispara en cada paso. Parecido al kick de la sección anterior, pero secuenciado.',
|
||||
availableModules: ['oscillator', 'vca', 'envelope', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Kick básico',
|
||||
desc: 'Sequencer → Envelope → Osc + VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!seq || !osc || !vca || !env) return false;
|
||||
return conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === env.id && c.to.port === 'gate');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sine grave',
|
||||
desc: 'Oscilador sine < 100 Hz, envelope rápido (decay < 0.4s)',
|
||||
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.waveform === 'sine' &&
|
||||
(osc.params.frequency ?? 440) < 100 &&
|
||||
(env.params.decay ?? 0.2) < 0.4;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: '808 rítmico',
|
||||
desc: 'Sine 40-60 Hz, decay 0.2-0.4s, sequencer 140 BPM',
|
||||
test: (mods) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!seq || !osc || !env) return false;
|
||||
const freq = osc.params.frequency ?? 440;
|
||||
const decay = env.params.decay ?? 0.2;
|
||||
return (seq.params.bpm ?? 140) === 140 &&
|
||||
freq >= 40 && freq <= 60 &&
|
||||
decay >= 0.2 && decay <= 0.4;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 7.6 ───────────────
|
||||
{
|
||||
id: 'w7-6',
|
||||
title: 'Swing y Shuffle',
|
||||
subtitle: 'Humaniza tu beat',
|
||||
description: 'El swing es el parámetro que hace que un beat metrónomico suene más humano — desplaza ligeramente ciertos pasos. El shuffle crea ese groove de jazz o swing hip-hop. El secuenciador tiene ambos.',
|
||||
concept: 'Sequencer con swing > 0 crea una sensación de shuffle. Úsalo en un patrón simple: kick, hi-hat, snare. El resultado: una música que fluye, no una máquina rígida.',
|
||||
availableModules: ['noise', 'filter', 'oscillator', 'vca', 'envelope', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 750, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2.5 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Sequenciador con swing',
|
||||
desc: 'Sequencer con parámetro swing > 0',
|
||||
test: (mods) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
if (!seq) return false;
|
||||
return (seq.params.swing ?? 0) > 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Dos capas de ritmo',
|
||||
desc: 'Al menos 2 fuentes de sonido (kick + hi-hat, por ejemplo)',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const vcas = mods.filter(m => m.type === 'vca');
|
||||
if (!seq) return false;
|
||||
const sources = (oscs.length > 0 ? 1 : 0) + (noise ? 1 : 0);
|
||||
return sources >= 2 && vcas.length >= 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Groove profesional',
|
||||
desc: 'Swing 15+, 2+ fuentes, envelope distintos (uno corto, uno más largo)',
|
||||
test: (mods) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
if (!seq || envs.length < 2) return false;
|
||||
const swing = seq.params.swing ?? 0;
|
||||
const decays = envs.map(e => e.params.decay ?? 0.2);
|
||||
const decayDiff = Math.max(...decays) - Math.min(...decays);
|
||||
return swing >= 15 && decayDiff > 0.05;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 7.7 ───────────────
|
||||
{
|
||||
id: 'w7-7',
|
||||
title: 'Delay en Secuencia',
|
||||
subtitle: 'Ecos secuenciados',
|
||||
description: 'Añade un delay a una secuencia. El delay repite el sonido secuenciado, creando una cola de ecos que se desvanecen. Muy usado en trance, techno y música electrónica para darle profundidad.',
|
||||
concept: 'Secuencia normal → Delay → Output. El delay time se puede sincronizar al BPM del secuenciador para ecos en tiempo. Feedback controla cuántas repeticiones. Wet controla qué tan presente están los ecos.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'sequencer', 'delay'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Delay en cadena',
|
||||
desc: 'Sequencer → Osc → VCA → Delay → Output',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!seq || !del || !out) return false;
|
||||
return conns.some(c => c.to.moduleId === del.id) &&
|
||||
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Con retroalimentación',
|
||||
desc: 'Delay con feedback > 0.3 para ecos múltiples',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
if (!del) return false;
|
||||
return (del.params.feedback ?? 0.4) > 0.3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Eco profundo',
|
||||
desc: 'Delay time 0.2-0.5s, feedback 0.4-0.8, wet > 0.4, filtro en la cadena',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!del || !flt) return false;
|
||||
const delayTime = del.params.delayTime ?? 0.3;
|
||||
const feedback = del.params.feedback ?? 0.4;
|
||||
const wet = del.params.wet ?? 0.5;
|
||||
return delayTime >= 0.2 && delayTime <= 0.5 &&
|
||||
feedback >= 0.4 && feedback <= 0.8 &&
|
||||
wet > 0.4;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 7.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w7-8',
|
||||
title: 'Beat Completo',
|
||||
subtitle: 'BOSS FINAL: Tu canción',
|
||||
description: 'Ahora junta todo: un secuenciador principal, un kick, un hi-hat, un bajo secuenciado y al menos un efecto. Crea un beat completo que suene profesional — ritmo, groove, profundidad.',
|
||||
concept: 'Secuenciador 140 BPM con swing. Kick drum (sine < 60 Hz + envelope rápido). Hi-hat (noise + filter HP + envelope corto). Bajo secuenciado (2 oscs detuned + filter). Delay o reverb. Mixer si es necesario.',
|
||||
availableModules: ['oscillator', 'noise', 'filter', 'vca', 'envelope', 'sequencer', 'mixer', 'delay', 'reverb', 'distortion', 'lfo'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 5 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Beat funcional',
|
||||
desc: 'Sequencer + 3 capas de sonido (kick, hi-hat, bass) → Output',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
const vcas = mods.filter(m => m.type === 'vca');
|
||||
if (!seq || !out || vcas.length < 3) return false;
|
||||
const modCount = mods.filter(m => m.type !== 'output').length;
|
||||
return modCount >= 10 && conns.some(c => c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Kick + Hi-hat + Bajo',
|
||||
desc: 'Oscillator sine + noise + 2 oscs detuned, todos con envelopes',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
if (oscs.length < 3 || !noise || envs.length < 3) return false;
|
||||
const sines = oscs.filter(o => o.params.waveform === 'sine');
|
||||
const detuned = oscs.filter(o => Math.abs(o.params.detune ?? 0) > 2);
|
||||
return sines.length > 0 && detuned.length >= 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Maestro del Ritmo',
|
||||
desc: '140 BPM, swing 15+, kick sine < 60 Hz, hi-hat noise HP > 5000 Hz, bass detuned, delay o reverb',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const sineOscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sine');
|
||||
const detunedOscs = mods.filter(m => m.type === 'oscillator' && Math.abs(m.params.detune ?? 0) > 2);
|
||||
const hpFilter = mods.find(m => m.type === 'filter' && m.params.type === 'highpass');
|
||||
const effects = mods.filter(m => ['delay', 'reverb'].includes(m.type));
|
||||
if (!seq || sineOscs.length === 0 || detunedOscs.length < 2 || !hpFilter || effects.length === 0) return false;
|
||||
const kick = sineOscs.find(o => (o.params.frequency ?? 440) < 60);
|
||||
const hpCutoff = hpFilter.params.frequency ?? 1000;
|
||||
return (seq.params.bpm ?? 140) === 140 &&
|
||||
(seq.params.swing ?? 0) >= 15 &&
|
||||
kick !== undefined &&
|
||||
hpCutoff > 5000;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
466
src/game/levels/world8.js
Normal file
466
src/game/levels/world8.js
Normal file
@@ -0,0 +1,466 @@
|
||||
/**
|
||||
* World 8 — "Texturas de Ruido" (Noise Textures)
|
||||
*
|
||||
* Teaches: noise types, wind sounds (bandpass), ocean waves (LFO on cutoff),
|
||||
* rain (noise + short envelope), radio static (noise + distortion),
|
||||
* industrial rhythm (noise + LFO on VCA), ambient texture (noise + reverb + delay)
|
||||
* 8 levels + boss challenge: "Paisaje Sonoro" (Soundscape)
|
||||
*/
|
||||
|
||||
export const WORLD_8 = {
|
||||
id: 'w8',
|
||||
name: 'Texturas de Ruido',
|
||||
subtitle: 'Más allá de las notas',
|
||||
icon: '⣿',
|
||||
color: '#88aaff',
|
||||
unlockStars: 84,
|
||||
levels: [
|
||||
// ─────────────── LEVEL 8.1 ───────────────
|
||||
{
|
||||
id: 'w8-1',
|
||||
title: 'Ruido Blanco',
|
||||
subtitle: 'El sonido puro',
|
||||
description: 'El ruido blanco es aleatoriedad pura — todas las frecuencias con igual intensidad. Suena como estática de TV o lluvia lejana. Es el punto de partida para texturas ruidosas.',
|
||||
concept: 'Noise (tipo "white") → VCA → Output. Envelope al VCA. Sonido: "sssshhhhh" — simple pero bonito. Es la base de muchas texturas.',
|
||||
availableModules: ['noise', 'vca', 'envelope'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 1.5 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Ruido básico',
|
||||
desc: 'Noise → VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!noise || !vca || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === noise.id && c.to.moduleId === vca.id) &&
|
||||
conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Con envelope',
|
||||
desc: 'Envelope dispara el VCA',
|
||||
test: (mods, conns) => {
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (!env || !vca) return false;
|
||||
return conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Ruido controlado',
|
||||
desc: 'Noise white + envelope con attack suave (< 0.1s), decay moderado (0.2-0.5s)',
|
||||
test: (mods) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!noise || !env) return false;
|
||||
const attack = env.params.attack ?? 0.01;
|
||||
const decay = env.params.decay ?? 0.2;
|
||||
return noise.params.type === 'white' && attack < 0.1 && decay >= 0.2 && decay <= 0.5;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 8.2 ───────────────
|
||||
{
|
||||
id: 'w8-2',
|
||||
title: 'Sonido de Viento',
|
||||
subtitle: 'Brisa y vendavales',
|
||||
description: 'El viento es ruido filtrado con un filtro bandpass — solo un rango de frecuencias pasa. Varías el cutoff y Q para cambiar el "tipo" de viento (brisa suave vs. huracán).',
|
||||
concept: 'Noise → Filter bandpass (cutoff ~3000 Hz, Q moderado ~3-5) → VCA → Output. Envelope suave al VCA. Resultado: "whoooosh", viento realista.',
|
||||
availableModules: ['noise', 'filter', 'vca', 'envelope'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 1.5 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Ruido filtrado',
|
||||
desc: 'Noise → Filter bandpass → VCA → Output',
|
||||
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');
|
||||
if (!noise || !flt || !vca) return false;
|
||||
return flt.params.type === 'bandpass' &&
|
||||
conns.some(c => c.from.moduleId === noise.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === vca.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Con resonancia',
|
||||
desc: 'Filtro bandpass con Q > 2',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!flt) return false;
|
||||
return flt.params.type === 'bandpass' && (flt.params.Q ?? 1) > 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Viento realista',
|
||||
desc: 'Bandpass 2000-4000 Hz, Q 3-5, envelope suave (attack 0.1-0.2s, decay 0.5+)',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!flt || !env) return false;
|
||||
const cutoff = flt.params.frequency ?? 1000;
|
||||
const Q = flt.params.Q ?? 1;
|
||||
const attack = env.params.attack ?? 0.01;
|
||||
const decay = env.params.decay ?? 0.2;
|
||||
return cutoff >= 2000 && cutoff <= 4000 && Q >= 3 && Q <= 5 &&
|
||||
attack >= 0.1 && attack <= 0.2 && decay >= 0.5;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 8.3 ───────────────
|
||||
{
|
||||
id: 'w8-3',
|
||||
title: 'Olas del Océano',
|
||||
subtitle: 'LFO al cutoff',
|
||||
description: 'El océano "respira" — la amplitud cambia lentamente. Se logra modulando el cutoff del filtro con un LFO muy lento (~0.2-0.5 Hz). El resultado: un sonido que crece y disminuye como olas.',
|
||||
concept: 'Noise → Filter LP → VCA → Output. LFO lento (0.2-0.5 Hz) al cutoff del filtro. Envelope suave al VCA. Resultado: un sonido hipnótico que respira.',
|
||||
availableModules: ['noise', 'filter', 'vca', 'lfo', 'envelope'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 750, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'LFO al filtro',
|
||||
desc: 'Noise → Filter → VCA. LFO al cutoff del filtro',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!noise || !flt || !lfo) return false;
|
||||
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'LFO lento',
|
||||
desc: 'LFO con frequency < 1 Hz para movimiento lento',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!lfo) return false;
|
||||
return (lfo.params.frequency ?? 2) < 1;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Olas hipnóticas',
|
||||
desc: 'LFO 0.2-0.5 Hz, filtro LP cutoff 500-3000 Hz, envelope suave (decay 1+)',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!lfo || !flt || !env) return false;
|
||||
const lfoFreq = lfo.params.frequency ?? 2;
|
||||
const cutoff = flt.params.frequency ?? 1000;
|
||||
const decay = env.params.decay ?? 0.2;
|
||||
return lfoFreq >= 0.2 && lfoFreq <= 0.5 &&
|
||||
cutoff >= 500 && cutoff <= 3000 &&
|
||||
flt.params.type === 'lowpass' &&
|
||||
decay >= 1;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 8.4 ───────────────
|
||||
{
|
||||
id: 'w8-4',
|
||||
title: 'Sonido de Lluvia',
|
||||
subtitle: 'Gotas percusivas',
|
||||
description: 'La lluvia es ruido + un envelope muy corto que dispara múltiples veces. Cada "gota" es un ataque y decaimiento rápidos. Varias gotas creadas con los mismos parámetros generan una ilusión de lluvia.',
|
||||
concept: 'Noise → VCA → Output. Envelope CORTO (attack 0, decay ~0.05-0.1s, sustain 0) al VCA. Un keyboard para disparar "gotas". Varias pulsaciones = lluvia.',
|
||||
availableModules: ['noise', 'vca', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 1.5 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Gota de lluvia',
|
||||
desc: 'Noise → VCA con envelope corto (decay < 0.15s)',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!noise || !vca || !env) return false;
|
||||
return conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv') &&
|
||||
(env.params.decay ?? 0.2) < 0.15;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Percusivo',
|
||||
desc: 'Envelope con attack 0, decay 0.05-0.1s, sustain 0',
|
||||
test: (mods) => {
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!env) return false;
|
||||
const decay = env.params.decay ?? 0.2;
|
||||
const sustain = env.params.sustain ?? 0.5;
|
||||
return (env.params.attack ?? 0.01) <= 0.01 && decay >= 0.05 && decay <= 0.1 && sustain < 0.05;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Lluvia realista',
|
||||
desc: 'Noise white, envelope ultra-corto (decay 0.03-0.08s), keyboard conectado',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!noise || !env || !kb) return false;
|
||||
const decay = env.params.decay ?? 0.2;
|
||||
const connected = conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id && c.to.port === 'gate');
|
||||
return noise.params.type === 'white' && decay >= 0.03 && decay <= 0.08 && connected;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 8.5 ───────────────
|
||||
{
|
||||
id: 'w8-5',
|
||||
title: 'Estática de Radio',
|
||||
subtitle: 'Ruido + Distorsión',
|
||||
description: 'La estática de radio es ruido MÁS distorsión — un efecto que "rompe" el sonido de forma agresiva. Crea ese sonido crispante, lo-fi, de radio rota o síntesis glitch.',
|
||||
concept: 'Noise → Distortion (distortion 0.6+) → VCA → Output. Envelope al VCA. La distorsión enfatiza ciertas partes del ruido, creando un sonido más agresivo y texturado.',
|
||||
availableModules: ['noise', 'vca', 'envelope', 'distortion'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -12 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 1.5 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Ruido distorsionado',
|
||||
desc: 'Noise → Distortion → VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (!noise || !dist || !vca) return false;
|
||||
return conns.some(c => c.from.moduleId === noise.id && c.to.moduleId === dist.id) &&
|
||||
conns.some(c => c.from.moduleId === dist.id && c.to.moduleId === vca.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Agresivo',
|
||||
desc: 'Distorsión > 0.4 para un sonido roto',
|
||||
test: (mods) => {
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
if (!dist) return false;
|
||||
return (dist.params.distortion ?? 0.4) > 0.4;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Estática completa',
|
||||
desc: 'Distorsión 0.6-0.9, wet 0.6+, envelope suave (decay 0.5+)',
|
||||
test: (mods) => {
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!dist || !env) return false;
|
||||
const distortion = dist.params.distortion ?? 0.4;
|
||||
const wet = dist.params.wet ?? 0.5;
|
||||
const decay = env.params.decay ?? 0.2;
|
||||
return distortion >= 0.6 && distortion <= 0.9 && wet >= 0.6 && decay >= 0.5;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 8.6 ───────────────
|
||||
{
|
||||
id: 'w8-6',
|
||||
title: 'Ritmo Industrial',
|
||||
subtitle: 'LFO modulando VCA',
|
||||
description: 'Ahora modulamos el VCA con un LFO en lugar del envelope — crea un efecto de "pulsación" o "tremolo". Combined con noise, crea un sonido industrial, maquínico, hipnótico.',
|
||||
concept: 'Noise → VCA. LFO (frequency ~1-2 Hz) al CV del VCA. Resultado: el ruido sube y baja rítmicamente, como una máquina industrial.',
|
||||
availableModules: ['noise', 'vca', 'lfo'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 1.5 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'LFO al VCA',
|
||||
desc: 'Noise → VCA. LFO al CV del VCA',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!noise || !vca || !lfo) return false;
|
||||
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === vca.id && c.to.port === 'cv') &&
|
||||
conns.some(c => c.from.moduleId === noise.id && c.to.moduleId === vca.id && c.to.port === 'in');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Pulsación',
|
||||
desc: 'LFO frequency 0.5-3 Hz para tremolo audible',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!lfo) return false;
|
||||
const freq = lfo.params.frequency ?? 2;
|
||||
return freq >= 0.5 && freq <= 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Industrial puro',
|
||||
desc: 'LFO 1-2 Hz, square waveform (si hay opción), amplitude > 0.5',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!lfo) return false;
|
||||
const freq = lfo.params.frequency ?? 2;
|
||||
const amplitude = lfo.params.amplitude ?? 0.5;
|
||||
return freq >= 1 && freq <= 2 && amplitude > 0.5;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 8.7 ───────────────
|
||||
{
|
||||
id: 'w8-7',
|
||||
title: 'Textura Ambiental',
|
||||
subtitle: 'Ruido + Reverb + Delay',
|
||||
description: 'Una textura ambiental es ruido filtrado + MUCHO reverb y delay. El reverb añade espacio (como un reverb de catedral), el delay crea repeticiones. El resultado: un sonido envolvente, envolvente, romántico.',
|
||||
concept: 'Noise → Filter LP (cutoff bajo ~1000 Hz) → Reverb (decay 4+) → Delay → Output. No necesitas envelope — deja que el sonido respire solo. Es puro ambiente.',
|
||||
availableModules: ['noise', 'filter', 'reverb', 'delay'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2.5 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Reverb en cadena',
|
||||
desc: 'Noise → Filter → Reverb → Output',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (!noise || !flt || !rev) return false;
|
||||
return conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === rev.id) &&
|
||||
conns.some(c => c.from.moduleId === rev.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Espacioso',
|
||||
desc: 'Reverb decay > 3, delay en cadena también',
|
||||
test: (mods, conns) => {
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
if (!rev || !del) return false;
|
||||
return (rev.params.decay ?? 2) > 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Ambiente etéreo',
|
||||
desc: 'LP < 1500 Hz, reverb decay 4+, delay feedback 0.4+, combinación crea sonido flotante',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
if (!flt || !rev || !del) return false;
|
||||
const cutoff = flt.params.frequency ?? 1000;
|
||||
const revDecay = rev.params.decay ?? 2;
|
||||
const delFeedback = del.params.feedback ?? 0.4;
|
||||
return cutoff <= 1500 && revDecay >= 4 && delFeedback >= 0.4;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 8.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w8-8',
|
||||
title: 'Paisaje Sonoro',
|
||||
subtitle: 'BOSS FINAL: Un mundo de sonido',
|
||||
description: 'Combina TODAS las texturas aprendidas en un único paisaje sonoro. Crea una composición con capas: viento, lluvia, olas, estática, ritmo industrial, ambiente. Una sinfonía de ruido y texturas.',
|
||||
concept: 'Mínimo 4 capas de ruido con diferentes características: 1) filtro bandpass (viento), 2) ruido + envelope corto (lluvia), 3) ruido + LFO al filtro (olas), 4) ruido + LFO al VCA (ritmo). Todo mezclado, con reverb y delay, fluyendo en armonía.',
|
||||
availableModules: ['noise', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'delay', 'reverb', 'distortion'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 6 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Múltiples texturas',
|
||||
desc: 'Al menos 3 canales de ruido con características diferentes, todos a output',
|
||||
test: (mods, conns) => {
|
||||
const noises = mods.filter(m => m.type === 'noise');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (noises.length < 3 || !out) return false;
|
||||
// Count different filter types or modulators
|
||||
const filters = mods.filter(m => m.type === 'filter');
|
||||
const lfos = mods.filter(m => m.type === 'lfo');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const total = filters.length + lfos.length + envs.length;
|
||||
return total >= 3 && conns.some(c => c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sonido espacioso',
|
||||
desc: 'Reverb y delay en cadena, crean profundidad y eco',
|
||||
test: (mods, conns) => {
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
if (!rev || !del) return false;
|
||||
// At least one should connect to output or to each other
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
return (conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === del.id) ||
|
||||
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === rev.id) ||
|
||||
(conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === out?.id) &&
|
||||
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out?.id)));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Maestro de Texturas',
|
||||
desc: '4+ noises, 2+ filters, 2+ LFOs, mixer, reverb decay 3+, delay feedback 0.4+, distorsión opcional',
|
||||
test: (mods, conns) => {
|
||||
const noises = mods.filter(m => m.type === 'noise');
|
||||
const filters = mods.filter(m => m.type === 'filter');
|
||||
const lfos = mods.filter(m => m.type === 'lfo');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const nonOutput = mods.filter(m => m.type !== 'output');
|
||||
if (noises.length < 4 || filters.length < 2 || lfos.length < 2 || !mixer || !rev || !del) return false;
|
||||
const revDecay = rev.params.decay ?? 2;
|
||||
const delFeedback = del.params.feedback ?? 0.4;
|
||||
return nonOutput.length >= 12 &&
|
||||
revDecay >= 3 && delFeedback >= 0.4 &&
|
||||
conns.length >= 15;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
486
src/game/levels/world9.js
Normal file
486
src/game/levels/world9.js
Normal file
@@ -0,0 +1,486 @@
|
||||
/**
|
||||
* World 9 — "Síntesis Sustractiva Clásica" (Classic Subtractive Synthesis)
|
||||
*
|
||||
* Teaches: Moog-style synthesis, resonant filters, acid bass, PWM simulation
|
||||
* 8 levels, boss challenges with complete subtractive synth
|
||||
*/
|
||||
|
||||
export const WORLD_9 = {
|
||||
id: 'w9',
|
||||
name: 'Síntesis Sustractiva',
|
||||
subtitle: 'Los sonidos clásicos del sintetizador',
|
||||
icon: '▽~',
|
||||
color: '#ff4466',
|
||||
unlockStars: 96,
|
||||
levels: [
|
||||
// ─────────────── LEVEL 9.1 ───────────────
|
||||
{
|
||||
id: 'w9-1',
|
||||
title: 'Lead Sawtooth',
|
||||
subtitle: 'La onda más rica en armónicos',
|
||||
description: 'El sawtooth es la onda fundamental de la síntesis sustractiva — contiene todos los armónicos. Conecta un oscilador sawtooth a un filtro lowpass para quitar brillo, y un VCA para controlar el volumen.',
|
||||
concept: 'Osc sawtooth → Filter LP → VCA → Output. El filtro controla el brillo, el VCA controla la amplitud. Ajusta la frecuencia y el cutoff del filtro para explorar sonidos.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Sawtooth básico',
|
||||
desc: 'Osc sawtooth → 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 osc.params.waveform === 'sawtooth' &&
|
||||
conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Filtro activo',
|
||||
desc: 'Filtro lowpass con cutoff controlable',
|
||||
test: (mods, conns) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!flt) return false;
|
||||
return flt.params.type === 'lowpass' &&
|
||||
(flt.params.frequency ?? 1000) > 500 &&
|
||||
(flt.params.Q ?? 1) >= 1;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Lead completo',
|
||||
desc: 'Sawtooth + LP + VCA + envelope + keyboard',
|
||||
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 kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!osc || !flt || !vca || !env || !kb) return false;
|
||||
return osc.params.waveform === 'sawtooth' &&
|
||||
flt.params.type === 'lowpass' &&
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id && c.to.port === 'gate') &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 9.2 ───────────────
|
||||
{
|
||||
id: 'w9-2',
|
||||
title: 'Filtro Resonante',
|
||||
subtitle: 'El corazón de Moog',
|
||||
description: 'La resonancia (Q alto) en el filtro crea un pico característico en el cutoff frequency. Este es el sonido Moog: cuando bajas el cutoff con resonancia, el filtro empieza a auto-oscilar y cantar.',
|
||||
concept: 'Osc sawtooth → Filter LP (Q > 4) → VCA → Output. Cuanto más alto el Q, más dramático el efecto. Baja el cutoff lentamente para escuchar la resonancia.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Resonancia perceptible',
|
||||
desc: 'Filtro LP con Q > 3',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!flt) return false;
|
||||
return flt.params.type === 'lowpass' &&
|
||||
(flt.params.Q ?? 1) > 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Moog Resonante',
|
||||
desc: 'Sawtooth + LP (Q > 5) + VCA + envelope',
|
||||
test: (mods) => {
|
||||
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');
|
||||
if (!osc || !flt || !vca || !env) return false;
|
||||
return osc.params.waveform === 'sawtooth' &&
|
||||
flt.params.type === 'lowpass' &&
|
||||
(flt.params.Q ?? 1) > 5 &&
|
||||
(env.params.attack ?? 0.01) < 0.1;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Barrido de Filtro',
|
||||
desc: 'LFO modulando el cutoff del filtro con resonancia alta',
|
||||
test: (mods, conns) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!flt || !lfo) return false;
|
||||
return flt.params.type === 'lowpass' &&
|
||||
(flt.params.Q ?? 1) > 4 &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 9.3 ───────────────
|
||||
{
|
||||
id: 'w9-3',
|
||||
title: 'Brass Stab',
|
||||
subtitle: 'El ataque metálico',
|
||||
description: 'Un "brass stab" es un sonido de trompeta: square wave, filtro que se abre rápido en el ataque y luego se cierra. El envelope en el filtro crea el efecto de "toque" de la trompeta.',
|
||||
concept: 'Osc square → Filter LP → VCA → Output. El truco: el envelope NO va al VCA sino al CUTOFF del filtro. Attack del env muy corto. El filtro sube y baja, no el volumen.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Square + Filtro',
|
||||
desc: 'Osc square → 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');
|
||||
if (!osc || !flt || !vca) return false;
|
||||
return osc.params.waveform === 'square' &&
|
||||
flt.params.type === 'lowpass' &&
|
||||
conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Envelope al Filtro',
|
||||
desc: 'Envelope conectado al cutoff del filtro',
|
||||
test: (mods, conns) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!flt || !env) return false;
|
||||
return conns.some(c => c.from.moduleId === env.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Brass Stab Perfecta',
|
||||
desc: 'Square + LP, envelope (attack < 0.02s) al cutoff, keyboard gatea el env',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!osc || !flt || !env || !kb) return false;
|
||||
return osc.params.waveform === 'square' &&
|
||||
flt.params.type === 'lowpass' &&
|
||||
(env.params.attack ?? 0.01) < 0.02 &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === flt.id && c.to.port === 'cutoff') &&
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id && c.to.port === 'gate');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 9.4 ───────────────
|
||||
{
|
||||
id: 'w9-4',
|
||||
title: 'Acid Bass 303',
|
||||
subtitle: 'El sonido de la danza',
|
||||
description: 'El acid bass es el legendario sonido del sintetizador TB-303: oscilador a frecuencia grave, filtro lowpass muy resonante, y un envelope que modula el cutoff para crear el "slide" característico.',
|
||||
concept: 'Osc sawtooth/square ~55 Hz → Sequencer freq. Filter LP (Q muy alto, ~8+) → VCA → Output. Envelope rápido al cutoff. El sequencer proporciona las notas; el filtro hace el sonido "acid".',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'sequencer', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Bajo + Secuenciador',
|
||||
desc: 'Sequencer → Osc grave + Filter → Output',
|
||||
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');
|
||||
if (!seq || !osc || !flt) return false;
|
||||
return (osc.params.frequency ?? 440) < 100 &&
|
||||
conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === osc.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Resonancia acid',
|
||||
desc: 'Filtro LP con Q > 6, envelope al cutoff',
|
||||
test: (mods, conns) => {
|
||||
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 === 'lowpass' &&
|
||||
(flt.params.Q ?? 1) > 6 &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: '303 Clásico',
|
||||
desc: 'Sequencer + osc < 60 Hz + LP (Q > 8) + envelope rápido al cutoff',
|
||||
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 env = mods.find(m => m.type === 'envelope');
|
||||
if (!seq || !osc || !flt || !env) return false;
|
||||
return (osc.params.frequency ?? 440) < 60 &&
|
||||
flt.params.type === 'lowpass' &&
|
||||
(flt.params.Q ?? 1) > 8 &&
|
||||
(env.params.decay ?? 0.2) < 0.3 &&
|
||||
conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === osc.id);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 9.5 ───────────────
|
||||
{
|
||||
id: 'w9-5',
|
||||
title: 'String Pad Detuned',
|
||||
subtitle: 'Capas de sierras',
|
||||
description: 'Los string pads de las sinfonías electrónicas usan múltiples osciladores ligeramente detuned, un filtro suave, y un envelope lento. El detune crea una "chorusing" natural que emula el sonido de múltiples instrumentos.',
|
||||
concept: '3 oscs sawtooth, cada uno con detune diferente (~0, +5, -7) → Mixer → Filter LP suave → VCA → Output. Envelope lento al VCA. Juntos crean una textura cálida y movible.',
|
||||
availableModules: ['oscillator', 'mixer', 'filter', 'vca', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 4 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Múltiples sierras',
|
||||
desc: '3 osciladores sawtooth → Mixer → Output',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
if (oscs.length < 3 || !mixer) return false;
|
||||
return oscs.every(o => o.params.waveform === 'sawtooth') &&
|
||||
oscs.some(o => conns.some(c => c.from.moduleId === o.id && c.to.moduleId === mixer.id));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Detune activo',
|
||||
desc: 'Al menos 2 osciladores con detune diferente (|diff| > 3)',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sawtooth');
|
||||
if (oscs.length < 3) return false;
|
||||
const detunes = oscs.map(o => o.params.detune ?? 0);
|
||||
const maxDiff = Math.max(...detunes) - Math.min(...detunes);
|
||||
return maxDiff > 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'String Pad Completa',
|
||||
desc: '3 saws detuned + mixer + LP + envelope lento al VCA',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sawtooth');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
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 (oscs.length < 3 || !mixer || !flt || !vca || !env) return false;
|
||||
const detunes = oscs.map(o => o.params.detune ?? 0);
|
||||
const maxDiff = Math.max(...detunes) - Math.min(...detunes);
|
||||
return maxDiff > 3 &&
|
||||
flt.params.type === 'lowpass' &&
|
||||
(env.params.attack ?? 0.01) < 0.1 &&
|
||||
(env.params.decay ?? 0.2) > 0.5 &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 9.6 ───────────────
|
||||
{
|
||||
id: 'w9-6',
|
||||
title: 'PWM Simulator',
|
||||
subtitle: 'Pseudo Pulse Width Modulation',
|
||||
description: 'El PWM (Pulse Width Modulation) es cuando varías el ancho del pulso de una onda square. Podemos simularla mezclando dos osciladores square ligeramente detuned — crean una "beating" que suena como PWM.',
|
||||
concept: '2 oscs square, uno a frecuencia base, otro detuned ~3-5 cents → Mixer → Filter → VCA → Output. El beating de frecuencias crea la ilusión de PWM. Un LFO puede modular más aún.',
|
||||
availableModules: ['oscillator', 'mixer', 'filter', 'vca', 'lfo', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dos squares',
|
||||
desc: '2 osciladores square → Mixer → Output',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'square');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
if (oscs.length < 2 || !mixer) return false;
|
||||
return oscs.some(o => conns.some(c => c.from.moduleId === o.id && c.to.moduleId === mixer.id));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Beating audible',
|
||||
desc: 'Detune entre squares > 2 cents para audible beating',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'square');
|
||||
if (oscs.length < 2) return false;
|
||||
const detunes = oscs.map(o => o.params.detune ?? 0);
|
||||
return Math.abs(detunes[0] - detunes[1]) > 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'PWM Dinámico',
|
||||
desc: '2 squares detuned + mixer + filter + LFO al detune de un osc',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'square');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (oscs.length < 2 || !mixer || !lfo || !flt) return false;
|
||||
const detunes = oscs.map(o => o.params.detune ?? 0);
|
||||
const hasDetune = Math.abs(detunes[0] - detunes[1]) > 2;
|
||||
const lfoToOsc = oscs.some(o =>
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === o.id && c.to.port === 'detune')
|
||||
);
|
||||
return hasDetune && lfoToOsc && flt.params.type === 'lowpass';
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 9.7 ───────────────
|
||||
{
|
||||
id: 'w9-7',
|
||||
title: 'Filter Sweep Técnica',
|
||||
subtitle: 'Control dinámico del timbre',
|
||||
description: 'El filter sweep es el corazón de la síntesis sustractiva: modular la frecuencia de cutoff con un LFO o envelope. Esto cambia el timbre del sonido en tiempo real. Es la vida de la síntesis.',
|
||||
concept: 'Osc sawtooth → Filter LP → VCA → Output. LFO (frecuencia baja ~0.2-2 Hz) → Cutoff del filter. También conecta envelope al cutoff para un sweep más rápido. Keyboard dispara ambos.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 4 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'LFO al Cutoff',
|
||||
desc: 'LFO conectado a 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: 2,
|
||||
name: 'LFO lento',
|
||||
desc: 'LFO con frecuencia < 2 Hz para sweep audible',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!lfo) return false;
|
||||
return (lfo.params.frequency ?? 2) < 2 &&
|
||||
(lfo.params.amplitude ?? 0.5) > 0.3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Sweep Completo',
|
||||
desc: 'Sawtooth + LP + LFO lento + envelope 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 env = mods.find(m => m.type === 'envelope');
|
||||
if (!osc || !flt || !lfo || !env) return false;
|
||||
return osc.params.waveform === 'sawtooth' &&
|
||||
flt.params.type === 'lowpass' &&
|
||||
(lfo.params.frequency ?? 2) < 2 &&
|
||||
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 === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 9.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w9-8',
|
||||
title: 'Sintetizador Clásico',
|
||||
subtitle: 'BOSS FINAL: Moog Completo',
|
||||
description: 'Construye el sintetizador sustractivo completo: múltiples osciladores, filtro resonante, envelopes, LFO, y todo conectado para crear sonidos ricos y expressivos. Este es el verdadero sintetizador analógico.',
|
||||
concept: 'Construye un synth con: 2+ osciladores (mezcla de saw/square), filtro LP resonante (Q > 4), 2+ envelopes, 1+ LFO, VCA, keyboard, y al menos un efecto. Todo debe sonar cohesivo y expressivo.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'keyboard', 'delay', 'distortion', 'reverb'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 5 },
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Síntesis funcional',
|
||||
desc: 'Múltiples oscs + filtro LP + VCA + envelope + keyboard',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(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 kb = mods.find(m => m.type === 'keyboard');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 2 || !flt || !vca || !env || !kb || !out) return false;
|
||||
return flt.params.type === 'lowpass' &&
|
||||
conns.some(c => c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Moog característico',
|
||||
desc: '2+ oscs + filtro LP resonante (Q > 4) + envelope modulando cutoff',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (oscs.length < 2 || !flt || !env) return false;
|
||||
return flt.params.type === 'lowpass' &&
|
||||
(flt.params.Q ?? 1) > 4 &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Maestro Sustractivo',
|
||||
desc: '2+ oscs detuned + LP (Q > 5) + 2 envs + LFO + efecto + keyboard',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
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));
|
||||
if (oscs.length < 2 || !flt || envs.length < 2 || !lfo || !kb || effects.length < 1) return false;
|
||||
const detunes = oscs.map(o => o.params.detune ?? 0);
|
||||
const hasDetune = Math.max(...detunes) - Math.min(...detunes) > 2;
|
||||
return flt.params.type === 'lowpass' &&
|
||||
(flt.params.Q ?? 1) > 5 &&
|
||||
hasDetune &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff') &&
|
||||
conns.length >= 12;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
@@ -188,6 +188,18 @@ html, body, #root {
|
||||
.knob-container.knob-modulated + .param-value {
|
||||
color: var(--accent2);
|
||||
}
|
||||
/* Ghost dot showing base value position while modulated */
|
||||
.knob-base-dot {
|
||||
fill: var(--text2);
|
||||
opacity: 0.4;
|
||||
}
|
||||
/* Live modulation number — highlight color + subtle glow */
|
||||
.param-value-live {
|
||||
color: var(--accent) !important;
|
||||
text-shadow: 0 0 6px rgba(0, 229, 255, 0.5);
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.knob-editing { display: flex; align-items: center; justify-content: center; }
|
||||
.knob-input {
|
||||
|
||||
Reference in New Issue
Block a user