Compare commits

38 Commits

Author SHA1 Message Date
Jose Luis
02db83b896 fix: VCA CV scaler always 1 so envelope works regardless of gain param
Bug: if VCA gain was 0 when CV was connected, cvMod (initialized with
p.gain=0) would multiply envelope by 0 = silence forever.

Fix: cvMod always has gain=1 (full pass-through). The envelope (0-1)
controls the VCA amplitude directly. When CV is connected, base gain
is zeroed so only the envelope signal is heard. When disconnected,
base gain is restored from the param value.

Before: cvMod.gain = p.gain → envelope × 0 = 0 (broken)
After:  cvMod.gain = 1      → envelope × 1 = envelope (correct)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:55:29 +01:00
Jose Luis
49c016d0a6 fix: prevent envelope release=0 causing sustain loop bug
Tone.Envelope with release=0 behaves unpredictably — the gate-off
ramp doesn't complete properly and the value snaps back to sustain.
Set minimum release to 0.001s (same as attack/decay already have).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:51:42 +01:00
Jose Luis
2a2b3b3341 fix: VCA zeroes on live CV connect + visual feedback for envelope control
VCA fix:
- connectWire now zeros VCA base gain immediately when CV is connected
  (previously only rebuildGraph did this, missing live-connect case)
- disconnectWire restores base gain from params when CV is removed

Visual modulation feedback:
- ModuleNode RAF loop now handles envelope sources (not just LFO)
- Reads actual Tone.js gain node value for real-time display
- VCA gain knob shows live envelope value during playback
- LFO visualization unchanged (simulated waveform as before)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:50:28 +01:00
Jose Luis
38dca9402f fix: VCA closes properly with envelope + add CV→Gate module
VCA fix:
- Add cvMod scaler (like oscillator/filter have) so envelope (0-1)
  is scaled by the gain param before modulating VCA
- Zero base gain when CV is connected (in rebuildGraph) so envelope
  = 0 produces silence instead of falling back to base gain
- updateParam keeps cvMod in sync with gain knob

New module: CV→Gate () in Utility category:
- Converts continuous CV signal (e.g. LFO) to gate on/off
- Threshold knob (0-1, default 0.5): signal above = gate on
- Reads analyser on master clock tick for threshold comparison
- Triggers/releases connected envelopes automatically
- Use case: LFO → CV→Gate → Envelope → VCA for rhythmic gating

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:44:28 +01:00
Jose Luis
7e6c960b0b fix: reduce main thread pressure to prevent audio buffer underruns
The periodic audio glitches were caused by main thread starvation:
~840 events/sec during playback starved the audio buffer.

Changes:
- Master clock 480→120 Hz (still 6x headroom for 300 BPM sixteenths)
- Connection cache: replace O(n) reduce hash with dirty flag (zero work
  on cache hit, flag set only when connections actually change)
- Tone.js lookAhead: 100ms→50ms for tighter scheduling
- ModuleNode LFO visualization RAF: 60fps→15fps (every 4th frame)
- ScopeDisplay RAF: 60fps→30fps (every 2nd frame)

Net effect: ~840 events/sec → ~200 events/sec during playback.
Audio processing gets 4x more main thread headroom.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:19:12 +01:00
Jose Luis
7596aea491 fix: derive master clock ticks from AudioContext.currentTime
The _masterTicks++ counter fell behind when Tone.Clock callbacks were
delayed by GC pauses, UI interactions, or tab throttling. The counter
never recovered, causing cumulative drift between sequencers.

Now ticks are derived from the callback's time parameter (which comes
from AudioContext.currentTime — hardware clock, always precise):
  ticks = Math.round((time - startTime) * MASTER_TICK_RATE)

If a callback is delayed by 50ms, the time is still correct and ticks
jump ahead to the right value. No accumulation, no drift, self-healing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:08:10 +01:00
Jose Luis
7d3a19ec35 fix: use integer tick counter to eliminate floating-point beat drift
Root cause: floor(elapsed * rateA) vs floor(elapsed * rateB) where
rateB = 2*rateA doesn't maintain exact 2:1 ratio due to floating-point
multiplication errors. This creates a beat/aliasing pattern where
sequencers at 80 and 160 BPM periodically go in and out of phase.

Fix: Master clock now uses an integer tick counter (_masterTicks++)
instead of floating-point elapsed time. Sequencers derive steps via:
  stepIdx = floor(ticks / ticksPerStep) % numSteps
where ticks is an integer — no floating-point accumulation possible.

Also bumped master clock to 480 Hz for cleaner division at common BPMs:
  80 BPM: 480*60/320 = 90 ticks/step (exact)
  120 BPM: 480*60/480 = 60 ticks/step (exact)
  160 BPM: 480*60/640 = 45 ticks/step (exact)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 18:01:56 +01:00
Jose Luis
8bdb953b52 fix: capture master clock start time from first tick callback
The _masterTime was captured from Tone.now() BEFORE the clock started,
but the time parameter in Tone.Clock callbacks comes from a different
scheduler timeline. This caused elapsed to drift systematically.

Now _masterTime is set from the first callback's own time parameter,
guaranteeing both are on the exact same clock source. Zero drift.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 17:57:10 +01:00
Jose Luis
18661961a1 fix: reduce master clock to 240 Hz + eliminate note-off timeouts
- Master clock 960→240 Hz: reduces CPU/GC pressure by 4x while still
  providing 12x headroom for 300 BPM sixteenths
- Remove Tone.getContext().setTimeout() for note-off scheduling —
  these accumulated over time causing periodic hiccups
- Note-off now happens at step boundary: previous gate turned off
  at the start of each new step (cleaner, zero accumulation)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 17:52:38 +01:00
Jose Luis
1f941d7e39 feat: global master clock for drift-free multi-sequencer timing
Replace independent Tone.Clock per sequencer/pianoroll with a single
shared master clock running at 960 Hz in audioEngine.

Architecture:
- Master clock starts/stops with audio engine (startAudio/stopAudio)
- Widgets subscribe via subscribeTick(id, callback) receiving
  (audioTime, elapsed) on every tick
- Each widget derives its own step/position from elapsed time and
  its own BPM, so different BPMs stay perfectly in sync
- BPM/steps/bars changes are read from refs (no clock restart needed)

Benefits:
- All timing derived from one clock source = zero relative drift
- No clock recreation on param changes = no glitches
- 960 Hz tick rate ≈ 1ms precision (plenty for musical timing)
- Sequencer at 80 BPM and 160 BPM maintain perfect 1:2 ratio forever

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 17:45:10 +01:00
Jose Luis
9dba156961 fix: eliminate multi-sequencer drift with time-based step calculation
Problem: two sequencers at different BPMs (e.g. 80 and 160) would
drift apart over time because each used an independent step counter
(step++) that accumulated floating-point rounding errors.

Fix: derive step/position from audio clock time (Tone.now()), not
from an incrementing counter. Step = floor(elapsed * rate) % numSteps.
This makes timing mathematically exact regardless of how long it runs.

Also:
- Sequencer note-off uses Tone.getContext().setTimeout() (audio-thread)
  instead of Tone.Transport.scheduleOnce() which needs Transport running
- Clock runs at 2x rate for tighter step edge detection
- PianoRoll uses same time-based position calculation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 17:42:30 +01:00
Jose Luis
b91b35f23d fix: eliminate audio timing jitter and rhythm drift
Root causes fixed:
- Sequencer: replaced setTimeout note-off with Tone.Transport.scheduleOnce
  for sample-accurate timing instead of main-thread-dependent setTimeout
- Sequencer + PianoRoll: decoupled visual updates from audio callbacks.
  Audio clock only writes to refs, RAF loop reads refs for visual step
  indicator. No more React setState inside Tone.Clock callbacks.
- audioEngine: added connection lookup cache (Map) to replace O(n²)
  array iterations in setSequencerSignals/triggerKeyboard. Cache rebuilds
  lazily only when connections change.

These changes eliminate the feedback loop where:
audio callback → setState → React render → main thread blocks →
setTimeout delayed → note-off late → drift compounds

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 17:35:41 +01:00
Jose Luis
1cf39f9b13 fix: unlock audio context on first user interaction
UI sounds weren't playing until the user hit Play because Tone.js
AudioContext was suspended. Now Tone.start() is called on the first
pointerdown or keydown event, so UI sounds work immediately.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 17:28:55 +01:00
Jose Luis
cf6e912905 fix: auto-center viewport when puzzle level loads
Call handleCenterView after level load with a short delay to let
the DOM settle, so modules are centered on screen on both mobile
and desktop.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:31:12 +01:00
Jose Luis
52045897e5 feat: add PWA support (installable app)
- Web app manifest with name, icons, theme color, standalone display
- Service worker with stale-while-revalidate caching strategy
- 192px and 512px PNG icons generated from favicon.svg
- Apple-specific meta tags for iOS home screen support
- Register service worker on page load

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:24:33 +01:00
Jose Luis
8b193126f7 fix: render fullscreen overlays via React Portal to document.body
The fullscreen piano/drumpad was rendering inside ModuleNode which has
CSS transform: scale(zoom). This breaks position: fixed (fixed elements
inside a transformed parent position relative to the transform, not the
viewport). Using createPortal to document.body fixes this.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:15:29 +01:00
Jose Luis
f0e7f7f37a fix: expand button for fullscreen + disable text selection
- Replace double-tap trigger with ⤢ expand button in module header
  for keyboard and drumpad modules (more reliable, no text selection)
- Disable user-select globally (except inputs/textareas)
- Fullscreen state managed in ModuleNode, passed to widgets as props
- Remove unused imports (useIsMobile, useRef) from widgets

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:11:32 +01:00
Jose Luis
892195410b fix: proper fullscreen piano (1 octave, big keys) + block native zoom
Fullscreen piano redesign:
- 1 octave with 7 large white keys filling the entire screen
- Gradient-lit keys with cyan press highlight
- Octave navigation buttons (◀ ▶) to shift up/down
- Note labels on each key (C4, D4, etc.)
- Black keys proportionally sized at 58% height
- touch-action: none to prevent any browser interference

Block native browser zoom:
- viewport meta: maximum-scale=1.0, user-scalable=no
- html touch-action: manipulation (prevents double-tap zoom on Safari)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:08:10 +01:00
Jose Luis
816e7270ed feat: fullscreen keyboard + new Drum Pad module
Keyboard fullscreen:
- Double-tap keyboard widget to enter fullscreen piano mode
- 2-octave touch-friendly piano with labeled keys
- Active key highlights cyan, close button to exit

Drum Pad module (🥁):
- New module type with 4x4 colored pad grid
- Each pad triggers a unique frequency (C2-D4 range)
- Outputs freq + gate signals (same as keyboard)
- Double-tap for fullscreen pad mode with large touch targets
- Color-coded pads with hit animation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 16:02:22 +01:00
Jose Luis
323f30cfb9 fix: collapsible bottom sheet + pinch-to-zoom on mobile
- Bottom sheet starts collapsed (handle bar only), swipe up to expand
- Tabs visible when collapsed in puzzle view, content hidden
- Swipe down or tap handle to collapse
- Add usePinchZoom hook: two-finger pinch gesture controls canvas zoom
- Pinch zoom wired into both Sandbox and Puzzle View canvases

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:53:28 +01:00
Jose Luis
8b66944e52 fix: enable touch panning and prevent page scroll on mobile
- Add touch-action: none on canvas to prevent browser scroll hijack
- Single-finger touch on empty canvas now triggers pan (pointerType check)
- Fix page bounce on mobile with position: fixed and 100dvh height

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:46:28 +01:00
Jose Luis
cd88fb5444 feat: add mobile-responsive UI for all views
- Add useIsMobile hook (768px breakpoint with matchMedia)
- Add BottomSheet component (swipe up/down, optional tabs, handle bar)
- Add MobileTabBar component (bottom nav with icons + labels)
- Sandbox mobile: compact toolbar, hamburger menu, action bar with
  START button, bottom sheet with module grid tiles
- World Map mobile: compact header, single-column level list,
  bottom tab bar (JUEGO/SANDBOX/CONFIG)
- Puzzle View mobile: icon-only top bar buttons, sidebar replaced
  by bottom sheet with 3 tabs (MISION/OBJETIVOS/MODULOS)
- ~200 lines of CSS media queries: touch targets 44px, port dots
  18px, zoom controls larger, modals full-width, level complete
  full-width buttons

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 15:40:50 +01:00
Jose Luis
4517e49ea6 feat: add clear/limpiar button to sandbox toolbar
Stops audio and removes all modules and connections from the canvas.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 05:13:42 +01:00
Jose Luis
589fbcf533 fix: align zoom controls to right edge in sandbox mode
The zoom panel was offset 220px from the right (legacy offset for a
non-existent right sidebar). Now sits flush at right:12px matching
the puzzle view layout.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 05:03:38 +01:00
Jose Luis
73532074b1 feat: home button to center view + fix sequencer step count growth
- Add ⌂ button to zoom bar (sandbox + puzzle) that centers camera on
  all modules
- Fix sequencer _steps array not growing when step count param increases
  (e.g. 8→32 now properly adds new empty steps)
- Make piano roll width dynamic based on bar count (BEAT_PX constant
  density instead of fixed ROLL_W)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 04:53:31 +01:00
Jose Luis
fce0bcdace fix: dynamic sizing for sequencer and piano roll modules
Module width now adapts to step/bar count so extra steps are never
hidden. Sequencer width scales with numSteps, piano roll width scales
with bar count using a fixed BEAT_PX density.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 04:48:27 +01:00
Jose Luis
64280874ea fix: use independent Tone.Clock per sequencer/pianoroll instance
Replace shared global Tone.Transport with per-instance Tone.Clock so
multiple sequencers and pianorolls run independently without interfering
with each other's timing.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 04:45:44 +01:00
Jose Luis
36eb31a652 fix: Transport lifecycle, scope zoom, clear button, and freq routing
- Fix pianoroll/sequencer Transport not resetting on stop/restart (notes
  were scheduled in the past and never fired)
- Stop and cancel Transport in stopAudio() to prevent stale events
- Add zoom +/- buttons to scope widget (6 levels, 64–2048 samples)
- Increase scope analyser buffer from 256 to 2048 for wider time view
- Add vertical grid lines to scope display
- Add "Limpiar" clear canvas button to PuzzleView
- Skip audio-graph connection for keyboard/seq/pianoroll freq→osc freq
  (direct frequency setting prevents inaudible ultrasonic values)
- Auto-trigger envelopes without gate connections for noise/ambient levels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 04:28:36 +01:00
Jose Luis
58d567c671 feat: fix target audio for all 96 levels and improve layout density
- Enhance targetAudio.js with envelope (ADSR), LFO modulation, effects
  (delay/reverb/distortion), and retrigger patterns for rhythmic sounds
- Fill in target audio configs for 87 levels (worlds 3-12) that had empty
  build arrays, making the "Objetivo" preview button functional everywhere
- Increase base sizes for modules, sidebar, ports, knobs, and typography
  so the UI feels less empty at 100% zoom

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 04:08:41 +01:00
Jose Luis
888b88e748 feat: implement real auto-solve that builds correct patches for all 96 levels
Replace the bypass auto-solve (which just set passed: true on all checks)
with a legitimate solver that loads actual module configurations and
connections via deserialize(), then validates through handleCheck().

Each solution defines the exact modules, parameters, and wiring needed
to pass all 3-star checks for every level across all 12 worlds.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 03:50:56 +01:00
Jose Luis
9123bf8c5c feat: add level search bar to world map
Search by level name, subtitle, ID, or world name. Shows filtered
results as a flat grid with world.level numbering and world color.
Escape key clears search, clear button resets and refocuses input.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 03:22:16 +01:00
Jose Luis
23ac673e51 fix: sidebar panels shrinking instead of scrolling
Flex children with default flex-shrink:1 were compressing to fit,
hiding the Mision panel content. Force flex-shrink:0 on all sidebar
children so they keep their natural size and the sidebar scrolls.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 03:20:27 +01:00
Jose Luis
be66d9a7cf feat: admin auto-solve button for quick level testing
- Add adminMode toggle in AdminPanel (green "Admin ON/OFF" button)
- Pass adminMode through GameApp → PuzzleView
- Show purple "🛠 Resolver" button in puzzle toolbar when admin is active
- Auto-solve gives 3 stars instantly and shows completion overlay
- Lets admin skip through all 96 levels for rapid testing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 03:15:31 +01:00
Jose Luis
1e3652f3b0 fix: puzzle sidebar scroll when content overflows
Add min-height: 0 to .gm-puzzle-content and .gm-puzzle-sidebar so
flexbox allows shrinking below content size, enabling overflow-y scroll.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 03:13:33 +01:00
Jose Luis
a1be6df355 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>
2026-03-21 03:03:29 +01:00
Jose Luis
f0100eb64f fix: LFO→cutoff modulation, visual knob feedback, persistent hints
- Fix LFO→Filter cutoff: add scaling Gain nodes so LFO (-1..1) maps to
  meaningful Hz modulation (±cutoff value). Same fix for LFO→Osc freq.
  Mod scale updates dynamically when user changes the base param value.
- Visual modulation indicator: knobs receiving LFO/modulation show a
  pulsing dashed ring animation (spin + pulse) around the knob arc
- Persist hint usage per level: using a hint permanently caps that level
  at 2 stars — survives reload/restart. No more cheating by restarting!
- Hint state stored in separate localStorage key (synthquest-hints)
- Admin reset also clears hint history

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 02:44:28 +01:00
Jose Luis
c4a2cb3cef feat: admin mode, worlds 4-6, and stereo output fix
- Admin panel: add/remove stars, unlock worlds, reset progress (🛠 button)
- World 4 "Modulación" (8 levels): vibrato, sirena, wah-wah, auto-pan, FM, wobble bass
- World 5 "Efectos" (8 levels): delay, slapback, reverb, distortion, dub echo, shoegaze, ambient
- World 6 "Diseño Sonoro" (8 levels): kick, hi-hat, snare, pad, reese bass, laser, trance arp, final boss
- Star unlock progression: W4=36★, W5=48★, W6=60★ (total 48 levels, 144 stars)
- Fix stereo output: left/right channels now route through Tone.Merge for true stereo separation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 02:38:17 +01:00
Jose Luis
41d993183f feat: add Worlds 2-3, patch persistence, and zoom controls
- World 2 (Filtros): 8 levels teaching filters, resonance, LFO modulation, acid bass
- World 3 (Envelopes): 8 levels teaching VCA, ADSR, pluck, tremolo, full synth lead
- Star-based world unlock system (12 stars for W2, 24 for W3)
- Level patch persistence: auto-saves player patches, restores on revisit
- Google Maps-style zoom controls (+/−/reset) in both puzzle and sandbox views
- Multi-world navigation in GameApp and WorldMap
- Target audio now supports filter chain for World 2 levels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 02:28:36 +01:00
43 changed files with 10938 additions and 405 deletions

View File

@@ -2,9 +2,14 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Reaktor — MontLab Modular Synth</title> <title>Reaktor — MontLab Modular Synth</title>
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="manifest" href="/manifest.json" />
<meta name="theme-color" content="#00e5ff" />
<link rel="apple-touch-icon" href="/icon-192.png" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

15
public/manifest.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "Reaktor — MontLab Modular Synth",
"short_name": "Reaktor",
"description": "Modular synthesizer & SynthQuest puzzle game",
"start_url": "/",
"display": "standalone",
"orientation": "any",
"background_color": "#08080f",
"theme_color": "#00e5ff",
"icons": [
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
]
}

33
public/sw.js Normal file
View File

@@ -0,0 +1,33 @@
const CACHE_NAME = 'reaktor-v1';
self.addEventListener('install', (e) => {
self.skipWaiting();
});
self.addEventListener('activate', (e) => {
e.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', (e) => {
// Only cache GET requests, skip API calls
if (e.request.method !== 'GET') return;
e.respondWith(
caches.match(e.request).then(cached => {
const fetching = fetch(e.request).then(response => {
if (response.ok) {
const clone = response.clone();
caches.open(CACHE_NAME).then(cache => cache.put(e.request, clone));
}
return response;
}).catch(() => cached);
return cached || fetching;
})
);
});

View File

@@ -3,11 +3,16 @@ import { state, subscribe, addModule, emit, addConnection, updateModulePosition,
import { startAudio, stopAudio, connectWire, rebuildGraph } from './engine/audioEngine.js'; import { startAudio, stopAudio, connectWire, rebuildGraph } from './engine/audioEngine.js';
import { getModuleDef } from './engine/moduleRegistry.js'; import { getModuleDef } from './engine/moduleRegistry.js';
import { autoSave, autoLoad, exportPatch, importPatch } from './engine/presets.js'; import { autoSave, autoLoad, exportPatch, importPatch } from './engine/presets.js';
import { playEngineStart, playEngineStop } from './engine/uiSounds.js';
import ModuleNode from './components/ModuleNode.jsx'; import ModuleNode from './components/ModuleNode.jsx';
import WireLayer from './components/WireLayer.jsx'; import WireLayer from './components/WireLayer.jsx';
import ModulePalette from './components/ModulePalette.jsx'; import ModulePalette from './components/ModulePalette.jsx';
import PresetModal from './components/PresetModal.jsx'; import PresetModal from './components/PresetModal.jsx';
import BottomSheet from './components/BottomSheet.jsx';
import { CHIPTUNE_PRESET } from './presets/chiptune.js'; import { CHIPTUNE_PRESET } from './presets/chiptune.js';
import { useIsMobile } from './hooks/useIsMobile.js';
import { usePinchZoom } from './hooks/usePinchZoom.js';
import { getModulesByCategory } from './engine/moduleRegistry.js';
export default function App({ onSwitchToGame }) { export default function App({ onSwitchToGame }) {
const [, forceUpdate] = useState(0); const [, forceUpdate] = useState(0);
@@ -17,6 +22,13 @@ export default function App({ onSwitchToGame }) {
const connectingRef = useRef(null); const connectingRef = useRef(null);
const [presetModal, setPresetModal] = useState(null); const [presetModal, setPresetModal] = useState(null);
const importRef = useRef(null); const importRef = useRef(null);
const isMobile = useIsMobile();
const [menuOpen, setMenuOpen] = useState(false);
// Pinch-to-zoom on mobile
const getZoom = useCallback(() => state.zoom, []);
const setZoom = useCallback((z) => { state.zoom = z; emit(); }, []);
usePinchZoom(containerRef, getZoom, setZoom);
// Subscribe to state changes // Subscribe to state changes
useEffect(() => { useEffect(() => {
@@ -85,10 +97,17 @@ export default function App({ onSwitchToGame }) {
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY }; state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
e.preventDefault(); e.preventDefault();
} else if (e.button === 0 && !connectingRef.current) { } else if (e.button === 0 && !connectingRef.current) {
// On mobile (touch), single finger on empty canvas = pan
if (isMobile && e.pointerType === 'touch') {
state.panning = true;
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
e.preventDefault();
return;
}
state.selectedModuleId = null; state.selectedModuleId = null;
emit(); emit();
} }
}, []); }, [isMobile]);
const handlePointerMove = useCallback((e) => { const handlePointerMove = useCallback((e) => {
if (state.panning && state.panStart) { if (state.panning && state.panStart) {
@@ -174,11 +193,49 @@ export default function App({ onSwitchToGame }) {
const handleContextMenu = useCallback((e) => e.preventDefault(), []); const handleContextMenu = useCallback((e) => e.preventDefault(), []);
// Zoom controls (Google Maps style)
const handleZoomIn = useCallback(() => {
state.zoom = Math.min(3, state.zoom * 1.25);
emit();
}, []);
const handleZoomOut = useCallback(() => {
state.zoom = Math.max(0.3, state.zoom / 1.25);
emit();
}, []);
const handleZoomReset = useCallback(() => {
state.zoom = 1;
state.camX = 0;
state.camY = 0;
emit();
}, []);
// Center view on all modules
const handleCenterView = useCallback(() => {
if (state.modules.length === 0) { state.camX = 0; state.camY = 0; emit(); return; }
const container = containerRef.current;
const cw = container?.clientWidth || 800;
const ch = container?.clientHeight || 600;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const m of state.modules) {
minX = Math.min(minX, m.x);
minY = Math.min(minY, m.y);
maxX = Math.max(maxX, m.x + 200);
maxY = Math.max(maxY, m.y + 150);
}
const cx = (minX + maxX) / 2 * state.zoom;
const cy = (minY + maxY) / 2 * state.zoom;
state.camX = cw / 2 - cx;
state.camY = ch / 2 - cy;
emit();
}, []);
const handleToggleAudio = async () => { const handleToggleAudio = async () => {
if (state.isRunning) { if (state.isRunning) {
stopAudio(); stopAudio();
playEngineStop();
} else { } else {
await startAudio(); await startAudio();
playEngineStart();
} }
emit(); emit();
}; };
@@ -204,41 +261,88 @@ export default function App({ onSwitchToGame }) {
emit(); emit();
}; };
const handleClearCanvas = () => {
if (state.isRunning) stopAudio();
deserialize({ modules: [], connections: [] });
emit();
};
// Flatten all modules for mobile grid
const allModuleDefs = Object.values(getModulesByCategory()).flat();
return ( return (
<div className="app"> <div className="app">
{/* Toolbar */} {/* Toolbar */}
<div className="toolbar"> <div className="toolbar">
{onSwitchToGame && ( {onSwitchToGame && !isMobile && (
<button className="toolbar-btn" onClick={onSwitchToGame} style={{ color: 'var(--yellow)' }}> <button className="toolbar-btn" onClick={onSwitchToGame} style={{ color: 'var(--yellow)' }}>
🎮 Game 🎮 Game
</button> </button>
)} )}
<span className="toolbar-title">Reaktor</span> <span className="toolbar-title">Reaktor</span>
<div className="toolbar-sep" /> {!isMobile && <div className="toolbar-sep" />}
<button className={`toolbar-btn ${state.isRunning ? 'active' : ''}`} onClick={handleToggleAudio}> {!isMobile && (
<button className={`toolbar-btn start-btn ${state.isRunning ? 'active' : ''}`} onClick={handleToggleAudio}>
{state.isRunning ? '⏹ Stop' : '▶ Start'} {state.isRunning ? '⏹ Stop' : '▶ Start'}
</button> </button>
<div className="toolbar-sep" /> )}
{!isMobile && <div className="toolbar-sep" />}
{!isMobile && (
<div className="toolbar-group"> <div className="toolbar-group">
<button className="toolbar-btn" onClick={() => setPresetModal('save')}>💾 Save</button> <button className="toolbar-btn save-btn" onClick={() => setPresetModal('save')}>💾 Save</button>
<button className="toolbar-btn" onClick={() => setPresetModal('load')}>📂 Load</button> <button className="toolbar-btn load-btn" onClick={() => setPresetModal('load')}>📂 Load</button>
<button className="toolbar-btn" onClick={exportPatch}>📤 Export</button> <button className="toolbar-btn export-btn" onClick={exportPatch}>📤 Export</button>
<button className="toolbar-btn" onClick={() => importRef.current?.click()}>📥 Import</button> <button className="toolbar-btn import-btn" onClick={() => importRef.current?.click()}>📥 Import</button>
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} /> <input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
</div> </div>
<div className="toolbar-sep" /> )}
<button className="toolbar-btn" onClick={handleLoadDemo} style={{ color: 'var(--yellow)' }}> {!isMobile && <div className="toolbar-sep" />}
{!isMobile && (
<>
<button className="toolbar-btn demo-btn" onClick={handleLoadDemo} style={{ color: 'var(--yellow)' }}>
🎮 Chiptune Demo 🎮 Chiptune Demo
</button> </button>
<button className="toolbar-btn clear-btn" onClick={handleClearCanvas}>
🗑 Limpiar
</button>
<div className="toolbar-sep" /> <div className="toolbar-sep" />
<span className="toolbar-label" style={{ color: state.isRunning ? 'var(--green)' : 'var(--text2)' }}> </>
)}
<span className="toolbar-label" style={{ color: state.isRunning ? 'var(--green)' : 'var(--text2)', marginLeft: isMobile ? 'auto' : undefined }}>
{state.isRunning ? '● LIVE' : '○ OFF'} {state.isRunning ? '● LIVE' : '○ OFF'}
</span> </span>
{!isMobile && (
<span className="toolbar-label" style={{ marginLeft: 'auto' }}> <span className="toolbar-label" style={{ marginLeft: 'auto' }}>
{state.modules.length} modules · {state.connections.length} wires {state.modules.length} modules · {state.connections.length} wires
</span> </span>
)}
{isMobile && (
<button className="mobile-menu-btn" onClick={() => setMenuOpen(true)}></button>
)}
</div> </div>
{/* Mobile menu overlay */}
{isMobile && menuOpen && (
<div className="mobile-menu-overlay" onClick={() => setMenuOpen(false)}>
<div className="mobile-menu-panel" onClick={e => e.stopPropagation()}>
{onSwitchToGame && (
<button className="toolbar-btn" onClick={() => { setMenuOpen(false); onSwitchToGame(); }} style={{ color: 'var(--yellow)' }}>
🎮 Game
</button>
)}
<button className="toolbar-btn" onClick={() => { setPresetModal('save'); setMenuOpen(false); }}>💾 Save</button>
<button className="toolbar-btn" onClick={() => { setPresetModal('load'); setMenuOpen(false); }}>📂 Load</button>
<button className="toolbar-btn" onClick={() => { exportPatch(); setMenuOpen(false); }}>📤 Export</button>
<button className="toolbar-btn" onClick={() => { importRef.current?.click(); setMenuOpen(false); }}>📥 Import</button>
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
<button className="toolbar-btn" onClick={() => { handleLoadDemo(); setMenuOpen(false); }} style={{ color: 'var(--yellow)' }}>
🎮 Chiptune Demo
</button>
<button className="toolbar-btn" onClick={() => { handleClearCanvas(); setMenuOpen(false); }}>🗑 Limpiar</button>
</div>
</div>
)}
{/* Main canvas area */} {/* Main canvas area */}
<div className="main-area"> <div className="main-area">
<div <div
@@ -262,10 +366,10 @@ export default function App({ onSwitchToGame }) {
<rect width="100%" height="100%" fill="url(#grid)" /> <rect width="100%" height="100%" fill="url(#grid)" />
</svg> </svg>
{/* Wire layer (behind modules, uses getBoundingClientRect) */} {/* Wire layer */}
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} zoom={state.zoom} camX={state.camX} camY={state.camY} /> <WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} zoom={state.zoom} camX={state.camX} camY={state.camY} />
{/* Modules container (offset by camera) */} {/* Modules container */}
<div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}> <div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
{state.modules.map(mod => ( {state.modules.map(mod => (
<ModuleNode <ModuleNode
@@ -279,11 +383,50 @@ export default function App({ onSwitchToGame }) {
</div> </div>
</div> </div>
{/* Module palette */} {/* Zoom controls */}
<ModulePalette onAddModule={handleAddModule} /> <div className="zoom-controls">
<button className="zoom-btn" onClick={handleZoomIn} title="Zoom in">+</button>
<button className="zoom-btn zoom-label" onClick={handleZoomReset} title="Reset zoom">
{(state.zoom * 100).toFixed(0)}%
</button>
<button className="zoom-btn" onClick={handleZoomOut} title="Zoom out"></button>
<button className="zoom-btn" onClick={handleCenterView} title="Centrar vista"></button>
</div> </div>
{/* Status bar */} {/* Desktop palette */}
{!isMobile && <ModulePalette onAddModule={handleAddModule} />}
</div>
{/* Mobile action bar */}
{isMobile && (
<div className="mobile-action-bar">
<button
className={`start-btn-mobile ${state.isRunning ? 'active' : ''}`}
onClick={handleToggleAudio}
>
{state.isRunning ? '⏹ STOP' : '▶ START'}
</button>
<button className="action-icon-btn" onClick={() => setPresetModal('save')}>💾</button>
<button className="action-icon-btn" onClick={exportPatch}>📤</button>
<button className="action-icon-btn" onClick={handleClearCanvas}>🗑</button>
</div>
)}
{/* Mobile bottom sheet with modules */}
{isMobile && (
<BottomSheet>
<div className="mobile-module-grid">
{allModuleDefs.map(def => (
<div key={def.type} className="mobile-module-tile" onClick={() => handleAddModule(def.type)}>
<span className="tile-icon">{def.icon}</span>
<span className="tile-name">{def.name}</span>
</div>
))}
</div>
</BottomSheet>
)}
{/* Status bar (hidden on mobile via CSS) */}
<div className="status-bar"> <div className="status-bar">
<span className="status-accent">Reaktor MontLab Modular Synth</span> <span className="status-accent">Reaktor MontLab Modular Synth</span>
<span>Zoom: {(state.zoom * 100).toFixed(0)}%</span> <span>Zoom: {(state.zoom * 100).toFixed(0)}%</span>

View File

@@ -0,0 +1,52 @@
import { useState, useRef, useCallback } from 'react';
export default function BottomSheet({ tabs, activeTab, onTabChange, children, className = '' }) {
const [expanded, setExpanded] = useState(false);
const startY = useRef(0);
const handleTouchStart = useCallback((e) => {
startY.current = e.touches[0].clientY;
}, []);
const handleTouchEnd = useCallback((e) => {
const deltaY = e.changedTouches[0].clientY - startY.current;
if (deltaY < -30) setExpanded(true);
if (deltaY > 30) setExpanded(false);
}, []);
return (
<div
className={`bottom-sheet ${expanded ? 'expanded' : 'collapsed'} ${className}`}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
<div className="bottom-sheet-handle" onClick={() => setExpanded(v => !v)}>
<div className="bottom-sheet-handle-bar" />
{!expanded && !tabs && (
<span className="bottom-sheet-peek-label">Modulos </span>
)}
</div>
{tabs && tabs.length > 0 && (
<div className="bottom-sheet-tabs" onClick={() => !expanded && setExpanded(true)}>
{tabs.map(tab => (
<button
key={tab.id}
className={`bottom-sheet-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => { onTabChange?.(tab.id); setExpanded(true); }}
>
{tab.label}
{activeTab === tab.id && <div className="bottom-sheet-tab-line" />}
</button>
))}
</div>
)}
{expanded && (
<div className="bottom-sheet-content">
{children}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,109 @@
import React, { useCallback, useState } from 'react';
import { createPortal } from 'react-dom';
import { triggerKeyboard } from '../engine/audioEngine.js';
// 4x4 pad layout — each pad maps to a MIDI note
const PAD_NOTES = [
{ note: 36, label: 'C2', color: '#ff4466' },
{ note: 38, label: 'D2', color: '#ff6644' },
{ note: 40, label: 'E2', color: '#ffcc00' },
{ note: 42, label: 'F#2', color: '#44ff88' },
{ note: 43, label: 'G2', color: '#00e5ff' },
{ note: 45, label: 'A2', color: '#aa55ff' },
{ note: 47, label: 'B2', color: '#ff4466' },
{ note: 48, label: 'C3', color: '#ff6644' },
{ note: 50, label: 'D3', color: '#ffcc00' },
{ note: 52, label: 'E3', color: '#44ff88' },
{ note: 53, label: 'F3', color: '#00e5ff' },
{ note: 55, label: 'G3', color: '#aa55ff' },
{ note: 57, label: 'A3', color: '#ff4466' },
{ note: 59, label: 'B3', color: '#ff6644' },
{ note: 60, label: 'C4', color: '#ffcc00' },
{ note: 62, label: 'D4', color: '#44ff88' },
];
function midiToFreq(midi) {
return 440 * Math.pow(2, (midi - 69) / 12);
}
function FullscreenDrumPad({ moduleId, onClose }) {
const [activePad, setActivePad] = useState(-1);
const hitPad = useCallback((pad, idx) => {
triggerKeyboard(moduleId, midiToFreq(pad.note), true);
setActivePad(idx);
setTimeout(() => {
triggerKeyboard(moduleId, midiToFreq(pad.note), false);
setActivePad(-1);
}, 150);
}, [moduleId]);
return (
<div className="drumpad-fullscreen">
<div className="drumpad-fs-header">
<span className="drumpad-fs-title">🥁 Drum Pads</span>
<button className="drumpad-fs-close" onClick={onClose}></button>
</div>
<div className="drumpad-fs-grid">
{PAD_NOTES.map((pad, i) => (
<div
key={i}
className="drumpad-fs-pad"
style={{
background: activePad === i ? pad.color : `${pad.color}15`,
borderColor: activePad === i ? pad.color : `${pad.color}40`,
color: activePad === i ? '#000' : pad.color,
}}
onPointerDown={() => hitPad(pad, i)}
>
{pad.label}
<span className="pad-label">{i + 1}</span>
</div>
))}
</div>
</div>
);
}
export default function DrumPadWidget({ moduleId, fullscreen, onCloseFullscreen }) {
const [activePad, setActivePad] = useState(-1);
const hitPad = useCallback((pad, idx) => {
triggerKeyboard(moduleId, midiToFreq(pad.note), true);
setActivePad(idx);
setTimeout(() => {
triggerKeyboard(moduleId, midiToFreq(pad.note), false);
setActivePad(-1);
}, 150);
}, [moduleId]);
return (
<>
<div>
<div className="drumpad-grid">
{PAD_NOTES.map((pad, i) => (
<div
key={i}
className={`drumpad-pad ${activePad === i ? 'active' : ''}`}
style={{
background: activePad === i ? pad.color : `${pad.color}15`,
borderColor: `${pad.color}60`,
}}
onPointerDown={(e) => { e.stopPropagation(); hitPad(pad, i); }}
>
{pad.label}
</div>
))}
</div>
<div style={{ fontSize: 9, color: 'var(--text2)', textAlign: 'center', marginTop: 2 }}>
Tap pads to trigger
</div>
</div>
{fullscreen && createPortal(
<FullscreenDrumPad moduleId={moduleId} onClose={onCloseFullscreen} />,
document.body
)}
</>
);
}

View File

@@ -1,10 +1,10 @@
import React, { useCallback, useEffect, useRef } from 'react'; import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { triggerKeyboard } from '../engine/audioEngine.js'; import { triggerKeyboard } from '../engine/audioEngine.js';
import { state } from '../engine/state.js'; import { state } from '../engine/state.js';
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
// Computer keyboard to semitone offset mapping (2 octaves)
const KEY_MAP = { const KEY_MAP = {
'z': 0, 's': 1, 'x': 2, 'd': 3, 'c': 4, 'v': 5, 'g': 6, 'z': 0, 's': 1, 'x': 2, 'd': 3, 'c': 4, 'v': 5, 'g': 6,
'b': 7, 'h': 8, 'n': 9, 'j': 10, 'm': 11, ',': 12, 'b': 7, 'h': 8, 'n': 9, 'j': 10, 'm': 11, ',': 12,
@@ -16,7 +16,87 @@ function midiToFreq(midi) {
return 440 * Math.pow(2, (midi - 69) / 12); return 440 * Math.pow(2, (midi - 69) / 12);
} }
export default function KeyboardWidget({ moduleId }) { // Fullscreen piano — 1 octave, big comfortable keys like a real piano app
function FullscreenPiano({ moduleId, initialOctave, onClose }) {
const [oct, setOct] = useState(initialOctave);
const [activeNotes, setActiveNotes] = useState(new Set());
const play = useCallback((semitone) => {
const midi = (oct + 1) * 12 + semitone;
triggerKeyboard(moduleId, midiToFreq(midi), true);
setActiveNotes(prev => new Set(prev).add(semitone));
}, [moduleId, oct]);
const stop = useCallback((semitone) => {
setActiveNotes(prev => {
const next = new Set(prev);
next.delete(semitone);
if (next.size === 0) triggerKeyboard(moduleId, 440, false);
return next;
});
}, [moduleId]);
// 1 octave: 7 white keys, 5 black keys
const whiteKeys = [
{ note: 0, name: 'C' },
{ note: 2, name: 'D' },
{ note: 4, name: 'E' },
{ note: 5, name: 'F' },
{ note: 7, name: 'G' },
{ note: 9, name: 'A' },
{ note: 11, name: 'B' },
];
// Black key positions relative to white key index (0-6)
const blackKeys = [
{ note: 1, name: 'C#', after: 0 },
{ note: 3, name: 'D#', after: 1 },
{ note: 6, name: 'F#', after: 3 },
{ note: 8, name: 'G#', after: 4 },
{ note: 10, name: 'A#', after: 5 },
];
return (
<div className="keyboard-fullscreen">
<div className="keyboard-fs-header">
<button className="keyboard-fs-close" onClick={onClose}></button>
<button className="keyboard-fs-oct-btn" onClick={() => setOct(o => Math.max(1, o - 1))}></button>
<span className="keyboard-fs-title">Octave {oct}</span>
<button className="keyboard-fs-oct-btn" onClick={() => setOct(o => Math.min(8, o + 1))}></button>
<div style={{ width: 36 }} />
</div>
<div className="keyboard-fs-keys">
{whiteKeys.map((k, i) => (
<div
key={k.note}
className={`keyboard-fs-white ${activeNotes.has(k.note) ? 'pressed' : ''}`}
onPointerDown={() => play(k.note)}
onPointerUp={() => stop(k.note)}
onPointerLeave={() => stop(k.note)}
onPointerCancel={() => stop(k.note)}
>
<span className="keyboard-fs-note-label">{k.name}{oct}</span>
</div>
))}
{blackKeys.map((k) => (
<div
key={k.note}
className={`keyboard-fs-black ${activeNotes.has(k.note) ? 'pressed' : ''}`}
style={{ left: `${(k.after + 0.65) * (100 / 7)}%`, width: `${(100 / 7) * 0.65}%` }}
onPointerDown={() => play(k.note)}
onPointerUp={() => stop(k.note)}
onPointerLeave={() => stop(k.note)}
onPointerCancel={() => stop(k.note)}
>
<span className="keyboard-fs-black-label">{k.name}</span>
</div>
))}
</div>
</div>
);
}
export default function KeyboardWidget({ moduleId, fullscreen, onCloseFullscreen }) {
const mod = state.modules.find(m => m.id === moduleId); const mod = state.modules.find(m => m.id === moduleId);
const octave = mod?.params?.octave ?? 4; const octave = mod?.params?.octave ?? 4;
const activeKeys = useRef(new Set()); const activeKeys = useRef(new Set());
@@ -47,7 +127,6 @@ export default function KeyboardWidget({ moduleId }) {
if (activeKeys.current.size === 0) stopNote(); if (activeKeys.current.size === 0) stopNote();
} }
}; };
window.addEventListener('keydown', handleDown); window.addEventListener('keydown', handleDown);
window.addEventListener('keyup', handleUp); window.addEventListener('keyup', handleUp);
return () => { return () => {
@@ -56,11 +135,12 @@ export default function KeyboardWidget({ moduleId }) {
}; };
}, [playNote, stopNote]); }, [playNote, stopNote]);
// Draw mini keyboard (1 octave) // Mini keyboard (1 octave)
const whites = [0, 2, 4, 5, 7, 9, 11]; const whites = [0, 2, 4, 5, 7, 9, 11];
const blacks = [1, 3, -1, 6, 8, 10]; const blacks = [1, 3, -1, 6, 8, 10];
return ( return (
<>
<div style={{ padding: '2px 0' }}> <div style={{ padding: '2px 0' }}>
<svg viewBox="0 0 140 30" style={{ width: '100%', height: 30 }}> <svg viewBox="0 0 140 30" style={{ width: '100%', height: 30 }}>
{whites.map((note, i) => ( {whites.map((note, i) => (
@@ -87,5 +167,15 @@ export default function KeyboardWidget({ moduleId }) {
Z-M / Q-I keys · Oct {octave} Z-M / Q-I keys · Oct {octave}
</div> </div>
</div> </div>
{fullscreen && createPortal(
<FullscreenPiano
moduleId={moduleId}
initialOctave={octave}
onClose={onCloseFullscreen}
/>,
document.body
)}
</>
); );
} }

View File

@@ -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}`; 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 }) { export default function Knob({ value, min, max, onChange, color = 'var(--accent)', label, unit, formatValue, modulated = false, liveValue }) {
const ref = useRef(null); const ref = useRef(null);
const dragRef = useRef(null); const dragRef = useRef(null);
const inputRef = useRef(null); const inputRef = useRef(null);
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [editText, setEditText] = useState(''); 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 angleDeg = START_ANGLE - norm * RANGE;
const cx = SIZE / 2, cy = SIZE / 2; 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 dotPos = polarToCart(cx, cy, RADIUS - 4, angleDeg);
const displayVal = formatValue ? formatValue(value) : // Also show base value indicator when modulated
value >= 1000 ? `${(value / 1000).toFixed(1)}k` : const baseNorm = Math.max(0, Math.min(1, (value - min) / (max - min)));
value >= 100 ? Math.round(value) : const baseAngle = START_ANGLE - baseNorm * RANGE;
value >= 1 ? value.toFixed(1) : const baseDotPos = polarToCart(cx, cy, RADIUS - 4, baseAngle);
value.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
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) => { const handlePointerDown = useCallback((e) => {
if (editing) return; if (editing) return;
@@ -122,11 +130,19 @@ export default function Knob({ value, min, max, onChange, color = 'var(--accent)
} }
return ( return (
<div className="knob-container" onWheel={handleWheel} onDoubleClick={handleDoubleClick}> <div className={`knob-container ${modulated ? 'knob-modulated' : ''}`} onWheel={handleWheel} onDoubleClick={handleDoubleClick}>
<svg className="knob-svg" viewBox={`0 0 ${SIZE} ${SIZE}`} <svg className="knob-svg" viewBox={`0 0 ${SIZE} ${SIZE}`}
onPointerDown={handlePointerDown} ref={ref}> onPointerDown={handlePointerDown} ref={ref}>
{/* Modulation glow ring */}
{modulated && (
<circle className="knob-mod-ring" cx={cx} cy={cy} r={RADIUS + 1} style={{ stroke: color }} />
)}
<path className="knob-track" d={trackPath} /> <path className="knob-track" d={trackPath} />
{fillPath && <path className="knob-fill" d={fillPath} style={{ stroke: color }} />} {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} /> <circle className="knob-dot" cx={dotPos.x} cy={dotPos.y} r={2} />
</svg> </svg>
</div> </div>

View File

@@ -0,0 +1,16 @@
export default function MobileTabBar({ tabs, activeTab, onTabChange }) {
return (
<nav className="mobile-tab-bar">
{tabs.map(tab => (
<button
key={tab.id}
className={`mobile-tab ${activeTab === tab.id ? 'active' : ''}`}
onClick={() => onTabChange(tab.id)}
>
<span className="mobile-tab-icon">{tab.icon}</span>
<span className="mobile-tab-label">{tab.label}</span>
</button>
))}
</nav>
);
}

View File

@@ -1,22 +1,130 @@
import React, { useCallback } from 'react'; import React, { useCallback, useState, useEffect, useRef } from 'react';
import { getModuleDef } from '../engine/moduleRegistry.js'; import { getModuleDef } from '../engine/moduleRegistry.js';
import { state, removeModule, updateModuleParam, isPortConnected, emit } from '../engine/state.js'; import { state, removeModule, updateModuleParam, isPortConnected, emit } from '../engine/state.js';
import { updateParam } from '../engine/audioEngine.js'; import { updateParam, getAudioNode } from '../engine/audioEngine.js';
import Knob from './Knob.jsx'; import Knob from './Knob.jsx';
import ScopeDisplay from './ScopeDisplay.jsx'; import ScopeDisplay from './ScopeDisplay.jsx';
import KeyboardWidget from './KeyboardWidget.jsx'; import KeyboardWidget from './KeyboardWidget.jsx';
import DrumPadWidget from './DrumPadWidget.jsx';
import SequencerWidget from './SequencerWidget.jsx'; import SequencerWidget from './SequencerWidget.jsx';
import PianoRollWidget from './PianoRollWidget.jsx'; import PianoRollWidget from './PianoRollWidget.jsx';
// Dynamic module widths for sequencer/pianoroll based on step/bar count
function getModuleWidth(mod, type) {
if (type === 'sequencer') {
const numSteps = parseInt(mod?.params?.steps || '16');
return Math.max(200, numSteps * 18 + 20); // CELL_W=18 + padding
}
if (type === 'pianoroll') {
const bars = parseInt(mod?.params?.bars || '4');
const totalBeats = bars * 4;
return 24 + totalBeats * 30 + 20; // KEY_W + beats*BEAT_PX + padding
}
return undefined;
}
// Map input port names → the param name they modulate (for visual feedback)
const PORT_TO_PARAM = {
filter: { cutoff: 'frequency' },
oscillator: { freq: 'frequency', detune: 'detune' },
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 }) { export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }) {
const def = getModuleDef(mod.type); const def = getModuleDef(mod.type);
if (!def) return null; if (!def) return null;
const isSelected = state.selectedModuleId === mod.id; const isSelected = state.selectedModuleId === mod.id;
const [fullscreen, setFullscreen] = useState(false);
// Merge default params // Merge default params
const params = { ...Object.fromEntries(Object.entries(def.params).map(([k, v]) => [k, v.default])), ...mod.params }; const params = { ...Object.fromEntries(Object.entries(def.params).map(([k, v]) => [k, v.default])), ...mod.params };
// Find which params are being modulated (have an incoming connection on their corresponding port)
const modulatedParams = new Set();
const portMap = PORT_TO_PARAM[mod.type] || {};
for (const conn of state.connections) {
if (conn.to.moduleId === mod.id && portMap[conn.to.port]) {
modulatedParams.add(portMap[conn.to.port]);
}
}
// ==================== Live modulation visualization (LFO + Envelope + any CV) ====================
const [liveValues, setLiveValues] = useState({});
const rafRef = useRef(null);
const startTimeRef = useRef(performance.now() / 1000);
useEffect(() => {
if (modulatedParams.size === 0) {
setLiveValues({});
return;
}
let frameCount = 0;
const tick = () => {
frameCount++;
rafRef.current = requestAnimationFrame(tick);
if (frameCount % 4 !== 0) return;
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) continue;
if (srcMod.type === 'lfo') {
// LFO: simulate waveform for smooth visual
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;
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;
} else if (srcMod.type === 'envelope') {
// Envelope: read the actual audio node gain value for real-time display
const audioEntry = getAudioNode(mod.id);
if (audioEntry?.node?.gain) {
const currentGain = audioEntry.node.gain.value;
newValues[paramName] = currentGain;
}
}
}
setLiveValues(newValues);
};
rafRef.current = requestAnimationFrame(tick);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [mod.id, mod.type, modulatedParams.size]);
const handleParamChange = useCallback((name, value) => { const handleParamChange = useCallback((name, value) => {
updateModuleParam(mod.id, name, value); updateModuleParam(mod.id, name, value);
updateParam(mod.id, name, value); updateParam(mod.id, name, value);
@@ -70,7 +178,7 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
style={{ style={{
left: mod.x * zoom, top: mod.y * zoom, left: mod.x * zoom, top: mod.y * zoom,
transform: `scale(${zoom})`, transformOrigin: 'top left', transform: `scale(${zoom})`, transformOrigin: 'top left',
...(mod.type === 'pianoroll' ? { width: 520 } : mod.type === 'sequencer' ? { width: 310 } : {}), ...(mod.type === 'pianoroll' ? { width: getModuleWidth(mod, 'pianoroll') } : mod.type === 'sequencer' ? { width: getModuleWidth(mod, 'sequencer') } : {}),
}} }}
data-module-id={mod.id} data-module-id={mod.id}
onPointerDown={(e) => { onPointerDown={(e) => {
@@ -82,6 +190,13 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
<div className="module-header" onPointerDown={handleHeaderDown}> <div className="module-header" onPointerDown={handleHeaderDown}>
<span className="type-icon">{def.icon}</span> <span className="type-icon">{def.icon}</span>
<span className="type-name">{def.name}</span> <span className="type-name">{def.name}</span>
{(mod.type === 'keyboard' || mod.type === 'drumpad') && (
<button
className="expand-btn"
onClick={(e) => { e.stopPropagation(); setFullscreen(true); }}
title="Pantalla completa"
></button>
)}
<button className="close-btn" onClick={handleDelete}></button> <button className="close-btn" onClick={handleDelete}></button>
</div> </div>
@@ -117,12 +232,18 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
max={paramDef.max} max={paramDef.max}
onChange={v => handleParamChange(name, v)} onChange={v => handleParamChange(name, v)}
color={color} color={color}
modulated={modulatedParams.has(name)}
liveValue={liveValues[name]}
/> />
<span className="param-value"> <span className={`param-value ${liveValues[name] !== undefined ? 'param-value-live' : ''}`}>
{params[name] >= 1000 ? `${(params[name] / 1000).toFixed(1)}k` : {(() => {
params[name] >= 100 ? Math.round(params[name]) : const v = liveValues[name] !== undefined ? liveValues[name] : params[name];
params[name] >= 1 ? Number(params[name]).toFixed(1) : const s = v >= 1000 ? `${(v / 1000).toFixed(1)}k` :
Number(params[name]).toFixed(3).replace(/0+$/, '').replace(/\.$/, '')} v >= 100 ? Math.round(v) :
v >= 1 ? Number(v).toFixed(1) :
Number(v).toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
return s;
})()}
{paramDef.unit ? ` ${paramDef.unit}` : ''} {paramDef.unit ? ` ${paramDef.unit}` : ''}
</span> </span>
</div> </div>
@@ -146,7 +267,10 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
{mod.type === 'scope' && <ScopeDisplay moduleId={mod.id} />} {mod.type === 'scope' && <ScopeDisplay moduleId={mod.id} />}
{/* Keyboard widget */} {/* Keyboard widget */}
{mod.type === 'keyboard' && <KeyboardWidget moduleId={mod.id} />} {mod.type === 'keyboard' && <KeyboardWidget moduleId={mod.id} fullscreen={fullscreen} onCloseFullscreen={() => setFullscreen(false)} />}
{/* Drum Pad widget */}
{mod.type === 'drumpad' && <DrumPadWidget moduleId={mod.id} fullscreen={fullscreen} onCloseFullscreen={() => setFullscreen(false)} />}
{/* Sequencer widget */} {/* Sequencer widget */}
{mod.type === 'sequencer' && <SequencerWidget moduleId={mod.id} />} {mod.type === 'sequencer' && <SequencerWidget moduleId={mod.id} />}

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import * as Tone from 'tone'; import * as Tone from 'tone';
import { state, updateModuleParam, emit } from '../engine/state.js'; import { state, updateModuleParam, emit } from '../engine/state.js';
import { setSequencerSignals } from '../engine/audioEngine.js'; import { setSequencerSignals, subscribeTick, unsubscribeTick, MASTER_TICK_RATE } from '../engine/audioEngine.js';
import { parseMidi } from '../utils/midiParser.js'; import { parseMidi } from '../utils/midiParser.js';
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
@@ -79,7 +79,7 @@ const MARIO_MELODY = [
{ note: 71, start: 70*s, duration: 2*s }, // B4 { note: 71, start: 70*s, duration: 2*s }, // B4
]; ];
const ROLL_W = 500; const BEAT_PX = 30; // pixels per beat — constant density regardless of bar count
const ROLL_H = 200; const ROLL_H = 200;
const KEY_W = 24; const KEY_W = 24;
const MIN_NOTE = 48; // C3 const MIN_NOTE = 48; // C3
@@ -90,11 +90,10 @@ const ROW_H = ROLL_H / NOTE_RANGE;
export default function PianoRollWidget({ moduleId }) { export default function PianoRollWidget({ moduleId }) {
const mod = state.modules.find(m => m.id === moduleId); const mod = state.modules.find(m => m.id === moduleId);
const canvasRef = useRef(null); const canvasRef = useRef(null);
const partRef = useRef(null);
const [playPos, setPlayPos] = useState(-1);
const [tool, setTool] = useState('draw'); // 'draw' | 'erase' const [tool, setTool] = useState('draw'); // 'draw' | 'erase'
const drawingRef = useRef(null); const drawingRef = useRef(null);
const rafRef = useRef(null); const rafRef = useRef(null);
const playPosRef = useRef(-1);
const midiInputRef = useRef(null); const midiInputRef = useRef(null);
const bpm = mod?.params?.bpm ?? 140; const bpm = mod?.params?.bpm ?? 140;
@@ -110,7 +109,8 @@ export default function PianoRollWidget({ moduleId }) {
const notesRef = useRef(notes); const notesRef = useRef(notes);
notesRef.current = notes; notesRef.current = notes;
const beatW = (ROLL_W - KEY_W) / totalBeats; const rollW = KEY_W + totalBeats * BEAT_PX;
const beatW = BEAT_PX;
// Draw the piano roll // Draw the piano roll
const draw = useCallback(() => { const draw = useCallback(() => {
@@ -195,8 +195,9 @@ export default function PianoRollWidget({ moduleId }) {
} }
// Playhead // Playhead
if (playPos >= 0 && playPos < totalBeats) { const currentPlayPos = playPosRef.current;
const px = KEY_W + playPos * beatW; if (currentPlayPos >= 0 && currentPlayPos < totalBeats) {
const px = KEY_W + currentPlayPos * beatW;
ctx.strokeStyle = '#ff6644'; ctx.strokeStyle = '#ff6644';
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.beginPath(); ctx.beginPath();
@@ -220,7 +221,7 @@ export default function PianoRollWidget({ moduleId }) {
ctx.fillStyle = 'rgba(0,229,255,0.3)'; ctx.fillStyle = 'rgba(0,229,255,0.3)';
ctx.fillRect(x, row * ROW_H, Math.max(nw, 2), ROW_H); ctx.fillRect(x, row * ROW_H, Math.max(nw, 2), ROW_H);
} }
}, [totalBeats, beatW, playPos]); }, [totalBeats, beatW, rollW]);
// Animation loop // Animation loop
useEffect(() => { useEffect(() => {
@@ -232,66 +233,76 @@ export default function PianoRollWidget({ moduleId }) {
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }; return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
}, [draw]); }, [draw]);
// Playback // Subscribe to global master clock for playback
const bpmRef = useRef(bpm);
const loopRef = useRef(loop);
const totalBeatsRef = useRef(totalBeats);
bpmRef.current = bpm;
loopRef.current = loop;
totalBeatsRef.current = totalBeats;
useEffect(() => { useEffect(() => {
if (!state.isRunning) { if (!state.isRunning) {
if (partRef.current) { partRef.current.stop(); partRef.current.dispose(); partRef.current = null; } unsubscribeTick(`pr-${moduleId}`);
setPlayPos(-1); playPosRef.current = -1;
return; return;
} }
Tone.getTransport().bpm.value = bpm; let currentNote = null;
let lastQuantPos = -1;
// Build Tone.Part from notes using musical time (bars:quarters:sixteenths) subscribeTick(`pr-${moduleId}`, (time, ticks) => {
// This lets the Transport BPM control actual playback speed const currentBpm = bpmRef.current;
const events = notesRef.current.map(n => { const currentLoop = loopRef.current;
// Convert beats to bars:quarters:sixteenths notation const currentTotalBeats = totalBeatsRef.current;
const totalSixteenths = Math.round(n.start * 4);
const barNum = Math.floor(totalSixteenths / 16); // Convert ticks to beats: each beat = MASTER_TICK_RATE * 60 / bpm ticks
const remainder = totalSixteenths % 16; // Position in sixteenths: ticks / (ticksPerSixteenth)
const quarterNum = Math.floor(remainder / 4); const ticksPerBeat = MASTER_TICK_RATE * 60 / currentBpm;
const sixteenthNum = remainder % 4; const rawPos = ticks / ticksPerBeat; // in beats
return { const pos = currentLoop ? rawPos % currentTotalBeats : rawPos;
time: `${barNum}:${quarterNum}:${sixteenthNum}`, const quantPos = Math.floor(pos * 4) / 4;
note: n.note,
dur: n.duration, if (quantPos === lastQuantPos) return;
}; const looped = lastQuantPos >= 0 && quantPos < lastQuantPos;
lastQuantPos = quantPos;
if (!currentLoop && rawPos >= currentTotalBeats) {
if (currentNote) {
setSequencerSignals(moduleId, 0, false);
currentNote = null;
}
playPosRef.current = -1;
return;
}
playPosRef.current = pos;
if (looped && currentNote) {
setSequencerSignals(moduleId, 0, false);
currentNote = null;
}
const allNotes = notesRef.current;
const activeNote = allNotes.find(n => quantPos >= n.start && quantPos < n.start + n.duration);
if (activeNote) {
if (!currentNote || currentNote.note !== activeNote.note || currentNote.start !== activeNote.start) {
setSequencerSignals(moduleId, midiToFreq(activeNote.note), true);
currentNote = activeNote;
}
} else {
if (currentNote) {
setSequencerSignals(moduleId, 0, false);
currentNote = null;
}
}
}); });
const part = new Tone.Part((time, ev) => {
setSequencerSignals(moduleId, midiToFreq(ev.note), true);
// Note-off: convert duration beats to musical time for proper BPM-relative timing
const durSixteenths = Math.round(ev.dur * 4);
const noteOffTime = time + (durSixteenths * (60 / (bpm * 4))) * 0.9;
Tone.getTransport().scheduleOnce(() => {
setSequencerSignals(moduleId, midiToFreq(ev.note), false);
}, noteOffTime);
}, events.map(ev => [ev.time, { note: ev.note, dur: ev.dur }]));
part.loop = loop;
part.loopEnd = `${bars}m`;
part.start(0);
if (Tone.getTransport().state !== 'started') {
Tone.getTransport().start();
}
partRef.current = part;
// Track playhead position
const posInterval = setInterval(() => {
if (Tone.getTransport().state === 'started') {
const pos = Tone.getTransport().seconds;
const beatDuration = 60 / bpm;
const currentBeat = (pos / beatDuration) % totalBeats;
setPlayPos(currentBeat);
}
}, 30);
return () => { return () => {
clearInterval(posInterval); unsubscribeTick(`pr-${moduleId}`);
if (partRef.current) { partRef.current.stop(); partRef.current.dispose(); partRef.current = null; }
}; };
}, [state.isRunning, moduleId, bpm, bars, loop]); }, [state.isRunning, moduleId]);
// Mouse interaction for drawing/erasing notes // Mouse interaction for drawing/erasing notes
const handleMouseDown = useCallback((e) => { const handleMouseDown = useCallback((e) => {
@@ -398,7 +409,7 @@ export default function PianoRollWidget({ moduleId }) {
}, [mod]); }, [mod]);
return ( return (
<div style={{ width: ROLL_W }}> <div style={{ width: rollW }}>
{/* Mini toolbar */} {/* Mini toolbar */}
<div style={{ display: 'flex', gap: 4, marginBottom: 3 }}> <div style={{ display: 'flex', gap: 4, marginBottom: 3 }}>
<button <button
@@ -436,9 +447,9 @@ export default function PianoRollWidget({ moduleId }) {
</div> </div>
<canvas <canvas
ref={canvasRef} ref={canvasRef}
width={ROLL_W} width={rollW}
height={ROLL_H} height={ROLL_H}
style={{ width: ROLL_W, height: ROLL_H, borderRadius: 4, cursor: tool === 'draw' ? 'crosshair' : 'pointer' }} style={{ width: rollW, height: ROLL_H, borderRadius: 4, cursor: tool === 'draw' ? 'crosshair' : 'pointer' }}
onPointerDown={handleMouseDown} onPointerDown={handleMouseDown}
/> />
</div> </div>

View File

@@ -1,9 +1,19 @@
import React, { useRef, useEffect } from 'react'; import React, { useRef, useEffect, useState } from 'react';
import { getAnalyserData } from '../engine/audioEngine.js'; import { getAnalyserData } from '../engine/audioEngine.js';
// Zoom levels: how many samples to display (from a 2048-sample buffer)
// Fewer samples = zoomed in (more detail), more samples = zoomed out (more time visible)
const ZOOM_LEVELS = [64, 128, 256, 512, 1024, 2048];
const DEFAULT_ZOOM = 2; // index → 256 samples
export default function ScopeDisplay({ moduleId }) { export default function ScopeDisplay({ moduleId }) {
const canvasRef = useRef(null); const canvasRef = useRef(null);
const rafRef = useRef(null); const rafRef = useRef(null);
const [zoomIdx, setZoomIdx] = useState(DEFAULT_ZOOM);
const zoomRef = useRef(ZOOM_LEVELS[DEFAULT_ZOOM]);
// Keep ref in sync so the draw loop picks it up without re-creating the effect
useEffect(() => { zoomRef.current = ZOOM_LEVELS[zoomIdx]; }, [zoomIdx]);
useEffect(() => { useEffect(() => {
const canvas = canvasRef.current; const canvas = canvasRef.current;
@@ -12,7 +22,13 @@ export default function ScopeDisplay({ moduleId }) {
const w = canvas.width = 160; const w = canvas.width = 160;
const h = canvas.height = 60; const h = canvas.height = 60;
let frameCount = 0;
const draw = () => { const draw = () => {
frameCount++;
rafRef.current = requestAnimationFrame(draw);
// Throttle to ~30fps to reduce main thread pressure during playback
if (frameCount % 2 !== 0) return;
ctx.fillStyle = '#050510'; ctx.fillStyle = '#050510';
ctx.fillRect(0, 0, w, h); ctx.fillRect(0, 0, w, h);
@@ -23,28 +39,74 @@ export default function ScopeDisplay({ moduleId }) {
ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2); ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2);
ctx.moveTo(0, h / 4); ctx.lineTo(w, h / 4); ctx.moveTo(0, h / 4); ctx.lineTo(w, h / 4);
ctx.moveTo(0, h * 3 / 4); ctx.lineTo(w, h * 3 / 4); ctx.moveTo(0, h * 3 / 4); ctx.lineTo(w, h * 3 / 4);
for (let x = w / 4; x < w; x += w / 4) {
ctx.moveTo(x, 0); ctx.lineTo(x, h);
}
ctx.stroke(); ctx.stroke();
const data = getAnalyserData(moduleId); const data = getAnalyserData(moduleId);
if (data && data.length > 0) { if (data && data.length > 0) {
const samplesToShow = zoomRef.current;
// Center the window in the buffer
const offset = Math.max(0, Math.floor((data.length - samplesToShow) / 2));
const end = Math.min(data.length, offset + samplesToShow);
ctx.strokeStyle = '#00e5ff'; ctx.strokeStyle = '#00e5ff';
ctx.lineWidth = 1.5; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.beginPath();
const step = w / data.length; const count = end - offset;
for (let i = 0; i < data.length; i++) { const step = w / count;
const y = h / 2 + data[i] * h / 2 * -1; for (let i = 0; i < count; i++) {
const y = h / 2 + data[offset + i] * h / 2 * -1;
if (i === 0) ctx.moveTo(0, y); if (i === 0) ctx.moveTo(0, y);
else ctx.lineTo(i * step, y); else ctx.lineTo(i * step, y);
} }
ctx.stroke(); ctx.stroke();
} }
rafRef.current = requestAnimationFrame(draw);
}; };
draw(); draw();
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); }; return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
}, [moduleId]); }, [moduleId]);
return <canvas ref={canvasRef} className="scope-canvas" />; const canZoomIn = zoomIdx > 0;
const canZoomOut = zoomIdx < ZOOM_LEVELS.length - 1;
return (
<div style={{ position: 'relative' }}>
<canvas ref={canvasRef} className="scope-canvas" />
<div style={{
position: 'absolute', bottom: 2, right: 2,
display: 'flex', gap: 2,
}}>
<button
onClick={() => canZoomOut && setZoomIdx(i => i + 1)}
disabled={!canZoomOut}
title="Zoom out (más tiempo)"
style={{
width: 18, height: 18, padding: 0,
background: canZoomOut ? '#1a1a3a' : '#0a0a15',
border: '1px solid #333', borderRadius: 3,
color: canZoomOut ? '#00e5ff' : '#333',
cursor: canZoomOut ? 'pointer' : 'default',
fontSize: 12, lineHeight: '16px', fontWeight: 'bold',
}}
></button>
<button
onClick={() => canZoomIn && setZoomIdx(i => i - 1)}
disabled={!canZoomIn}
title="Zoom in (más detalle)"
style={{
width: 18, height: 18, padding: 0,
background: canZoomIn ? '#1a1a3a' : '#0a0a15',
border: '1px solid #333', borderRadius: 3,
color: canZoomIn ? '#00e5ff' : '#333',
cursor: canZoomIn ? 'pointer' : 'default',
fontSize: 12, lineHeight: '16px', fontWeight: 'bold',
}}
>+</button>
</div>
</div>
);
} }

View File

@@ -1,14 +1,13 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'; import React, { useState, useEffect, useRef, useCallback } from 'react';
import * as Tone from 'tone'; import * as Tone from 'tone';
import { state, updateModuleParam, emit } from '../engine/state.js'; import { state, updateModuleParam, emit } from '../engine/state.js';
import { setSequencerSignals, getAudioNode } from '../engine/audioEngine.js'; import { setSequencerSignals, getAudioNode, subscribeTick, unsubscribeTick, MASTER_TICK_RATE } from '../engine/audioEngine.js';
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B']; const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
function midiToFreq(midi) { return 440 * Math.pow(2, (midi - 69) / 12); } function midiToFreq(midi) { return 440 * Math.pow(2, (midi - 69) / 12); }
function noteLabel(midi) { return NOTE_NAMES[midi % 12] + Math.floor(midi / 12 - 1); } function noteLabel(midi) { return NOTE_NAMES[midi % 12] + Math.floor(midi / 12 - 1); }
// Default notes: C minor pentatonic pattern
const DEFAULT_STEPS = [ const DEFAULT_STEPS = [
{ midi: 60, gate: true }, { midi: 63, gate: true }, { midi: 65, gate: false }, { midi: 67, gate: true }, { midi: 60, gate: true }, { midi: 63, gate: true }, { midi: 65, gate: false }, { midi: 67, gate: true },
{ midi: 70, gate: true }, { midi: 67, gate: true }, { midi: 63, gate: false }, { midi: 60, gate: true }, { midi: 70, gate: true }, { midi: 67, gate: true }, { midi: 63, gate: false }, { midi: 60, gate: true },
@@ -18,17 +17,24 @@ const DEFAULT_STEPS = [
export default function SequencerWidget({ moduleId }) { export default function SequencerWidget({ moduleId }) {
const mod = state.modules.find(m => m.id === moduleId); const mod = state.modules.find(m => m.id === moduleId);
const [currentStep, setCurrentStep] = useState(-1); const currentStepRef = useRef(-1);
const seqRef = useRef(null); const [visualStep, setVisualStep] = useState(-1);
const stepsRef = useRef(null); const stepsRef = useRef(null);
const rafRef = useRef(null);
// Init steps data // Init steps data
const numSteps = parseInt(mod?.params?.steps || '16'); const numSteps = parseInt(mod?.params?.steps || '16');
if (!mod?.params?._steps) { if (mod) {
if (!mod.params._steps) {
const initial = DEFAULT_STEPS.slice(0, numSteps); const initial = DEFAULT_STEPS.slice(0, numSteps);
while (initial.length < numSteps) initial.push({ midi: 60, gate: false }); while (initial.length < numSteps) initial.push({ midi: 60, gate: false });
if (mod) {
mod.params._steps = initial; mod.params._steps = initial;
} else if (mod.params._steps.length < numSteps) {
while (mod.params._steps.length < numSteps) {
mod.params._steps.push({ midi: 60, gate: false });
}
} else if (mod.params._steps.length > numSteps) {
mod.params._steps = mod.params._steps.slice(0, numSteps);
} }
} }
const steps = mod?.params?._steps || DEFAULT_STEPS; const steps = mod?.params?._steps || DEFAULT_STEPS;
@@ -36,46 +42,69 @@ export default function SequencerWidget({ moduleId }) {
const bpm = mod?.params?.bpm ?? 140; const bpm = mod?.params?.bpm ?? 140;
// Start/stop sequencer when audio engine runs // Visual update loop — decoupled from audio, uses RAF
useEffect(() => {
const tick = () => {
setVisualStep(currentStepRef.current);
rafRef.current = requestAnimationFrame(tick);
};
if (state.isRunning) {
rafRef.current = requestAnimationFrame(tick);
}
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [state.isRunning]);
// Subscribe to global master clock — derive step from elapsed time
const bpmRef = useRef(bpm);
const numStepsRef = useRef(numSteps);
bpmRef.current = bpm;
numStepsRef.current = numSteps;
useEffect(() => { useEffect(() => {
if (!state.isRunning) { if (!state.isRunning) {
if (seqRef.current) { seqRef.current.stop(); seqRef.current.dispose(); seqRef.current = null; } unsubscribeTick(`seq-${moduleId}`);
setCurrentStep(-1); currentStepRef.current = -1;
setVisualStep(-1);
return; return;
} }
Tone.getTransport().bpm.value = bpm; let lastStepIdx = -1;
let lastGateOn = false;
subscribeTick(`seq-${moduleId}`, (time, ticks) => {
const currentBpm = bpmRef.current;
const currentNumSteps = numStepsRef.current;
// ticksPerStep = MASTER_TICK_RATE / sixteenthsPerSecond
// sixteenthsPerSecond = bpm * 4 / 60
const ticksPerStep = MASTER_TICK_RATE * 60 / (currentBpm * 4);
const stepIdx = Math.floor(ticks / ticksPerStep) % currentNumSteps;
if (stepIdx === lastStepIdx) return;
lastStepIdx = stepIdx;
// Turn off previous note at step boundary (no setTimeout needed)
if (lastGateOn) {
setSequencerSignals(moduleId, 0, false);
lastGateOn = false;
}
const seq = new Tone.Sequence((time, stepIdx) => {
const s = stepsRef.current[stepIdx]; const s = stepsRef.current[stepIdx];
if (!s) return; if (!s) return;
setCurrentStep(stepIdx);
currentStepRef.current = stepIdx;
if (s.gate) { if (s.gate) {
setSequencerSignals(moduleId, midiToFreq(s.midi), true); setSequencerSignals(moduleId, midiToFreq(s.midi), true);
Tone.getTransport().scheduleOnce(() => { lastGateOn = true;
setSequencerSignals(moduleId, midiToFreq(s.midi), false);
}, time + Tone.Time('16n').toSeconds() * 0.8);
} else {
setSequencerSignals(moduleId, midiToFreq(s.midi), false);
} }
}, Array.from({ length: numSteps }, (_, i) => i), '16n'); });
seq.start(0);
if (Tone.getTransport().state !== 'started') {
Tone.getTransport().start();
}
seqRef.current = seq;
return () => { return () => {
if (seqRef.current) { seqRef.current.stop(); seqRef.current.dispose(); seqRef.current = null; } unsubscribeTick(`seq-${moduleId}`);
}; };
}, [state.isRunning, moduleId, numSteps]); }, [state.isRunning, moduleId]);
// Update BPM live
useEffect(() => {
if (state.isRunning) Tone.getTransport().bpm.value = bpm;
}, [bpm]);
const toggleGate = (idx) => { const toggleGate = (idx) => {
steps[idx] = { ...steps[idx], gate: !steps[idx].gate }; steps[idx] = { ...steps[idx], gate: !steps[idx].gate };
@@ -99,20 +128,17 @@ export default function SequencerWidget({ moduleId }) {
return ( return (
<div style={{ width: W + 4, overflow: 'hidden' }}> <div style={{ width: W + 4, overflow: 'hidden' }}>
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: W, height: H, display: 'block' }}> <svg viewBox={`0 0 ${W} ${H}`} style={{ width: W, height: H, display: 'block' }}>
{/* Steps */}
{steps.slice(0, numSteps).map((s, i) => { {steps.slice(0, numSteps).map((s, i) => {
const x = i * CELL_W; const x = i * CELL_W;
const isActive = i === currentStep; const isActive = i === visualStep;
const barHeight = ((s.midi - 36) / 60) * (CELL_H - 4); const barHeight = ((s.midi - 36) / 60) * (CELL_H - 4);
return ( return (
<g key={i}> <g key={i}>
{/* Background */}
<rect x={x + 1} y={0} width={CELL_W - 2} height={CELL_H} <rect x={x + 1} y={0} width={CELL_W - 2} height={CELL_H}
rx={2} fill={isActive ? '#1a2a40' : '#0c0c18'} rx={2} fill={isActive ? '#1a2a40' : '#0c0c18'}
stroke={isActive ? '#00e5ff' : '#222'} strokeWidth={isActive ? 1.5 : 0.5} stroke={isActive ? '#00e5ff' : '#222'} strokeWidth={isActive ? 1.5 : 0.5}
/> />
{/* Note bar */}
{s.gate && ( {s.gate && (
<rect x={x + 3} y={CELL_H - barHeight - 2} width={CELL_W - 6} height={barHeight} <rect x={x + 3} y={CELL_H - barHeight - 2} width={CELL_W - 6} height={barHeight}
rx={1} rx={1}
@@ -120,17 +146,14 @@ export default function SequencerWidget({ moduleId }) {
opacity={0.9} opacity={0.9}
/> />
)} )}
{/* Inactive marker */}
{!s.gate && ( {!s.gate && (
<line x1={x + CELL_W / 2} y1={CELL_H / 2 - 3} x2={x + CELL_W / 2} y2={CELL_H / 2 + 3} <line x1={x + CELL_W / 2} y1={CELL_H / 2 - 3} x2={x + CELL_W / 2} y2={CELL_H / 2 + 3}
stroke="#333" strokeWidth={1.5} /> stroke="#333" strokeWidth={1.5} />
)} )}
{/* Note name */}
<text x={x + CELL_W / 2} y={CELL_H + 10} textAnchor="middle" <text x={x + CELL_W / 2} y={CELL_H + 10} textAnchor="middle"
fontSize={6} fill={s.gate ? '#88ccff' : '#333'} fontFamily="monospace"> fontSize={6} fill={s.gate ? '#88ccff' : '#333'} fontFamily="monospace">
{noteLabel(s.midi)} {noteLabel(s.midi)}
</text> </text>
{/* Click areas: top half = note up, bottom half = note down, middle = toggle gate */}
<rect x={x} y={0} width={CELL_W} height={CELL_H / 3} <rect x={x} y={0} width={CELL_W} height={CELL_H / 3}
fill="transparent" style={{ cursor: 'pointer' }} fill="transparent" style={{ cursor: 'pointer' }}
onClick={() => changeNote(i, 1)} onClick={() => changeNote(i, 1)}
@@ -146,11 +169,10 @@ export default function SequencerWidget({ moduleId }) {
</g> </g>
); );
})} })}
{/* Playhead line */} {visualStep >= 0 && (
{currentStep >= 0 && (
<line <line
x1={currentStep * CELL_W + CELL_W / 2} y1={0} x1={visualStep * CELL_W + CELL_W / 2} y1={0}
x2={currentStep * CELL_W + CELL_W / 2} y2={CELL_H} x2={visualStep * CELL_W + CELL_W / 2} y2={CELL_H}
stroke="#00e5ff" strokeWidth={1} opacity={0.4} stroke="#00e5ff" strokeWidth={1} opacity={0.4}
/> />
)} )}

View File

@@ -1,10 +1,19 @@
import React from 'react'; import React, { useState, useEffect } from 'react';
import { wirePath } from '../utils/bezier.js'; import { wirePath } from '../utils/bezier.js';
import { state, removeConnection } from '../engine/state.js'; import { state, removeConnection } from '../engine/state.js';
import { disconnectWire } from '../engine/audioEngine.js'; import { disconnectWire } from '../engine/audioEngine.js';
import { getModuleDef, PORT_TYPE } from '../engine/moduleRegistry.js'; import { getModuleDef, PORT_TYPE } from '../engine/moduleRegistry.js';
export default function WireLayer({ portPositions, tempWire, containerRef, zoom, camX, camY }) { 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 getPortPos = (moduleId, portName, direction) => {
const key = `${moduleId}-${portName}-${direction}`; const key = `${moduleId}-${portName}-${direction}`;
const el = portPositions.current[key]; const el = portPositions.current[key];

View File

@@ -12,6 +12,48 @@ const audioNodes = {};
// Active keyboard state // Active keyboard state
const keyboardState = { frequency: 440, gate: false }; const keyboardState = { frequency: 440, gate: false };
// ==================== Global Master Clock ====================
// Single clock with integer tick counter. All sequencers/piano rolls
// derive their step positions from this shared tick count.
// Using integers avoids floating-point drift entirely.
export const MASTER_TICK_RATE = 120; // Hz — 6x headroom for 300 BPM sixteenths (20 Hz). Lower = less main thread pressure.
let _masterClock = null;
const _tickListeners = new Map(); // id → callback(audioTime, ticks)
export function subscribeTick(id, callback) {
_tickListeners.set(id, callback);
}
export function unsubscribeTick(id) {
_tickListeners.delete(id);
}
function startMasterClock() {
if (_masterClock) return;
let _startTime = 0;
let _started = false;
_masterClock = new Tone.Clock((time) => {
if (!_started) { _startTime = time; _started = true; }
// Derive ticks from precise AudioContext.currentTime, not a counter.
// Counters fall behind when callbacks are delayed (GC, UI, tab throttle).
// The time parameter is always accurate regardless of callback jitter.
const ticks = Math.round((time - _startTime) * MASTER_TICK_RATE);
for (const cb of _tickListeners.values()) {
cb(time, ticks);
}
}, MASTER_TICK_RATE);
_masterClock.start();
}
function stopMasterClock() {
if (_masterClock) {
try { _masterClock.stop(); } catch {}
try { _masterClock.dispose(); } catch {}
_masterClock = null;
}
_tickListeners.clear();
}
// ==================== Node creation ==================== // ==================== Node creation ====================
function createNode(mod) { function createNode(mod) {
@@ -24,11 +66,16 @@ function createNode(mod) {
case 'oscillator': { case 'oscillator': {
const osc = new Tone.Oscillator({ type: p.waveform, frequency: p.frequency, detune: p.detune }); const osc = new Tone.Oscillator({ type: p.waveform, frequency: p.frequency, detune: p.detune });
osc.start(); osc.start();
// Modulation scaler for freq input: LFO (-1..1) × scale → added to osc.frequency
// Scale = half the current frequency so modulation is musically meaningful
const freqMod = new Tone.Gain(p.frequency * 0.5);
freqMod.connect(osc.frequency);
return { return {
node: osc, node: osc,
inputs: { freq: osc.frequency, detune: osc.detune }, _freqMod: freqMod,
inputs: { freq: freqMod, detune: osc.detune },
outputs: { out: osc }, outputs: { out: osc },
dispose: () => { osc.stop(); osc.dispose(); }, dispose: () => { osc.stop(); freqMod.disconnect(); freqMod.dispose(); osc.dispose(); },
}; };
} }
case 'lfo': { case 'lfo': {
@@ -53,11 +100,16 @@ function createNode(mod) {
} }
case 'filter': { case 'filter': {
const filter = new Tone.Filter({ type: p.type, frequency: p.frequency, Q: p.Q }); const filter = new Tone.Filter({ type: p.type, frequency: p.frequency, Q: p.Q });
// Modulation scaler for cutoff input: LFO (-1..1) × scale → added to filter.frequency
// Scale = cutoff value so full LFO sweep covers 0 to 2× the cutoff
const cutoffMod = new Tone.Gain(p.frequency);
cutoffMod.connect(filter.frequency);
return { return {
node: filter, node: filter,
inputs: { in: filter, cutoff: filter.frequency }, _cutoffMod: cutoffMod,
inputs: { in: filter, cutoff: cutoffMod },
outputs: { out: filter }, outputs: { out: filter },
dispose: () => filter.dispose(), dispose: () => { cutoffMod.disconnect(); cutoffMod.dispose(); filter.dispose(); },
}; };
} }
case 'envelope': { case 'envelope': {
@@ -74,13 +126,17 @@ function createNode(mod) {
}; };
} }
case 'vca': { case 'vca': {
// Use a Multiply node: in × cv
const gain = new Tone.Gain(p.gain); const gain = new Tone.Gain(p.gain);
// CV scaler: always gain=1 so envelope (0-1) passes through fully.
// When CV is connected, base gain is zeroed — envelope controls amplitude entirely.
const cvMod = new Tone.Gain(1);
cvMod.connect(gain.gain);
return { return {
node: gain, node: gain,
inputs: { in: gain, cv: gain.gain }, _cvMod: cvMod,
inputs: { in: gain, cv: cvMod },
outputs: { out: gain }, outputs: { out: gain },
dispose: () => gain.dispose(), dispose: () => { cvMod.disconnect(); cvMod.dispose(); gain.dispose(); },
}; };
} }
case 'delay': { case 'delay': {
@@ -126,7 +182,7 @@ function createNode(mod) {
}; };
} }
case 'scope': { case 'scope': {
const analyser = new Tone.Analyser('waveform', 256); const analyser = new Tone.Analyser('waveform', 2048);
return { return {
node: analyser, node: analyser,
inputs: { in: analyser }, inputs: { in: analyser },
@@ -135,17 +191,47 @@ function createNode(mod) {
dispose: () => analyser.dispose(), dispose: () => analyser.dispose(),
}; };
} }
case 'output': { case 'cv2gate': {
const gain = new Tone.Gain(Tone.dbToGain(p.volume)); // Converts a continuous CV signal to gate on/off based on threshold.
gain.toDestination(); // Uses an analyser to read the CV value and triggers connected envelopes.
const analyser = new Tone.Analyser('waveform', 32);
const gateSig = new Tone.Signal(0);
return { return {
node: gain, node: analyser,
inputs: { left: gain, right: gain }, _gateSig: gateSig,
outputs: {}, _gateState: false,
dispose: () => { gain.disconnect(); gain.dispose(); }, inputs: { in: analyser },
outputs: { gate: gateSig },
dispose: () => { analyser.dispose(); gateSig.dispose(); },
}; };
} }
case 'keyboard': { case 'output': {
// True stereo output: separate left/right channels → merge → master gain → destination
const leftGain = new Tone.Gain(1);
const rightGain = new Tone.Gain(1);
const merge = new Tone.Merge();
const master = new Tone.Gain(Tone.dbToGain(p.volume));
leftGain.connect(merge, 0, 0);
rightGain.connect(merge, 0, 1);
merge.connect(master);
master.toDestination();
return {
node: master,
_merge: merge,
_leftGain: leftGain,
_rightGain: rightGain,
inputs: { left: leftGain, right: rightGain },
outputs: {},
dispose: () => {
leftGain.disconnect(); leftGain.dispose();
rightGain.disconnect(); rightGain.dispose();
merge.disconnect(); merge.dispose();
master.disconnect(); master.dispose();
},
};
}
case 'keyboard':
case 'drumpad': {
const freqSig = new Tone.Signal(440); const freqSig = new Tone.Signal(440);
const gateSig = new Tone.Signal(0); const gateSig = new Tone.Signal(0);
return { return {
@@ -220,6 +306,17 @@ export function connectWire(conn) {
const toEntry = ensureNode(conn.to.moduleId); const toEntry = ensureNode(conn.to.moduleId);
if (!fromEntry || !toEntry) return; if (!fromEntry || !toEntry) return;
// Skip audio-graph connection for keyboard/sequencer/pianoroll freq → oscillator freq.
// These signals carry absolute Hz values that would be mangled by the oscillator's
// frequency-modulation Gain scaler. Instead, triggerKeyboard / setSequencerSignals
// set the oscillator frequency directly when notes are played.
const fromMod = state.modules.find(m => m.id === conn.from.moduleId);
const toMod = state.modules.find(m => m.id === conn.to.moduleId);
if (fromMod && ['keyboard', 'drumpad', 'sequencer', 'pianoroll'].includes(fromMod.type) &&
conn.from.port === 'freq' && toMod?.type === 'oscillator' && conn.to.port === 'freq') {
return; // handled imperatively in triggerKeyboard / setSequencerSignals
}
const output = fromEntry.outputs[conn.from.port]; const output = fromEntry.outputs[conn.from.port];
const input = toEntry.inputs[conn.to.port]; const input = toEntry.inputs[conn.to.port];
if (!output || input === undefined || input === null) return; if (!output || input === undefined || input === null) return;
@@ -231,6 +328,11 @@ export function connectWire(conn) {
} catch (e) { } catch (e) {
console.warn('connect error', e); console.warn('connect error', e);
} }
// When CV is connected to VCA, zero the base gain so only envelope controls it
if (toMod?.type === 'vca' && conn.to.port === 'cv') {
toEntry.node.gain.value = 0;
}
} }
export function disconnectWire(conn) { export function disconnectWire(conn) {
@@ -249,6 +351,12 @@ export function disconnectWire(conn) {
} catch (e) { } catch (e) {
// Tone.js may throw if not connected // Tone.js may throw if not connected
} }
// When CV is disconnected from VCA, restore base gain from params
const toMod = state.modules.find(m => m.id === conn.to.moduleId);
if (toMod?.type === 'vca' && conn.to.port === 'cv') {
toEntry.node.gain.value = toMod.params?.gain ?? 0.8;
}
} }
export function updateParam(moduleId, paramName, value) { export function updateParam(moduleId, paramName, value) {
@@ -262,7 +370,11 @@ export function updateParam(moduleId, paramName, value) {
switch (mod.type) { switch (mod.type) {
case 'oscillator': case 'oscillator':
if (paramName === 'waveform') entry.node.type = value; if (paramName === 'waveform') entry.node.type = value;
else if (paramName === 'frequency') entry.node.frequency.value = value; else if (paramName === 'frequency') {
entry.node.frequency.value = value;
// Update mod scaler proportionally
if (entry._freqMod) entry._freqMod.gain.value = value * 0.5;
}
else if (paramName === 'detune') entry.node.detune.value = value; else if (paramName === 'detune') entry.node.detune.value = value;
break; break;
case 'lfo': case 'lfo':
@@ -275,7 +387,11 @@ export function updateParam(moduleId, paramName, value) {
break; break;
case 'filter': case 'filter':
if (paramName === 'type') entry.node.type = value; if (paramName === 'type') entry.node.type = value;
else if (paramName === 'frequency') entry.node.frequency.value = value; else if (paramName === 'frequency') {
entry.node.frequency.value = value;
// Update mod scaler proportionally
if (entry._cutoffMod) entry._cutoffMod.gain.value = value;
}
else if (paramName === 'Q') entry.node.Q.value = value; else if (paramName === 'Q') entry.node.Q.value = value;
break; break;
case 'envelope': case 'envelope':
@@ -285,7 +401,12 @@ export function updateParam(moduleId, paramName, value) {
else if (paramName === 'release') entry.node.release = value; else if (paramName === 'release') entry.node.release = value;
break; break;
case 'vca': case 'vca':
if (paramName === 'gain') entry.node.gain.value = value; if (paramName === 'gain') {
// Only update base gain if no CV is connected (CV zeroes it)
const hasCV = state.connections.some(c => c.to.moduleId === moduleId && c.to.port === 'cv');
if (!hasCV) entry.node.gain.value = value;
// cvMod stays at 1 always — envelope controls full range
}
break; break;
case 'delay': case 'delay':
if (paramName === 'delayTime') entry.node.delayTime.value = value; if (paramName === 'delayTime') entry.node.delayTime.value = value;
@@ -310,6 +431,8 @@ export function updateParam(moduleId, paramName, value) {
if (paramName === 'volume') entry.node.gain.value = Tone.dbToGain(value); if (paramName === 'volume') entry.node.gain.value = Tone.dbToGain(value);
break; break;
case 'keyboard': case 'keyboard':
case 'drumpad':
case 'cv2gate':
case 'sequencer': case 'sequencer':
case 'pianoroll': case 'pianoroll':
// All params stored in state, managed by widgets // All params stored in state, managed by widgets
@@ -317,22 +440,50 @@ export function updateParam(moduleId, paramName, value) {
} }
} }
// Cache connection lookups for hot-path audio scheduling
// Rebuilt only when connections actually change (dirty flag, no computation on hit)
let _connCacheDirty = true;
const _connByModulePort = new Map(); // "moduleId-portName" → [connections]
export function invalidateConnectionCache() {
_connCacheDirty = true;
}
function getConnectionsFrom(moduleId, portName) {
if (_connCacheDirty) {
_connByModulePort.clear();
for (const conn of state.connections) {
const key = `${conn.from.moduleId}-${conn.from.port}`;
if (!_connByModulePort.has(key)) _connByModulePort.set(key, []);
_connByModulePort.get(key).push(conn);
}
_connCacheDirty = false;
}
return _connByModulePort.get(`${moduleId}-${portName}`) || [];
}
export function setSequencerSignals(moduleId, freq, gate) { export function setSequencerSignals(moduleId, freq, gate) {
const entry = audioNodes[moduleId]; const entry = audioNodes[moduleId];
if (!entry) return; if (!entry) return;
if (entry._freqSig) entry._freqSig.value = freq; if (entry._freqSig) entry._freqSig.value = freq;
if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0; if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0;
// Set connected oscillator frequencies directly
for (const conn of getConnectionsFrom(moduleId, 'freq')) {
const oscEntry = audioNodes[conn.to.moduleId];
if (oscEntry?.node?.frequency) {
oscEntry.node.frequency.value = freq;
}
}
// Trigger connected envelopes // Trigger connected envelopes
for (const conn of state.connections) { for (const conn of getConnectionsFrom(moduleId, 'gate')) {
if (conn.from.moduleId === moduleId && conn.from.port === 'gate') {
const envEntry = audioNodes[conn.to.moduleId]; const envEntry = audioNodes[conn.to.moduleId];
if (envEntry && envEntry.node instanceof Tone.Envelope) { if (envEntry && envEntry.node instanceof Tone.Envelope) {
if (gate) envEntry.node.triggerAttack(); if (gate) envEntry.node.triggerAttack();
else envEntry.node.triggerRelease(); else envEntry.node.triggerRelease();
} }
} }
}
} }
export function triggerKeyboard(moduleId, freq, gate) { export function triggerKeyboard(moduleId, freq, gate) {
@@ -341,27 +492,43 @@ export function triggerKeyboard(moduleId, freq, gate) {
if (entry._freqSig) entry._freqSig.value = freq; if (entry._freqSig) entry._freqSig.value = freq;
if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0; if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0;
// Also trigger any connected envelopes // Set connected oscillator frequencies directly
for (const conn of state.connections) { for (const conn of getConnectionsFrom(moduleId, 'freq')) {
if (conn.from.moduleId === moduleId && conn.from.port === 'gate') { const oscEntry = audioNodes[conn.to.moduleId];
if (oscEntry?.node?.frequency) {
oscEntry.node.frequency.value = freq;
}
}
// Trigger connected envelopes
for (const conn of getConnectionsFrom(moduleId, 'gate')) {
const envEntry = audioNodes[conn.to.moduleId]; const envEntry = audioNodes[conn.to.moduleId];
if (envEntry && envEntry.node instanceof Tone.Envelope) { if (envEntry && envEntry.node instanceof Tone.Envelope) {
if (gate) envEntry.node.triggerAttack(); if (gate) envEntry.node.triggerAttack();
else envEntry.node.triggerRelease(); else envEntry.node.triggerRelease();
} }
} }
}
} }
export async function startAudio() { export async function startAudio() {
await Tone.start(); await Tone.start();
state.isRunning = true; state.isRunning = true;
startMasterClock();
// Rebuild entire audio graph // Rebuild entire audio graph
rebuildGraph(); rebuildGraph();
} }
export function stopAudio() { export function stopAudio() {
stopMasterClock();
// Stop and reset Transport
try {
Tone.getTransport().stop();
Tone.getTransport().cancel();
Tone.getTransport().position = 0;
} catch (e) {}
// Destroy all nodes // Destroy all nodes
for (const id of Object.keys(audioNodes)) { for (const id of Object.keys(audioNodes)) {
destroyNode(parseInt(id)); destroyNode(parseInt(id));
@@ -384,6 +551,55 @@ export function rebuildGraph() {
for (const conn of state.connections) { for (const conn of state.connections) {
connectWire(conn); connectWire(conn);
} }
// Zero base gain on VCAs with active CV connection.
// When envelope controls VCA, base gain must be 0 so silence is possible.
for (const mod of state.modules) {
if (mod.type !== 'vca') continue;
const hasCV = state.connections.some(c => c.to.moduleId === mod.id && c.to.port === 'cv');
const entry = audioNodes[mod.id];
if (entry && hasCV) entry.node.gain.value = 0;
}
// Auto-trigger envelopes that have no gate connection (free-running mode).
// This allows noise/ambient patches to work without a keyboard/sequencer.
for (const mod of state.modules) {
if (mod.type !== 'envelope') continue;
const hasGateInput = state.connections.some(
c => c.to.moduleId === mod.id && c.to.port === 'gate'
);
if (!hasGateInput) {
const entry = audioNodes[mod.id];
if (entry && entry.node && typeof entry.node.triggerAttack === 'function') {
entry.node.triggerAttack();
}
}
}
// Register CV→Gate modules on master clock for threshold detection
for (const mod of state.modules) {
if (mod.type !== 'cv2gate') continue;
const entry = audioNodes[mod.id];
if (!entry) continue;
subscribeTick(`cv2gate-${mod.id}`, () => {
const data = entry.node.getValue();
const sample = typeof data === 'number' ? data : (data?.[0] ?? 0);
const threshold = mod.params?.threshold ?? 0.5;
const gateOn = sample > threshold;
if (gateOn !== entry._gateState) {
entry._gateState = gateOn;
entry._gateSig.value = gateOn ? 1 : 0;
// Trigger/release connected envelopes
for (const conn of getConnectionsFrom(mod.id, 'gate')) {
const envEntry = audioNodes[conn.to.moduleId];
if (envEntry && envEntry.node instanceof Tone.Envelope) {
if (gateOn) envEntry.node.triggerAttack();
else envEntry.node.triggerRelease();
}
}
}
});
}
} }
export function getAnalyserData(moduleId) { export function getAnalyserData(moduleId) {

View File

@@ -117,7 +117,7 @@ defineModule('envelope', {
attack: { type: 'knob', min: 0.001, max: 4, default: 0.01, unit: 's', label: 'Attack' }, attack: { type: 'knob', min: 0.001, max: 4, default: 0.01, unit: 's', label: 'Attack' },
decay: { type: 'knob', min: 0.001, max: 4, default: 0.2, unit: 's', label: 'Decay' }, decay: { type: 'knob', min: 0.001, max: 4, default: 0.2, unit: 's', label: 'Decay' },
sustain: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Sustain' }, sustain: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Sustain' },
release: { type: 'knob', min: 0, max: 8, default: 0.5, unit: 's', label: 'Release' }, release: { type: 'knob', min: 0.001, max: 8, default: 0.5, unit: 's', label: 'Release' },
}, },
}); });
@@ -226,6 +226,23 @@ defineModule('scope', {
params: {}, params: {},
}); });
// ==================== CV TO GATE ====================
defineModule('cv2gate', {
name: 'CV→Gate',
icon: '⚡',
category: 'Utility',
inputs: [
{ name: 'in', type: PORT_TYPE.CONTROL, label: 'CV In' },
],
outputs: [
{ name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' },
],
params: {
threshold: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Thresh' },
},
});
// ==================== OUTPUT ==================== // ==================== OUTPUT ====================
defineModule('output', { defineModule('output', {
@@ -258,6 +275,20 @@ defineModule('keyboard', {
}, },
}); });
// ==================== DRUM PAD ====================
defineModule('drumpad', {
name: 'Drum Pad',
icon: '🥁',
category: 'Source',
inputs: [],
outputs: [
{ name: 'freq', type: PORT_TYPE.AUDIO, label: 'Freq' },
{ name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' },
],
params: {},
});
// ==================== SEQUENCER ==================== // ==================== SEQUENCER ====================
defineModule('sequencer', { defineModule('sequencer', {

View File

@@ -2,6 +2,9 @@
* state.js — Centralized reactive state for the modular synth * state.js — Centralized reactive state for the modular synth
* Uses a simple pub/sub pattern for React integration * Uses a simple pub/sub pattern for React integration
*/ */
import { playConnect, playDisconnect, playModuleAdd, playModuleDelete } from './uiSounds.js';
import { getModuleDef } from './moduleRegistry.js';
import { invalidateConnectionCache } from './audioEngine.js';
let _listeners = new Set(); let _listeners = new Set();
let _nextModuleId = 1; let _nextModuleId = 1;
@@ -40,9 +43,15 @@ export function emit() {
export function addModule(type, x, y) { export function addModule(type, x, y) {
const id = _nextModuleId++; 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; state.selectedModuleId = id;
emit(); emit();
playModuleAdd();
return id; return id;
} }
@@ -53,6 +62,7 @@ export function removeModule(id) {
); );
if (state.selectedModuleId === id) state.selectedModuleId = null; if (state.selectedModuleId === id) state.selectedModuleId = null;
emit(); emit();
playModuleDelete();
} }
export function updateModulePosition(id, x, y) { export function updateModulePosition(id, x, y) {
@@ -78,19 +88,23 @@ export function addConnection(fromModuleId, fromPort, toModuleId, toPort) {
c.to.moduleId === toModuleId && c.to.port === toPort c.to.moduleId === toModuleId && c.to.port === toPort
); );
if (inputTaken) { if (inputTaken) {
// Remove old connection to this input // Remove old connection to this input (silent — connect sound will play)
removeConnection(inputTaken.id); removeConnection(inputTaken.id, true);
} }
const id = _nextConnectionId++; const id = _nextConnectionId++;
state.connections.push({ id, from: { moduleId: fromModuleId, port: fromPort }, to: { moduleId: toModuleId, port: toPort } }); state.connections.push({ id, from: { moduleId: fromModuleId, port: fromPort }, to: { moduleId: toModuleId, port: toPort } });
invalidateConnectionCache();
emit(); emit();
playConnect();
return id; return id;
} }
export function removeConnection(id) { export function removeConnection(id, _silent = false) {
state.connections = state.connections.filter(c => c.id !== id); state.connections = state.connections.filter(c => c.id !== id);
invalidateConnectionCache();
emit(); emit();
if (!_silent) playDisconnect();
} }
export function getModule(id) { export function getModule(id) {

223
src/engine/uiSounds.js Normal file
View 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);
}

124
src/game/AdminPanel.jsx Normal file
View File

@@ -0,0 +1,124 @@
/**
* AdminPanel.jsx — Debug/admin panel for SynthQuest
* Allows adding/removing stars and unlocking levels for testing
*/
import React, { useState } from 'react';
import { loadProgress, saveProgress, resetProgress } from './gameState.js';
export default function AdminPanel({ worlds, onClose, adminMode, onToggleAdmin }) {
const [, refresh] = useState(0);
const p = loadProgress();
const totalStars = Object.values(p.completedLevels).reduce((s, l) => s + (l.stars || 0), 0);
const setStars = (levelId, stars) => {
if (stars <= 0) {
delete p.completedLevels[levelId];
} else {
p.completedLevels[levelId] = { stars: Math.min(3, stars), completedAt: Date.now() };
}
saveProgress();
refresh(n => n + 1);
};
const unlockWorld = (world) => {
// Give 1 star to each level in all previous worlds up to the requirement
let needed = world.unlockStars || 0;
for (const w of worlds) {
if (w.id === world.id) break;
for (const level of w.levels) {
if (needed <= 0) break;
const existing = p.completedLevels[level.id]?.stars || 0;
if (existing < 1) {
p.completedLevels[level.id] = { stars: 1, completedAt: Date.now() };
needed -= 1;
}
}
}
saveProgress();
refresh(n => n + 1);
};
const giveAllStars = () => {
for (const w of worlds) {
for (const level of w.levels) {
p.completedLevels[level.id] = { stars: 3, completedAt: Date.now() };
}
}
saveProgress();
refresh(n => n + 1);
};
const handleReset = () => {
resetProgress();
refresh(n => n + 1);
};
return (
<div className="admin-overlay" onClick={onClose}>
<div className="admin-panel" onClick={e => e.stopPropagation()}>
<div className="admin-header">
<h2>🛠 Admin Mode</h2>
<span className="admin-total">Total: {totalStars}</span>
<button className="admin-close" onClick={onClose}></button>
</div>
<div className="admin-actions">
<button
className={`admin-action-btn ${adminMode ? 'active' : ''}`}
onClick={onToggleAdmin}
>
{adminMode ? '🛠 Admin ON' : '🛠 Admin OFF'}
</button>
<button className="admin-action-btn gold" onClick={giveAllStars}> Todo</button>
<button className="admin-action-btn danger" onClick={handleReset}>Reset Progreso</button>
</div>
<div className="admin-worlds">
{worlds.map((world, wi) => {
const worldStars = world.levels.reduce((s, l) => {
return s + (p.completedLevels[l.id]?.stars || 0);
}, 0);
const isUnlocked = !world.unlockStars || totalStars >= world.unlockStars;
return (
<div key={world.id} className="admin-world">
<div className="admin-world-header">
<span className="admin-world-icon" style={{ color: world.color }}>{world.icon}</span>
<span className="admin-world-name">M{wi + 1}: {world.name}</span>
<span className="admin-world-stars"> {worldStars}/{world.levels.length * 3}</span>
{!isUnlocked && (
<button className="admin-unlock-btn" onClick={() => unlockWorld(world)}>
🔓 Desbloquear
</button>
)}
</div>
<div className="admin-levels">
{world.levels.map((level, li) => {
const stars = p.completedLevels[level.id]?.stars || 0;
return (
<div key={level.id} className="admin-level">
<span className="admin-level-num">{wi + 1}.{li + 1}</span>
<span className="admin-level-name">{level.title}</span>
<div className="admin-star-btns">
{[0, 1, 2, 3].map(s => (
<button
key={s}
className={`admin-star-btn ${stars >= s && s > 0 ? 'active' : ''} ${s === 0 ? 'zero' : ''}`}
onClick={() => setStars(level.id, s)}
>
{s === 0 ? '✕' : '★'.repeat(s)}
</button>
))}
</div>
</div>
);
})}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}

View File

@@ -1,54 +1,93 @@
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import WorldMap from './WorldMap.jsx'; import WorldMap from './WorldMap.jsx';
import PuzzleView from './PuzzleView.jsx'; import PuzzleView from './PuzzleView.jsx';
import AdminPanel from './AdminPanel.jsx';
import { WORLD_1 } from './levels/world1.js'; import { WORLD_1 } from './levels/world1.js';
import { WORLD_2 } from './levels/world2.js';
import { WORLD_3 } from './levels/world3.js';
import { WORLD_4 } from './levels/world4.js';
import { WORLD_5 } from './levels/world5.js';
import { WORLD_6 } from './levels/world6.js';
import { 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, WORLD_7, WORLD_8, WORLD_9, WORLD_10, WORLD_11, WORLD_12];
export default function GameApp({ onSwitchToSandbox }) { export default function GameApp({ onSwitchToSandbox }) {
const [view, setView] = useState('map'); // 'map' | 'puzzle' const [view, setView] = useState('map');
const [currentLevel, setCurrentLevel] = useState(null); const [currentLevel, setCurrentLevel] = useState(null);
const [currentLevelIndex, setCurrentLevelIndex] = useState(0); const [currentLevelIndex, setCurrentLevelIndex] = useState(0);
const [currentWorld, setCurrentWorld] = useState(null);
const [showAdmin, setShowAdmin] = useState(false);
const [adminMode, setAdminMode] = useState(false);
const worldLevels = WORLD_1.levels; const handleSelectLevel = useCallback((level, world) => {
const idx = world.levels.findIndex(l => l.id === level.id);
const handleSelectLevel = useCallback((level) => {
const idx = worldLevels.findIndex(l => l.id === level.id);
setCurrentLevel(level); setCurrentLevel(level);
setCurrentLevelIndex(idx); setCurrentLevelIndex(idx);
setCurrentWorld(world);
setView('puzzle'); setView('puzzle');
}, [worldLevels]); }, []);
const handleBack = useCallback(() => { const handleBack = useCallback(() => {
setView('map'); setView('map');
setCurrentLevel(null); setCurrentLevel(null);
setCurrentWorld(null);
}, []); }, []);
const handleNextLevel = useCallback(() => { const handleNextLevel = useCallback(() => {
if (!currentWorld) return;
const nextIdx = currentLevelIndex + 1; const nextIdx = currentLevelIndex + 1;
if (nextIdx < worldLevels.length) { if (nextIdx < currentWorld.levels.length) {
setCurrentLevel(worldLevels[nextIdx]); setCurrentLevel(currentWorld.levels[nextIdx]);
setCurrentLevelIndex(nextIdx); setCurrentLevelIndex(nextIdx);
} else {
// Move to next world's first level if unlocked
const worldIdx = allWorlds.findIndex(w => w.id === currentWorld.id);
if (worldIdx < allWorlds.length - 1) {
const nextWorld = allWorlds[worldIdx + 1];
setCurrentWorld(nextWorld);
setCurrentLevel(nextWorld.levels[0]);
setCurrentLevelIndex(0);
} else { } else {
setView('map'); setView('map');
} }
}, [currentLevelIndex, worldLevels]); }
}, [currentLevelIndex, currentWorld]);
if (view === 'puzzle' && currentLevel) { if (view === 'puzzle' && currentLevel && currentWorld) {
return ( return (
<PuzzleView <PuzzleView
key={currentLevel.id} key={currentLevel.id}
level={currentLevel} level={currentLevel}
levelIndex={currentLevelIndex} levelIndex={currentLevelIndex}
worldLevels={worldLevels} worldLevels={currentWorld.levels}
onBack={handleBack} onBack={handleBack}
onNextLevel={handleNextLevel} onNextLevel={handleNextLevel}
adminMode={adminMode}
/> />
); );
} }
return ( return (
<>
<WorldMap <WorldMap
onSelectLevel={handleSelectLevel} onSelectLevel={handleSelectLevel}
onSandbox={onSwitchToSandbox} onSandbox={onSwitchToSandbox}
onAdmin={() => setShowAdmin(true)}
/> />
{showAdmin && (
<AdminPanel
worlds={allWorlds}
onClose={() => setShowAdmin(false)}
adminMode={adminMode}
onToggleAdmin={() => setAdminMode(a => !a)}
/>
)}
</>
); );
} }

View File

@@ -1,4 +1,5 @@
import React, { useEffect, useState } from 'react'; 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 }) { export default function LevelComplete({ stars, checks, levelTitle, onNext, onRetry, onMap, isLastLevel, hintPenalty }) {
const [showStars, setShowStars] = useState(0); const [showStars, setShowStars] = useState(0);
@@ -6,7 +7,10 @@ export default function LevelComplete({ stars, checks, levelTitle, onNext, onRet
useEffect(() => { useEffect(() => {
const timers = []; const timers = [];
for (let i = 1; i <= stars; i++) { 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); return () => timers.forEach(clearTimeout);
}, [stars]); }, [stars]);
@@ -14,7 +18,7 @@ export default function LevelComplete({ stars, checks, levelTitle, onNext, onRet
const messages = [ const messages = [
'', '',
'Has dado el primer paso...', 'Has dado el primer paso...',
hintPenalty ? 'Reinicia sin pista para conseguir 3 estrellas.' : 'Buen trabajo — casi perfecto.', hintPenalty ? 'Pista usada — tercera estrella bloqueada permanentemente.' : 'Buen trabajo — casi perfecto.',
'Ejecucion impecable.', 'Ejecucion impecable.',
]; ];
@@ -56,9 +60,9 @@ export default function LevelComplete({ stars, checks, levelTitle, onNext, onRet
</div> </div>
<div className="gm-complete-actions"> <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}> <button className="gm-btn secondary" onClick={onRetry}>
{hintPenalty ? '↺ Sin pista' : 'Reintentar'} Reintentar
</button> </button>
{stars >= 1 && !isLastLevel && ( {stars >= 1 && !isLastLevel && (
<button className="gm-btn primary" onClick={onNext}>Siguiente </button> <button className="gm-btn primary" onClick={onNext}>Siguiente </button>

View File

@@ -4,11 +4,16 @@ import { startAudio, stopAudio, connectWire, rebuildGraph } from '../engine/audi
import { getModuleDef } from '../engine/moduleRegistry.js'; import { getModuleDef } from '../engine/moduleRegistry.js';
import ModuleNode from '../components/ModuleNode.jsx'; import ModuleNode from '../components/ModuleNode.jsx';
import WireLayer from '../components/WireLayer.jsx'; import WireLayer from '../components/WireLayer.jsx';
import BottomSheet from '../components/BottomSheet.jsx';
import { useIsMobile } from '../hooks/useIsMobile.js';
import { usePinchZoom } from '../hooks/usePinchZoom.js';
import { playTarget, stopTarget, isTargetPlaying } from './targetAudio.js'; import { playTarget, stopTarget, isTargetPlaying } from './targetAudio.js';
import LevelComplete from './LevelComplete.jsx'; import LevelComplete from './LevelComplete.jsx';
import { completeLevel } from './gameState.js'; import { completeLevel, saveLevelPatch, getLevelPatch, clearLevelPatch, markHintUsed, wasHintUsed } from './gameState.js';
import { playLevelComplete, playFail, playHint, playEngineStart, playEngineStop, playNav } from '../engine/uiSounds.js';
import { SOLUTIONS } from './autoSolver.js';
export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onNextLevel }) { export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onNextLevel, adminMode }) {
const [, forceUpdate] = useState(0); const [, forceUpdate] = useState(0);
const containerRef = useRef(null); const containerRef = useRef(null);
const portPositions = useRef({}); const portPositions = useRef({});
@@ -18,21 +23,64 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
const [showHint, setShowHint] = useState(false); const [showHint, setShowHint] = useState(false);
const [result, setResult] = useState(null); const [result, setResult] = useState(null);
const [targetPlaying, setTargetPlaying] = useState(false); const [targetPlaying, setTargetPlaying] = useState(false);
const isMobile = useIsMobile();
const [mobileTab, setMobileTab] = useState('mission');
// Pinch-to-zoom on mobile
const getZoom = useCallback(() => state.zoom, []);
const setZoom = useCallback((z) => { state.zoom = z; emit(); }, []);
usePinchZoom(containerRef, getZoom, setZoom);
useEffect(() => { useEffect(() => {
const unsub = subscribe(() => forceUpdate(n => n + 1)); const unsub = subscribe(() => {
forceUpdate(n => n + 1);
// Auto-save patch on every state change (debounced below)
scheduleSave();
});
return unsub; return unsub;
}, [level.id]);
// Debounced auto-save of the current patch
const saveTimerRef = useRef(null);
const scheduleSave = useCallback(() => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
if (state.modules.length > 0) {
saveLevelPatch(level.id, state.modules, state.connections);
}
}, 1000);
}, [level.id]);
useEffect(() => {
return () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
};
}, []); }, []);
useEffect(() => { useEffect(() => {
loadLevel(); loadLevel();
// Center view on modules after level loads and DOM settles
const timer = setTimeout(() => handleCenterView(), 100);
return () => { return () => {
clearTimeout(timer);
stopAudio(); stopAudio();
stopTarget(); stopTarget();
}; };
}, [level.id]); }, [level.id]);
const loadLevel = useCallback(() => { const loadLevel = useCallback((forceReset = false) => {
// Check for a saved patch first (unless explicitly resetting)
const saved = !forceReset ? getLevelPatch(level.id) : null;
if (saved) {
const data = {
modules: saved.modules.map(m => ({
id: m.id, type: m.type, x: m.x, y: m.y, params: { ...m.params },
})),
connections: saved.connections.map(c => ({ ...c })),
camera: { camX: 0, camY: 0, zoom: 1 },
};
deserialize(data);
} else {
const data = { const data = {
modules: (level.preplacedModules || []).map(m => ({ modules: (level.preplacedModules || []).map(m => ({
id: m.id, type: m.type, x: m.x, y: m.y, params: { ...m.params }, id: m.id, type: m.type, x: m.x, y: m.y, params: { ...m.params },
@@ -41,9 +89,12 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
camera: { camX: 0, camY: 0, zoom: 1 }, camera: { camX: 0, camY: 0, zoom: 1 },
}; };
deserialize(data); deserialize(data);
}
setResult(null); setResult(null);
setHintUsed(false); // Restore persisted hint state — no cheating by reloading!
setShowHint(false); const hintPersisted = wasHintUsed(level.id);
setHintUsed(hintPersisted);
setShowHint(hintPersisted); // If they used it before, show it again
if (state.isRunning) stopAudio(); if (state.isRunning) stopAudio();
}, [level]); }, [level]);
@@ -89,10 +140,17 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY }; state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
e.preventDefault(); e.preventDefault();
} else if (e.button === 0 && !connectingRef.current) { } else if (e.button === 0 && !connectingRef.current) {
// On mobile (touch), single finger on empty canvas = pan
if (isMobile && e.pointerType === 'touch') {
state.panning = true;
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
e.preventDefault();
return;
}
state.selectedModuleId = null; state.selectedModuleId = null;
emit(); emit();
} }
}, []); }, [isMobile]);
const handlePointerMove = useCallback((e) => { const handlePointerMove = useCallback((e) => {
if (state.panning && state.panStart) { if (state.panning && state.panStart) {
@@ -168,6 +226,42 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
const handleContextMenu = useCallback((e) => e.preventDefault(), []); const handleContextMenu = useCallback((e) => e.preventDefault(), []);
// Zoom controls (Google Maps style)
const handleZoomIn = useCallback(() => {
state.zoom = Math.min(3, state.zoom * 1.25);
emit();
}, []);
const handleZoomOut = useCallback(() => {
state.zoom = Math.max(0.3, state.zoom / 1.25);
emit();
}, []);
const handleZoomReset = useCallback(() => {
state.zoom = 1;
state.camX = 0;
state.camY = 0;
emit();
}, []);
// Center view on all modules
const handleCenterView = useCallback(() => {
if (state.modules.length === 0) { state.camX = 0; state.camY = 0; emit(); return; }
const container = containerRef.current;
const cw = container?.clientWidth || 800;
const ch = container?.clientHeight || 600;
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
for (const m of state.modules) {
minX = Math.min(minX, m.x);
minY = Math.min(minY, m.y);
maxX = Math.max(maxX, m.x + 200);
maxY = Math.max(maxY, m.y + 150);
}
const cx = (minX + maxX) / 2 * state.zoom;
const cy = (minY + maxY) / 2 * state.zoom;
state.camX = cw / 2 - cx;
state.camY = ch / 2 - cy;
emit();
}, []);
const handleAddModule = (type) => { const handleAddModule = (type) => {
const x = (-state.camX + 250) / state.zoom + Math.random() * 30; const x = (-state.camX + 250) / state.zoom + Math.random() * 30;
const y = (-state.camY + 150) / state.zoom + Math.random() * 30; const y = (-state.camY + 150) / state.zoom + Math.random() * 30;
@@ -178,8 +272,10 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
const handleToggleAudio = async () => { const handleToggleAudio = async () => {
if (state.isRunning) { if (state.isRunning) {
stopAudio(); stopAudio();
playEngineStop();
} else { } else {
await startAudio(); await startAudio();
playEngineStart();
} }
emit(); emit();
}; };
@@ -195,10 +291,19 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
} }
}; };
// Reveal hint — permanently caps this attempt at 2 stars // Clear canvas — remove all user-added modules and reset to preplaced only
const handleClearCanvas = () => {
if (state.isRunning) stopAudio();
clearLevelPatch(level.id);
loadLevel(true);
};
// Reveal hint — PERMANENTLY caps this level at 2 stars (persisted, survives reload)
const handleRevealHint = () => { const handleRevealHint = () => {
setHintUsed(true); setHintUsed(true);
setShowHint(true); setShowHint(true);
markHintUsed(level.id);
playHint();
}; };
const handleCheck = () => { const handleCheck = () => {
@@ -222,16 +327,35 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
if (stars >= 1) { if (stars >= 1) {
completeLevel(level.id, stars); completeLevel(level.id, stars);
playLevelComplete();
} else {
playFail();
} }
}; };
// Admin auto-solve — loads the actual solution modules/connections and validates naturally
const handleAutoSolve = () => {
const solution = SOLUTIONS[level.id];
if (!solution) {
console.warn(`No auto-solve solution for level ${level.id}`);
return;
}
// Load the solution patch into the engine state
deserialize(solution);
emit();
// Now run the normal check logic against the loaded patch
setTimeout(() => {
handleCheck();
}, 50);
};
const isLastLevel = levelIndex >= worldLevels.length - 1; const isLastLevel = levelIndex >= worldLevels.length - 1;
return ( return (
<div className="gm-puzzle"> <div className="gm-puzzle">
{/* Top bar */} {/* Top bar */}
<div className="gm-puzzle-bar"> <div className="gm-puzzle-bar">
<button className="gm-btn icon" onClick={onBack}> Mapa</button> <button className="gm-btn icon" onClick={() => { playNav(); onBack(); }}>{isMobile ? '←' : '← Mapa'}</button>
<div className="gm-puzzle-title"> <div className="gm-puzzle-title">
<span className="gm-puzzle-num">{levelIndex + 1}/{worldLevels.length}</span> <span className="gm-puzzle-num">{levelIndex + 1}/{worldLevels.length}</span>
<span className="gm-puzzle-name">{level.title}</span> <span className="gm-puzzle-name">{level.title}</span>
@@ -241,22 +365,32 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
className={`gm-btn ${targetPlaying ? 'active' : 'target'}`} className={`gm-btn ${targetPlaying ? 'active' : 'target'}`}
onClick={handlePlayTarget} onClick={handlePlayTarget}
> >
{targetPlaying ? '⏹ Parar' : '🎯 Objetivo'} {targetPlaying ? '⏹' : '🎯'}{!isMobile && <span className="btn-label">{targetPlaying ? ' Parar' : ' Objetivo'}</span>}
</button> </button>
<button <button
className={`gm-btn ${state.isRunning ? 'active' : ''}`} className={`gm-btn ${state.isRunning ? 'active' : ''}`}
onClick={handleToggleAudio} onClick={handleToggleAudio}
> >
{state.isRunning ? '⏹ Parar' : ' Mi Sonido'} {state.isRunning ? '⏹' : '▶'}{!isMobile && <span className="btn-label">{state.isRunning ? ' Parar' : ' Mi Sonido'}</span>}
</button> </button>
{!isMobile && (
<button className="gm-btn clear" onClick={handleClearCanvas} title="Limpiar canvas">
🗑 Limpiar
</button>
)}
<button className="gm-btn check" onClick={handleCheck}> <button className="gm-btn check" onClick={handleCheck}>
Comprobar {!isMobile && <span className="btn-label"> Comprobar</span>}
</button> </button>
{adminMode && (
<button className="gm-btn admin-solve" onClick={handleAutoSolve} title="Admin: resolver nivel">
🛠 Resolver
</button>
)}
</div> </div>
</div> </div>
<div className="gm-puzzle-content"> <div className="gm-puzzle-content">
{/* Left sidebar */} {/* Left sidebar (desktop only — hidden on mobile via CSS) */}
<div className="gm-puzzle-sidebar"> <div className="gm-puzzle-sidebar">
{/* Description — always visible */} {/* Description — always visible */}
<div className="gm-concept-panel"> <div className="gm-concept-panel">
@@ -307,7 +441,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
})} })}
{hintUsed && ( {hintUsed && (
<div className="gm-hint-warning"> <div className="gm-hint-warning">
Pista usada maximo 2 estrellas. Reinicia para intentar sin pista. Pista usada maximo 2 estrellas en este nivel (permanente).
</div> </div>
)} )}
</div> </div>
@@ -330,7 +464,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
</div> </div>
)} )}
<button className="gm-btn danger" onClick={loadLevel} style={{ marginTop: 'auto' }}> <button className="gm-btn danger" onClick={() => loadLevel(true)} style={{ marginTop: 'auto' }}>
Reiniciar Nivel Reiniciar Nivel
</button> </button>
</div> </div>
@@ -372,6 +506,16 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
</div> </div>
</div> </div>
{/* Zoom controls — top right */}
<div className="zoom-controls">
<button className="zoom-btn" onClick={handleZoomIn} title="Acercar">+</button>
<button className="zoom-btn zoom-label" onClick={handleZoomReset} title="Resetear zoom">
{(state.zoom * 100).toFixed(0)}%
</button>
<button className="zoom-btn" onClick={handleZoomOut} title="Alejar"></button>
<button className="zoom-btn" onClick={handleCenterView} title="Centrar vista"></button>
</div>
{state.modules.length > 0 && state.connections.length === 0 && ( {state.modules.length > 0 && state.connections.length === 0 && (
<div className="gm-canvas-hint"> <div className="gm-canvas-hint">
Arrastra de un puerto (circulo) a otro para conectar modulos Arrastra de un puerto (circulo) a otro para conectar modulos
@@ -380,6 +524,89 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
</div> </div>
</div> </div>
{/* Mobile bottom sheet with tabs (replaces sidebar) */}
{isMobile && (
<BottomSheet
tabs={[
{ id: 'mission', label: 'MISION' },
{ id: 'objectives', label: 'OBJETIVOS' },
{ id: 'modules', label: 'MODULOS' },
]}
activeTab={mobileTab}
onTabChange={setMobileTab}
>
{mobileTab === 'mission' && (
<div>
<p className="puzzle-mission-text">{level.description}</p>
{!showHint ? (
<button className="puzzle-hint-btn" onClick={handleRevealHint}>
<span className="puzzle-hint-icon">💡</span>
<span className="puzzle-hint-label">Mostrar Pista</span>
<span className="puzzle-hint-penalty">max </span>
</button>
) : (
<div style={{ marginTop: 8, padding: '10px 12px', background: 'var(--surface)', borderRadius: 8, border: '1px solid var(--yellow)' }}>
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--yellow)', marginBottom: 6 }}>💡 Pista <span className="puzzle-hint-penalty">max </span></div>
<p style={{ fontSize: 12, color: 'var(--text)', lineHeight: 1.5 }}>{level.concept}</p>
</div>
)}
</div>
)}
{mobileTab === 'objectives' && (
<div>
{level.checks.map((check, i) => {
const passed = result?.checks?.[i]?.passed;
const cappedByStar = hintUsed && check.star === 3;
return (
<div key={i} className="puzzle-obj-item">
<span className="puzzle-obj-star">{'★'.repeat(check.star)}</span>
<span className="puzzle-obj-desc" style={passed === true ? { color: 'var(--green)' } : passed === false ? { color: 'var(--red)' } : {}}>
{check.desc}
{cappedByStar && ' 🔒'}
</span>
{passed === true && !cappedByStar && <span style={{ color: 'var(--green)', fontWeight: 700 }}></span>}
{passed === false && <span style={{ color: 'var(--red)', fontWeight: 700 }}></span>}
</div>
);
})}
{hintUsed && (
<div style={{ marginTop: 8, padding: '6px 8px', background: 'rgba(255,204,0,0.08)', borderRadius: 4, fontSize: 10, color: 'var(--yellow)' }}>
Pista usada maximo 2 estrellas (permanente).
</div>
)}
</div>
)}
{mobileTab === 'modules' && (
<div>
{level.availableModules.length > 0 ? (
<div className="mobile-module-grid" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
{level.availableModules.map(type => {
const def = getModuleDef(type);
if (!def) return null;
return (
<div key={type} className="mobile-module-tile" onClick={() => handleAddModule(type)}>
<span className="tile-icon">{def.icon}</span>
<span className="tile-name">{def.name}</span>
</div>
);
})}
</div>
) : (
<p style={{ fontSize: 12, color: 'var(--text2)' }}>No hay modulos extra disponibles para este nivel.</p>
)}
<button className="gm-btn danger" onClick={() => loadLevel(true)} style={{ width: '100%', marginTop: 12, justifyContent: 'center' }}>
Reiniciar Nivel
</button>
<button className="gm-btn clear" onClick={handleClearCanvas} style={{ width: '100%', marginTop: 6, justifyContent: 'center' }}>
🗑 Limpiar
</button>
</div>
)}
</BottomSheet>
)}
{/* Level complete overlay */} {/* Level complete overlay */}
{result && result.stars >= 1 && ( {result && result.stars >= 1 && (
<LevelComplete <LevelComplete
@@ -388,7 +615,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
levelTitle={level.title} levelTitle={level.title}
isLastLevel={isLastLevel} isLastLevel={isLastLevel}
hintPenalty={result.hintPenalty} hintPenalty={result.hintPenalty}
onRetry={loadLevel} onRetry={() => setResult(null)}
onMap={onBack} onMap={onBack}
onNext={onNextLevel} onNext={onNextLevel}
/> />

View File

@@ -1,8 +1,21 @@
import React from 'react'; import React, { useState, useRef } from 'react';
import MobileTabBar from '../components/MobileTabBar.jsx';
import { useIsMobile } from '../hooks/useIsMobile.js';
import { WORLD_1 } from './levels/world1.js'; import { WORLD_1 } from './levels/world1.js';
import { getLevelProgress, isLevelUnlocked } from './gameState.js'; import { WORLD_2 } from './levels/world2.js';
import { WORLD_3 } from './levels/world3.js';
import { 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]; 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 }) { function Stars({ count, max = 3 }) {
return ( return (
@@ -14,13 +27,45 @@ function Stars({ count, max = 3 }) {
); );
} }
export default function WorldMap({ onSelectLevel, onSandbox }) { function getTotalStars() {
const world = WORLD_1; const p = loadProgress();
const totalStars = world.levels.reduce((s, l) => { return Object.values(p.completedLevels).reduce((s, l) => s + (l.stars || 0), 0);
const p = getLevelProgress(l.id); }
return s + (p?.stars || 0);
}, 0); function getMaxStars() {
const maxStars = world.levels.length * 3; return worlds.reduce((s, w) => s + w.levels.length * 3, 0);
}
function isWorldUnlocked(world) {
if (!world.unlockStars) return true; // World 1 always unlocked
return getTotalStars() >= world.unlockStars;
}
const MOBILE_TABS = [
{ id: 'game', label: 'JUEGO', icon: '🎮' },
{ id: 'sandbox', label: 'SANDBOX', icon: '🎛' },
{ id: 'config', label: 'CONFIG', icon: '⚙' },
];
export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
const totalStars = getTotalStars();
const maxStars = getMaxStars();
const [search, setSearch] = useState('');
const searchRef = useRef(null);
const isMobile = useIsMobile();
const query = search.trim().toLowerCase();
// Build flat search results when there's a query
const searchResults = query ? worlds.flatMap((world, worldIdx) => {
return world.levels.map((level, idx) => ({ level, world, worldIdx, idx }))
.filter(({ level }) =>
level.title.toLowerCase().includes(query) ||
level.subtitle.toLowerCase().includes(query) ||
level.id.toLowerCase().includes(query) ||
world.name.toLowerCase().includes(query)
);
}) : [];
return ( return (
<div className="gm-worldmap"> <div className="gm-worldmap">
@@ -40,72 +85,151 @@ export default function WorldMap({ onSelectLevel, onSandbox }) {
<button className="gm-sandbox-btn" onClick={onSandbox}> <button className="gm-sandbox-btn" onClick={onSandbox}>
🎛 Sandbox 🎛 Sandbox
</button> </button>
{onAdmin && (
<button className="gm-admin-btn" onClick={onAdmin} title="Admin Mode">
🛠
</button>
)}
</div> </div>
</div> </div>
{/* World section */} {/* Search bar */}
<div className="gm-world-section"> <div className="gm-search-bar">
<div className="gm-world-header"> <span className="gm-search-icon">🔍</span>
<span className="gm-world-icon" style={{ color: world.color }}>{world.icon}</span> <input
<div> ref={searchRef}
<h2 className="gm-world-name">Mundo 1: {world.name}</h2> className="gm-search-input"
<p className="gm-world-sub">{world.subtitle}</p> type="text"
</div> placeholder="Buscar nivel por nombre, mundo..."
value={search}
onChange={e => setSearch(e.target.value)}
onKeyDown={e => e.key === 'Escape' && (setSearch(''), searchRef.current?.blur())}
/>
{search && (
<button className="gm-search-clear" onClick={() => { setSearch(''); searchRef.current?.focus(); }}></button>
)}
</div> </div>
{/* Level grid */} {/* Search results */}
{query ? (
<div className="gm-search-results">
{searchResults.length === 0 ? (
<div className="gm-search-empty">No se encontraron niveles para "{search}"</div>
) : (
<div className="gm-search-count">{searchResults.length} nivel{searchResults.length !== 1 ? 'es' : ''} encontrado{searchResults.length !== 1 ? 's' : ''}</div>
)}
<div className="gm-level-grid"> <div className="gm-level-grid">
{world.levels.map((level, idx) => { {searchResults.map(({ level, world, worldIdx, idx }) => {
const progress = getLevelProgress(level.id); const progress = getLevelProgress(level.id);
const unlocked = isLevelUnlocked(level.id, world.levels); const levelUnlocked = isLevelUnlocked(level.id, world.levels) && isWorldUnlocked(world);
const stars = progress?.stars || 0; const stars = progress?.stars || 0;
const isBoss = idx === world.levels.length - 1; const isBoss = idx === world.levels.length - 1;
return ( return (
<div <div
key={level.id} key={level.id}
className={`gm-level-card ${unlocked ? 'unlocked' : 'locked'} ${isBoss ? 'boss' : ''} ${stars === 3 ? 'perfect' : ''}`} className={`gm-level-card ${levelUnlocked ? 'unlocked' : 'locked'} ${isBoss ? 'boss' : ''} ${stars === 3 ? 'perfect' : ''}`}
onClick={() => unlocked && onSelectLevel(level)} onClick={() => levelUnlocked && onSelectLevel(level, world)}
>
<div className="gm-level-number" style={{ color: world.color }}>{worldIdx + 1}.{idx + 1}</div>
<div className="gm-level-info">
<h3 className="gm-level-title">{level.title}</h3>
<p className="gm-level-subtitle">{world.name} {level.subtitle}</p>
</div>
{levelUnlocked ? (
<Stars count={stars} />
) : (
<span className="gm-lock">🔒</span>
)}
{!levelUnlocked && <div className="gm-lock-overlay" />}
</div>
);
})}
</div>
</div>
) : (
/* All worlds (normal view) */
worlds.map((world, worldIdx) => {
const unlocked = isWorldUnlocked(world);
const worldStars = world.levels.reduce((s, l) => {
const p = getLevelProgress(l.id);
return s + (p?.stars || 0);
}, 0);
const worldMaxStars = world.levels.length * 3;
if (!unlocked) {
return (
<div key={world.id} className="gm-world-section gm-locked-world">
<div className="gm-world-header">
<span className="gm-world-icon" style={{ color: '#666' }}>{world.icon}</span>
<div>
<h2 className="gm-world-name" style={{ color: '#666' }}>Mundo {worldIdx + 1}: {world.name}</h2>
<p className="gm-world-sub">Consigue {world.unlockStars} estrellas para desbloquear ({totalStars}/{world.unlockStars})</p>
</div>
<span className="gm-lock" style={{ fontSize: 24 }}>🔒</span>
</div>
</div>
);
}
return (
<div key={world.id} className="gm-world-section">
<div className="gm-world-header">
<span className="gm-world-icon" style={{ color: world.color }}>{world.icon}</span>
<div>
<h2 className="gm-world-name">Mundo {worldIdx + 1}: {world.name}</h2>
<p className="gm-world-sub">{world.subtitle}</p>
</div>
<div className="gm-world-stars">
<span className="star filled"></span> {worldStars}/{worldMaxStars}
</div>
</div>
<div className="gm-level-grid">
{world.levels.map((level, idx) => {
const progress = getLevelProgress(level.id);
const levelUnlocked = isLevelUnlocked(level.id, world.levels);
const stars = progress?.stars || 0;
const isBoss = idx === world.levels.length - 1;
return (
<div
key={level.id}
className={`gm-level-card ${levelUnlocked ? 'unlocked' : 'locked'} ${isBoss ? 'boss' : ''} ${stars === 3 ? 'perfect' : ''}`}
onClick={() => levelUnlocked && onSelectLevel(level, world)}
> >
<div className="gm-level-number">{idx + 1}</div> <div className="gm-level-number">{idx + 1}</div>
<div className="gm-level-info"> <div className="gm-level-info">
<h3 className="gm-level-title">{level.title}</h3> <h3 className="gm-level-title">{level.title}</h3>
<p className="gm-level-subtitle">{level.subtitle}</p> <p className="gm-level-subtitle">{level.subtitle}</p>
</div> </div>
{unlocked ? ( {levelUnlocked ? (
<Stars count={stars} /> <Stars count={stars} />
) : ( ) : (
<span className="gm-lock">🔒</span> <span className="gm-lock">🔒</span>
)} )}
{!unlocked && <div className="gm-lock-overlay" />} {!levelUnlocked && <div className="gm-lock-overlay" />}
</div> </div>
); );
})} })}
</div> </div>
</div> </div>
);
})
)}
{/* Future worlds teaser */} {/* Mobile tab bar */}
<div className="gm-world-section gm-locked-world"> {isMobile && (
<div className="gm-world-header"> <MobileTabBar
<span className="gm-world-icon" style={{ color: '#666' }}></span> tabs={MOBILE_TABS}
<div> activeTab="game"
<h2 className="gm-world-name" style={{ color: '#666' }}>Mundo 2: Filtros</h2> onTabChange={(id) => {
<p className="gm-world-sub">Proximamente... Consigue {Math.ceil(maxStars * 0.6)} estrellas para desbloquear</p> if (id === 'sandbox') onSandbox?.();
</div> if (id === 'config') onAdmin?.();
<span className="gm-lock" style={{ fontSize: 24 }}>🔒</span> }}
</div> />
</div> )}
<div className="gm-world-section gm-locked-world">
<div className="gm-world-header">
<span className="gm-world-icon" style={{ color: '#666' }}></span>
<div>
<h2 className="gm-world-name" style={{ color: '#666' }}>Mundo 3: Envelopes</h2>
<p className="gm-world-sub">Proximamente...</p>
</div>
<span className="gm-lock" style={{ fontSize: 24 }}>🔒</span>
</div>
</div>
</div> </div>
); );
} }

1913
src/game/autoSolver.js Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,18 +1,20 @@
/** /**
* gameState.js — Game progress persistence * gameState.js — Game progress persistence
* Tracks completed levels, stars earned, and unlocks * Tracks completed levels, stars earned, unlocks, and saved patches per level
*/ */
const STORAGE_KEY = 'synthquest-progress'; const STORAGE_KEY = 'synthquest-progress';
const PATCHES_KEY = 'synthquest-patches';
const defaultProgress = { const defaultProgress = {
currentWorld: 'w1', currentWorld: 'w1',
completedLevels: {}, // { levelId: { stars: 3, bestTime: 12.5 } } completedLevels: {}, // { levelId: { stars: 3 } }
unlockedWorlds: ['w1'], unlockedWorlds: ['w1'],
totalStars: 0, totalStars: 0,
}; };
let _progress = null; let _progress = null;
let _patches = null; // { levelId: { modules, connections } }
export function loadProgress() { export function loadProgress() {
if (_progress) return _progress; if (_progress) return _progress;
@@ -50,15 +52,98 @@ export function getLevelProgress(levelId) {
export function isLevelUnlocked(levelId, worldLevels) { export function isLevelUnlocked(levelId, worldLevels) {
const p = loadProgress(); const p = loadProgress();
// First level is always unlocked
const idx = worldLevels.findIndex(l => l.id === levelId); const idx = worldLevels.findIndex(l => l.id === levelId);
if (idx === 0) return true; if (idx === 0) return true;
// Previous level must have at least 1 star
const prevId = worldLevels[idx - 1]?.id; const prevId = worldLevels[idx - 1]?.id;
return prevId && p.completedLevels[prevId]?.stars >= 1; return prevId && p.completedLevels[prevId]?.stars >= 1;
} }
export function resetProgress() { export function resetProgress() {
_progress = { ...defaultProgress }; _progress = { ...defaultProgress };
_patches = {};
_hints = {};
saveProgress(); saveProgress();
savePatches();
saveHints();
}
// ==================== Level patch persistence ====================
function loadPatches() {
if (_patches) return _patches;
try {
const raw = localStorage.getItem(PATCHES_KEY);
_patches = raw ? JSON.parse(raw) : {};
} catch {
_patches = {};
}
return _patches;
}
function savePatches() {
if (!_patches) return;
try {
localStorage.setItem(PATCHES_KEY, JSON.stringify(_patches));
} catch {}
}
export function saveLevelPatch(levelId, modules, connections) {
const patches = loadPatches();
patches[levelId] = {
modules: modules.map(m => ({ id: m.id, type: m.type, x: m.x, y: m.y, params: { ...m.params } })),
connections: connections.map(c => ({ ...c })),
savedAt: Date.now(),
};
savePatches();
}
export function getLevelPatch(levelId) {
const patches = loadPatches();
return patches[levelId] || null;
}
export function clearLevelPatch(levelId) {
const patches = loadPatches();
delete patches[levelId];
savePatches();
}
// ==================== Hint tracking (persisted, no cheating!) ====================
const HINTS_KEY = 'synthquest-hints';
let _hints = null; // { levelId: true }
function loadHints() {
if (_hints) return _hints;
try {
const raw = localStorage.getItem(HINTS_KEY);
_hints = raw ? JSON.parse(raw) : {};
} catch {
_hints = {};
}
return _hints;
}
function saveHints() {
if (!_hints) return;
try {
localStorage.setItem(HINTS_KEY, JSON.stringify(_hints));
} catch {}
}
export function markHintUsed(levelId) {
const hints = loadHints();
hints[levelId] = true;
saveHints();
}
export function wasHintUsed(levelId) {
const hints = loadHints();
return !!hints[levelId];
}
export function clearHintForLevel(levelId) {
const hints = loadHints();
delete hints[levelId];
saveHints();
} }

576
src/game/levels/world10.js Normal file
View File

@@ -0,0 +1,576 @@
/**
* 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: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 220, detune: 0 } },
{ type: 'oscillator', params: { waveform: 'sine', frequency: 330, detune: 0 } },
],
envelope: { attack: 0.05, decay: 0.3, sustain: 0.4, release: 0.2 },
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: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 220, detune: 0 } },
{ type: 'oscillator', params: { waveform: 'sine', frequency: 220, detune: 8 } },
],
envelope: { attack: 0.06, decay: 0.35, sustain: 0.35, release: 0.25 },
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: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 165, detune: 0 } },
],
effects: [
{ type: 'delay', delayTime: 0.035, feedback: 0.15, wet: 0.8 },
],
envelope: { attack: 0.08, decay: 0.4, sustain: 0.3, release: 0.3 },
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: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 262, detune: 0 } },
],
effects: [
{ type: 'reverb', decay: 1.5, wet: 0.4 },
],
envelope: { attack: 0.07, decay: 0.4, sustain: 0.35, release: 0.25 },
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: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 196, detune: 0 } },
],
effects: [
{ type: 'reverb', decay: 4.2, wet: 0.65 },
],
envelope: { attack: 0.06, decay: 0.8, sustain: 0.4, release: 0.5 },
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: [
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: 0 } },
],
effects: [
{ type: 'delay', delayTime: 0.15, feedback: 0.08, wet: 0.75 },
],
envelope: { attack: 0.05, decay: 0.35, sustain: 0.4, release: 0.2 },
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: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 280, detune: 0 } },
],
filter: { type: 'lowpass', frequency: 3500, Q: 1.5 },
effects: [
{ type: 'distortion', distortion: 0.45, wet: 0.5 },
{ type: 'delay', delayTime: 0.3, feedback: 0.35, wet: 0.55 },
{ type: 'reverb', decay: 2.2, wet: 0.45 },
],
envelope: { attack: 0.08, decay: 0.45, sustain: 0.25, release: 0.3 },
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: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 110, detune: 0 } },
{ type: 'oscillator', params: { waveform: 'sine', frequency: 110, detune: 10 } },
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220, detune: 0 } },
],
filter: { type: 'lowpass', frequency: 4000, Q: 1.3 },
lfo: { frequency: 0.6, type: 'sine', min: 2000, max: 5000, target: 'frequency' },
effects: [
{ type: 'delay', delayTime: 0.25, feedback: 0.4, wet: 0.6 },
{ type: 'reverb', decay: 3, wet: 0.55 },
],
envelope: { attack: 0.1, decay: 0.5, sustain: 0.3, release: 0.4 },
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;
},
},
],
},
],
};

596
src/game/levels/world11.js Normal file
View File

@@ -0,0 +1,596 @@
/**
* 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: [
{ type: 'noise', params: { type: 'white' } },
],
filter: { type: 'lowpass', frequency: 1500, Q: 9.5 },
lfo: { frequency: 1, type: 'sine', min: 600, max: 3500, target: 'frequency' },
envelope: { attack: 0.1, decay: 0.4, sustain: 0.3, release: 0.25 },
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: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 200, detune: 0 } },
{ type: 'oscillator', params: { waveform: 'sine', frequency: 8, detune: 0 } },
],
envelope: { attack: 0.08, decay: 0.35, sustain: 0.35, release: 0.2 },
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: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 60, detune: 0 } },
{ type: 'oscillator', params: { waveform: 'sine', frequency: 60, detune: -4 } },
],
filter: { type: 'lowpass', frequency: 2500, Q: 0.85 },
lfo: [
{ frequency: 0.3, type: 'sine', min: 1000, max: 4000, target: 'frequency' },
{ frequency: 0.15, type: 'sine', min: 0.3, max: 0.9, target: 'amplitude' },
],
effects: [
{ type: 'reverb', decay: 5, wet: 0.7 },
],
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: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220, detune: 0 } },
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220, detune: 3 } },
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: -2 } },
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: 5 } },
],
filter: { type: 'lowpass', frequency: 3500, Q: 1.2 },
envelope: { attack: 0.07, decay: 0.4, sustain: 0.35, release: 0.25 },
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: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 150, detune: 0 } },
],
filter: { type: 'lowpass', frequency: 2800, Q: 1.3 },
lfo: { frequency: 2.5, type: 'square', min: 0.1, max: 0.95, target: 'amplitude' },
envelope: { attack: 0.03, decay: 0.25, sustain: 0.1, release: 0.15 },
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: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 120, detune: 0 } },
],
filter: { type: 'lowpass', frequency: 2000, Q: 1.5 },
effects: [
{ type: 'delay', delayTime: 0.5, feedback: 0.8, wet: 0.9 },
{ type: 'reverb', decay: 3.5, wet: 0.6 },
],
envelope: { attack: 0.1, decay: 1, sustain: 0.4, release: 0.5 },
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: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 100, detune: 0 } },
],
filter: { type: 'lowpass', frequency: 2500, Q: 1.2 },
lfo: [
{ frequency: 0.4, type: 'sine', min: 1.5, max: 6.5, target: 'frequency' },
{ frequency: 4.5, type: 'sine', min: 1000, max: 4500, target: 'frequency' },
],
envelope: { attack: 0.08, decay: 0.4, sustain: 0.3, release: 0.25 },
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: [
{ type: 'noise', params: { type: 'white' } },
{ type: 'oscillator', params: { waveform: 'sine', frequency: 80, detune: 0 } },
{ type: 'oscillator', params: { waveform: 'sine', frequency: 80, detune: -3 } },
{ type: 'oscillator', params: { waveform: 'sine', frequency: 7, detune: 0 } },
],
filter: { type: 'lowpass', frequency: 2200, Q: 8 },
lfo: [
{ frequency: 0.25, type: 'sine', min: 1000, max: 3500, target: 'frequency' },
{ frequency: 2, type: 'sine', min: 0.2, max: 0.9, target: 'amplitude' },
],
effects: [
{ type: 'distortion', distortion: 0.4, wet: 0.35 },
{ type: 'delay', delayTime: 0.4, feedback: 0.65, wet: 0.7 },
{ type: 'reverb', decay: 3.2, wet: 0.5 },
],
envelope: { attack: 0.12, decay: 0.6, sustain: 0.25, release: 0.4 },
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;
},
},
],
},
],
};

605
src/game/levels/world12.js Normal file
View File

@@ -0,0 +1,605 @@
/**
* 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: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55, detune: 0 } },
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55, detune: -5 } },
],
filter: { type: 'lowpass', frequency: 1800, Q: 0.9 },
lfo: { frequency: 0.2, type: 'sine', min: 800, max: 3200, target: 'frequency' },
effects: [
{ type: 'reverb', decay: 5.5, wet: 0.65 },
],
envelope: { attack: 0.01, decay: 2, sustain: 0.8, release: 0.6 },
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: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55, detune: 0 } },
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 82, detune: 0 } },
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 82, detune: 4 } },
],
filter: { type: 'lowpass', frequency: 4500, Q: 1.1 },
envelope: { attack: 0.01, decay: 0.18, sustain: 0.05, release: 0.1 },
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: [
{ type: 'oscillator', params: { waveform: 'square', frequency: 440, detune: 0 } },
],
filter: { type: 'lowpass', frequency: 3800, Q: 3 },
lfo: { frequency: 0.5, type: 'sine', min: 2000, max: 5500, target: 'frequency' },
effects: [
{ type: 'reverb', decay: 2.8, wet: 0.45 },
],
envelope: { attack: 0.04, decay: 0.5, sustain: 0.5, release: 0.3 },
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: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 110, detune: 0 } },
{ type: 'oscillator', params: { waveform: 'sine', frequency: 110, detune: -6 } },
],
filter: { type: 'lowpass', frequency: 2500, Q: 0.95 },
lfo: { frequency: 0.15, type: 'sine', min: 1200, max: 3800, target: 'frequency' },
effects: [
{ type: 'reverb', decay: 4.5, wet: 0.7 },
],
envelope: { attack: 0.15, decay: 1.5, sustain: 0.6, release: 0.5 },
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: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 130, detune: 0 } },
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 165, detune: 0 } },
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 165, detune: 3 } },
],
filter: { type: 'lowpass', frequency: 3000, Q: 1.4 },
lfo: { frequency: 0.4, type: 'sine', min: 1500, max: 5000, target: 'frequency' },
effects: [
{ type: 'reverb', decay: 2, wet: 0.35 },
],
envelope: { attack: 0.08, decay: 0.6, sustain: 0.4, release: 0.3 },
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: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55, detune: 0 } },
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55, detune: -5 } },
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 0 } },
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 4 } },
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: 0 } },
],
filter: { type: 'lowpass', frequency: 3500, Q: 1.2 },
lfo: { frequency: 0.35, type: 'sine', min: 1500, max: 4500, target: 'frequency' },
effects: [
{ type: 'reverb', decay: 2.5, wet: 0.5 },
],
envelope: { attack: 0.1, decay: 0.6, sustain: 0.35, release: 0.4 },
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: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 50, detune: 0 } },
{ type: 'oscillator', params: { waveform: 'sine', frequency: 50, detune: -7 } },
],
filter: { type: 'lowpass', frequency: 1500, Q: 0.85 },
lfo: { frequency: 0.12, type: 'sine', min: 600, max: 2500, target: 'frequency' },
effects: [
{ type: 'delay', delayTime: 0.6, feedback: 0.5, wet: 0.6 },
{ type: 'reverb', decay: 6, wet: 0.75 },
],
envelope: { attack: 0.05, decay: 1.5, sustain: 0.2, release: 1 },
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: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 50, detune: 0 } },
{ type: 'oscillator', params: { waveform: 'sine', frequency: 50, detune: -5 } },
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 0 } },
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 4 } },
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: 0 } },
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: -3 } },
],
filter: { type: 'lowpass', frequency: 3800, Q: 1.5 },
lfo: [
{ frequency: 0.3, type: 'sine', min: 1500, max: 4500, target: 'frequency' },
{ frequency: 0.8, type: 'sine', min: 0.3, max: 0.9, target: 'amplitude' },
],
effects: [
{ type: 'delay', delayTime: 0.35, feedback: 0.45, wet: 0.5 },
{ type: 'reverb', decay: 3, wet: 0.55 },
],
envelope: { attack: 0.1, decay: 0.7, sustain: 0.4, release: 0.5 },
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;
},
},
],
},
],
};

490
src/game/levels/world2.js Normal file
View File

@@ -0,0 +1,490 @@
/**
* World 2 — "Filtros" (Filters)
*
* Teaches: lowpass, highpass, bandpass, resonance, cutoff modulation
* 8 levels, progressive difficulty
*/
export const WORLD_2 = {
id: 'w2',
name: 'Filtros',
subtitle: 'Esculpe el timbre con filtros',
icon: '▽',
color: '#ff6644',
unlockStars: 12, // Need 12 stars from World 1 to unlock
levels: [
// ─────────────── LEVEL 2.1 ───────────────
{
id: 'w2-1',
title: 'El Paso Bajo',
subtitle: 'Quita los agudos',
description: 'Un filtro paso bajo (lowpass) deja pasar las frecuencias graves y elimina las agudas. Es el filtro más usado en síntesis — piensa en cómo suena la música debajo del agua. Conecta el oscilador a través del filtro.',
concept: 'Conecta: Oscillator → Filter → Output. El filtro ya está en modo lowpass. El knob "Cutoff" controla hasta qué frecuencia deja pasar. Bájalo para un sonido más oscuro.',
availableModules: [],
preplacedModules: [
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: true },
{ id: 2, type: 'filter', x: 340, y: 80, params: { type: 'lowpass', frequency: 2000, Q: 1 }, locked: false },
{ id: 3, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
],
filter: { type: 'lowpass', frequency: 800 },
duration: 2.5,
},
checks: [
{
star: 1,
name: 'Señal filtrada',
desc: 'Conecta oscilador → filtro → salida',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const flt = mods.find(m => m.type === 'filter');
const out = mods.find(m => m.type === 'output');
if (!osc || !flt || !out) return false;
const oscToFlt = conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id);
const fltToOut = conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id);
return oscToFlt && fltToOut;
},
},
{
star: 2,
name: 'Cutoff bajo',
desc: 'Baja el cutoff por debajo de 1200 Hz',
test: (mods) => {
const flt = mods.find(m => m.type === 'filter');
return flt && (flt.params.frequency ?? 2000) < 1200;
},
},
{
star: 3,
name: 'Sonido oscuro',
desc: 'Cutoff cercano a 800 Hz (±200 Hz)',
test: (mods) => {
const flt = mods.find(m => m.type === 'filter');
if (!flt) return false;
return Math.abs((flt.params.frequency ?? 2000) - 800) <= 200;
},
},
],
},
// ─────────────── LEVEL 2.2 ───────────────
{
id: 'w2-2',
title: 'El Paso Alto',
subtitle: 'Solo los agudos',
description: 'El filtro paso alto (highpass) es lo opuesto: elimina los graves y deja pasar los agudos. Es perfecto para quitar el "barro" de un sonido o crear texturas etéreas y delgadas.',
concept: 'Cambia el tipo de filtro a "highpass". Sube el cutoff para que solo pasen las frecuencias altas. Un cutoff de ~2000 Hz eliminará todo lo grave.',
availableModules: ['filter'],
preplacedModules: [
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: true },
{ id: 2, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
],
filter: { type: 'highpass', frequency: 2000 },
duration: 2.5,
},
checks: [
{
star: 1,
name: 'Filtro conectado',
desc: 'Coloca un filtro entre oscilador y salida',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const flt = mods.find(m => m.type === 'filter');
const out = mods.find(m => m.type === 'output');
if (!osc || !flt || !out) return false;
const oscToFlt = conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id);
const fltToOut = conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id);
return oscToFlt && fltToOut;
},
},
{
star: 2,
name: 'Modo highpass',
desc: 'Cambia el filtro a highpass',
test: (mods) => {
const flt = mods.find(m => m.type === 'filter');
return flt && flt.params.type === 'highpass';
},
},
{
star: 3,
name: 'Cutoff preciso',
desc: 'Cutoff cercano a 2000 Hz (±300 Hz)',
test: (mods) => {
const flt = mods.find(m => m.type === 'filter');
if (!flt) return false;
return flt.params.type === 'highpass' && Math.abs((flt.params.frequency ?? 1000) - 2000) <= 300;
},
},
],
},
// ─────────────── LEVEL 2.3 ───────────────
{
id: 'w2-3',
title: 'Resonancia',
subtitle: 'El pico que canta',
description: 'La resonancia (Q) amplifica las frecuencias justo alrededor del punto de corte. Con poca resonancia el filtro es suave. Con mucha, el filtro "canta" — es el sonido ácido clásico del TB-303.',
concept: 'Sube el knob "Reso" (Q) del filtro a un valor alto (~8-12). Mantén el cutoff bajo (~600 Hz) con lowpass. Escucharás cómo el filtro enfatiza esa frecuencia.',
availableModules: [],
preplacedModules: [
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sawtooth', frequency: 110, detune: 0 }, locked: true },
{ id: 2, type: 'filter', x: 340, y: 80, params: { type: 'lowpass', frequency: 1000, Q: 1 }, locked: false },
{ id: 3, type: 'output', x: 600, y: 100, params: { volume: -8 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110 } },
],
filter: { type: 'lowpass', frequency: 600, Q: 10 },
duration: 2.5,
},
checks: [
{
star: 1,
name: 'Señal filtrada',
desc: 'Conecta oscilador → filtro → salida',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const flt = mods.find(m => m.type === 'filter');
const out = mods.find(m => m.type === 'output');
if (!osc || !flt || !out) return false;
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id);
},
},
{
star: 2,
name: 'Resonancia alta',
desc: 'Sube la resonancia (Q) por encima de 5',
test: (mods) => {
const flt = mods.find(m => m.type === 'filter');
return flt && (flt.params.Q ?? 1) > 5;
},
},
{
star: 3,
name: 'Sonido ácido',
desc: 'Q alto (~8-12) y cutoff bajo (~600 Hz ±200)',
test: (mods) => {
const flt = mods.find(m => m.type === 'filter');
if (!flt) return false;
const q = flt.params.Q ?? 1;
const freq = flt.params.frequency ?? 1000;
return q >= 7 && q <= 15 && Math.abs(freq - 600) <= 200;
},
},
],
},
// ─────────────── LEVEL 2.4 ───────────────
{
id: 'w2-4',
title: 'Banda Pasante',
subtitle: 'Solo el medio',
description: 'El filtro bandpass deja pasar solo un rango estrecho de frecuencias alrededor del cutoff. Es como poner un lowpass y un highpass a la vez. Crea sonidos nasales, tipo telefono o walkie-talkie.',
concept: 'Cambia el tipo a "bandpass". El cutoff define el centro de la banda. Sube la Q para hacerla más estrecha (más nasal). Un cutoff de ~1000 Hz con Q alta suena como una voz por teléfono.',
availableModules: ['filter'],
preplacedModules: [
{ id: 1, type: 'noise', x: 80, y: 80, params: { type: 'white' }, locked: true },
{ id: 2, type: 'output', x: 600, y: 100, params: { volume: -10 }, locked: true },
],
target: {
build: [
{ type: 'noise', params: { type: 'white' } },
],
filter: { type: 'bandpass', frequency: 1000, Q: 8 },
duration: 2,
},
checks: [
{
star: 1,
name: 'Ruido filtrado',
desc: 'Coloca filtro entre noise y salida',
test: (mods, conns) => {
const noise = mods.find(m => m.type === 'noise');
const flt = mods.find(m => m.type === 'filter');
const out = mods.find(m => m.type === 'output');
if (!noise || !flt || !out) return false;
return conns.some(c => c.from.moduleId === noise.id && c.to.moduleId === flt.id) &&
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id);
},
},
{
star: 2,
name: 'Modo bandpass',
desc: 'Filtro en modo bandpass',
test: (mods) => {
const flt = mods.find(m => m.type === 'filter');
return flt && flt.params.type === 'bandpass';
},
},
{
star: 3,
name: 'Nasal perfecto',
desc: 'Bandpass a ~1000 Hz con Q alta (>5)',
test: (mods) => {
const flt = mods.find(m => m.type === 'filter');
if (!flt) return false;
return flt.params.type === 'bandpass' &&
Math.abs((flt.params.frequency ?? 1000) - 1000) <= 300 &&
(flt.params.Q ?? 1) > 5;
},
},
],
},
// ─────────────── LEVEL 2.5 ───────────────
{
id: 'w2-5',
title: 'Filtro en Movimiento',
subtitle: 'LFO → Cutoff',
description: 'Los filtros estáticos son útiles, pero los filtros en movimiento son mágicos. Conectar un LFO al cutoff de un filtro crea un barrido cíclico — es el sonido "wah-wah" clásico del funk y la música electrónica.',
concept: 'Conecta un LFO a la entrada "Cutoff" del filtro. El LFO modulará el punto de corte automáticamente. Ajusta la velocidad del LFO (~2-4 Hz) para un wobble rítmico.',
availableModules: ['lfo'],
preplacedModules: [
{ id: 1, type: 'oscillator', x: 80, y: 60, params: { waveform: 'sawtooth', frequency: 110, detune: 0 }, locked: true },
{ id: 2, type: 'filter', x: 340, y: 60, params: { type: 'lowpass', frequency: 800, Q: 5 }, locked: false },
{ id: 3, type: 'output', x: 600, y: 80, params: { volume: -8 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110 } },
],
filter: { type: 'lowpass', frequency: 800, Q: 5 },
duration: 3,
},
checks: [
{
star: 1,
name: 'Cadena de audio',
desc: 'Oscilador → filtro → salida conectados',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const flt = mods.find(m => m.type === 'filter');
const out = mods.find(m => m.type === 'output');
if (!osc || !flt || !out) return false;
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id);
},
},
{
star: 2,
name: 'LFO conectado',
desc: 'Conecta un LFO a la entrada Cutoff del filtro',
test: (mods, conns) => {
const lfo = mods.find(m => m.type === 'lfo');
const flt = mods.find(m => m.type === 'filter');
if (!lfo || !flt) return false;
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
},
},
{
star: 3,
name: 'Wobble rítmico',
desc: 'LFO entre 1-6 Hz, resonancia > 3',
test: (mods) => {
const lfo = mods.find(m => m.type === 'lfo');
const flt = mods.find(m => m.type === 'filter');
if (!lfo || !flt) return false;
const rate = lfo.params.frequency ?? 2;
return rate >= 1 && rate <= 6 && (flt.params.Q ?? 1) > 3;
},
},
],
},
// ─────────────── LEVEL 2.6 ───────────────
{
id: 'w2-6',
title: 'Dos Filtros',
subtitle: 'Escultura sónica',
description: 'Los ingenieros de sonido encadenan filtros para obtener formas más complejas. Un highpass para quitar el subgrave seguido de un lowpass para suavizar los agudos es una técnica estándar de mezcla.',
concept: 'Conecta: Oscilador → Filtro 1 (highpass, ~200 Hz) → Filtro 2 (lowpass, ~3000 Hz) → Output. Esto deja solo las frecuencias medias — "limpia" el sonido.',
availableModules: ['filter'],
preplacedModules: [
{ id: 1, type: 'oscillator', x: 60, y: 80, params: { waveform: 'sawtooth', frequency: 110, detune: 0 }, locked: true },
{ id: 2, type: 'output', x: 720, y: 100, params: { volume: -6 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110 } },
],
duration: 2.5,
},
checks: [
{
star: 1,
name: 'Cadena doble',
desc: 'Oscilador → filtro → filtro → salida',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const flts = mods.filter(m => m.type === 'filter');
const out = mods.find(m => m.type === 'output');
if (!osc || flts.length < 2 || !out) return false;
// Check chain exists
const oscToFlt = flts.some(f => conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === f.id));
const fltToOut = flts.some(f => conns.some(c => c.from.moduleId === f.id && c.to.moduleId === out.id));
const fltToFlt = flts.some(f1 => flts.some(f2 =>
f1.id !== f2.id && conns.some(c => c.from.moduleId === f1.id && c.to.moduleId === f2.id)
));
return oscToFlt && fltToOut && fltToFlt;
},
},
{
star: 2,
name: 'Highpass + Lowpass',
desc: 'Un filtro en highpass y otro en lowpass',
test: (mods) => {
const flts = mods.filter(m => m.type === 'filter');
if (flts.length < 2) return false;
const types = flts.map(f => f.params.type);
return types.includes('highpass') && types.includes('lowpass');
},
},
{
star: 3,
name: 'Banda limpia',
desc: 'HP ~200 Hz (±100) + LP ~3000 Hz (±1000)',
test: (mods) => {
const flts = mods.filter(m => m.type === 'filter');
const hp = flts.find(f => f.params.type === 'highpass');
const lp = flts.find(f => f.params.type === 'lowpass');
if (!hp || !lp) return false;
return Math.abs((hp.params.frequency ?? 1000) - 200) <= 100 &&
Math.abs((lp.params.frequency ?? 1000) - 3000) <= 1000;
},
},
],
},
// ─────────────── LEVEL 2.7 ───────────────
{
id: 'w2-7',
title: 'Filtro + Mezcla',
subtitle: 'Timbres paralelos',
description: 'Filtra dos osciladores de forma diferente y mézclalos. Es la base del diseño de sonido: capas con diferentes caracteres tímbricos que juntas crean algo más rico que la suma de sus partes.',
concept: 'Dos osciladores, cada uno con su propio filtro (diferentes cutoffs), ambos al mixer, mixer al output. Uno oscuro y gordo (LP bajo), otro brillante (LP alto o sin filtro).',
availableModules: ['oscillator', 'filter', 'mixer'],
preplacedModules: [
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110 } },
{ type: 'oscillator', params: { waveform: 'square', frequency: 220 } },
],
duration: 2.5,
},
checks: [
{
star: 1,
name: 'Dos cadenas',
desc: 'Dos osciladores, cada uno filtrado, al mixer',
test: (mods, conns) => {
const oscs = mods.filter(m => m.type === 'oscillator');
const flts = mods.filter(m => m.type === 'filter');
const mixer = mods.find(m => m.type === 'mixer');
const out = mods.find(m => m.type === 'output');
return oscs.length >= 2 && flts.length >= 2 && mixer && out &&
conns.some(c => c.from.moduleId === mixer.id && c.to.moduleId === out.id);
},
},
{
star: 2,
name: 'Filtros diferentes',
desc: 'Los dos filtros tienen cutoffs distintos (diferencia > 500 Hz)',
test: (mods) => {
const flts = mods.filter(m => m.type === 'filter');
if (flts.length < 2) return false;
const freqs = flts.map(f => f.params.frequency ?? 1000);
return Math.abs(freqs[0] - freqs[1]) > 500;
},
},
{
star: 3,
name: 'Capas contrastadas',
desc: 'Un filtro oscuro (<600 Hz) y otro brillante (>3000 Hz)',
test: (mods) => {
const flts = mods.filter(m => m.type === 'filter');
if (flts.length < 2) return false;
const freqs = flts.map(f => f.params.frequency ?? 1000).sort((a, b) => a - b);
return freqs[0] < 600 && freqs[freqs.length - 1] > 3000;
},
},
],
},
// ─────────────── LEVEL 2.8: BOSS ───────────────
{
id: 'w2-8',
title: 'Acid Bass',
subtitle: 'BOSS: El sonido TB-303',
description: 'El Roland TB-303 definió el acid house. Su secreto: un oscilador cuadrado/sierra a frecuencia baja, un filtro lowpass con MUCHA resonancia, y modulación del cutoff. Recrea ese sonido legendario.',
concept: 'Oscilador saw a ~55-110 Hz → Filtro lowpass con Q alta (~12-15) y cutoff medio (~400-800 Hz) → Output. Añade un LFO lento (~0.5-2 Hz) modulando el cutoff para el movimiento ácido.',
availableModules: ['oscillator', 'filter', 'lfo'],
preplacedModules: [
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 82 } },
],
filter: { type: 'lowpass', frequency: 500, Q: 14 },
duration: 3,
},
checks: [
{
star: 1,
name: 'Cadena ácida',
desc: 'Oscilador → filtro → salida con LFO al cutoff',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const flt = mods.find(m => m.type === 'filter');
const lfo = mods.find(m => m.type === 'lfo');
const out = mods.find(m => m.type === 'output');
if (!osc || !flt || !out || !lfo) return false;
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id) &&
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
},
},
{
star: 2,
name: 'Resonancia ácida',
desc: 'Filtro lowpass con Q > 10',
test: (mods) => {
const flt = mods.find(m => m.type === 'filter');
return flt && flt.params.type === 'lowpass' && (flt.params.Q ?? 1) > 10;
},
},
{
star: 3,
name: '303 auténtico',
desc: 'Saw/square baja (<130Hz), Q>10, cutoff 300-900Hz, LFO lento (<3Hz)',
test: (mods) => {
const osc = mods.find(m => m.type === 'oscillator');
const flt = mods.find(m => m.type === 'filter');
const lfo = mods.find(m => m.type === 'lfo');
if (!osc || !flt || !lfo) return false;
const freq = osc.params.frequency ?? 440;
const wave = osc.params.waveform;
const cutoff = flt.params.frequency ?? 1000;
const q = flt.params.Q ?? 1;
const rate = lfo.params.frequency ?? 2;
return freq < 130 && (wave === 'sawtooth' || wave === 'square') &&
flt.params.type === 'lowpass' && q > 10 &&
cutoff >= 300 && cutoff <= 900 && rate < 3;
},
},
],
},
],
};

511
src/game/levels/world3.js Normal file
View File

@@ -0,0 +1,511 @@
/**
* World 3 — "Envelopes" (ADSR)
*
* Teaches: attack, decay, sustain, release, VCA, amplitude shaping, sound design
* 8 levels, progressive difficulty
*/
export const WORLD_3 = {
id: 'w3',
name: 'Envelopes',
subtitle: 'Dale forma al sonido en el tiempo',
icon: '⏤╲',
color: '#aa55ff',
unlockStars: 24, // Need 24 stars from World 1+2 to unlock
levels: [
// ─────────────── LEVEL 3.1 ───────────────
{
id: 'w3-1',
title: 'El VCA',
subtitle: 'Control de volumen',
description: 'Un VCA (Voltage Controlled Amplifier) es un amplificador cuyo volumen se puede controlar con una señal externa. Pasa el oscilador por un VCA para poder controlar su volumen.',
concept: 'Conecta: Oscilador → VCA (input "In") → Output. El knob "Gain" del VCA controla cuánto deja pasar. Es como un grifo para el sonido.',
availableModules: [],
preplacedModules: [
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: true },
{ id: 2, type: 'vca', x: 340, y: 80, params: { gain: 0.5 }, locked: false },
{ id: 3, type: 'output', x: 580, y: 100, params: { volume: -6 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
],
duration: 2,
},
checks: [
{
star: 1,
name: 'VCA conectado',
desc: 'Conecta oscilador → VCA → salida',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const vca = mods.find(m => m.type === 'vca');
const out = mods.find(m => m.type === 'output');
if (!osc || !vca || !out) return false;
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === vca.id && c.to.port === 'in') &&
conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id);
},
},
{
star: 2,
name: 'Volumen moderado',
desc: 'Gain del VCA por debajo de 0.7',
test: (mods) => {
const vca = mods.find(m => m.type === 'vca');
return vca && (vca.params.gain ?? 0.8) < 0.7;
},
},
{
star: 3,
name: 'Medio volumen',
desc: 'Gain cercano a 0.5 (±0.1)',
test: (mods) => {
const vca = mods.find(m => m.type === 'vca');
return vca && Math.abs((vca.params.gain ?? 0.8) - 0.5) <= 0.1;
},
},
],
},
// ─────────────── LEVEL 3.2 ───────────────
{
id: 'w3-2',
title: 'ADSR',
subtitle: 'Las 4 fases del sonido',
description: 'Todo sonido tiene una forma en el tiempo: el Attack (subida), Decay (bajada), Sustain (mantenimiento) y Release (apagado). Un Envelope genera esa curva ADSR que puedes usar para controlar el VCA.',
concept: 'Conecta el Envelope al VCA: la salida del Envelope → entrada CV del VCA. Conecta el Keyboard al Gate del Envelope para que se dispare al tocar. Toca notas y escucha cómo el Envelope da forma al volumen.',
availableModules: ['envelope', 'keyboard'],
preplacedModules: [
{ id: 1, type: 'oscillator', x: 80, y: 60, params: { waveform: 'square', frequency: 440, detune: 0 }, locked: true },
{ id: 2, type: 'vca', x: 400, y: 60, params: { gain: 0 }, locked: false },
{ id: 3, type: 'output', x: 620, y: 80, params: { volume: -6 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'square', frequency: 440 } },
],
envelope: { attack: 0.2, decay: 0.15, sustain: 0.6, release: 0.5 },
duration: 2,
},
checks: [
{
star: 1,
name: 'Cadena con VCA',
desc: 'Oscilador → VCA → Salida',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const vca = mods.find(m => m.type === 'vca');
const out = mods.find(m => m.type === 'output');
if (!osc || !vca || !out) return false;
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === vca.id) &&
conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id);
},
},
{
star: 2,
name: 'Envelope al VCA',
desc: 'Conecta Envelope → VCA (CV) y Keyboard → Envelope (Gate)',
test: (mods, conns) => {
const env = mods.find(m => m.type === 'envelope');
const vca = mods.find(m => m.type === 'vca');
const kb = mods.find(m => m.type === 'keyboard');
if (!env || !vca || !kb) return false;
return conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv') &&
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id && c.to.port === 'gate');
},
},
{
star: 3,
name: 'Keyboard controla frecuencia',
desc: 'Keyboard → Osc (Freq) para tocar melodías',
test: (mods, conns) => {
const kb = mods.find(m => m.type === 'keyboard');
const osc = mods.find(m => m.type === 'oscillator');
if (!kb || !osc) return false;
return conns.some(c => c.from.moduleId === kb.id && c.from.port === 'freq' && c.to.moduleId === osc.id && c.to.port === 'freq');
},
},
],
},
// ─────────────── LEVEL 3.3 ───────────────
{
id: 'w3-3',
title: 'Percusión',
subtitle: 'Attack rápido, decay corto',
description: 'Los sonidos percusivos tienen un attack instantáneo y un decay corto sin sustain. Piensa en un tambor, un clic, un bleep — el sonido aparece de golpe y muere rápido. Configura un envelope percusivo.',
concept: 'Attack muy bajo (~0.001s), Decay corto (~0.1-0.2s), Sustain a 0, Release corto. Esto crea un "blip" percusivo. Perfecto para hi-hats, kicks sintéticos, y bleeps 8-bit.',
availableModules: ['envelope', 'keyboard'],
preplacedModules: [
{ id: 1, type: 'oscillator', x: 80, y: 60, params: { waveform: 'sine', frequency: 440, detune: 0 }, locked: true },
{ id: 2, type: 'vca', x: 400, y: 60, params: { gain: 0 }, locked: false },
{ id: 3, type: 'output', x: 620, y: 80, params: { volume: -6 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
],
envelope: { attack: 0.005, decay: 0.15, sustain: 0, release: 0.1 },
duration: 2,
},
checks: [
{
star: 1,
name: 'Señal con envelope',
desc: 'Osc → VCA → Out, con Envelope al CV y Keyboard al Gate',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const vca = mods.find(m => m.type === 'vca');
const env = mods.find(m => m.type === 'envelope');
const kb = mods.find(m => m.type === 'keyboard');
const out = mods.find(m => m.type === 'output');
if (!osc || !vca || !env || !kb || !out) return false;
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === vca.id) &&
conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id) &&
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv') &&
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id);
},
},
{
star: 2,
name: 'Sin sustain',
desc: 'Sustain a 0 (o casi)',
test: (mods) => {
const env = mods.find(m => m.type === 'envelope');
return env && (env.params.sustain ?? 0.5) < 0.05;
},
},
{
star: 3,
name: 'Blip perfecto',
desc: 'Attack <0.01s, Decay 0.05-0.3s, Sustain ~0',
test: (mods) => {
const env = mods.find(m => m.type === 'envelope');
if (!env) return false;
return (env.params.attack ?? 0.01) < 0.015 &&
(env.params.decay ?? 0.2) >= 0.05 && (env.params.decay ?? 0.2) <= 0.3 &&
(env.params.sustain ?? 0.5) < 0.05;
},
},
],
},
// ─────────────── LEVEL 3.4 ───────────────
{
id: 'w3-4',
title: 'Pad Atmosférico',
subtitle: 'Suave y envolvente',
description: 'Los pads son sonidos largos y suaves que rellenan el fondo de una mezcla. Se consiguen con un attack lento (el sonido entra gradualmente), sustain alto, y release largo (se desvanece lentamente).',
concept: 'Attack lento (~1-2s), Decay corto (~0.3s), Sustain alto (~0.7-0.9), Release largo (~2-4s). El sonido "respira" — entra suave y se queda flotando.',
availableModules: ['envelope', 'keyboard'],
preplacedModules: [
{ id: 1, type: 'oscillator', x: 80, y: 60, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: true },
{ id: 2, type: 'vca', x: 400, y: 60, params: { gain: 0 }, locked: false },
{ id: 3, type: 'output', x: 620, y: 80, params: { volume: -6 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
],
envelope: { attack: 1.2, decay: 0.3, sustain: 0.75, release: 2.5 },
duration: 2,
},
checks: [
{
star: 1,
name: 'Señal con envelope',
desc: 'Osc → VCA → Out, Envelope al CV, Keyboard al Gate',
test: (mods, conns) => {
const env = mods.find(m => m.type === 'envelope');
const vca = mods.find(m => m.type === 'vca');
const kb = mods.find(m => m.type === 'keyboard');
if (!env || !vca || !kb) return false;
return conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv') &&
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id);
},
},
{
star: 2,
name: 'Attack lento',
desc: 'Attack mayor de 0.5 segundos',
test: (mods) => {
const env = mods.find(m => m.type === 'envelope');
return env && (env.params.attack ?? 0.01) > 0.5;
},
},
{
star: 3,
name: 'Pad perfecto',
desc: 'Attack >0.8s, Sustain >0.6, Release >1.5s',
test: (mods) => {
const env = mods.find(m => m.type === 'envelope');
if (!env) return false;
return (env.params.attack ?? 0.01) > 0.8 &&
(env.params.sustain ?? 0.5) > 0.6 &&
(env.params.release ?? 0.5) > 1.5;
},
},
],
},
// ─────────────── LEVEL 3.5 ───────────────
{
id: 'w3-5',
title: 'Pluck',
subtitle: 'Cuerdas pulsadas',
description: 'El sonido de una cuerda pulsada (guitarra, arpa) tiene un attack rápido y un decay medio. No tiene sustain real — el sonido decrece naturalmente. El filtro ayuda a que suene más natural.',
concept: 'Envelope con Attack rápido (~0.001s), Decay medio (~0.4-0.8s), Sustain bajo (~0.1), Release ~0.3s. Usa una onda triangle o saw con un filtro lowpass para suavizar.',
availableModules: ['envelope', 'keyboard', 'filter'],
preplacedModules: [
{ id: 1, type: 'oscillator', x: 80, y: 60, params: { waveform: 'triangle', frequency: 440, detune: 0 }, locked: false },
{ id: 2, type: 'vca', x: 500, y: 60, params: { gain: 0 }, locked: false },
{ id: 3, type: 'output', x: 760, y: 80, params: { volume: -6 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'triangle', frequency: 440 } },
],
filter: { type: 'lowpass', frequency: 3000, Q: 2 },
envelope: { attack: 0.008, decay: 0.5, sustain: 0.05, release: 0.2 },
duration: 2,
},
checks: [
{
star: 1,
name: 'Cadena completa',
desc: 'Osc → (Filter →) VCA → Out con Envelope y Keyboard',
test: (mods, conns) => {
const env = mods.find(m => m.type === 'envelope');
const vca = mods.find(m => m.type === 'vca');
const kb = mods.find(m => m.type === 'keyboard');
const out = mods.find(m => m.type === 'output');
if (!env || !vca || !kb || !out) return false;
return conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id) &&
conns.some(c => c.from.moduleId === env.id && c.to.port === 'cv') &&
conns.some(c => c.from.moduleId === kb.id && c.to.port === 'gate');
},
},
{
star: 2,
name: 'Forma pluck',
desc: 'Attack rápido (<0.02s), Sustain bajo (<0.2)',
test: (mods) => {
const env = mods.find(m => m.type === 'envelope');
if (!env) return false;
return (env.params.attack ?? 0.01) < 0.02 && (env.params.sustain ?? 0.5) < 0.2;
},
},
{
star: 3,
name: 'Pluck natural',
desc: 'Pluck shape + filtro lowpass en la cadena',
test: (mods, conns) => {
const env = mods.find(m => m.type === 'envelope');
const flt = mods.find(m => m.type === 'filter');
if (!env || !flt) return false;
return (env.params.attack ?? 0.01) < 0.02 &&
(env.params.sustain ?? 0.5) < 0.2 &&
(env.params.decay ?? 0.2) >= 0.3 &&
flt.params.type === 'lowpass';
},
},
],
},
// ─────────────── LEVEL 3.6 ───────────────
{
id: 'w3-6',
title: 'Filtro Dinámico',
subtitle: 'Envelope → Cutoff',
description: 'Los envelopes no solo controlan volumen — ¡también pueden controlar el filtro! Conectar un envelope al cutoff crea sonidos que se "abren" y "cierran" con cada nota. Es la técnica más importante de síntesis sustractiva.',
concept: 'Conecta un segundo Envelope a la entrada Cutoff del filtro. Keyboard → Gate de ambos envelopes. Un envelope controla volumen (VCA), otro controla brillo (filtro cutoff).',
availableModules: ['envelope', 'keyboard', 'filter'],
preplacedModules: [
{ id: 1, type: 'oscillator', x: 60, y: 40, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: true },
{ id: 2, type: 'vca', x: 520, y: 40, params: { gain: 0 }, locked: false },
{ id: 3, type: 'output', x: 760, y: 80, params: { volume: -6 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
],
filter: { type: 'lowpass', frequency: 800, Q: 4 },
envelope: { attack: 0.01, decay: 0.3, sustain: 0.4, release: 0.2 },
duration: 2,
},
checks: [
{
star: 1,
name: 'Doble envelope',
desc: 'Dos envelopes: uno al VCA, otro al filtro cutoff',
test: (mods, conns) => {
const envs = mods.filter(m => m.type === 'envelope');
const vca = mods.find(m => m.type === 'vca');
const flt = mods.find(m => m.type === 'filter');
if (envs.length < 2 || !vca || !flt) return false;
const envToVca = envs.some(e => conns.some(c => c.from.moduleId === e.id && c.to.moduleId === vca.id && c.to.port === 'cv'));
const envToFlt = envs.some(e => conns.some(c => c.from.moduleId === e.id && c.to.moduleId === flt.id && c.to.port === 'cutoff'));
return envToVca && envToFlt;
},
},
{
star: 2,
name: 'Gates conectados',
desc: 'Keyboard → Gate de ambos envelopes',
test: (mods, conns) => {
const kb = mods.find(m => m.type === 'keyboard');
const envs = mods.filter(m => m.type === 'envelope');
if (!kb || envs.length < 2) return false;
const gatedEnvs = envs.filter(e =>
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === e.id && c.to.port === 'gate')
);
return gatedEnvs.length >= 2;
},
},
{
star: 3,
name: 'Envelopes distintos',
desc: 'Los dos envelopes tienen decays diferentes (>0.1s diferencia)',
test: (mods) => {
const envs = mods.filter(m => m.type === 'envelope');
if (envs.length < 2) return false;
const decays = envs.map(e => e.params.decay ?? 0.2);
return Math.abs(decays[0] - decays[1]) > 0.1;
},
},
],
},
// ─────────────── LEVEL 3.7 ───────────────
{
id: 'w3-7',
title: 'Tremolo',
subtitle: 'LFO → Volumen',
description: 'El tremolo es una variación rítmica del volumen. Se consigue conectando un LFO a la ganancia del VCA. Es un efecto clásico de guitarras, órganos y sintetizadores vintage.',
concept: 'Conecta un LFO a la entrada CV del VCA (no del filtro). Un LFO a ~4-8 Hz con amplitud moderada crea un tremolo clásico. Más lento (~1-2 Hz) suena como un "pulso".',
availableModules: ['lfo'],
preplacedModules: [
{ id: 1, type: 'oscillator', x: 80, y: 60, params: { waveform: 'sine', frequency: 440, detune: 0 }, locked: true },
{ id: 2, type: 'vca', x: 340, y: 60, params: { gain: 0.7 }, locked: false },
{ id: 3, type: 'output', x: 580, y: 80, params: { volume: -6 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
],
lfo: { frequency: 6, type: 'sine', min: 0.2, max: 1.0, target: 'amplitude' },
duration: 3,
},
checks: [
{
star: 1,
name: 'Cadena básica',
desc: 'Oscilador → VCA → Salida',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const vca = mods.find(m => m.type === 'vca');
const out = mods.find(m => m.type === 'output');
if (!osc || !vca || !out) return false;
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === vca.id) &&
conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id);
},
},
{
star: 2,
name: 'LFO al VCA',
desc: 'Conecta LFO → VCA (CV)',
test: (mods, conns) => {
const lfo = mods.find(m => m.type === 'lfo');
const vca = mods.find(m => m.type === 'vca');
if (!lfo || !vca) return false;
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === vca.id && c.to.port === 'cv');
},
},
{
star: 3,
name: 'Tremolo rítmico',
desc: 'LFO entre 3-10 Hz (tremolo audible)',
test: (mods) => {
const lfo = mods.find(m => m.type === 'lfo');
if (!lfo) return false;
const rate = lfo.params.frequency ?? 2;
return rate >= 3 && rate <= 10;
},
},
],
},
// ─────────────── LEVEL 3.8: BOSS ───────────────
{
id: 'w3-8',
title: 'Synth Lead Completo',
subtitle: 'BOSS: Ponlo todo junto',
description: 'Es hora de construir un sonido de lead completo desde cero. Combina todo lo que has aprendido: oscilador, filtro con envelope, VCA con envelope, y keyboard para tocar. Es el patch clásico de síntesis sustractiva.',
concept: 'Keyboard → Osc (freq) + Env1 (gate) + Env2 (gate). Osc → Filter → VCA → Output. Env1 → Filter cutoff (decay medio para "apertura"). Env2 → VCA cv (sustain para mantener). Ajusta para un lead expresivo.',
availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'keyboard'],
preplacedModules: [
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -6 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
],
filter: { type: 'lowpass', frequency: 2000, Q: 6 },
envelope: { attack: 0.05, decay: 0.3, sustain: 0.5, release: 0.6 },
duration: 3,
},
checks: [
{
star: 1,
name: 'Cadena sustractiva',
desc: 'Osc → Filter → VCA → Output',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const flt = mods.find(m => m.type === 'filter');
const vca = mods.find(m => m.type === 'vca');
const out = mods.find(m => m.type === 'output');
if (!osc || !flt || !vca || !out) return false;
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === vca.id) &&
conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id);
},
},
{
star: 2,
name: 'Doble modulación',
desc: 'Envelope al filtro cutoff Y envelope al VCA cv',
test: (mods, conns) => {
const envs = mods.filter(m => m.type === 'envelope');
const flt = mods.find(m => m.type === 'filter');
const vca = mods.find(m => m.type === 'vca');
if (envs.length < 2 || !flt || !vca) return false;
const envToFlt = envs.some(e => conns.some(c => c.from.moduleId === e.id && c.to.moduleId === flt.id && c.to.port === 'cutoff'));
const envToVca = envs.some(e => conns.some(c => c.from.moduleId === e.id && c.to.moduleId === vca.id && c.to.port === 'cv'));
return envToFlt && envToVca;
},
},
{
star: 3,
name: 'Lead expresivo',
desc: 'Keyboard controla freq + gates, envelopes distintos',
test: (mods, conns) => {
const kb = mods.find(m => m.type === 'keyboard');
const osc = mods.find(m => m.type === 'oscillator');
const envs = mods.filter(m => m.type === 'envelope');
if (!kb || !osc || envs.length < 2) return false;
// KB → osc freq
const kbFreq = conns.some(c => c.from.moduleId === kb.id && c.from.port === 'freq' && c.to.moduleId === osc.id);
// KB → both env gates
const gated = envs.filter(e =>
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === e.id && c.to.port === 'gate')
);
// Envelopes have different settings
const decays = envs.map(e => e.params.decay ?? 0.2);
const diffDecay = Math.abs(decays[0] - decays[1]) > 0.05;
return kbFreq && gated.length >= 2 && diffDecay;
},
},
],
},
],
};

529
src/game/levels/world4.js Normal file
View File

@@ -0,0 +1,529 @@
/**
* World 4 — "Modulación" (Modulation)
*
* Teaches: LFO routing, vibrato, PWM, FM synthesis, ring modulation, complex patches
* 8 levels, progressive difficulty
*/
export const WORLD_4 = {
id: 'w4',
name: 'Modulación',
subtitle: 'Haz que el sonido viva y respire',
icon: '∿',
color: '#ffcc00',
unlockStars: 36,
levels: [
// ─────────────── LEVEL 4.1 ───────────────
{
id: 'w4-1',
title: 'Vibrato',
subtitle: 'LFO → Frecuencia',
description: 'El vibrato es una oscilación sutil de la frecuencia. Cantantes, violinistas y sintetizadores lo usan para dar expresividad. Se consigue conectando un LFO lento a la frecuencia del oscilador.',
concept: 'Conecta un LFO a la entrada "Freq" del oscilador. Un LFO a ~5-7 Hz con amplitud pequeña crea un vibrato natural. Demasiado rápido o amplio suena a sirena.',
availableModules: ['lfo'],
preplacedModules: [
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sine', frequency: 440, detune: 0 }, locked: true },
{ id: 2, type: 'output', x: 500, y: 100, params: { volume: -6 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
],
lfo: { frequency: 6, type: 'sine', min: 420, max: 460, target: 'frequency' },
duration: 3,
},
checks: [
{
star: 1,
name: 'Señal básica',
desc: 'Oscilador conectado a la salida',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const out = mods.find(m => m.type === 'output');
return osc && out && conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === out.id);
},
},
{
star: 2,
name: 'LFO a frecuencia',
desc: 'Conecta LFO → Osc (Freq)',
test: (mods, conns) => {
const lfo = mods.find(m => m.type === 'lfo');
const osc = mods.find(m => m.type === 'oscillator');
if (!lfo || !osc) return false;
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === osc.id && c.to.port === 'freq');
},
},
{
star: 3,
name: 'Vibrato musical',
desc: 'LFO entre 4-8 Hz (vibrato natural)',
test: (mods) => {
const lfo = mods.find(m => m.type === 'lfo');
if (!lfo) return false;
const rate = lfo.params.frequency ?? 2;
return rate >= 4 && rate <= 8;
},
},
],
},
// ─────────────── LEVEL 4.2 ───────────────
{
id: 'w4-2',
title: 'Sirena',
subtitle: 'LFO lento = pitch sweep',
description: 'Cuando el LFO es muy lento y con mucha amplitud, el vibrato se convierte en un barrido de frecuencia — como una sirena. Los DJs y productores usan este efecto para crear tensión y transiciones.',
concept: 'Usa un LFO muy lento (~0.2-0.5 Hz) con forma de onda sine o triangle conectado a la frecuencia del oscilador. La velocidad lenta crea un sweep dramático arriba y abajo.',
availableModules: ['lfo'],
preplacedModules: [
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'square', frequency: 440, detune: 0 }, locked: false },
{ id: 2, type: 'output', x: 500, y: 100, params: { volume: -6 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'square', frequency: 440 } },
],
lfo: { frequency: 0.3, type: 'sine', min: 200, max: 800, target: 'frequency' },
duration: 4,
},
checks: [
{
star: 1,
name: 'LFO conectado',
desc: 'LFO → Osc (Freq) → Output',
test: (mods, conns) => {
const lfo = mods.find(m => m.type === 'lfo');
const osc = mods.find(m => m.type === 'oscillator');
if (!lfo || !osc) return false;
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === osc.id && c.to.port === 'freq');
},
},
{
star: 2,
name: 'Sweep lento',
desc: 'LFO por debajo de 1 Hz',
test: (mods) => {
const lfo = mods.find(m => m.type === 'lfo');
return lfo && (lfo.params.frequency ?? 2) < 1;
},
},
{
star: 3,
name: 'Sirena perfecta',
desc: 'LFO 0.1-0.5 Hz, onda sine o triangle',
test: (mods) => {
const lfo = mods.find(m => m.type === 'lfo');
if (!lfo) return false;
const rate = lfo.params.frequency ?? 2;
const wave = lfo.params.waveform ?? 'sine';
return rate >= 0.1 && rate <= 0.5 && (wave === 'sine' || wave === 'triangle');
},
},
],
},
// ─────────────── LEVEL 4.3 ───────────────
{
id: 'w4-3',
title: 'Wah-Wah Rítmico',
subtitle: 'LFO square → Cutoff',
description: 'Un LFO con onda cuadrada crea cambios bruscos en el cutoff del filtro — el filtro salta entre abierto y cerrado. Es un efecto rítmico perfecto para música electrónica y funk.',
concept: 'LFO square a ~2-4 Hz conectado al cutoff del filtro. La onda cuadrada crea un on/off rítmico. Ajusta el cutoff base del filtro y la resonancia para darle más carácter.',
availableModules: ['lfo'],
preplacedModules: [
{ id: 1, type: 'oscillator', x: 60, y: 60, params: { waveform: 'sawtooth', frequency: 110, detune: 0 }, locked: true },
{ id: 2, type: 'filter', x: 300, y: 60, params: { type: 'lowpass', frequency: 600, Q: 4 }, locked: false },
{ id: 3, type: 'output', x: 560, y: 80, params: { volume: -8 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110 } },
],
filter: { type: 'lowpass', frequency: 800, Q: 5 },
lfo: { frequency: 3, type: 'square', min: 400, max: 4000, target: 'frequency' },
duration: 3,
},
checks: [
{
star: 1,
name: 'Cadena con LFO',
desc: 'Osc → Filter → Out, LFO al Cutoff',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const flt = mods.find(m => m.type === 'filter');
const lfo = mods.find(m => m.type === 'lfo');
const out = mods.find(m => m.type === 'output');
if (!osc || !flt || !lfo || !out) return false;
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id) &&
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
},
},
{
star: 2,
name: 'LFO cuadrado',
desc: 'LFO con onda square',
test: (mods) => {
const lfo = mods.find(m => m.type === 'lfo');
return lfo && (lfo.params.waveform ?? 'sine') === 'square';
},
},
{
star: 3,
name: 'Wah rítmico',
desc: 'LFO square a 2-4 Hz, filtro con Q > 3',
test: (mods) => {
const lfo = mods.find(m => m.type === 'lfo');
const flt = mods.find(m => m.type === 'filter');
if (!lfo || !flt) return false;
const rate = lfo.params.frequency ?? 2;
return (lfo.params.waveform ?? 'sine') === 'square' &&
rate >= 2 && rate <= 4 && (flt.params.Q ?? 1) > 3;
},
},
],
},
// ─────────────── LEVEL 4.4 ───────────────
{
id: 'w4-4',
title: 'Auto-Pan',
subtitle: 'Sonido en movimiento',
description: 'Conectar LFOs a los niveles de un mixer permite mover el sonido entre canales. Si envías el mismo oscilador al Left y Right con LFOs invertidos, el sonido viaja de un altavoz al otro.',
concept: 'Conecta el oscilador al output con dos cables (Left y Right). Añade un LFO que module algo para crear movimiento estéreo. El efecto auto-pan crea una sensación de espacio.',
availableModules: ['lfo', 'vca', 'mixer'],
preplacedModules: [
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sine', frequency: 440, detune: 0 }, locked: true },
{ id: 2, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
],
lfo: { frequency: 2, type: 'sine', min: 0.3, max: 1.0, target: 'amplitude' },
duration: 3,
},
checks: [
{
star: 1,
name: 'Estéreo',
desc: 'Oscilador conectado a ambos canales (Left + Right)',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const out = mods.find(m => m.type === 'output');
if (!osc || !out) return false;
// Direct or through other modules to both channels
const toLeft = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
const toRight = conns.some(c => c.to.moduleId === out.id && c.to.port === 'right');
return toLeft && toRight;
},
},
{
star: 2,
name: 'LFO presente',
desc: 'Hay al menos un LFO conectado',
test: (mods, conns) => {
const lfo = mods.find(m => m.type === 'lfo');
if (!lfo) return false;
return conns.some(c => c.from.moduleId === lfo.id);
},
},
{
star: 3,
name: 'Modulación estéreo',
desc: 'LFO modula VCA(s) en la cadena estéreo',
test: (mods, conns) => {
const lfo = mods.find(m => m.type === 'lfo');
const vcas = mods.filter(m => m.type === 'vca');
if (!lfo || vcas.length < 1) return false;
return vcas.some(v => conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === v.id));
},
},
],
},
// ─────────────── LEVEL 4.5 ───────────────
{
id: 'w4-5',
title: 'Doble Modulación',
subtitle: 'LFO al filter + LFO al VCA',
description: 'Un solo LFO puede modular múltiples destinos a la vez. Conectar el mismo LFO al cutoff del filtro y al gain del VCA crea un sonido que se abre y se hace más fuerte simultáneamente — un efecto muy dinámico.',
concept: 'Usa un LFO y conéctalo tanto al Cutoff del filtro como al CV del VCA. El mismo movimiento cíclico afecta brillo y volumen a la vez. Ajusta ~2-3 Hz.',
availableModules: ['lfo'],
preplacedModules: [
{ id: 1, type: 'oscillator', x: 60, y: 40, params: { waveform: 'sawtooth', frequency: 110, detune: 0 }, locked: true },
{ id: 2, type: 'filter', x: 280, y: 40, params: { type: 'lowpass', frequency: 800, Q: 5 }, locked: false },
{ id: 3, type: 'vca', x: 480, y: 40, params: { gain: 0.6 }, locked: false },
{ id: 4, type: 'output', x: 680, y: 60, params: { volume: -8 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110 } },
],
filter: { type: 'lowpass', frequency: 1200, Q: 6 },
lfo: { frequency: 2.5, type: 'sine', min: 400, max: 3500, target: 'frequency' },
duration: 3,
},
checks: [
{
star: 1,
name: 'Cadena completa',
desc: 'Osc → Filter → VCA → Output',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const flt = mods.find(m => m.type === 'filter');
const vca = mods.find(m => m.type === 'vca');
const out = mods.find(m => m.type === 'output');
if (!osc || !flt || !vca || !out) return false;
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === vca.id) &&
conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id);
},
},
{
star: 2,
name: 'LFO a dos destinos',
desc: 'Un LFO conectado al Cutoff Y al CV',
test: (mods, conns) => {
const lfo = mods.find(m => m.type === 'lfo');
const flt = mods.find(m => m.type === 'filter');
const vca = mods.find(m => m.type === 'vca');
if (!lfo || !flt || !vca) return false;
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff') &&
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === vca.id && c.to.port === 'cv');
},
},
{
star: 3,
name: 'Pulso rítmico',
desc: 'LFO a 1-4 Hz, filtro resonante (Q > 4)',
test: (mods) => {
const lfo = mods.find(m => m.type === 'lfo');
const flt = mods.find(m => m.type === 'filter');
if (!lfo || !flt) return false;
const rate = lfo.params.frequency ?? 2;
return rate >= 1 && rate <= 4 && (flt.params.Q ?? 1) > 4;
},
},
],
},
// ─────────────── LEVEL 4.6 ───────────────
{
id: 'w4-6',
title: 'Cross-Modulation',
subtitle: 'Oscilador modula oscilador',
description: 'Cuando un oscilador modula la frecuencia de otro oscilador a velocidades audibles (>20 Hz), se crea FM synthesis — timbres metálicos, campanas, y texturas inarmónicas que no puedes conseguir de otra forma.',
concept: 'Conecta la salida de un oscilador a la entrada "Freq" de otro. Si el modulador está a frecuencia audible (>50 Hz), se crea FM. Frequencies bajas = vibrato, altas = nuevos timbres.',
availableModules: ['oscillator', 'mixer'],
preplacedModules: [
{ id: 1, type: 'output', x: 600, y: 120, params: { volume: -8 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 220 } },
{ type: 'oscillator', params: { waveform: 'sine', frequency: 880 } },
],
duration: 3,
},
checks: [
{
star: 1,
name: 'Dos osciladores',
desc: 'Al menos 2 osciladores con uno modulando al otro',
test: (mods, conns) => {
const oscs = mods.filter(m => m.type === 'oscillator');
if (oscs.length < 2) return false;
// One osc connected to another osc's freq
return oscs.some(o1 => oscs.some(o2 =>
o1.id !== o2.id && conns.some(c =>
c.from.moduleId === o1.id && c.to.moduleId === o2.id && c.to.port === 'freq'
)
));
},
},
{
star: 2,
name: 'Sonido audible',
desc: 'El oscilador portador está conectado a la salida',
test: (mods, conns) => {
const out = mods.find(m => m.type === 'output');
if (!out) return false;
// Something reaches the output
return conns.some(c => c.to.moduleId === out.id);
},
},
{
star: 3,
name: 'FM metálico',
desc: 'Modulador > 50 Hz (crea timbres FM reales)',
test: (mods, conns) => {
const oscs = mods.filter(m => m.type === 'oscillator');
if (oscs.length < 2) return false;
// Find modulator: osc that connects to another osc's freq
for (const o1 of oscs) {
for (const o2 of oscs) {
if (o1.id !== o2.id) {
const isModulating = conns.some(c =>
c.from.moduleId === o1.id && c.to.moduleId === o2.id && c.to.port === 'freq'
);
if (isModulating && (o1.params.frequency ?? 440) > 50) return true;
}
}
}
return false;
},
},
],
},
// ─────────────── LEVEL 4.7 ───────────────
{
id: 'w4-7',
title: 'Modulación Compleja',
subtitle: 'Multi-destino',
description: 'Los sintetizadores modulares brillan cuando conectas múltiples fuentes de modulación a múltiples destinos. Un LFO al filtro, un envelope al VCA, el keyboard a la frecuencia — cada conexión añade expresividad.',
concept: 'Construye un patch con: Keyboard → Osc freq + Env gate. LFO → Filter cutoff. Envelope → VCA cv. Cada fuente de modulación controla un aspecto diferente del sonido.',
availableModules: ['lfo', 'envelope', 'keyboard'],
preplacedModules: [
{ id: 1, type: 'oscillator', x: 60, y: 40, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: false },
{ id: 2, type: 'filter', x: 280, y: 40, params: { type: 'lowpass', frequency: 1000, Q: 4 }, locked: false },
{ id: 3, type: 'vca', x: 480, y: 40, params: { gain: 0 }, locked: false },
{ id: 4, type: 'output', x: 680, y: 60, params: { volume: -6 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
],
filter: { type: 'lowpass', frequency: 1500, Q: 5 },
envelope: { attack: 0.1, decay: 0.3, sustain: 0.5, release: 0.4 },
lfo: { frequency: 3, type: 'sine', min: 600, max: 3000, target: 'frequency' },
duration: 3,
},
checks: [
{
star: 1,
name: 'Cadena sustractiva',
desc: 'Osc → Filter → VCA → Output',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const flt = mods.find(m => m.type === 'filter');
const vca = mods.find(m => m.type === 'vca');
const out = mods.find(m => m.type === 'output');
if (!osc || !flt || !vca || !out) return false;
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === vca.id) &&
conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id);
},
},
{
star: 2,
name: 'Tres moduladores',
desc: 'LFO, Envelope y Keyboard todos conectados',
test: (mods, conns) => {
const lfo = mods.find(m => m.type === 'lfo');
const env = mods.find(m => m.type === 'envelope');
const kb = mods.find(m => m.type === 'keyboard');
if (!lfo || !env || !kb) return false;
const lfoConn = conns.some(c => c.from.moduleId === lfo.id);
const envConn = conns.some(c => c.from.moduleId === env.id);
const kbConn = conns.some(c => c.from.moduleId === kb.id);
return lfoConn && envConn && kbConn;
},
},
{
star: 3,
name: 'Routing correcto',
desc: 'KB→freq, LFO→cutoff, Env→VCA cv, KB→gate',
test: (mods, conns) => {
const lfo = mods.find(m => m.type === 'lfo');
const env = mods.find(m => m.type === 'envelope');
const kb = mods.find(m => m.type === 'keyboard');
const osc = mods.find(m => m.type === 'oscillator');
const flt = mods.find(m => m.type === 'filter');
const vca = mods.find(m => m.type === 'vca');
if (!lfo || !env || !kb || !osc || !flt || !vca) return false;
return conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === osc.id && c.to.port === 'freq') &&
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff') &&
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv') &&
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id && c.to.port === 'gate');
},
},
],
},
// ─────────────── LEVEL 4.8: BOSS ───────────────
{
id: 'w4-8',
title: 'Dubstep Wobble',
subtitle: 'BOSS: El bajo que wobbles',
description: 'El wobble bass de dubstep es modulación pura: un oscilador grave con un filtro lowpass resonante modulado por un LFO. Añade un envelope para el ataque y tienes el sonido que definió un género.',
concept: 'Osc saw grave (~55 Hz) → Filter LP resonante → VCA → Output. LFO (~1-3 Hz) → Filter cutoff. Envelope → VCA cv. Keyboard → gate + freq. Q alta (~10+) para ese sonido agresivo.',
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'keyboard'],
preplacedModules: [
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55 } },
],
filter: { type: 'lowpass', frequency: 400, Q: 10 },
envelope: { attack: 0.02, decay: 0.2, sustain: 0.7, release: 0.3 },
lfo: { frequency: 1.5, type: 'sine', min: 200, max: 2000, target: 'frequency' },
duration: 4,
},
checks: [
{
star: 1,
name: 'Cadena con modulación',
desc: 'Osc → Filter → VCA → Output con LFO al cutoff',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const flt = mods.find(m => m.type === 'filter');
const vca = mods.find(m => m.type === 'vca');
const lfo = mods.find(m => m.type === 'lfo');
const out = mods.find(m => m.type === 'output');
if (!osc || !flt || !vca || !lfo || !out) return false;
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === vca.id) &&
conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id) &&
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
},
},
{
star: 2,
name: 'Wobble bass',
desc: 'Osc grave (<130 Hz), LFO lento (1-3 Hz), Q > 8',
test: (mods) => {
const osc = mods.find(m => m.type === 'oscillator');
const flt = mods.find(m => m.type === 'filter');
const lfo = mods.find(m => m.type === 'lfo');
if (!osc || !flt || !lfo) return false;
return (osc.params.frequency ?? 440) < 130 &&
(lfo.params.frequency ?? 2) >= 1 && (lfo.params.frequency ?? 2) <= 3 &&
(flt.params.Q ?? 1) > 8;
},
},
{
star: 3,
name: 'Wobble completo',
desc: 'Todo lo anterior + Envelope al VCA + Keyboard al gate',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const flt = mods.find(m => m.type === 'filter');
const lfo = mods.find(m => m.type === 'lfo');
const env = mods.find(m => m.type === 'envelope');
const vca = mods.find(m => m.type === 'vca');
const kb = mods.find(m => m.type === 'keyboard');
if (!osc || !flt || !lfo || !env || !vca || !kb) return false;
return (osc.params.frequency ?? 440) < 130 &&
(flt.params.Q ?? 1) > 8 &&
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv') &&
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id && c.to.port === 'gate');
},
},
],
},
],
};

519
src/game/levels/world5.js Normal file
View File

@@ -0,0 +1,519 @@
/**
* World 5 — "Efectos" (Effects)
*
* Teaches: delay, reverb, distortion, effect chains, wet/dry mixing
* 8 levels, progressive difficulty
*/
export const WORLD_5 = {
id: 'w5',
name: 'Efectos',
subtitle: 'Transforma el sonido con efectos',
icon: '◈',
color: '#44ff88',
unlockStars: 48,
levels: [
// ─────────────── LEVEL 5.1 ───────────────
{
id: 'w5-1',
title: 'El Eco',
subtitle: 'Delay básico',
description: 'El delay repite el sonido después de un tiempo. Es como gritar en un cañón y escuchar tu voz rebotando. El delay más simple tiene un tiempo de repetición y un feedback que controla cuántas veces se repite.',
concept: 'Conecta: Oscilador → Delay → Output. El knob "Time" controla el tiempo entre repeticiones. El "Feedback" controla cuántas repeticiones. Empieza con un feedback bajo (~0.3).',
availableModules: [],
preplacedModules: [
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sine', frequency: 440, detune: 0 }, locked: true },
{ id: 2, type: 'delay', x: 340, y: 80, params: { time: 0.3, feedback: 0.3, mix: 0.5 }, locked: false },
{ id: 3, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
],
effects: [
{ type: 'delay', time: 0.35, feedback: 0.4, wet: 0.6 },
],
duration: 3,
},
checks: [
{
star: 1,
name: 'Delay conectado',
desc: 'Oscilador → Delay → Salida',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const del = mods.find(m => m.type === 'delay');
const out = mods.find(m => m.type === 'output');
if (!osc || !del || !out) return false;
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === del.id) &&
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out.id);
},
},
{
star: 2,
name: 'Repeticiones',
desc: 'Feedback por encima de 0.2',
test: (mods) => {
const del = mods.find(m => m.type === 'delay');
return del && (del.params.feedback ?? 0) > 0.2;
},
},
{
star: 3,
name: 'Eco rítmico',
desc: 'Delay time 0.2-0.5s, feedback 0.3-0.6',
test: (mods) => {
const del = mods.find(m => m.type === 'delay');
if (!del) return false;
const t = del.params.time ?? 0.3;
const fb = del.params.feedback ?? 0;
return t >= 0.2 && t <= 0.5 && fb >= 0.3 && fb <= 0.6;
},
},
],
},
// ─────────────── LEVEL 5.2 ───────────────
{
id: 'w5-2',
title: 'Slapback',
subtitle: 'El delay rockabilly',
description: 'El slapback es un delay muy corto (50-120ms) con una sola repetición. Es el efecto clásico de las voces de Elvis y el rockabilly — da presencia sin crear un eco largo.',
concept: 'Delay con tiempo corto (~0.05-0.12s) y feedback muy bajo (~0.1 o menos). Una sola repetición rápida. El mix controla cuánto delay se mezcla con la señal original.',
availableModules: ['delay'],
preplacedModules: [
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'square', frequency: 330, detune: 0 }, locked: true },
{ id: 2, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'square', frequency: 330 } },
],
effects: [
{ type: 'delay', time: 0.08, feedback: 0.05, wet: 0.5 },
],
duration: 2,
},
checks: [
{
star: 1,
name: 'Delay en la cadena',
desc: 'Osc → Delay → Salida',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const del = mods.find(m => m.type === 'delay');
const out = mods.find(m => m.type === 'output');
if (!osc || !del || !out) return false;
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === del.id) &&
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out.id);
},
},
{
star: 2,
name: 'Tiempo corto',
desc: 'Delay time menor de 0.15s',
test: (mods) => {
const del = mods.find(m => m.type === 'delay');
return del && (del.params.time ?? 0.3) < 0.15;
},
},
{
star: 3,
name: 'Slapback perfecto',
desc: 'Time 0.05-0.12s, feedback < 0.15',
test: (mods) => {
const del = mods.find(m => m.type === 'delay');
if (!del) return false;
const t = del.params.time ?? 0.3;
const fb = del.params.feedback ?? 0.3;
return t >= 0.05 && t <= 0.12 && fb < 0.15;
},
},
],
},
// ─────────────── LEVEL 5.3 ───────────────
{
id: 'w5-3',
title: 'Reverb Espacial',
subtitle: 'El sonido del espacio',
description: 'La reverb simula el sonido de un espacio acústico — desde una habitación pequeña hasta una catedral enorme. Es quizás el efecto más usado en toda la producción musical.',
concept: 'Conecta: Oscilador → Reverb → Output. El knob de "decay" (o room size) controla el tamaño del espacio. Más largo = catedral. Más corto = habitación pequeña.',
availableModules: [],
preplacedModules: [
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'triangle', frequency: 440, detune: 0 }, locked: true },
{ id: 2, type: 'reverb', x: 340, y: 80, params: { decay: 2, mix: 0.4 }, locked: false },
{ id: 3, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'triangle', frequency: 440 } },
],
effects: [
{ type: 'reverb', decay: 5.5, wet: 0.55 },
],
duration: 3,
},
checks: [
{
star: 1,
name: 'Reverb conectada',
desc: 'Oscilador → Reverb → Salida',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const rev = mods.find(m => m.type === 'reverb');
const out = mods.find(m => m.type === 'output');
if (!osc || !rev || !out) return false;
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === rev.id) &&
conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === out.id);
},
},
{
star: 2,
name: 'Espacio grande',
desc: 'Decay mayor de 3 segundos',
test: (mods) => {
const rev = mods.find(m => m.type === 'reverb');
return rev && (rev.params.decay ?? 2) > 3;
},
},
{
star: 3,
name: 'Catedral',
desc: 'Decay > 5s, mix 0.3-0.6 (no demasiado)',
test: (mods) => {
const rev = mods.find(m => m.type === 'reverb');
if (!rev) return false;
return (rev.params.decay ?? 2) > 5 &&
(rev.params.mix ?? 0.5) >= 0.3 && (rev.params.mix ?? 0.5) <= 0.6;
},
},
],
},
// ─────────────── LEVEL 5.4 ───────────────
{
id: 'w5-4',
title: 'Distorsión',
subtitle: 'Rompe la señal',
description: 'La distorsión amplifica la señal hasta que se "rompe", creando armónicos nuevos. Desde el overdrive suave de un amplificador de guitarra hasta el fuzz salvaje — la distorsión añade agresividad y presencia.',
concept: 'Conecta: Oscilador → Distortion → Output. Sube el "Drive" para más distorsión. Con una onda sine pura, escucharás cómo aparecen armónicos que no estaban antes.',
availableModules: ['distortion'],
preplacedModules: [
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sine', frequency: 220, detune: 0 }, locked: true },
{ id: 2, type: 'output', x: 600, y: 100, params: { volume: -10 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 220 } },
],
effects: [
{ type: 'distortion', amount: 6 },
],
duration: 2.5,
},
checks: [
{
star: 1,
name: 'Distorsión conectada',
desc: 'Osc → Distortion → Salida',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const dist = mods.find(m => m.type === 'distortion');
const out = mods.find(m => m.type === 'output');
if (!osc || !dist || !out) return false;
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === dist.id) &&
conns.some(c => c.from.moduleId === dist.id && c.to.moduleId === out.id);
},
},
{
star: 2,
name: 'Drive alto',
desc: 'Distorsión con drive moderado-alto',
test: (mods) => {
const dist = mods.find(m => m.type === 'distortion');
return dist && (dist.params.drive ?? 1) > 3;
},
},
{
star: 3,
name: 'Fuzz sine',
desc: 'Onda sine con drive > 5 (máxima transformación)',
test: (mods) => {
const osc = mods.find(m => m.type === 'oscillator');
const dist = mods.find(m => m.type === 'distortion');
if (!osc || !dist) return false;
return osc.params.waveform === 'sine' && (dist.params.drive ?? 1) > 5;
},
},
],
},
// ─────────────── LEVEL 5.5 ───────────────
{
id: 'w5-5',
title: 'Cadena de Efectos',
subtitle: 'Orden importa',
description: 'El orden de los efectos cambia radicalmente el resultado. Distorsión antes de delay suena diferente a delay antes de distorsión. Experimenta encadenando efectos en diferente orden.',
concept: 'Prueba: Osc → Distortion → Delay → Output (la distorsión se repite limpia). El orden crea caracteres distintos. Encadena al menos 2 efectos diferentes.',
availableModules: ['delay', 'distortion'],
preplacedModules: [
{ id: 1, type: 'oscillator', x: 60, y: 80, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: true },
{ id: 2, type: 'output', x: 740, y: 100, params: { volume: -10 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
],
effects: [
{ type: 'distortion', amount: 3 },
{ type: 'delay', time: 0.35, feedback: 0.35, wet: 0.5 },
],
duration: 3,
},
checks: [
{
star: 1,
name: 'Dos efectos',
desc: 'Al menos 2 módulos de efecto en la cadena',
test: (mods, conns) => {
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
const out = mods.find(m => m.type === 'output');
return effects.length >= 2 && out && conns.some(c => c.to.moduleId === out.id);
},
},
{
star: 2,
name: 'Efectos encadenados',
desc: 'Los efectos están conectados en serie (uno al otro)',
test: (mods, conns) => {
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
if (effects.length < 2) return false;
// Check if any effect connects to another effect
return effects.some(e1 => effects.some(e2 =>
e1.id !== e2.id && conns.some(c => c.from.moduleId === e1.id && c.to.moduleId === e2.id)
));
},
},
{
star: 3,
name: 'Cadena completa',
desc: 'Osc → efecto1 → efecto2 → Output (cadena lineal)',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
const out = mods.find(m => m.type === 'output');
if (!osc || effects.length < 2 || !out) return false;
// Osc → some effect
const oscToFx = effects.find(e => conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === e.id));
if (!oscToFx) return false;
// That effect → another effect
const fxToFx = effects.find(e => e.id !== oscToFx.id &&
conns.some(c => c.from.moduleId === oscToFx.id && c.to.moduleId === e.id));
if (!fxToFx) return false;
// Second effect → output
return conns.some(c => c.from.moduleId === fxToFx.id && c.to.moduleId === out.id);
},
},
],
},
// ─────────────── LEVEL 5.6 ───────────────
{
id: 'w5-6',
title: 'Delay + Filtro',
subtitle: 'Dub echo',
description: 'El sonido dub es delay con feedback alto pasado por un filtro que va quitando agudos. Cada repetición suena más oscura y lejana — es el efecto que definió el reggae dub en los 70.',
concept: 'Osc → Delay (feedback alto ~0.5-0.7) → Filter (lowpass, cutoff bajo ~800 Hz) → Output. El filtro después del delay oscurece las repeticiones, creando profundidad.',
availableModules: ['delay', 'filter'],
preplacedModules: [
{ id: 1, type: 'oscillator', x: 60, y: 80, params: { waveform: 'square', frequency: 330, detune: 0 }, locked: true },
{ id: 2, type: 'output', x: 720, y: 100, params: { volume: -8 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'square', frequency: 330 } },
],
filter: { type: 'lowpass', frequency: 850, Q: 2 },
effects: [
{ type: 'delay', time: 0.4, feedback: 0.6, wet: 0.6 },
],
duration: 3,
},
checks: [
{
star: 1,
name: 'Delay + Filter',
desc: 'Osc → Delay → Filter → Output',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const del = mods.find(m => m.type === 'delay');
const flt = mods.find(m => m.type === 'filter');
const out = mods.find(m => m.type === 'output');
if (!osc || !del || !flt || !out) return false;
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === del.id) &&
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === flt.id) &&
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id);
},
},
{
star: 2,
name: 'Echo largo',
desc: 'Delay feedback > 0.4, time > 0.2s',
test: (mods) => {
const del = mods.find(m => m.type === 'delay');
return del && (del.params.feedback ?? 0) > 0.4 && (del.params.time ?? 0.3) > 0.2;
},
},
{
star: 3,
name: 'Dub echo',
desc: 'Feedback 0.5-0.7, filtro lowpass cutoff < 1000 Hz',
test: (mods) => {
const del = mods.find(m => m.type === 'delay');
const flt = mods.find(m => m.type === 'filter');
if (!del || !flt) return false;
const fb = del.params.feedback ?? 0;
return fb >= 0.5 && fb <= 0.7 &&
flt.params.type === 'lowpass' && (flt.params.frequency ?? 2000) < 1000;
},
},
],
},
// ─────────────── LEVEL 5.7 ───────────────
{
id: 'w5-7',
title: 'Shoegaze Wall',
subtitle: 'Reverb + Distorsión',
description: 'El sonido shoegaze (My Bloody Valentine, Slowdive) es una pared de sonido creada con distorsión y reverb masiva. La distorsión aplasta la señal y la reverb la convierte en una nube etérea.',
concept: 'Osc → Distortion (drive medio) → Reverb (decay largo, mix alto) → Output. La combinación de distorsión y reverb crea una textura densa y atmosférica.',
availableModules: ['distortion', 'reverb'],
preplacedModules: [
{ id: 1, type: 'oscillator', x: 60, y: 80, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: false },
{ id: 2, type: 'output', x: 720, y: 100, params: { volume: -10 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
],
effects: [
{ type: 'distortion', amount: 5 },
{ type: 'reverb', decay: 6.5, wet: 0.65 },
],
duration: 4,
},
checks: [
{
star: 1,
name: 'Dist + Reverb',
desc: 'Osc → Distortion → Reverb → Output',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const dist = mods.find(m => m.type === 'distortion');
const rev = mods.find(m => m.type === 'reverb');
const out = mods.find(m => m.type === 'output');
if (!osc || !dist || !rev || !out) return false;
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === dist.id) &&
conns.some(c => c.from.moduleId === dist.id && c.to.moduleId === rev.id) &&
conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === out.id);
},
},
{
star: 2,
name: 'Pared de sonido',
desc: 'Drive > 3, reverb decay > 4s',
test: (mods) => {
const dist = mods.find(m => m.type === 'distortion');
const rev = mods.find(m => m.type === 'reverb');
if (!dist || !rev) return false;
return (dist.params.drive ?? 1) > 3 && (rev.params.decay ?? 2) > 4;
},
},
{
star: 3,
name: 'Shoegaze perfecto',
desc: 'Drive 4-8, decay > 6s, reverb mix > 0.5',
test: (mods) => {
const dist = mods.find(m => m.type === 'distortion');
const rev = mods.find(m => m.type === 'reverb');
if (!dist || !rev) return false;
const drive = dist.params.drive ?? 1;
return drive >= 4 && drive <= 8 &&
(rev.params.decay ?? 2) > 6 && (rev.params.mix ?? 0.5) > 0.5;
},
},
],
},
// ─────────────── LEVEL 5.8: BOSS ───────────────
{
id: 'w5-8',
title: 'Ambient Scape',
subtitle: 'BOSS: Paisaje sonoro',
description: 'Crea un paisaje sonoro ambient completo: un sonido que evoluciona lentamente, envuelto en efectos. Combina osciladores, filtros, modulación y efectos para crear una textura atmosférica.',
concept: 'Osc → Filter (LFO al cutoff) → Delay → Reverb → Output. Envelope al VCA para control. Experimenta con tiempos largos, feedback alto, y modulación lenta para un sonido que "flota".',
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'delay', 'reverb', 'keyboard'],
preplacedModules: [
{ id: 1, type: 'output', x: 900, y: 120, params: { volume: -8 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 110 } },
],
filter: { type: 'lowpass', frequency: 1200, Q: 3 },
envelope: { attack: 0.5, decay: 0.3, sustain: 0.6, release: 1.5 },
lfo: { frequency: 0.5, type: 'sine', min: 400, max: 4000, target: 'frequency' },
effects: [
{ type: 'delay', time: 0.5, feedback: 0.5, wet: 0.5 },
{ type: 'reverb', decay: 5, wet: 0.6 },
],
duration: 5,
},
checks: [
{
star: 1,
name: 'Cadena con efectos',
desc: 'Al menos un efecto (delay/reverb) conectado a la salida',
test: (mods, conns) => {
const effects = mods.filter(m => ['delay', 'reverb'].includes(m.type));
const out = mods.find(m => m.type === 'output');
if (effects.length === 0 || !out) return false;
return effects.some(e => conns.some(c => c.from.moduleId === e.id && c.to.moduleId === out.id)) ||
conns.some(c => c.to.moduleId === out.id && effects.some(e => c.from.moduleId === e.id));
},
},
{
star: 2,
name: 'Modulación + Efectos',
desc: 'Tiene oscilador, filtro, y al menos 2 efectos conectados',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const flt = mods.find(m => m.type === 'filter');
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
if (!osc || !flt || effects.length < 2) return false;
// Check osc is connected to something
return conns.some(c => c.from.moduleId === osc.id) && effects.length >= 2;
},
},
{
star: 3,
name: 'Paisaje completo',
desc: 'Osc+Filter+LFO(cutoff)+Delay+Reverb, todo conectado',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const flt = mods.find(m => m.type === 'filter');
const lfo = mods.find(m => m.type === 'lfo');
const del = mods.find(m => m.type === 'delay');
const rev = mods.find(m => m.type === 'reverb');
const out = mods.find(m => m.type === 'output');
if (!osc || !flt || !lfo || !del || !rev || !out) return false;
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff') &&
conns.some(c => c.to.moduleId === out.id);
},
},
],
},
],
};

554
src/game/levels/world6.js Normal file
View File

@@ -0,0 +1,554 @@
/**
* World 6 — "Diseño Sonoro" (Sound Design Mastery)
*
* Teaches: putting it ALL together, real-world sound recreation
* 8 levels, boss challenges combining everything learned
*/
export const WORLD_6 = {
id: 'w6',
name: 'Diseño Sonoro',
subtitle: 'Combina todo para crear sonidos reales',
icon: '◉',
color: '#ff44aa',
unlockStars: 60,
levels: [
// ─────────────── LEVEL 6.1 ───────────────
{
id: 'w6-1',
title: 'Kick Drum',
subtitle: 'El latido del beat',
description: 'Un kick sintético se crea con un oscilador sine a frecuencia baja + un envelope muy rápido en el VCA para el golpe. Algunos añaden un pitch envelope para el "click" del ataque.',
concept: 'Osc sine a ~55 Hz → VCA → Output. Envelope con attack 0, decay ~0.2s, sustain 0. El envelope al VCA crea el golpe. Para el click: un segundo osc más agudo con decay ultra-corto.',
availableModules: ['oscillator', 'vca', 'envelope', 'keyboard', 'mixer'],
preplacedModules: [
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55 } },
],
envelope: { attack: 0, decay: 0.25, sustain: 0, release: 0.1 },
duration: 2,
},
checks: [
{
star: 1,
name: 'Kick básico',
desc: 'Osc sine grave + VCA + Envelope → Output',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const vca = mods.find(m => m.type === 'vca');
const env = mods.find(m => m.type === 'envelope');
const out = mods.find(m => m.type === 'output');
if (!osc || !vca || !env || !out) return false;
return (osc.params.frequency ?? 440) < 100 &&
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
},
},
{
star: 2,
name: 'Punch',
desc: 'Sine < 80 Hz, envelope rápido (attack < 0.01, decay < 0.3)',
test: (mods) => {
const osc = mods.find(m => m.type === 'oscillator');
const env = mods.find(m => m.type === 'envelope');
if (!osc || !env) return false;
return (osc.params.frequency ?? 440) < 80 &&
osc.params.waveform === 'sine' &&
(env.params.attack ?? 0.01) < 0.01 &&
(env.params.decay ?? 0.2) < 0.3 &&
(env.params.sustain ?? 0.5) < 0.1;
},
},
{
star: 3,
name: '808 Kick',
desc: 'Frecuencia 40-60 Hz, decay 0.15-0.4s, keyboard conectado',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const env = mods.find(m => m.type === 'envelope');
const kb = mods.find(m => m.type === 'keyboard');
if (!osc || !env || !kb) return false;
const freq = osc.params.frequency ?? 440;
const decay = env.params.decay ?? 0.2;
return freq >= 40 && freq <= 60 && decay >= 0.15 && decay <= 0.4 &&
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id && c.to.port === 'gate');
},
},
],
},
// ─────────────── LEVEL 6.2 ───────────────
{
id: 'w6-2',
title: 'Hi-Hat',
subtitle: 'Noise + Filtro + Envelope',
description: 'Los hi-hats son ruido blanco filtrado con un envelope corto. El ruido proporciona la textura metálica, el filtro highpass quita los graves, y el envelope corto le da el "tss".',
concept: 'Noise → Filter (highpass, cutoff alto ~6000+ Hz) → VCA → Output. Envelope corto (attack 0, decay ~0.05-0.15s, sustain 0) al VCA. Keyboard al gate del envelope.',
availableModules: ['noise', 'filter', 'vca', 'envelope', 'keyboard'],
preplacedModules: [
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -10 }, locked: true },
],
target: {
build: [
{ type: 'noise', params: { type: 'white' } },
],
filter: { type: 'highpass', frequency: 7000, Q: 2 },
envelope: { attack: 0, decay: 0.08, sustain: 0, release: 0 },
duration: 2,
},
checks: [
{
star: 1,
name: 'Ruido filtrado',
desc: 'Noise → Filter → VCA → Output con envelope',
test: (mods, conns) => {
const noise = mods.find(m => m.type === 'noise');
const flt = mods.find(m => m.type === 'filter');
const vca = mods.find(m => m.type === 'vca');
const env = mods.find(m => m.type === 'envelope');
if (!noise || !flt || !vca || !env) return false;
return conns.some(c => c.from.moduleId === noise.id && c.to.moduleId === flt.id) &&
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
},
},
{
star: 2,
name: 'Sonido metálico',
desc: 'Filtro highpass, cutoff > 4000 Hz',
test: (mods) => {
const flt = mods.find(m => m.type === 'filter');
return flt && flt.params.type === 'highpass' && (flt.params.frequency ?? 1000) > 4000;
},
},
{
star: 3,
name: 'Hi-hat cerrado',
desc: 'HP > 6000 Hz, envelope ultra-corto (decay < 0.1s)',
test: (mods) => {
const flt = mods.find(m => m.type === 'filter');
const env = mods.find(m => m.type === 'envelope');
if (!flt || !env) return false;
return flt.params.type === 'highpass' && (flt.params.frequency ?? 1000) > 6000 &&
(env.params.decay ?? 0.2) < 0.1 && (env.params.sustain ?? 0.5) < 0.05;
},
},
],
},
// ─────────────── LEVEL 6.3 ───────────────
{
id: 'w6-3',
title: 'Snare',
subtitle: 'Tono + Ruido',
description: 'Un snare es la combinación de un cuerpo tonal (oscilador) y una cola de ruido (noise). Se mezclan juntos con envelopes diferentes — el tono muere rápido y el ruido un poco después.',
concept: 'Dos cadenas: 1) Osc sine (~200 Hz) → VCA1 → Mixer. 2) Noise → Filter HP → VCA2 → Mixer. Mixer → Output. Envelopes diferentes: el tono más corto que el ruido.',
availableModules: ['oscillator', 'noise', 'filter', 'vca', 'envelope', 'keyboard', 'mixer'],
preplacedModules: [
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 200 } },
{ type: 'noise', params: { type: 'white' } },
],
filter: { type: 'highpass', frequency: 3000, Q: 1.5 },
envelope: { attack: 0, decay: 0.12, sustain: 0, release: 0.05 },
duration: 2,
},
checks: [
{
star: 1,
name: 'Dos fuentes',
desc: 'Oscilador Y Noise, ambos al mixer → output',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const noise = mods.find(m => m.type === 'noise');
const mixer = mods.find(m => m.type === 'mixer');
const out = mods.find(m => m.type === 'output');
return osc && noise && mixer && out &&
conns.some(c => c.to.moduleId === out.id);
},
},
{
star: 2,
name: 'Envelopes',
desc: 'Al menos 2 envelopes controlando VCAs',
test: (mods, conns) => {
const envs = mods.filter(m => m.type === 'envelope');
const vcas = mods.filter(m => m.type === 'vca');
if (envs.length < 2 || vcas.length < 2) return false;
const envToVca = envs.filter(e =>
vcas.some(v => conns.some(c => c.from.moduleId === e.id && c.to.moduleId === v.id && c.to.port === 'cv'))
);
return envToVca.length >= 2;
},
},
{
star: 3,
name: 'Snare realista',
desc: 'Osc ~150-250 Hz, noise filtrado HP, decays diferentes',
test: (mods) => {
const osc = mods.find(m => m.type === 'oscillator');
const flt = mods.find(m => m.type === 'filter');
const envs = mods.filter(m => m.type === 'envelope');
if (!osc || !flt || envs.length < 2) return false;
const freq = osc.params.frequency ?? 440;
const decays = envs.map(e => e.params.decay ?? 0.2);
return freq >= 150 && freq <= 250 &&
flt.params.type === 'highpass' &&
Math.abs(decays[0] - decays[1]) > 0.03;
},
},
],
},
// ─────────────── LEVEL 6.4 ───────────────
{
id: 'w6-4',
title: 'Pad Espacial',
subtitle: 'Capas + Efectos',
description: 'Un pad espacial combina múltiples osciladores detuned, un filtro suave, un envelope lento, y efectos de reverb/delay para crear una textura inmersiva que rellena todo el espectro.',
concept: 'Dos oscs saw detuned → Mixer → Filter LP → VCA → Reverb → Output. Envelope lento al VCA. LFO lento al cutoff. Reverb con decay largo. El resultado: un colchón de sonido etéreo.',
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'reverb', 'keyboard'],
preplacedModules: [
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: -8 } },
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 8 } },
],
filter: { type: 'lowpass', frequency: 1500, Q: 3 },
envelope: { attack: 1, decay: 0.4, sustain: 0.7, release: 2 },
lfo: { frequency: 0.6, type: 'sine', min: 600, max: 3500, target: 'frequency' },
effects: [
{ type: 'reverb', decay: 5.5, wet: 0.6 },
],
duration: 5,
},
checks: [
{
star: 1,
name: 'Múltiples osciladores',
desc: 'Al menos 2 osciladores mezclados',
test: (mods, conns) => {
const oscs = mods.filter(m => m.type === 'oscillator');
const mixer = mods.find(m => m.type === 'mixer');
return oscs.length >= 2 && mixer;
},
},
{
star: 2,
name: 'Con efectos',
desc: 'Reverb en la cadena con decay > 3s',
test: (mods) => {
const rev = mods.find(m => m.type === 'reverb');
return rev && (rev.params.decay ?? 2) > 3;
},
},
{
star: 3,
name: 'Pad completo',
desc: '2+ oscs detuned, filtro, LFO al cutoff, envelope al VCA, reverb',
test: (mods, conns) => {
const oscs = mods.filter(m => m.type === 'oscillator');
const flt = mods.find(m => m.type === 'filter');
const lfo = mods.find(m => m.type === 'lfo');
const env = mods.find(m => m.type === 'envelope');
const vca = mods.find(m => m.type === 'vca');
const rev = mods.find(m => m.type === 'reverb');
if (oscs.length < 2 || !flt || !lfo || !env || !vca || !rev) return false;
// Check detune
const hasDetune = oscs.some(o => Math.abs(o.params.detune ?? 0) > 2);
// Check LFO to cutoff
const lfoToCutoff = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
// Check env to VCA
const envToVca = conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
return hasDetune && lfoToCutoff && envToVca;
},
},
],
},
// ─────────────── LEVEL 6.5 ───────────────
{
id: 'w6-5',
title: 'Bajo Reese',
subtitle: 'El bajo de Drum & Bass',
description: 'El Reese bass es un bajo icónico del Drum & Bass: dos osciladores sawtooth detuned a frecuencia grave, pasados por un filtro lowpass que se abre y cierra. Es gordo, agresivo y hipnótico.',
concept: 'Dos oscs sawtooth a ~55 Hz, uno con detune +7-12. Mixer → Filter LP resonante → VCA → Output. LFO lento (~0.3-1 Hz) al cutoff del filtro. El "movimiento" del filtro es lo que le da vida.',
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'mixer', 'envelope', 'keyboard'],
preplacedModules: [
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: -9 } },
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: 9 } },
],
filter: { type: 'lowpass', frequency: 400, Q: 8 },
lfo: { frequency: 0.7, type: 'sine', min: 200, max: 2000, target: 'frequency' },
envelope: { attack: 0.05, decay: 0.2, sustain: 0.6, release: 0.3 },
duration: 4,
},
checks: [
{
star: 1,
name: 'Dos sierras graves',
desc: '2 osciladores saw < 100 Hz mezclados',
test: (mods) => {
const oscs = mods.filter(m => m.type === 'oscillator');
if (oscs.length < 2) return false;
return oscs.filter(o => o.params.waveform === 'sawtooth' && (o.params.frequency ?? 440) < 100).length >= 2;
},
},
{
star: 2,
name: 'Detune + Filtro',
desc: 'Osciladores detuned, filtro LP en la cadena',
test: (mods) => {
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sawtooth');
const flt = mods.find(m => m.type === 'filter');
if (oscs.length < 2 || !flt) return false;
const hasDetune = oscs.some(o => Math.abs(o.params.detune ?? 0) > 3);
return hasDetune && flt.params.type === 'lowpass';
},
},
{
star: 3,
name: 'Reese Bass',
desc: 'Detuned saws + LP resonante + LFO al cutoff',
test: (mods, conns) => {
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sawtooth');
const flt = mods.find(m => m.type === 'filter');
const lfo = mods.find(m => m.type === 'lfo');
if (oscs.length < 2 || !flt || !lfo) return false;
const hasDetune = oscs.some(o => Math.abs(o.params.detune ?? 0) > 3);
const isLPres = flt.params.type === 'lowpass' && (flt.params.Q ?? 1) > 3;
const lfoToCut = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
return hasDetune && isLPres && lfoToCut;
},
},
],
},
// ─────────────── LEVEL 6.6 ───────────────
{
id: 'w6-6',
title: 'Efecto Laser',
subtitle: 'Pew pew!',
description: 'El sonido laser clásico de los juegos retro es un oscilador cuya frecuencia baja rápidamente — un pitch sweep descendente. Se consigue con un envelope que modula la frecuencia del oscilador.',
concept: 'Osc square/saw → VCA → Output. Envelope al VCA (ataque rápido, decay corto). Un SEGUNDO envelope a la frecuencia del osc (empieza agudo y baja rápido). Keyboard dispara ambos.',
availableModules: ['oscillator', 'vca', 'envelope', 'keyboard'],
preplacedModules: [
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'square', frequency: 440 } },
],
envelope: { attack: 0.01, decay: 0.15, sustain: 0.05, release: 0.1 },
duration: 2,
},
checks: [
{
star: 1,
name: 'Sonido con envelope',
desc: 'Osc → VCA → Output con envelope y keyboard',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const vca = mods.find(m => m.type === 'vca');
const env = mods.find(m => m.type === 'envelope');
const kb = mods.find(m => m.type === 'keyboard');
const out = mods.find(m => m.type === 'output');
return osc && vca && env && kb && out &&
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
},
},
{
star: 2,
name: 'Pitch envelope',
desc: 'Un envelope conectado a la frecuencia del oscilador',
test: (mods, conns) => {
const envs = mods.filter(m => m.type === 'envelope');
const osc = mods.find(m => m.type === 'oscillator');
if (!osc || envs.length < 2) return false;
return envs.some(e => conns.some(c =>
c.from.moduleId === e.id && c.to.moduleId === osc.id && c.to.port === 'freq'
));
},
},
{
star: 3,
name: 'Pew pew!',
desc: 'Osc square/saw, pitch env corto (decay < 0.2s), keyboard a ambos gates',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const envs = mods.filter(m => m.type === 'envelope');
const kb = mods.find(m => m.type === 'keyboard');
if (!osc || envs.length < 2 || !kb) return false;
const wave = osc.params.waveform;
const pitchEnv = envs.find(e => conns.some(c =>
c.from.moduleId === e.id && c.to.moduleId === osc.id && c.to.port === 'freq'
));
if (!pitchEnv) return false;
const gated = envs.filter(e =>
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === e.id && c.to.port === 'gate')
);
return (wave === 'square' || wave === 'sawtooth') &&
(pitchEnv.params.decay ?? 0.2) < 0.2 &&
gated.length >= 2;
},
},
],
},
// ─────────────── LEVEL 6.7 ───────────────
{
id: 'w6-7',
title: 'Arpegio Trance',
subtitle: 'Secuenciador + Synth',
description: 'Los arpegios de trance son notas rápidas que crean patrones hipnóticos. Usa el secuenciador para disparar notas en el sintetizador con un envelope corto y un filtro que sube y baja.',
concept: 'Sequencer → Osc freq + Envelope gate. Osc → Filter → VCA → Delay → Output. Envelope corto al VCA (pluck). LFO lento al cutoff del filtro. El delay repite el patrón.',
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'delay', 'sequencer'],
preplacedModules: [
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 330 } },
],
filter: { type: 'lowpass', frequency: 2000, Q: 4 },
envelope: { attack: 0.005, decay: 0.15, sustain: 0.1, release: 0.08 },
lfo: { frequency: 1.5, type: 'sine', min: 1000, max: 4000, target: 'frequency' },
effects: [
{ type: 'delay', time: 0.25, feedback: 0.35, wet: 0.45 },
],
triggerPattern: { interval: 0.25, count: 16 },
duration: 4,
},
checks: [
{
star: 1,
name: 'Secuenciador activo',
desc: 'Sequencer conectado al oscilador',
test: (mods, conns) => {
const seq = mods.find(m => m.type === 'sequencer');
const osc = mods.find(m => m.type === 'oscillator');
if (!seq || !osc) return false;
return conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === osc.id);
},
},
{
star: 2,
name: 'Synth con envolvente',
desc: 'Osc → Filter → VCA → Output con envelope al VCA',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const flt = mods.find(m => m.type === 'filter');
const vca = mods.find(m => m.type === 'vca');
const env = mods.find(m => m.type === 'envelope');
const out = mods.find(m => m.type === 'output');
if (!osc || !flt || !vca || !env || !out) return false;
return conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
},
},
{
star: 3,
name: 'Trance completo',
desc: 'Sequencer + synth sustractivo completo + delay',
test: (mods, conns) => {
const seq = mods.find(m => m.type === 'sequencer');
const osc = mods.find(m => m.type === 'oscillator');
const flt = mods.find(m => m.type === 'filter');
const vca = mods.find(m => m.type === 'vca');
const del = mods.find(m => m.type === 'delay');
const env = mods.find(m => m.type === 'envelope');
if (!seq || !osc || !flt || !vca || !del || !env) return false;
return conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === osc.id) &&
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
},
},
],
},
// ─────────────── LEVEL 6.8: BOSS ───────────────
{
id: 'w6-8',
title: 'Tu Sintetizador',
subtitle: 'BOSS FINAL: Diseña tu propio sonido',
description: 'Has aprendido osciladores, filtros, envelopes, modulación y efectos. Ahora construye el sintetizador más completo que puedas. Sin restricciones. Sin guía. Solo tu creatividad y todo lo que has aprendido.',
concept: 'Construye un patch completo con al menos: 2 osciladores, 1 filtro, 1 VCA, 2 envelopes, 1 LFO, 1 efecto, y un keyboard. ¡Hazlo sonar increíble!',
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'noise', 'keyboard', 'delay', 'reverb', 'distortion', 'sequencer'],
preplacedModules: [
{ id: 1, type: 'output', x: 950, y: 160, params: { volume: -8 }, locked: true },
],
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 165, detune: -6 } },
{ type: 'oscillator', params: { waveform: 'square', frequency: 165, detune: 6 } },
{ type: 'noise', params: { type: 'white' } },
],
filter: { type: 'lowpass', frequency: 1800, Q: 6 },
envelope: { attack: 0.2, decay: 0.4, sustain: 0.5, release: 0.8 },
lfo: { frequency: 2, type: 'sine', min: 0.3, max: 1.2, target: 'amplitude' },
effects: [
{ type: 'distortion', amount: 2 },
{ type: 'delay', time: 0.3, feedback: 0.4, wet: 0.4 },
{ type: 'reverb', decay: 3.5, wet: 0.45 },
],
duration: 5,
},
checks: [
{
star: 1,
name: 'Patch funcional',
desc: 'Al menos 5 módulos conectados con sonido a la salida',
test: (mods, conns) => {
const out = mods.find(m => m.type === 'output');
if (!out) return false;
// Count non-output modules
const modCount = mods.filter(m => m.type !== 'output').length;
// Something reaches output
const hasOutput = conns.some(c => c.to.moduleId === out.id);
return modCount >= 5 && hasOutput && conns.length >= 5;
},
},
{
star: 2,
name: 'Síntesis completa',
desc: 'Tiene osc + filtro + VCA + envelope + efecto, todos conectados',
test: (mods, conns) => {
const osc = mods.find(m => m.type === 'oscillator');
const flt = mods.find(m => m.type === 'filter');
const vca = mods.find(m => m.type === 'vca');
const env = mods.find(m => m.type === 'envelope');
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
if (!osc || !flt || !vca || !env || effects.length === 0) return false;
// All main pieces should have connections
const oscConn = conns.some(c => c.from.moduleId === osc.id);
const envConn = conns.some(c => c.from.moduleId === env.id);
return oscConn && envConn && conns.length >= 7;
},
},
{
star: 3,
name: 'Maestro del Sonido',
desc: '8+ módulos, 2+ oscs, 2+ envelopes, LFO, efecto, keyboard — ¡todo!',
test: (mods, conns) => {
const oscs = mods.filter(m => m.type === 'oscillator');
const envs = mods.filter(m => m.type === 'envelope');
const lfo = mods.find(m => m.type === 'lfo');
const kb = mods.find(m => m.type === 'keyboard');
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
const nonOutput = mods.filter(m => m.type !== 'output');
return nonOutput.length >= 8 && oscs.length >= 2 && envs.length >= 2 &&
lfo && kb && effects.length >= 1 && conns.length >= 10;
},
},
],
},
],
};

547
src/game/levels/world7.js Normal file
View File

@@ -0,0 +1,547 @@
/**
* 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: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 330 } },
],
envelope: { attack: 0.01, decay: 0.12, sustain: 0, release: 0.05 },
triggerPattern: { interval: 0.5, count: 4 },
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: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: -8 } },
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: 8 } },
],
filter: { type: 'lowpass', frequency: 600, Q: 5 },
envelope: { attack: 0.02, decay: 0.15, sustain: 0.3, release: 0.1 },
lfo: { frequency: 1, type: 'sine', min: 300, max: 1500, target: 'frequency' },
triggerPattern: { interval: 1, count: 3 },
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: [
{ type: 'oscillator', params: { waveform: 'square', frequency: 220 } },
],
filter: { type: 'lowpass', frequency: 2000, Q: 2 },
envelope: { attack: 0.005, decay: 0.15, sustain: 0.05, release: 0.08 },
lfo: { frequency: 2.5, type: 'sine', min: 1000, max: 4000, target: 'frequency' },
triggerPattern: { interval: 0.375, count: 7 },
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: [
{ type: 'noise', params: { type: 'white' } },
],
filter: { type: 'lowpass', frequency: 1000, Q: 3.5 },
envelope: { attack: 0, decay: 0.1, sustain: 0, release: 0 },
triggerPattern: { interval: 0.5, count: 4 },
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: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 50 } },
],
envelope: { attack: 0, decay: 0.3, sustain: 0, release: 0.1 },
triggerPattern: { interval: 1, count: 2 },
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: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55 } },
{ type: 'noise', params: { type: 'white' } },
],
filter: { type: 'highpass', frequency: 6500, Q: 1.5 },
envelope: { attack: 0, decay: 0.08, sustain: 0, release: 0 },
triggerPattern: { interval: 0.5, count: 5 },
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: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
],
filter: { type: 'lowpass', frequency: 1500, Q: 2 },
envelope: { attack: 0.01, decay: 0.12, sustain: 0, release: 0.05 },
effects: [
{ type: 'delay', time: 0.35, feedback: 0.5, wet: 0.55 },
],
triggerPattern: { interval: 0.5, count: 6 },
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: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 50 } },
{ type: 'noise', params: { type: 'white' } },
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: -8 } },
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: 8 } },
],
filter: { type: 'highpass', frequency: 7000, Q: 1.5 },
envelope: { attack: 0, decay: 0.15, sustain: 0, release: 0.05 },
effects: [
{ type: 'delay', time: 0.3, feedback: 0.4, wet: 0.35 },
{ type: 'reverb', decay: 2.5, wet: 0.25 },
],
triggerPattern: { interval: 1, count: 5 },
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;
},
},
],
},
],
};

537
src/game/levels/world8.js Normal file
View File

@@ -0,0 +1,537 @@
/**
* 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: [
{ type: 'noise', params: { type: 'white' } },
],
envelope: { attack: 0.1, decay: 0.3, sustain: 0.1, release: 0.2 },
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: [
{ type: 'noise', params: { type: 'white' } },
],
filter: { type: 'bandpass', frequency: 3000, Q: 4 },
envelope: { attack: 0.15, decay: 0.6, sustain: 0.05, release: 0.3 },
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: [
{ type: 'noise', params: { type: 'white' } },
],
filter: { type: 'lowpass', frequency: 1200, Q: 1 },
lfo: { frequency: 0.3, type: 'sine', min: 500, max: 2500, target: 'frequency' },
envelope: { attack: 0.2, decay: 0.5, sustain: 0.3, release: 0.4 },
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: [
{ type: 'noise', params: { type: 'white' } },
],
envelope: { attack: 0.01, decay: 0.06, sustain: 0, release: 0.02 },
triggerPattern: { interval: 0.15, count: 1 },
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: [
{ type: 'noise', params: { type: 'white' } },
],
effects: [
{ type: 'distortion', distortion: 0.75, wet: 0.7 },
],
envelope: { attack: 0.08, decay: 0.6, sustain: 0.1, release: 0.25 },
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: [
{ type: 'noise', params: { type: 'pink' } },
],
lfo: { frequency: 1.5, type: 'square', min: 0.2, max: 1, target: 'amplitude' },
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: [
{ type: 'noise', params: { type: 'brown' } },
],
filter: { type: 'lowpass', frequency: 900, Q: 0.8 },
effects: [
{ type: 'delay', delayTime: 0.4, feedback: 0.45, wet: 0.6 },
{ type: 'reverb', decay: 4.5, wet: 0.7 },
],
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: [
{ type: 'noise', params: { type: 'white' } },
{ type: 'noise', params: { type: 'pink' } },
{ type: 'noise', params: { type: 'brown' } },
{ type: 'noise', params: { type: 'white' } },
],
filter: { type: 'bandpass', frequency: 2800, Q: 3.5 },
lfo: [
{ frequency: 0.35, type: 'sine', min: 600, max: 2200, target: 'frequency' },
{ frequency: 1.2, type: 'square', min: 0.1, max: 0.9, target: 'amplitude' },
],
effects: [
{ type: 'delay', delayTime: 0.35, feedback: 0.5, wet: 0.5 },
{ type: 'reverb', decay: 3.5, wet: 0.55 },
],
envelope: { attack: 0.12, decay: 0.4, sustain: 0.2, release: 0.3 },
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;
},
},
],
},
],
};

553
src/game/levels/world9.js Normal file
View File

@@ -0,0 +1,553 @@
/**
* 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: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220, detune: 0 } },
],
filter: { type: 'lowpass', frequency: 4000, Q: 1.2 },
envelope: { attack: 0.05, decay: 0.3, sustain: 0.4, release: 0.2 },
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: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 165, detune: 0 } },
],
filter: { type: 'lowpass', frequency: 2500, Q: 6 },
lfo: { frequency: 0.8, type: 'sine', min: 1000, max: 4500, target: 'frequency' },
envelope: { attack: 0.08, decay: 0.4, sustain: 0.3, release: 0.25 },
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: [
{ type: 'oscillator', params: { waveform: 'square', frequency: 330, detune: 0 } },
],
filter: { type: 'lowpass', frequency: 1800, Q: 2 },
envelope: { attack: 0.01, decay: 0.35, sustain: 0.1, release: 0.15 },
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: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: 0 } },
],
filter: { type: 'lowpass', frequency: 800, Q: 9 },
envelope: { attack: 0.02, decay: 0.25, sustain: 0.05, release: 0.15 },
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: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 0 } },
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 5 } },
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: -7 } },
],
filter: { type: 'lowpass', frequency: 3500, Q: 0.9 },
envelope: { attack: 0.08, decay: 0.8, sustain: 0.5, release: 0.4 },
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: [
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: 0 } },
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: 4 } },
],
filter: { type: 'lowpass', frequency: 3000, Q: 1.5 },
lfo: { frequency: 0.6, type: 'sine', min: 2500, max: 4500, target: 'frequency' },
envelope: { attack: 0.06, decay: 0.35, sustain: 0.35, release: 0.2 },
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: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 130, detune: 0 } },
],
filter: { type: 'lowpass', frequency: 2000, Q: 2 },
lfo: { frequency: 1, type: 'sine', min: 500, max: 5000, target: 'frequency' },
envelope: { attack: 0.07, decay: 0.5, sustain: 0.2, release: 0.25 },
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: [
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 0 } },
{ type: 'oscillator', params: { waveform: 'square', frequency: 110, detune: 3 } },
],
filter: { type: 'lowpass', frequency: 3000, Q: 6 },
lfo: { frequency: 0.5, type: 'sine', min: 1000, max: 4000, target: 'frequency' },
effects: [
{ type: 'reverb', decay: 2.5, wet: 0.4 },
],
envelope: { attack: 0.08, decay: 0.5, sustain: 0.3, release: 0.3 },
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;
},
},
],
},
],
};

View File

@@ -1,12 +1,18 @@
/** /**
* targetAudio.js — Plays the "target" sound for a puzzle level * targetAudio.js — Plays the "target" sound for a puzzle level
* Builds a temporary Tone.js graph from the level's target config * Builds a temporary Tone.js graph from the level's target config
*
* Extended to support:
* - Envelopes (amplitude shaping)
* - LFO (modulation)
* - Effects (delay, reverb, distortion)
*/ */
import * as Tone from 'tone'; import * as Tone from 'tone';
let _activeNodes = []; let _activeNodes = [];
let _isPlaying = false; let _isPlaying = false;
let _stopTimeout = null; let _stopTimeout = null;
let _loops = []; // Track Tone.Loop instances for cleanup
export function isTargetPlaying() { export function isTargetPlaying() {
return _isPlaying; return _isPlaying;
@@ -25,7 +31,88 @@ export async function playTarget(target) {
const output = new Tone.Gain(0.5).toDestination(); const output = new Tone.Gain(0.5).toDestination();
nodes.push(output); nodes.push(output);
// Build oscillators from target.build // Build effects chain (will connect to this)
let effectChain = output;
// Effects array (in order: distortion → delay → reverb)
if (target.effects && target.effects.length > 0) {
const effectNodes = [];
for (const effect of target.effects) {
if (effect.type === 'distortion') {
const distortion = new Tone.Distortion(effect.amount ?? 0.4);
effectNodes.push(distortion);
} else if (effect.type === 'delay') {
const delay = new Tone.Delay(effect.time ?? 0.3);
delay.feedback.value = effect.feedback ?? 0.3;
delay.wet.value = effect.wet ?? 0.5;
effectNodes.push(delay);
} else if (effect.type === 'reverb') {
const reverb = new Tone.Reverb(effect.decay ?? 2.5);
reverb.wet.value = effect.wet ?? 0.5;
effectNodes.push(reverb);
}
}
// Chain effects together, then to output
if (effectNodes.length > 0) {
for (let i = 0; i < effectNodes.length - 1; i++) {
effectNodes[i].connect(effectNodes[i + 1]);
}
effectNodes[effectNodes.length - 1].connect(output);
effectChain = effectNodes[0];
nodes.push(...effectNodes);
}
}
// Optional filter in the chain
let destination = effectChain;
if (target.filter) {
const filter = new Tone.Filter({
type: target.filter.type || 'lowpass',
frequency: target.filter.frequency || 1000,
Q: target.filter.Q || 1,
});
filter.connect(effectChain);
destination = filter;
nodes.push(filter);
}
// Optional envelope
let envelope = null;
if (target.envelope) {
envelope = new Tone.AmplitudeEnvelope({
attack: target.envelope.attack ?? 0.01,
decay: target.envelope.decay ?? 0.1,
sustain: target.envelope.sustain ?? 0.3,
release: target.envelope.release ?? 0.5,
});
envelope.connect(destination);
destination = envelope;
nodes.push(envelope);
}
// Optional LFO for modulation
let lfo = null;
if (target.lfo) {
lfo = new Tone.LFO({
frequency: target.lfo.frequency ?? 5,
type: target.lfo.type ?? 'sine',
min: target.lfo.min ?? 0.5,
max: target.lfo.max ?? 1.5,
});
// Route LFO to the specified target
if (target.lfo.target === 'amplitude' && envelope) {
lfo.connect(envelope.gain);
} else if (target.lfo.target === 'frequency' && target.build.length > 0) {
// LFO will be connected to oscillators below
}
lfo.start();
nodes.push(lfo);
}
// Build oscillators / noise from target.build
for (const spec of target.build) { for (const spec of target.build) {
if (spec.type === 'oscillator') { if (spec.type === 'oscillator') {
const osc = new Tone.Oscillator({ const osc = new Tone.Oscillator({
@@ -33,12 +120,44 @@ export async function playTarget(target) {
frequency: spec.params.frequency || 440, frequency: spec.params.frequency || 440,
detune: spec.params.detune || 0, detune: spec.params.detune || 0,
}); });
osc.connect(output); osc.connect(destination);
// Connect LFO to frequency if specified
if (lfo && target.lfo?.target === 'frequency') {
lfo.connect(osc.frequency);
}
osc.start(); osc.start();
nodes.push(osc); nodes.push(osc);
} else if (spec.type === 'noise') {
const noise = new Tone.Noise(spec.params.type || 'white');
noise.connect(destination);
noise.start();
nodes.push(noise);
} }
} }
// Handle envelope retriggering with triggerPattern
if (envelope && target.triggerPattern) {
const pattern = target.triggerPattern;
const interval = pattern.interval ?? 0.5;
const count = pattern.count ?? Math.ceil((target.duration || 2) / interval);
const loop = new Tone.Loop((time) => {
envelope.triggerAttackRelease(
target.envelope.attack + target.envelope.decay + target.envelope.release,
time
);
}, interval);
loop.start(0);
nodes.push(loop);
_loops.push(loop);
} else if (envelope) {
// Single trigger if no pattern
envelope.triggerAttack();
}
_activeNodes = nodes; _activeNodes = nodes;
// Auto-stop after duration // Auto-stop after duration
@@ -51,6 +170,17 @@ export function stopTarget() {
clearTimeout(_stopTimeout); clearTimeout(_stopTimeout);
_stopTimeout = null; _stopTimeout = null;
} }
// Stop and cleanup loops
for (const loop of _loops) {
try {
loop.stop();
loop.dispose();
} catch {}
}
_loops = [];
// Stop and cleanup nodes
for (const node of _activeNodes) { for (const node of _activeNodes) {
try { try {
if (node.stop) node.stop(); if (node.stop) node.stop();

14
src/hooks/useIsMobile.js Normal file
View File

@@ -0,0 +1,14 @@
import { useState, useEffect } from 'react';
export function useIsMobile(breakpoint = 768) {
const [isMobile, setIsMobile] = useState(() => window.innerWidth <= breakpoint);
useEffect(() => {
const mql = window.matchMedia(`(max-width: ${breakpoint}px)`);
const handler = (e) => setIsMobile(e.matches);
mql.addEventListener('change', handler);
return () => mql.removeEventListener('change', handler);
}, [breakpoint]);
return isMobile;
}

48
src/hooks/usePinchZoom.js Normal file
View File

@@ -0,0 +1,48 @@
import { useRef, useEffect } from 'react';
export function usePinchZoom(containerRef, getZoom, setZoom) {
const pinchRef = useRef({ active: false, startDist: 0, startZoom: 1 });
useEffect(() => {
const el = containerRef.current;
if (!el) return;
const getDistance = (t1, t2) =>
Math.sqrt((t1.clientX - t2.clientX) ** 2 + (t1.clientY - t2.clientY) ** 2);
const onTouchStart = (e) => {
if (e.touches.length === 2) {
e.preventDefault();
pinchRef.current = {
active: true,
startDist: getDistance(e.touches[0], e.touches[1]),
startZoom: getZoom(),
};
}
};
const onTouchMove = (e) => {
if (pinchRef.current.active && e.touches.length === 2) {
e.preventDefault();
const dist = getDistance(e.touches[0], e.touches[1]);
const scale = dist / pinchRef.current.startDist;
const newZoom = Math.max(0.3, Math.min(3, pinchRef.current.startZoom * scale));
setZoom(newZoom);
}
};
const onTouchEnd = () => {
pinchRef.current.active = false;
};
el.addEventListener('touchstart', onTouchStart, { passive: false });
el.addEventListener('touchmove', onTouchMove, { passive: false });
el.addEventListener('touchend', onTouchEnd);
return () => {
el.removeEventListener('touchstart', onTouchStart);
el.removeEventListener('touchmove', onTouchMove);
el.removeEventListener('touchend', onTouchEnd);
};
}, [containerRef, getZoom, setZoom]);
}

View File

@@ -28,9 +28,16 @@ html, body, #root {
width: 100%; height: 100%; overflow: hidden; width: 100%; height: 100%; overflow: hidden;
background: var(--bg); color: var(--text); background: var(--bg); color: var(--text);
font-family: 'Inter', 'SF Pro', -apple-system, system-ui, sans-serif; font-family: 'Inter', 'SF Pro', -apple-system, system-ui, sans-serif;
font-size: 12px; font-size: 13px;
-webkit-font-smoothing: antialiased; -webkit-font-smoothing: antialiased;
touch-action: pan-x pan-y;
-ms-touch-action: pan-x pan-y;
} }
/* Block native browser zoom gestures globally */
html { touch-action: manipulation; }
/* Prevent text selection globally (except inputs/textareas) */
* { -webkit-user-select: none; user-select: none; }
input, textarea, [contenteditable] { -webkit-user-select: text; user-select: text; }
/* ===== Layout ===== */ /* ===== Layout ===== */
.app { display: flex; flex-direction: column; height: 100vh; } .app { display: flex; flex-direction: column; height: 100vh; }
@@ -89,7 +96,7 @@ html, body, #root {
/* ===== Modules ===== */ /* ===== Modules ===== */
.module { .module {
position: absolute; width: 180px; min-width: 180px; position: absolute; width: 200px; min-width: 200px;
background: var(--surface); border: 1px solid var(--border); background: var(--surface); border: 1px solid var(--border);
border-radius: 8px; user-select: none; z-index: 2; border-radius: 8px; user-select: none; z-index: 2;
box-shadow: 0 4px 16px rgba(0,0,0,0.4); box-shadow: 0 4px 16px rgba(0,0,0,0.4);
@@ -100,13 +107,13 @@ html, body, #root {
.module-header { .module-header {
display: flex; align-items: center; gap: 6px; display: flex; align-items: center; gap: 6px;
padding: 6px 10px; border-bottom: 1px solid var(--border); padding: 8px 12px; border-bottom: 1px solid var(--border);
cursor: grab; border-radius: 8px 8px 0 0; cursor: grab; border-radius: 8px 8px 0 0;
background: var(--surface2); background: var(--surface2);
} }
.module-header .type-icon { font-size: 14px; } .module-header .type-icon { font-size: 16px; }
.module-header .type-name { .module-header .type-name {
font-size: 11px; font-weight: 600; text-transform: uppercase; font-size: 12px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.5px; color: var(--text); letter-spacing: 0.5px; color: var(--text);
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
} }
@@ -117,18 +124,26 @@ html, body, #root {
} }
.module-header .close-btn:hover { background: var(--red); color: #fff; } .module-header .close-btn:hover { background: var(--red); color: #fff; }
.module-body { padding: 8px 10px; display: flex; flex-direction: column; gap: 6px; } .module-header .expand-btn {
width: 18px; height: 18px; border: none; background: transparent;
color: var(--text2); cursor: pointer; font-size: 13px; border-radius: 3px;
display: flex; align-items: center; justify-content: center;
margin-left: auto;
}
.module-header .expand-btn:hover { background: var(--accent); color: #000; }
.module-body { padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
/* Ports */ /* Ports */
.port-row { .port-row {
display: flex; align-items: center; gap: 6px; display: flex; align-items: center; gap: 8px;
position: relative; height: 20px; position: relative; height: 24px;
} }
.port-row.input { flex-direction: row; } .port-row.input { flex-direction: row; }
.port-row.output { flex-direction: row-reverse; } .port-row.output { flex-direction: row-reverse; }
.port-dot { .port-dot {
width: 12px; height: 12px; border-radius: 50%; width: 14px; height: 14px; border-radius: 50%;
border: 2px solid var(--border); background: var(--surface); border: 2px solid var(--border); background: var(--surface);
cursor: pointer; flex-shrink: 0; transition: all 0.15s; cursor: pointer; flex-shrink: 0; transition: all 0.15s;
position: relative; z-index: 5; position: relative; z-index: 5;
@@ -149,25 +164,58 @@ html, body, #root {
} }
.port-label { .port-label {
font-size: 10px; color: var(--text2); text-transform: uppercase; font-size: 11px; color: var(--text2); text-transform: uppercase;
letter-spacing: 0.3px; white-space: nowrap; letter-spacing: 0.3px; white-space: nowrap;
} }
/* Knobs */ /* Knobs */
.param-row { .param-row {
display: flex; align-items: center; gap: 6px; display: flex; align-items: center; gap: 8px;
} }
.param-label { .param-label {
font-size: 10px; color: var(--text2); width: 48px; font-size: 11px; color: var(--text2); width: 50px;
text-transform: uppercase; letter-spacing: 0.3px; flex-shrink: 0; text-transform: uppercase; letter-spacing: 0.3px; flex-shrink: 0;
} }
.knob-container { position: relative; width: 32px; height: 32px; flex-shrink: 0; } .knob-container { position: relative; width: 36px; height: 36px; flex-shrink: 0; }
.knob-svg { width: 32px; height: 32px; cursor: pointer; } .knob-svg { width: 36px; height: 36px; cursor: pointer; }
.knob-track { fill: none; stroke: var(--knob-track); stroke-width: 3; stroke-linecap: round; } .knob-track { fill: none; stroke: var(--knob-track); stroke-width: 3; stroke-linecap: round; }
.knob-fill { fill: none; stroke-width: 3; stroke-linecap: round; } .knob-fill { fill: none; stroke-width: 3; stroke-linecap: round; }
.knob-dot { fill: var(--text); } .knob-dot { fill: var(--text); }
/* Modulation indicator: pulsing ring around modulated knobs */
.knob-mod-ring {
fill: none;
stroke-width: 1.5;
stroke-dasharray: 3 2;
opacity: 0.7;
animation: knob-mod-pulse 1.2s ease-in-out infinite alternate, knob-mod-spin 4s linear infinite;
}
@keyframes knob-mod-pulse {
from { opacity: 0.3; stroke-width: 1; }
to { opacity: 0.9; stroke-width: 2; }
}
@keyframes knob-mod-spin {
from { stroke-dashoffset: 0; }
to { stroke-dashoffset: 30; }
}
.knob-modulated .param-label,
.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-editing { display: flex; align-items: center; justify-content: center; }
.knob-input { .knob-input {
width: 48px; height: 22px; padding: 0 4px; width: 48px; height: 22px; padding: 0 4px;
@@ -178,8 +226,8 @@ html, body, #root {
.knob-input:focus { box-shadow: 0 0 6px rgba(0,229,255,0.3); } .knob-input:focus { box-shadow: 0 0 6px rgba(0,229,255,0.3); }
.param-value { .param-value {
font-size: 10px; color: var(--accent); font-family: 'JetBrains Mono', monospace; font-size: 11px; color: var(--accent); font-family: 'JetBrains Mono', monospace;
min-width: 40px; text-align: right; min-width: 45px; text-align: right;
} }
/* Select param */ /* Select param */
@@ -192,7 +240,7 @@ html, body, #root {
/* Scope canvas */ /* Scope canvas */
.scope-canvas { .scope-canvas {
width: 100%; height: 60px; border-radius: 4px; width: 100%; height: 70px; border-radius: 4px;
background: #050510; border: 1px solid var(--border); background: #050510; border: 1px solid var(--border);
} }
@@ -278,11 +326,11 @@ html, body, #root {
width: 56px; height: 56px; display: flex; align-items: center; justify-content: center; width: 56px; height: 56px; display: flex; align-items: center; justify-content: center;
border: 2px solid var(--accent); border-radius: 12px; background: rgba(0,229,255,0.05); border: 2px solid var(--accent); border-radius: 12px; background: rgba(0,229,255,0.05);
} }
.gm-title { font-size: 22px; font-weight: 800; color: var(--text); letter-spacing: 1px; } .gm-title { font-size: 24px; font-weight: 800; color: var(--text); letter-spacing: 1px; }
.gm-tagline { font-size: 12px; color: var(--text2); margin-top: 2px; } .gm-tagline { font-size: 13px; color: var(--text2); margin-top: 2px; }
.gm-header-right { display: flex; align-items: center; gap: 16px; } .gm-header-right { display: flex; align-items: center; gap: 16px; }
.gm-total-stars { font-size: 16px; color: var(--yellow); font-weight: 700; } .gm-total-stars { font-size: 18px; color: var(--yellow); font-weight: 700; }
.gm-sandbox-btn { .gm-sandbox-btn {
padding: 8px 16px; border: 1px solid var(--border); border-radius: 6px; padding: 8px 16px; border: 1px solid var(--border); border-radius: 6px;
background: var(--surface); color: var(--text2); cursor: pointer; background: var(--surface); color: var(--text2); cursor: pointer;
@@ -290,25 +338,47 @@ html, body, #root {
} }
.gm-sandbox-btn:hover { border-color: var(--accent); color: var(--text); } .gm-sandbox-btn:hover { border-color: var(--accent); color: var(--text); }
/* Level search bar */
.gm-search-bar {
display: flex; align-items: center; gap: 8px;
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
padding: 8px 14px; margin-bottom: 24px; transition: border-color 0.15s;
}
.gm-search-bar:focus-within { border-color: var(--accent); }
.gm-search-icon { font-size: 14px; opacity: 0.5; flex-shrink: 0; }
.gm-search-input {
flex: 1; background: none; border: none; outline: none;
color: var(--text); font-size: 13px; font-family: inherit;
}
.gm-search-input::placeholder { color: var(--text2); }
.gm-search-clear {
background: none; border: none; color: var(--text2); cursor: pointer;
font-size: 14px; padding: 2px 4px; border-radius: 4px;
}
.gm-search-clear:hover { color: var(--text); background: var(--surface2); }
.gm-search-results { margin-bottom: 24px; }
.gm-search-empty { color: var(--text2); font-size: 13px; padding: 16px 0; text-align: center; }
.gm-search-count { color: var(--text2); font-size: 11px; margin-bottom: 12px; }
/* World sections */ /* World sections */
.gm-world-section { margin-bottom: 32px; } .gm-world-section { margin-bottom: 32px; }
.gm-locked-world { opacity: 0.4; } .gm-locked-world { opacity: 0.4; }
.gm-world-header { .gm-world-header {
display: flex; align-items: center; gap: 12px; margin-bottom: 16px; display: flex; align-items: center; gap: 12px; margin-bottom: 16px;
} }
.gm-world-icon { font-size: 28px; } .gm-world-icon { font-size: 32px; }
.gm-world-name { font-size: 16px; font-weight: 700; color: var(--text); } .gm-world-name { font-size: 18px; font-weight: 700; color: var(--text); }
.gm-world-sub { font-size: 11px; color: var(--text2); margin-top: 2px; } .gm-world-sub { font-size: 12px; color: var(--text2); margin-top: 2px; }
/* Level grid */ /* Level grid */
.gm-level-grid { .gm-level-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
gap: 12px; gap: 14px;
} }
.gm-level-card { .gm-level-card {
display: flex; align-items: center; gap: 12px; display: flex; align-items: center; gap: 14px;
padding: 14px 16px; border-radius: 10px; cursor: pointer; padding: 16px 18px; border-radius: 10px; cursor: pointer;
background: var(--surface); border: 1px solid var(--border); background: var(--surface); border: 1px solid var(--border);
transition: all 0.2s; position: relative; overflow: hidden; transition: all 0.2s; position: relative; overflow: hidden;
} }
@@ -322,18 +392,18 @@ html, body, #root {
.gm-level-card.perfect { border-color: var(--green); } .gm-level-card.perfect { border-color: var(--green); }
.gm-level-number { .gm-level-number {
width: 36px; height: 36px; border-radius: 50%; width: 42px; height: 42px; border-radius: 50%;
background: var(--surface2); display: flex; align-items: center; justify-content: center; background: var(--surface2); display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 14px; color: var(--accent); flex-shrink: 0; font-weight: 800; font-size: 16px; color: var(--accent); flex-shrink: 0;
border: 1px solid var(--border); border: 1px solid var(--border);
} }
.gm-level-info { flex: 1; min-width: 0; } .gm-level-info { flex: 1; min-width: 0; }
.gm-level-title { font-size: 13px; font-weight: 600; color: var(--text); } .gm-level-title { font-size: 14px; font-weight: 600; color: var(--text); }
.gm-level-subtitle { font-size: 10px; color: var(--text2); margin-top: 2px; } .gm-level-subtitle { font-size: 11px; color: var(--text2); margin-top: 2px; }
.gm-stars { display: flex; gap: 2px; } .gm-stars { display: flex; gap: 3px; }
.gm-stars .star { font-size: 16px; } .gm-stars .star { font-size: 18px; }
.gm-stars .star.filled { color: var(--yellow); } .gm-stars .star.filled { color: var(--yellow); }
.gm-stars .star.empty { color: var(--border); } .gm-stars .star.empty { color: var(--border); }
@@ -357,14 +427,14 @@ html, body, #root {
font-size: 10px; color: var(--text2); background: var(--surface); font-size: 10px; color: var(--text2); background: var(--surface);
padding: 2px 8px; border-radius: 4px; font-weight: 600; padding: 2px 8px; border-radius: 4px; font-weight: 600;
} }
.gm-puzzle-name { font-size: 14px; font-weight: 700; color: var(--text); } .gm-puzzle-name { font-size: 15px; font-weight: 700; color: var(--text); }
.gm-puzzle-actions { margin-left: auto; display: flex; gap: 8px; } .gm-puzzle-actions { margin-left: auto; display: flex; gap: 8px; }
/* Buttons */ /* Buttons */
.gm-btn { .gm-btn {
padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px; padding: 7px 16px; border: 1px solid var(--border); border-radius: 6px;
background: var(--surface); color: var(--text); cursor: pointer; background: var(--surface); color: var(--text); cursor: pointer;
font-size: 12px; font-weight: 600; font-family: inherit; transition: all 0.15s; font-size: 13px; font-weight: 600; font-family: inherit; transition: all 0.15s;
white-space: nowrap; white-space: nowrap;
} }
.gm-btn:hover { border-color: var(--accent); } .gm-btn:hover { border-color: var(--accent); }
@@ -381,13 +451,16 @@ html, body, #root {
.gm-btn.danger:hover { background: rgba(255,68,102,0.1); } .gm-btn.danger:hover { background: rgba(255,68,102,0.1); }
/* Puzzle layout */ /* Puzzle layout */
.gm-puzzle-content { flex: 1; display: flex; overflow: hidden; } .gm-puzzle-content { flex: 1; display: flex; overflow: hidden; min-height: 0; }
.gm-puzzle-sidebar { .gm-puzzle-sidebar {
width: 280px; flex-shrink: 0; background: var(--panel); width: 320px; flex-shrink: 0; background: var(--panel);
border-right: 1px solid var(--border); overflow-y: auto; border-right: 1px solid var(--border); overflow-y: auto;
padding: 12px; display: flex; flex-direction: column; gap: 12px; padding: 14px; display: flex; flex-direction: column; gap: 14px;
min-height: 0; /* Allow flex item to shrink below content — enables scrolling */
} }
/* Prevent sidebar children from shrinking — forces overflow → scroll */
.gm-puzzle-sidebar > * { flex-shrink: 0; }
.gm-puzzle-canvas-wrap { .gm-puzzle-canvas-wrap {
flex: 1; position: relative; overflow: hidden; flex: 1; position: relative; overflow: hidden;
@@ -399,11 +472,11 @@ html, body, #root {
overflow: hidden; overflow: hidden;
} }
.gm-concept-header { .gm-concept-header {
padding: 10px 12px; cursor: pointer; display: flex; justify-content: space-between; padding: 12px 14px; cursor: pointer; display: flex; justify-content: space-between;
align-items: center; font-size: 12px; font-weight: 600; color: var(--yellow); align-items: center; font-size: 13px; font-weight: 600; color: var(--yellow);
} }
.gm-concept-body { padding: 0 12px 12px; } .gm-concept-body { padding: 0 14px 14px; }
.gm-concept-desc { font-size: 11px; color: var(--text); line-height: 1.5; margin-bottom: 8px; } .gm-concept-desc { font-size: 12px; color: var(--text); line-height: 1.5; margin-bottom: 8px; }
.gm-concept-tip { .gm-concept-tip {
font-size: 10px; color: var(--text2); line-height: 1.5; font-size: 10px; color: var(--text2); line-height: 1.5;
padding: 8px; background: var(--bg); border-radius: 4px; padding: 8px; background: var(--bg); border-radius: 4px;
@@ -413,15 +486,15 @@ html, body, #root {
/* Objectives */ /* Objectives */
.gm-objectives { .gm-objectives {
background: var(--surface); border: 1px solid var(--border); border-radius: 8px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
padding: 10px 12px; padding: 12px 14px;
} }
.gm-obj-title { .gm-obj-title {
font-size: 10px; font-weight: 700; color: var(--text2); font-size: 11px; font-weight: 700; color: var(--text2);
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px;
} }
.gm-obj { .gm-obj {
display: flex; align-items: center; gap: 8px; padding: 6px 0; display: flex; align-items: center; gap: 10px; padding: 8px 0;
border-bottom: 1px solid var(--border); font-size: 11px; border-bottom: 1px solid var(--border); font-size: 12px;
} }
.gm-obj:last-child { border-bottom: none; } .gm-obj:last-child { border-bottom: none; }
.gm-obj-star { color: var(--yellow); font-size: 12px; flex-shrink: 0; width: 30px; } .gm-obj-star { color: var(--yellow); font-size: 12px; flex-shrink: 0; width: 30px; }
@@ -441,14 +514,14 @@ html, body, #root {
/* Hint panel */ /* Hint panel */
.gm-hint-panel { } .gm-hint-panel { }
.gm-hint-btn { .gm-hint-btn {
width: 100%; display: flex; align-items: center; gap: 8px; width: 100%; display: flex; align-items: center; gap: 10px;
padding: 10px 12px; border: 1px dashed var(--yellow); border-radius: 8px; padding: 12px 14px; border: 1px dashed var(--yellow); border-radius: 8px;
background: rgba(255,204,0,0.04); cursor: pointer; background: rgba(255,204,0,0.04); cursor: pointer;
font-family: inherit; transition: all 0.15s; font-family: inherit; transition: all 0.15s;
} }
.gm-hint-btn:hover { background: rgba(255,204,0,0.1); border-style: solid; } .gm-hint-btn:hover { background: rgba(255,204,0,0.1); border-style: solid; }
.gm-hint-icon { font-size: 16px; } .gm-hint-icon { font-size: 18px; }
.gm-hint-label { font-size: 12px; font-weight: 600; color: var(--yellow); flex: 1; text-align: left; } .gm-hint-label { font-size: 13px; font-weight: 600; color: var(--yellow); flex: 1; text-align: left; }
.gm-hint-penalty { .gm-hint-penalty {
font-size: 9px; color: var(--red); background: rgba(255,68,102,0.15); font-size: 9px; color: var(--red); background: rgba(255,68,102,0.15);
padding: 2px 6px; border-radius: 3px; font-weight: 700; padding: 2px 6px; border-radius: 3px; font-weight: 700;
@@ -459,16 +532,16 @@ html, body, #root {
overflow: hidden; overflow: hidden;
} }
.gm-hint-header { .gm-hint-header {
padding: 8px 12px; display: flex; justify-content: space-between; align-items: center; padding: 10px 14px; display: flex; justify-content: space-between; align-items: center;
font-size: 12px; font-weight: 600; color: var(--yellow); font-size: 13px; font-weight: 600; color: var(--yellow);
background: rgba(255,204,0,0.06); background: rgba(255,204,0,0.06);
} }
.gm-hint-penalty-tag { .gm-hint-penalty-tag {
font-size: 9px; color: var(--red); background: rgba(255,68,102,0.15); font-size: 10px; color: var(--red); background: rgba(255,68,102,0.15);
padding: 2px 6px; border-radius: 3px; font-weight: 700; padding: 3px 7px; border-radius: 3px; font-weight: 700;
} }
.gm-hint-text { .gm-hint-text {
padding: 8px 12px 12px; font-size: 11px; color: var(--text); line-height: 1.5; padding: 10px 14px 14px; font-size: 12px; color: var(--text); line-height: 1.5;
} }
.gm-hint-penalty-msg { .gm-hint-penalty-msg {
@@ -482,24 +555,24 @@ html, body, #root {
/* Module palette (game) */ /* Module palette (game) */
.gm-module-palette { .gm-module-palette {
background: var(--surface); border: 1px solid var(--border); border-radius: 8px; background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
padding: 10px 12px; padding: 12px 14px;
} }
.gm-palette-title { .gm-palette-title {
font-size: 10px; font-weight: 700; color: var(--text2); font-size: 11px; font-weight: 700; color: var(--text2);
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px;
} }
.gm-palette-item { .gm-palette-item {
display: flex; align-items: center; gap: 8px; display: flex; align-items: center; gap: 10px;
padding: 8px; border-radius: 6px; cursor: pointer; padding: 10px; border-radius: 6px; cursor: pointer;
transition: all 0.15s; font-size: 12px; color: var(--text); transition: all 0.15s; font-size: 13px; color: var(--text);
} }
.gm-palette-item:hover { background: var(--surface2); } .gm-palette-item:hover { background: var(--surface2); }
.gm-palette-icon { font-size: 16px; width: 24px; text-align: center; } .gm-palette-icon { font-size: 18px; width: 28px; text-align: center; }
.gm-palette-name { flex: 1; font-weight: 500; } .gm-palette-name { flex: 1; font-weight: 500; }
.gm-palette-add { .gm-palette-add {
width: 22px; height: 22px; border-radius: 50%; width: 26px; height: 26px; border-radius: 50%;
background: var(--surface2); display: flex; align-items: center; justify-content: center; background: var(--surface2); display: flex; align-items: center; justify-content: center;
font-size: 14px; color: var(--accent); font-weight: 700; font-size: 16px; color: var(--accent); font-weight: 700;
} }
/* Canvas hint */ /* Canvas hint */
@@ -566,3 +639,527 @@ html, body, #root {
.gm-check-star { color: var(--yellow); } .gm-check-star { color: var(--yellow); }
.gm-complete-actions { display: flex; gap: 8px; justify-content: center; } .gm-complete-actions { display: flex; gap: 8px; justify-content: center; }
/* World stars counter */
.gm-world-stars {
display: flex; align-items: center; gap: 4px;
font-size: 14px; color: var(--text2);
background: var(--surface); border-radius: 12px; padding: 4px 10px;
}
.gm-world-stars .star.filled { color: var(--yellow); }
/* ===== Zoom Controls (Google Maps style) ===== */
.zoom-controls {
position: absolute;
top: 12px;
right: 12px;
z-index: 50;
display: flex;
flex-direction: column;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
}
.zoom-btn {
width: 36px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
color: var(--text);
border: none;
cursor: pointer;
font-size: 18px;
font-weight: 600;
transition: background 0.15s;
}
.zoom-btn:hover {
background: var(--surface2);
}
.zoom-btn:active {
background: var(--border);
}
.zoom-btn.zoom-label {
font-size: 10px;
font-weight: 500;
color: var(--text2);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
height: 26px;
width: 36px;
}
/* Zoom positioning inside puzzle canvas */
.gm-puzzle-canvas-wrap .zoom-controls {
top: 12px;
right: 12px;
}
/* Position zoom inside sandbox main-area */
.main-area .zoom-controls {
top: 12px;
right: 12px;
}
/* ===== Admin Panel ===== */
.gm-admin-btn {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text2);
padding: 6px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
transition: all 0.15s;
}
.gm-admin-btn:hover { background: var(--surface2); color: var(--text); }
.admin-overlay {
position: fixed; inset: 0; z-index: 1000;
background: rgba(0,0,0,0.7);
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px);
}
.admin-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
width: 90%; max-width: 700px; max-height: 85vh;
overflow-y: auto;
padding: 20px;
}
.admin-header {
display: flex; align-items: center; gap: 12px;
margin-bottom: 16px; padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.admin-header h2 { font-size: 18px; color: var(--yellow); flex: 1; }
.admin-total { color: var(--yellow); font-size: 14px; font-weight: 600; }
.admin-close {
background: none; border: none; color: var(--text2);
cursor: pointer; font-size: 18px; padding: 4px 8px;
}
.admin-close:hover { color: var(--text); }
.admin-actions {
display: flex; gap: 8px; margin-bottom: 16px;
}
.admin-action-btn {
padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border);
background: var(--surface); color: var(--text); cursor: pointer;
font-size: 12px; font-weight: 500; transition: all 0.15s;
}
.admin-action-btn.gold { border-color: var(--yellow); color: var(--yellow); }
.admin-action-btn.gold:hover { background: var(--yellow); color: var(--bg); }
.admin-action-btn.active { border-color: var(--green); color: var(--green); background: rgba(68, 255, 136, 0.1); }
.admin-action-btn.active:hover { background: var(--green); color: var(--bg); }
.admin-action-btn.danger { border-color: var(--red); color: var(--red); }
.admin-action-btn.danger:hover { background: var(--red); color: #fff; }
/* Admin auto-solve button in puzzle bar */
.gm-btn.admin-solve {
background: rgba(170, 85, 255, 0.15); border-color: var(--purple); color: var(--purple);
}
.gm-btn.admin-solve:hover { background: var(--purple); color: #fff; }
.admin-world { margin-bottom: 16px; }
.admin-world-header {
display: flex; align-items: center; gap: 8px;
padding: 8px 0; border-bottom: 1px solid var(--border);
margin-bottom: 6px;
}
.admin-world-icon { font-size: 18px; }
.admin-world-name { flex: 1; font-size: 13px; font-weight: 600; color: var(--text); }
.admin-world-stars { font-size: 12px; color: var(--yellow); }
.admin-unlock-btn {
padding: 3px 10px; border-radius: 4px; border: 1px solid var(--green);
background: transparent; color: var(--green); cursor: pointer;
font-size: 11px; transition: all 0.15s;
}
.admin-unlock-btn:hover { background: var(--green); color: var(--bg); }
.admin-levels { display: flex; flex-direction: column; gap: 2px; }
.admin-level {
display: flex; align-items: center; gap: 8px;
padding: 4px 8px; border-radius: 4px;
transition: background 0.1s;
}
.admin-level:hover { background: var(--surface); }
.admin-level-num { font-size: 11px; color: var(--text2); width: 24px; }
.admin-level-name { flex: 1; font-size: 12px; color: var(--text); }
.admin-star-btns { display: flex; gap: 3px; }
.admin-star-btn {
padding: 2px 6px; border-radius: 3px; border: 1px solid var(--border);
background: transparent; cursor: pointer; font-size: 11px;
color: var(--text2); transition: all 0.1s;
}
.admin-star-btn:hover { border-color: var(--yellow); color: var(--yellow); }
.admin-star-btn.active { background: var(--yellow); color: var(--bg); border-color: var(--yellow); }
.admin-star-btn.zero { color: var(--red); }
.admin-star-btn.zero:hover { border-color: var(--red); color: var(--red); }
/* ===== Fullscreen Keyboard ===== */
.keyboard-fullscreen {
position: fixed; inset: 0; z-index: 500;
background: #050510; display: flex; flex-direction: column;
animation: fadeIn 0.2s ease-out; touch-action: none;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.keyboard-fs-header {
display: flex; align-items: center; justify-content: center;
gap: 16px; padding: 8px 16px; background: var(--panel);
border-bottom: 1px solid var(--border); flex-shrink: 0;
}
.keyboard-fs-title {
font-size: 16px; font-weight: 700; color: var(--text);
font-family: 'JetBrains Mono', monospace; letter-spacing: 1px;
}
.keyboard-fs-close {
width: 36px; height: 36px; border-radius: 8px;
background: var(--surface); border: 1px solid var(--border);
color: var(--text); font-size: 16px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.keyboard-fs-oct-btn {
width: 44px; height: 36px; border-radius: 8px;
background: var(--surface); border: 1px solid var(--border);
color: var(--accent); font-size: 16px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.keyboard-fs-oct-btn:active { background: var(--accent); color: #000; }
.keyboard-fs-keys {
flex: 1; position: relative; display: flex;
touch-action: none; user-select: none;
}
.keyboard-fs-white {
flex: 1; height: 100%; background: linear-gradient(to bottom, #1e1e38, #14142a);
border-right: 2px solid #0a0a18; position: relative;
display: flex; align-items: flex-end; justify-content: center;
padding-bottom: 20px; cursor: pointer; transition: background 0.05s;
}
.keyboard-fs-white.pressed, .keyboard-fs-white:active {
background: linear-gradient(to bottom, var(--accent), #0a8a9e);
}
.keyboard-fs-note-label {
font-size: 14px; font-weight: 600; color: var(--text2);
font-family: 'JetBrains Mono', monospace; pointer-events: none;
}
.keyboard-fs-white.pressed .keyboard-fs-note-label,
.keyboard-fs-white:active .keyboard-fs-note-label { color: #000; }
.keyboard-fs-black {
position: absolute; top: 0; height: 58%;
background: linear-gradient(to bottom, #0a0a16, #060610);
border: 2px solid #222; border-top: none;
border-radius: 0 0 6px 6px;
z-index: 2; cursor: pointer; transition: background 0.05s;
display: flex; align-items: flex-end; justify-content: center;
padding-bottom: 8px;
}
.keyboard-fs-black.pressed, .keyboard-fs-black:active {
background: linear-gradient(to bottom, #0088aa, #006688);
border-color: var(--accent);
}
.keyboard-fs-black-label {
font-size: 10px; font-weight: 600; color: #555;
font-family: 'JetBrains Mono', monospace; pointer-events: none;
}
.keyboard-fs-black.pressed .keyboard-fs-black-label,
.keyboard-fs-black:active .keyboard-fs-black-label { color: var(--accent); }
/* ===== Drum Pad ===== */
.drumpad-grid {
display: grid; grid-template-columns: repeat(4, 1fr);
gap: 3px; padding: 2px 0;
}
.drumpad-pad {
aspect-ratio: 1; border-radius: 4px;
display: flex; align-items: center; justify-content: center;
cursor: pointer; transition: all 0.05s; border: 1px solid var(--border);
font-size: 8px; font-weight: 600; color: var(--text2);
font-family: 'JetBrains Mono', monospace; user-select: none;
touch-action: none;
}
.drumpad-pad:active { transform: scale(0.92); }
.drumpad-pad.active { border-color: var(--accent); box-shadow: 0 0 8px rgba(0,229,255,0.3); }
.drumpad-fullscreen {
position: fixed; inset: 0; z-index: 500;
background: #050510; display: flex; flex-direction: column;
animation: fadeIn 0.2s ease-out;
}
.drumpad-fs-header {
display: flex; align-items: center; justify-content: space-between;
padding: 12px 16px; background: var(--panel); border-bottom: 1px solid var(--border);
}
.drumpad-fs-title { font-size: 14px; font-weight: 600; color: var(--text); }
.drumpad-fs-close {
width: 36px; height: 36px; border-radius: 8px;
background: var(--surface); border: 1px solid var(--border);
color: var(--text); font-size: 16px; cursor: pointer;
display: flex; align-items: center; justify-content: center;
}
.drumpad-fs-grid {
flex: 1; display: grid; grid-template-columns: repeat(4, 1fr);
gap: 8px; padding: 16px; touch-action: none;
}
.drumpad-fs-pad {
border-radius: 8px; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 4px;
cursor: pointer; transition: all 0.05s;
border: 2px solid var(--border); font-size: 12px;
font-weight: 600; color: var(--text2);
font-family: 'JetBrains Mono', monospace;
user-select: none; touch-action: none;
}
.drumpad-fs-pad:active { transform: scale(0.95); border-color: var(--accent); }
.drumpad-fs-pad .pad-label { font-size: 10px; color: var(--text2); }
/* ============================================
MOBILE RESPONSIVE — max-width: 768px
============================================ */
/* --- Bottom Sheet --- */
.bottom-sheet {
display: none;
}
/* --- Mobile Tab Bar --- */
.mobile-tab-bar {
display: none;
}
@media (max-width: 768px) {
/* --- Prevent page scroll/bounce --- */
html, body, #root { overflow: hidden; position: fixed; width: 100%; height: 100%; }
.app, .gm-puzzle { overflow: hidden; height: 100dvh; }
/* --- Sandbox Toolbar --- */
.toolbar { height: 44px; padding: 0 12px; gap: 6px; }
.toolbar-title { font-size: 13px; letter-spacing: 0.8px; }
.toolbar-sep, .toolbar .status-text,
.toolbar-btn.save-btn, .toolbar-btn.load-btn,
.toolbar-btn.export-btn, .toolbar-btn.import-btn,
.toolbar-btn.demo-btn, .toolbar-btn.clear-btn { display: none; }
.toolbar-btn.start-btn { padding: 4px 10px; font-size: 11px; }
/* Hamburger menu button (added via JS) */
.mobile-menu-btn {
padding: 6px 10px; background: var(--surface); border: 1px solid var(--border);
border-radius: 6px; color: var(--text2); cursor: pointer; font-size: 18px;
font-weight: 600; line-height: 1;
}
.mobile-menu-btn:hover { border-color: var(--accent); color: var(--text); }
.mobile-menu-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
z-index: 200; display: flex; justify-content: flex-end;
}
.mobile-menu-panel {
width: 260px; background: var(--panel); border-left: 1px solid var(--border);
padding: 16px; display: flex; flex-direction: column; gap: 6px;
animation: slideInRight 0.2s ease-out;
}
@keyframes slideInRight {
from { transform: translateX(100%); }
to { transform: translateX(0); }
}
.mobile-menu-panel .toolbar-btn {
display: flex; width: 100%; padding: 10px 14px;
font-size: 13px; text-align: left;
}
/* --- Mobile Action Bar (Sandbox) --- */
.mobile-action-bar {
display: flex; align-items: center; gap: 8px;
padding: 0 12px; height: 48px; background: var(--panel);
border-top: 1px solid var(--border); flex-shrink: 0;
}
.mobile-action-bar .start-btn-mobile {
flex: 1; padding: 8px 14px; background: var(--accent); color: #000;
border: none; border-radius: 6px; font-size: 12px; font-weight: 700;
font-family: 'JetBrains Mono', monospace; letter-spacing: 1px; cursor: pointer;
text-transform: uppercase;
}
.mobile-action-bar .start-btn-mobile.active {
background: var(--red);
}
.mobile-action-bar .action-icon-btn {
padding: 8px 12px; background: var(--surface); border: 1px solid var(--border);
border-radius: 6px; color: var(--text2); cursor: pointer; font-size: 14px;
}
.mobile-action-bar .action-icon-btn:hover { border-color: var(--accent); }
/* --- Status Bar --- */
.status-bar { display: none; }
/* --- Module Palette --- */
.palette { display: none; }
/* --- Bottom Sheet (visible on mobile) --- */
.bottom-sheet {
display: flex; flex-direction: column;
background: var(--panel); border-top: 1px solid var(--border);
border-radius: 16px 16px 0 0; flex-shrink: 0;
transition: max-height 0.3s ease;
overflow: hidden;
}
.bottom-sheet.collapsed { max-height: 42px; }
.bottom-sheet.collapsed:has(.bottom-sheet-tabs) { max-height: 76px; }
.bottom-sheet.expanded { max-height: 55vh; }
.bottom-sheet-handle {
display: flex; align-items: center; justify-content: center;
gap: 8px; padding: 10px 0 6px; cursor: pointer; min-height: 34px;
}
.bottom-sheet-handle-bar {
width: 40px; height: 4px; background: var(--border);
border-radius: 2px;
}
.bottom-sheet-peek-label {
font-size: 10px; font-weight: 600; color: var(--text2);
font-family: 'JetBrains Mono', monospace; letter-spacing: 1px;
text-transform: uppercase;
}
.bottom-sheet-tabs {
display: flex; padding: 0 16px; gap: 0;
border-bottom: 1px solid var(--border);
}
.bottom-sheet-tab {
flex: 1; padding: 8px 0; background: none; border: none;
color: var(--text2); font-size: 10px; font-weight: 700;
font-family: 'JetBrains Mono', monospace; letter-spacing: 1px;
cursor: pointer; text-align: center; position: relative;
text-transform: uppercase;
}
.bottom-sheet-tab.active { color: var(--accent); }
.bottom-sheet-tab-line {
position: absolute; bottom: 0; left: 50%; transform: translateX(-50%);
width: 100%; height: 2px; background: var(--accent); border-radius: 1px;
}
.bottom-sheet-content {
padding: 12px 16px; overflow-y: auto; flex: 1;
}
/* Module grid tiles (mobile palette) */
.mobile-module-grid {
display: grid; grid-template-columns: repeat(5, 1fr);
gap: 8px;
}
.mobile-module-tile {
display: flex; flex-direction: column; align-items: center; gap: 4px;
padding: 10px 4px; background: var(--surface); border: 1px solid var(--border);
border-radius: 8px; cursor: pointer; transition: all 0.15s;
}
.mobile-module-tile:hover, .mobile-module-tile:active {
border-color: var(--accent); background: var(--surface2);
}
.mobile-module-tile .tile-icon { font-size: 20px; }
.mobile-module-tile .tile-name {
font-size: 9px; font-weight: 600; color: var(--text2);
font-family: 'JetBrains Mono', monospace; text-transform: uppercase;
}
/* --- Canvas adjustments --- */
.node-canvas { cursor: default; touch-action: none; }
.zoom-controls { right: 8px; top: 8px; }
.zoom-btn { width: 40px; height: 36px; min-height: 44px; }
.port-dot { width: 18px; height: 18px; }
/* --- Mobile Tab Bar (visible on mobile) --- */
.mobile-tab-bar {
display: flex; align-items: center; height: 56px;
background: var(--panel); border-top: 1px solid var(--border);
flex-shrink: 0; z-index: 10;
}
.mobile-tab {
flex: 1; display: flex; flex-direction: column; align-items: center;
gap: 3px; padding: 6px 0; background: none; border: none;
cursor: pointer; color: var(--text2); transition: color 0.15s;
}
.mobile-tab.active { color: var(--accent); }
.mobile-tab-icon { font-size: 18px; }
.mobile-tab-label {
font-size: 9px; font-weight: 600; letter-spacing: 1px;
font-family: 'JetBrains Mono', monospace; text-transform: uppercase;
}
/* --- World Map Mobile --- */
.gm-worldmap { padding: 0 12px 80px; }
.gm-header { padding: 12px 0; gap: 6px; }
.gm-header-top { gap: 8px; }
.gm-logo-icon { width: 32px; height: 32px; font-size: 16px; }
.gm-title { font-size: 18px; }
.gm-tagline { display: none; }
.gm-header-actions .gm-btn { display: none; }
.gm-search-bar { margin: 0 0 12px; }
.gm-level-grid { grid-template-columns: 1fr; }
.gm-level-card { padding: 10px 12px; }
.gm-world-section { margin-bottom: 16px; }
/* --- Puzzle View Mobile --- */
.gm-puzzle-bar { height: 44px; padding: 0 10px; gap: 6px; }
.gm-puzzle-bar .gm-btn { padding: 6px 10px; }
.gm-puzzle-bar .gm-btn .btn-label { display: none; }
.gm-puzzle-name { font-size: 13px; }
.gm-puzzle-num { font-size: 9px; padding: 2px 6px; }
.gm-puzzle-sidebar { display: none; }
.gm-puzzle-canvas-wrap { width: 100%; }
/* Puzzle bottom sheet specific */
.puzzle-mission-text {
font-size: 12px; color: var(--text); line-height: 1.5;
}
.puzzle-hint-btn {
display: flex; align-items: center; gap: 8px;
padding: 10px 12px; border: 1px dashed var(--yellow);
border-radius: 8px; background: rgba(255,204,0,0.04);
cursor: pointer; width: 100%; margin-top: 8px;
}
.puzzle-hint-btn:hover { background: rgba(255,204,0,0.1); border-style: solid; }
.puzzle-hint-icon { font-size: 16px; }
.puzzle-hint-label { font-size: 12px; font-weight: 600; color: var(--yellow); flex: 1; }
.puzzle-hint-penalty {
font-size: 9px; font-weight: 700; color: var(--red);
background: rgba(255,68,102,0.15); padding: 2px 6px; border-radius: 3px;
}
.puzzle-obj-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 0; border-bottom: 1px solid var(--border);
font-size: 12px;
}
.puzzle-obj-item:last-child { border-bottom: none; }
.puzzle-obj-star { color: var(--yellow); width: 30px; flex-shrink: 0; }
.puzzle-obj-desc { color: var(--text2); flex: 1; }
/* --- Level Complete Modal Mobile --- */
.gm-complete-overlay { padding: 0 16px; }
.gm-complete-card {
min-width: unset; max-width: unset; width: 100%;
padding: 24px 20px;
}
.gm-complete-actions { flex-direction: column; width: 100%; }
.gm-complete-actions .gm-btn { width: 100%; justify-content: center; }
.gm-complete-actions .gm-btn.primary {
order: -1; padding: 14px;
font-size: 13px; font-weight: 700;
}
/* --- Preset Modal Mobile --- */
.modal { min-width: unset; max-width: unset; width: calc(100% - 32px); }
/* --- General touch targets --- */
.gm-btn { min-height: 44px; display: flex; align-items: center; }
.gm-palette-item { padding: 12px 10px; }
}

View File

@@ -15,3 +15,23 @@ function Root() {
} }
createRoot(document.getElementById('root')).render(<Root />); createRoot(document.getElementById('root')).render(<Root />);
// Configure and unlock audio context on first user interaction
import * as Tone from 'tone';
Tone.getContext().lookAhead = 0.05; // 50ms — tighter than default 100ms
const unlockAudio = () => {
if (Tone.context.state !== 'running') {
Tone.start().catch(() => {});
}
document.removeEventListener('pointerdown', unlockAudio);
document.removeEventListener('keydown', unlockAudio);
};
document.addEventListener('pointerdown', unlockAudio);
document.addEventListener('keydown', unlockAudio);
// Register service worker for PWA
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {});
});
}