Compare commits

58 Commits

Author SHA1 Message Date
Jose Luis
925043e055 feat: universal modulation animation for all source types
- Read params fresh each tick instead of stale closure
- Add oscillator FM, noise, and generic fallback animations
- Any modulation source now shows visual feedback on target knob

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 18:08:44 +01:00
Jose Luis
a0a3b58b49 fix: envelope param animation reads source node instead of receiver
Was reading the receiving module's gain.value (always 0 when CV connected)
instead of the envelope's Tone.Envelope.value for live modulation display.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-22 17:58:19 +01:00
Jose Luis
13612bfa99 fix: Workshop load doesn't stop audio — matches loadPreset pattern
Calling stopAudio() before deserialize+rebuildGraph broke the audio
graph because rebuildGraph needs isRunning=true to work properly.
Now follows the same pattern as loadPreset(): deserialize then
rebuildGraph (which destroys and recreates all nodes internally).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 21:11:02 +01:00
Jose Luis
acbe4257ae docs: explain why base levels can't be edited from admin
The 96 base levels have JavaScript test() functions in their checks
that validate gameplay objectives. These can't be serialized to a
database — they need to stay as code. Custom levels from admin panel
work for tutorials/challenges but without the star check system.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 21:07:39 +01:00
Jose Luis
12569dba76 feat: Admin SynthQuest level management + user dropdown with admin access
SynthQuest admin:
- New "🎮 SynthQuest" section in admin sidebar
- List custom levels with world, ID, title, patch status
- Create new level: world selector, title, subtitle, description,
  concept (hint), available modules (tag input), boss flag, sort order
- Edit existing levels inline
- Import patch base from sandbox JSON export (📥 button per level)
- Delete levels with confirmation

Server:
- custom_levels table (PostgreSQL)
- CRUD API at /api/v1/admin/levels
- POST /:id/import-patch to import sandbox JSON as preplaced modules

Admin access:
- User badge is now a hover dropdown with "🛠 Admin" + "Cerrar sesion"
- Admin visible in Sandbox toolbar, Workshop nav, and user dropdown
- onSwitchToAdmin passed through navigation chain

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 21:05:36 +01:00
Jose Luis
f43a315047 fix: Workshop nav — replace tabs with back arrow to Sandbox
Simpler navigation: "← Volver" button + "Workshop" title instead
of the Sandbox/SynthQuest/Workshop tab bar.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:55:44 +01:00
Jose Luis
b0522d8b0f fix: don't overwrite Workshop-loaded patch with autoLoad/chiptune
When switching from Workshop to Sandbox after loading a patch,
the Sandbox's useEffect was running autoLoad() which overwrote
the just-loaded patch. Now it skips if modules are already present.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:54:18 +01:00
Jose Luis
e53ec600ad feat: Phase 4 — Admin panel (dashboard, users, moderation)
AdminPanel2 component with sidebar navigation:
- Dashboard: KPI cards (users, patches, premium, flagged)
- Users: search, filter by role, table with role dropdown to
  change user/premium/admin/banned per user
- Workshop moderation: filter flagged/deleted, approve/delete/restore
  actions per patch with status badges

Features:
- Role-protected: non-admins see 🔒 locked screen
- Sidebar nav: Dashboard / Usuarios / Workshop / Volver
- Admin button visible in Workshop nav for admin users
- Responsive: sidebar becomes horizontal tabs on mobile,
  KPIs 2x2 grid, table rows wrap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:53:32 +01:00
Jose Luis
c673745b09 fix: Workshop mobile layout + navigation from all modes
Mobile:
- Workshop nav tabs full-width, hide logo, hide hero header
- Search/sort/share go full-width stacked
- Tags scroll horizontally
- Share button large and prominent
- Patch cards single column, shorter previews
- Auth modal fits mobile viewport

Navigation:
- Workshop button in Sandbox hamburger menu (mobile)
- Workshop tab in WorldMap mobile tab bar
- GameApp passes onWorkshop prop through to WorldMap

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:49:13 +01:00
Jose Luis
3b80070c9a fix: Workshop share from saved presets + clean load
Share:
- Share modal now shows user's saved presets to pick from
- No longer grabs live canvas (which had serialization issues)
- Auto-fills title from preset name
- Shows module/wire count per preset

Load:
- Stops audio before loading (prevents ghost sounds)
- Deep clones patch data (prevents reference issues)
- Calls deserialize → rebuildGraph → emit in correct order
- Switches to Sandbox after loading

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:42:19 +01:00
Jose Luis
982654c3ef feat: Phase 3 — Workshop (community patch sharing)
Server:
- GET /api/v1/workshop — browse patches (search, tags, sort)
- POST /api/v1/workshop — share a patch (auth required)
- GET /api/v1/workshop/:id — single patch detail
- DELETE /api/v1/workshop/:id — soft delete (owner/admin)
- POST/DELETE /api/v1/workshop/:id/like — like/unlike
- POST /api/v1/workshop/:id/report — flag for moderation

Client:
- Workshop page with nav bar (Sandbox/SynthQuest/Workshop tabs)
- Search bar + tag filters (ambient, bass, drums, etc.)
- Sort by recent/popular
- Patch cards: title, author, tags, likes, module count
- "Cargar" button loads patch into Sandbox
- Share modal: title, description, tags, shares current canvas
- User badge + login button in Workshop nav
- Responsive: single column on mobile

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:33:53 +01:00
Jose Luis
64ffa36c09 feat: Phase 2 — data sync (presets + game progress)
Server:
- GET/PUT /api/v1/sync/presets — upsert with last-write-wins
- DELETE /api/v1/sync/presets/:id
- GET/PUT /api/v1/sync/progress — game progress upsert

Client:
- syncService.js: offline-first sync layer
  - localStorage remains primary store
  - Pushes to server when logged in
  - Merges server data into local on sync
  - Auto-sync every 30s + on tab focus
- AuthContext starts/stops sync on login/logout
- Sync runs on session restore (refresh token)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:28:46 +01:00
Jose Luis
3523111019 feat: frontend auth — login/register modal + user badge
- API service (api.js): fetch wrapper with JWT, auto-refresh on 401
- AuthContext: user state, login/register/logout, loading, roles
- AuthModal: tabbed login/register form matching .pen design
- User badge in toolbar (Sandbox + WorldMap) with initial avatar
- "Entrar" button when not logged in
- CSS: auth overlay, card, tabs, inputs, error state, user badge
- Auth is opt-in: app works fully without login

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 20:22:43 +01:00
Jose Luis
e129fd3739 fix: Dockerfile install devDeps for vite build + build tools for argon2 2026-03-21 20:10:03 +01:00
Jose Luis
6a4a308fd9 feat: Phase 1 — Fastify backend with auth, users, admin API
Backend stack:
- Fastify v5 with JWT auth, CORS, cookies, rate limiting
- PostgreSQL via Drizzle ORM with full schema:
  users, presets, game_progress, shared_patches, likes, refresh_tokens
- Argon2 password hashing, httpOnly refresh cookie rotation

API endpoints:
- POST /api/v1/auth/register|login|refresh|logout
- GET|PATCH /api/v1/users/me (profile)
- GET /api/v1/admin/stats (dashboard KPIs)
- GET|PATCH /api/v1/admin/users (list, role change, ban)
- GET|PATCH /api/v1/admin/patches (moderation)
- GET /api/health

Infrastructure:
- Vite proxy /api → localhost:3001 for dev
- .env.example with all config vars
- Dockerfile updated: installs server deps, serves SPA + API
- npm run dev:server for backend hot-reload
- npm run db:push for schema sync

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 19:58:21 +01:00
Jose Luis
b058997889 refactor: restructure to monorepo with npm workspaces (Phase 0)
Move frontend to packages/client/, server to packages/server/.
Root package.json uses npm workspaces to orchestrate both.

Structure:
  reaktor/
    packages/client/  (React + Vite + Tone.js frontend)
    packages/server/  (static file server, future API)
    dist/             (built output, shared)
    docker-compose.yml (app + PostgreSQL for future backend)

- npm run dev → runs Vite dev server from client workspace
- npm run build → builds client, outputs to root dist/
- npm run start → runs server.js serving dist/
- Dockerfile updated for multi-stage monorepo build
- docker-compose.yml added with PostgreSQL service (ready for Phase 1)
- All imports and paths preserved, zero functionality change

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 19:52:57 +01:00
Jose Luis
4baa86eed0 docs: add producto.md — product roadmap and vision
Living product document covering:
- Current features (Sandbox, SynthQuest, Mobile PWA)
- 6-phase roadmap (monorepo → auth → sync → workshop → admin → payments)
- Tech stack decisions (Fastify, PostgreSQL, Drizzle, JWT)
- Design principles (offline-first, opt-in, mobile-first)
- Success metrics per phase

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-21 19:29:52 +01:00
Jose Luis
4f4d2bfae5 Merge feat/mobile-ui: responsive mobile UI, audio engine fixes, new modules
Mobile UI:
- Responsive layout for all views (Sandbox, World Map, Puzzle View)
- Bottom sheet with swipe gestures for module palette and puzzle tabs
- Mobile tab bar navigation (Game/Sandbox/Config)
- Touch panning, pinch-to-zoom, native zoom blocking
- PWA support (installable, offline-capable)

New modules:
- Drum Pad (🥁): 4x4 colored pad grid with gate/freq output
- CV→Gate (): converts continuous CV to gate signal with threshold
- Fullscreen mode for Keyboard and Drum Pad (portal-based)

Audio engine:
- Global master clock (120 Hz) with time-derived ticks (no drift)
- Connection cache with dirty flag (zero overhead on cache hit)
- Reduced main thread pressure (throttled RAF loops, lower clock rate)
- VCA properly zeroes with CV control, envelope release min 0.001s
- Audio context unlocked on first interaction for immediate UI sounds
2026-03-21 19:12:17 +01:00
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
Jose Luis
00c4ec8e00 feat: double-click knobs to type exact values
Double-clicking a knob opens an inline text input for precise value
entry. Enter confirms, Escape cancels, blur auto-commits. Value is
clamped to the knob's min/max range. Styled to match the synth theme.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 02:17:07 +01:00
Jose Luis
e077e7f553 fix: wire connections + hint system with star penalty
- Replace elementFromPoint with bounding rect distance search for port
  detection. The SVG wire overlay was intercepting pointer events,
  requiring users to wait for animations before connecting. Now finds
  the closest port-dot within 18px radius regardless of z-index.
- Add hint system: concept text hidden behind "Mostrar Pista" button.
  Using the hint permanently caps the level at 2 stars max. Reiniciar
  resets the penalty. Visual feedback in objectives and completion screen.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 02:15:07 +01:00
92 changed files with 18520 additions and 2092 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
node_modules node_modules
dist dist
.vite .vite
.env

View File

@@ -1,13 +1,22 @@
FROM node:20-alpine AS build # Stage 1: Build frontend
FROM node:20-alpine AS build-client
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ COPY package.json package-lock.json* ./
RUN npm install COPY packages/client/package.json packages/client/
COPY . . COPY packages/server/package.json packages/server/
RUN npm run build RUN npm install --include=dev
COPY packages/client packages/client
RUN npm run build -w packages/client
# Stage 2: Production
FROM node:20-alpine FROM node:20-alpine
RUN apk add --no-cache python3 make g++
WORKDIR /app WORKDIR /app
COPY --from=build /app/dist ./dist COPY package.json package-lock.json* ./
COPY server.js . COPY packages/server/package.json packages/server/
RUN npm install -w packages/server --omit=dev && apk del python3 make g++
COPY --from=build-client /app/dist ./dist
COPY packages/server packages/server
ENV NODE_ENV=production PORT=80
EXPOSE 80 EXPOSE 80
CMD ["node", "server.js"] CMD ["node", "packages/server/src/index.js"]

24
docker-compose.yml Normal file
View File

@@ -0,0 +1,24 @@
services:
app:
build: .
ports:
- "80:80"
depends_on:
db:
condition: service_healthy
db:
image: postgres:16-alpine
environment:
POSTGRES_USER: reaktor
POSTGRES_PASSWORD: reaktor_dev
POSTGRES_DB: reaktor
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U reaktor"]
interval: 5s
retries: 5
volumes:
pgdata:

3000
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,12 @@
{ {
"name": "reaktor-montlab", "name": "reaktor",
"version": "1.0.0",
"private": true, "private": true,
"type": "module", "workspaces": ["packages/*"],
"scripts": { "scripts": {
"dev": "vite", "dev": "npm run dev -w packages/client",
"build": "vite build", "dev:server": "npm run dev -w packages/server",
"preview": "vite preview", "build": "npm run build -w packages/client",
"start": "node server.js" "start": "node packages/server/src/index.js",
}, "db:push": "npm run db:push -w packages/server"
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tone": "^14.8.49"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.0",
"vite": "^5.4.0"
} }
} }

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>

View File

@@ -0,0 +1,20 @@
{
"name": "@reaktor/client",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"react": "^18.2.0",
"react-dom": "^18.2.0",
"tone": "^14.8.49"
},
"devDependencies": {
"@vitejs/plugin-react": "^4.2.0",
"vite": "^5.4.0"
}
}

View File

Before

Width:  |  Height:  |  Size: 496 B

After

Width:  |  Height:  |  Size: 496 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

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" }
]
}

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;
})
);
});

465
packages/client/src/App.jsx Normal file
View File

@@ -0,0 +1,465 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { state, subscribe, addModule, emit, addConnection, updateModulePosition, deserialize } from './engine/state.js';
import { startAudio, stopAudio, connectWire, rebuildGraph } from './engine/audioEngine.js';
import { getModuleDef } from './engine/moduleRegistry.js';
import { autoSave, autoLoad, exportPatch, importPatch } from './engine/presets.js';
import { playEngineStart, playEngineStop } from './engine/uiSounds.js';
import ModuleNode from './components/ModuleNode.jsx';
import WireLayer from './components/WireLayer.jsx';
import ModulePalette from './components/ModulePalette.jsx';
import PresetModal from './components/PresetModal.jsx';
import BottomSheet from './components/BottomSheet.jsx';
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';
import { useAuth } from './services/AuthContext.jsx';
export default function App({ onSwitchToGame, onSwitchToWorkshop, onSwitchToAdmin }) {
const [, forceUpdate] = useState(0);
const containerRef = useRef(null);
const portPositions = useRef({});
const [tempWire, setTempWire] = useState(null);
const connectingRef = useRef(null);
const [presetModal, setPresetModal] = useState(null);
const { user, isLoggedIn, isAdmin, openAuth, logout } = useAuth();
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
useEffect(() => {
const unsub = subscribe(() => forceUpdate(n => n + 1));
return unsub;
}, []);
// Auto-load on mount, but skip if modules already loaded (e.g. from Workshop)
useEffect(() => {
if (state.modules.length > 0) return; // Already loaded (Workshop, etc.)
const loaded = autoLoad();
if (!loaded || state.modules.length === 0) {
deserialize(CHIPTUNE_PRESET);
}
}, []);
// Auto-save interval
useEffect(() => {
const interval = setInterval(autoSave, 3000);
return () => clearInterval(interval);
}, []);
// Port position reporting
const handlePortPosition = useCallback((moduleId, portName, direction, el) => {
const key = `${moduleId}-${portName}-${direction}`;
portPositions.current[key] = el;
}, []);
// Start connecting a wire
const handleStartConnect = useCallback((info) => {
connectingRef.current = info;
const containerRect = containerRef.current.getBoundingClientRect();
setTempWire({
portType: info.portType,
startX: info.startX - containerRect.left,
startY: info.startY - containerRect.top,
endX: info.startX - containerRect.left,
endY: info.startY - containerRect.top,
});
}, []);
// Find port-dot element at pointer position (including nearby)
// Robust port detection — searches all port-dots by bounding rect distance
// instead of elementFromPoint (which gets blocked by SVG wire overlay)
const findPortAtPoint = (clientX, clientY) => {
const portDots = document.querySelectorAll('.port-dot[data-module-id]');
let closest = null;
let closestDist = 18;
for (const dot of portDots) {
const rect = dot.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const dist = Math.sqrt((clientX - cx) ** 2 + (clientY - cy) ** 2);
if (dist < closestDist) {
closestDist = dist;
closest = dot;
}
}
return closest;
};
// Canvas pointer events
const handlePointerDown = useCallback((e) => {
if (e.button === 1 || (e.button === 0 && e.altKey) || e.button === 2) {
state.panning = true;
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
e.preventDefault();
} 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;
emit();
}
}, [isMobile]);
const handlePointerMove = useCallback((e) => {
if (state.panning && state.panStart) {
state.camX = e.clientX - state.panStart.x;
state.camY = e.clientY - state.panStart.y;
emit();
return;
}
if (state.dragging) {
const newX = e.clientX / state.zoom - state.dragging.offsetX;
const newY = e.clientY / state.zoom - state.dragging.offsetY;
updateModulePosition(state.dragging.moduleId, newX, newY);
return;
}
if (connectingRef.current && containerRef.current) {
const containerRect = containerRef.current.getBoundingClientRect();
setTempWire(prev => prev ? {
...prev,
endX: e.clientX - containerRect.left,
endY: e.clientY - containerRect.top,
} : null);
}
}, []);
const handlePointerUp = useCallback((e) => {
if (state.panning) {
state.panning = false;
state.panStart = null;
}
if (state.dragging) {
state.dragging = null;
emit();
}
// End connecting
if (connectingRef.current) {
const portEl = findPortAtPoint(e.clientX, e.clientY);
if (portEl) {
finishConnection(portEl);
}
connectingRef.current = null;
setTempWire(null);
}
}, []);
const finishConnection = (portEl) => {
const from = connectingRef.current;
if (!from) return;
// Read data attributes directly — clean and reliable
const targetModuleId = parseInt(portEl.dataset.moduleId);
const targetPort = portEl.dataset.portName;
const targetDirection = portEl.dataset.portDirection;
if (!targetModuleId || !targetPort || !targetDirection) return;
if (targetModuleId === from.moduleId && targetPort === from.port) return;
// Determine from/to
let fromMod, fromPort, toMod, toPort;
if (from.direction === 'output' && targetDirection === 'input') {
fromMod = from.moduleId; fromPort = from.port;
toMod = targetModuleId; toPort = targetPort;
} else if (from.direction === 'input' && targetDirection === 'output') {
fromMod = targetModuleId; fromPort = targetPort;
toMod = from.moduleId; toPort = from.port;
} else {
return; // same direction — invalid
}
const connId = addConnection(fromMod, fromPort, toMod, toPort);
if (connId && state.isRunning) {
const conn = state.connections.find(c => c.id === connId);
if (conn) connectWire(conn);
}
};
const handleWheel = useCallback((e) => {
e.preventDefault();
const delta = -e.deltaY * 0.001;
state.zoom = Math.max(0.3, Math.min(3, state.zoom + delta));
emit();
}, []);
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 () => {
if (state.isRunning) {
stopAudio();
playEngineStop();
} else {
await startAudio();
playEngineStart();
}
emit();
};
const handleAddModule = (type) => {
const x = (-state.camX + 300) / state.zoom + Math.random() * 50;
const y = (-state.camY + 200) / state.zoom + Math.random() * 50;
addModule(type, x, y);
if (state.isRunning) rebuildGraph();
};
const handleImport = async (e) => {
const file = e.target.files[0];
if (!file) return;
await importPatch(file);
emit();
e.target.value = '';
};
const handleLoadDemo = () => {
deserialize(CHIPTUNE_PRESET);
if (state.isRunning) rebuildGraph();
emit();
};
const handleClearCanvas = () => {
if (state.isRunning) stopAudio();
deserialize({ modules: [], connections: [] });
emit();
};
// Flatten all modules for mobile grid
const allModuleDefs = Object.values(getModulesByCategory()).flat();
return (
<div className="app">
{/* Toolbar */}
<div className="toolbar">
{onSwitchToGame && !isMobile && (
<button className="toolbar-btn" onClick={onSwitchToGame} style={{ color: 'var(--yellow)' }}>
🎮 Game
</button>
)}
{onSwitchToWorkshop && !isMobile && (
<button className="toolbar-btn" onClick={onSwitchToWorkshop}>
🎵 Workshop
</button>
)}
<span className="toolbar-title">Reaktor</span>
{!isMobile && <div className="toolbar-sep" />}
{!isMobile && (
<button className={`toolbar-btn start-btn ${state.isRunning ? 'active' : ''}`} onClick={handleToggleAudio}>
{state.isRunning ? '⏹ Stop' : '▶ Start'}
</button>
)}
{!isMobile && <div className="toolbar-sep" />}
{!isMobile && (
<div className="toolbar-group">
<button className="toolbar-btn save-btn" onClick={() => setPresetModal('save')}>💾 Save</button>
<button className="toolbar-btn load-btn" onClick={() => setPresetModal('load')}>📂 Load</button>
<button className="toolbar-btn export-btn" onClick={exportPatch}>📤 Export</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} />
</div>
)}
{!isMobile && <div className="toolbar-sep" />}
{!isMobile && (
<>
<button className="toolbar-btn demo-btn" onClick={handleLoadDemo} style={{ color: 'var(--yellow)' }}>
🎮 Chiptune Demo
</button>
<button className="toolbar-btn clear-btn" onClick={handleClearCanvas}>
🗑 Limpiar
</button>
<div className="toolbar-sep" />
</>
)}
<span className="toolbar-label" style={{ color: state.isRunning ? 'var(--green)' : 'var(--text2)', marginLeft: isMobile ? 'auto' : undefined }}>
{state.isRunning ? '● LIVE' : '○ OFF'}
</span>
{!isMobile && (
<span className="toolbar-label" style={{ marginLeft: 'auto' }}>
{state.modules.length} modules · {state.connections.length} wires
</span>
)}
{isLoggedIn ? (
<div className="user-dropdown">
<div className="user-badge">
<div className="user-avatar">{user.username?.[0]?.toUpperCase()}</div>
<span className="user-name">{user.username}</span>
</div>
<div className="user-dropdown-menu">
{isAdmin && onSwitchToAdmin && <button onClick={onSwitchToAdmin}>🛠 Admin</button>}
<button onClick={logout}>Cerrar sesion</button>
</div>
</div>
) : (
<button className="login-btn" onClick={openAuth}>Entrar</button>
)}
{isMobile && (
<button className="mobile-menu-btn" onClick={() => setMenuOpen(true)}></button>
)}
</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>
{onSwitchToWorkshop && (
<button className="toolbar-btn" onClick={() => { setMenuOpen(false); onSwitchToWorkshop(); }} style={{ color: 'var(--accent)' }}>
🎵 Workshop
</button>
)}
</div>
</div>
)}
{/* Main canvas area */}
<div className="main-area">
<div
ref={containerRef}
className={`node-canvas ${state.panning ? 'grabbing' : ''} ${connectingRef.current ? 'connecting' : ''}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onWheel={handleWheel}
onContextMenu={handleContextMenu}
>
{/* Grid background */}
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', zIndex: 0 }}>
<defs>
<pattern id="grid" width={20 * state.zoom} height={20 * state.zoom}
patternUnits="userSpaceOnUse"
x={state.camX % (20 * state.zoom)} y={state.camY % (20 * state.zoom)}>
<circle cx={1} cy={1} r={0.8} fill="#1a1a30" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
</svg>
{/* Wire layer */}
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} zoom={state.zoom} camX={state.camX} camY={state.camY} />
{/* Modules container */}
<div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
{state.modules.map(mod => (
<ModuleNode
key={mod.id}
mod={mod}
zoom={state.zoom}
onStartConnect={handleStartConnect}
onPortPosition={handlePortPosition}
/>
))}
</div>
</div>
{/* Zoom controls */}
<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>
{/* 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">
<span className="status-accent">Reaktor MontLab Modular Synth</span>
<span>Zoom: {(state.zoom * 100).toFixed(0)}%</span>
<span>RClick: pan · Wheel: zoom · Drag port: wire · Click wire: delete</span>
</div>
{presetModal && <PresetModal mode={presetModal} onClose={() => setPresetModal(null)} />}
</div>
);
}

View File

@@ -0,0 +1,466 @@
import React, { useState, useEffect, useCallback, useRef } from 'react';
import { admin as adminApi, levels as levelsApi } from '../services/api.js';
import { useAuth } from '../services/AuthContext.jsx';
function Sidebar({ active, onNavigate, onBack }) {
const items = [
{ id: 'dashboard', icon: '📊', label: 'Dashboard' },
{ id: 'users', icon: '👥', label: 'Usuarios' },
{ id: 'workshop', icon: '🎛', label: 'Workshop' },
{ id: 'levels', icon: '🎮', label: 'SynthQuest' },
];
return (
<div className="adm-sidebar">
<div className="adm-sidebar-logo">
<div className="auth-logo-box" style={{ width: 28, height: 28, fontSize: 14 }}>~</div>
<span style={{ fontSize: 14, fontWeight: 700, color: 'var(--text)' }}>Admin</span>
</div>
{items.map(item => (
<button key={item.id}
className={`adm-sidebar-item ${active === item.id ? 'active' : ''}`}
onClick={() => onNavigate(item.id)}>
<span>{item.icon}</span> {item.label}
</button>
))}
<div style={{ flex: 1 }} />
<button className="adm-sidebar-item" onClick={onBack}>
Volver a la app
</button>
</div>
);
}
function DashboardView() {
const [stats, setStats] = useState(null);
useEffect(() => {
adminApi.stats().then(setStats).catch(() => {});
}, []);
if (!stats) return <p style={{ color: 'var(--text2)' }}>Cargando...</p>;
const kpis = [
{ label: 'USUARIOS TOTALES', value: stats.users, color: 'var(--text)' },
{ label: 'PATCHES COMPARTIDOS', value: stats.patches, color: 'var(--text)' },
{ label: 'PREMIUM', value: stats.premium, color: 'var(--yellow)' },
{ label: 'REPORTADOS', value: stats.flagged, color: 'var(--red)' },
];
return (
<div>
<h2 className="adm-page-title">Dashboard</h2>
<div className="adm-kpi-grid">
{kpis.map(k => (
<div key={k.label} className="adm-kpi-card">
<span className="adm-kpi-label">{k.label}</span>
<span className="adm-kpi-value" style={{ color: k.color }}>{k.value}</span>
</div>
))}
</div>
</div>
);
}
function UsersView() {
const [users, setUsers] = useState([]);
const [search, setSearch] = useState('');
const [filter, setFilter] = useState('');
const load = useCallback(async () => {
const params = new URLSearchParams();
if (search) params.set('q', search);
if (filter) params.set('role', filter);
const data = await adminApi.users(params.toString());
setUsers(data.users || []);
}, [search, filter]);
useEffect(() => { load(); }, [load]);
const changeRole = async (id, role) => {
await adminApi.updateUser(id, { role });
load();
};
return (
<div>
<h2 className="adm-page-title">Usuarios</h2>
<div className="adm-toolbar">
<div className="ws-search" style={{ flex: 1 }}>
<span>🔍</span>
<input placeholder="Buscar usuario..." value={search}
onChange={e => setSearch(e.target.value)} />
</div>
<div className="ws-tags">
<button className={`ws-tag ${!filter ? 'active' : ''}`} onClick={() => setFilter('')}>Todos</button>
<button className={`ws-tag ${filter === 'premium' ? 'active' : ''}`} onClick={() => setFilter('premium')} style={{ color: 'var(--yellow)' }}>Premium</button>
<button className={`ws-tag ${filter === 'banned' ? 'active' : ''}`} onClick={() => setFilter('banned')} style={{ color: 'var(--red)' }}>Banned</button>
</div>
</div>
<div className="adm-table">
<div className="adm-table-head">
<span className="adm-col-grow">USUARIO</span>
<span className="adm-col-md">EMAIL</span>
<span className="adm-col-sm">ROL</span>
<span className="adm-col-sm">REGISTRO</span>
<span className="adm-col-xs">ACCIONES</span>
</div>
{users.map(u => (
<div key={u.id} className={`adm-table-row ${u.role === 'banned' ? 'banned' : ''}`}>
<div className="adm-col-grow adm-user-cell">
<div className="user-avatar" style={{ background: u.role === 'banned' ? 'var(--red)' : 'var(--accent)' }}>
{u.username?.[0]?.toUpperCase()}
</div>
<span>{u.username}</span>
</div>
<span className="adm-col-md adm-text-muted">{u.email}</span>
<span className="adm-col-sm">
<span className={`adm-role-badge ${u.role}`}>
{u.role === 'premium' ? '★ ' : u.role === 'banned' ? '🚫 ' : ''}{u.role}
</span>
</span>
<span className="adm-col-sm adm-text-muted">
{new Date(u.createdAt).toLocaleDateString('es')}
</span>
<div className="adm-col-xs">
<select className="adm-action-select" value={u.role}
onChange={e => changeRole(u.id, e.target.value)}>
<option value="user">User</option>
<option value="premium">Premium</option>
<option value="admin">Admin</option>
<option value="banned">Banned</option>
</select>
</div>
</div>
))}
</div>
</div>
);
}
function WorkshopModView() {
const [patches, setPatches] = useState([]);
const [filter, setFilter] = useState('');
const load = useCallback(async () => {
const params = new URLSearchParams();
if (filter === 'flagged') params.set('flagged', 'true');
if (filter === 'deleted') params.set('deleted', 'true');
const data = await adminApi.patches(params.toString());
setPatches(data.patches || []);
}, [filter]);
useEffect(() => { load(); }, [load]);
const moderate = async (id, action) => {
await adminApi.updatePatch(id, { action });
load();
};
return (
<div>
<h2 className="adm-page-title">Workshop Moderacion</h2>
<div className="adm-toolbar">
<div className="ws-tags">
<button className={`ws-tag ${!filter ? 'active' : ''}`} onClick={() => setFilter('')}>Todos</button>
<button className={`ws-tag ${filter === 'flagged' ? 'active' : ''}`}
onClick={() => setFilter('flagged')} style={{ color: 'var(--yellow)' }}> Reportados</button>
<button className={`ws-tag ${filter === 'deleted' ? 'active' : ''}`}
onClick={() => setFilter('deleted')} style={{ color: 'var(--red)' }}>🚫 Eliminados</button>
</div>
</div>
<div className="adm-table">
<div className="adm-table-head">
<span className="adm-col-grow">PATCH</span>
<span className="adm-col-sm">LIKES</span>
<span className="adm-col-sm">ESTADO</span>
<span className="adm-col-xs">ACCIONES</span>
</div>
{patches.map(p => (
<div key={p.id} className={`adm-table-row ${p.isDeleted ? 'banned' : ''}`}>
<div className="adm-col-grow">
<strong style={{ color: 'var(--text)' }}>{p.title}</strong>
{p.tags?.length > 0 && (
<div style={{ display: 'flex', gap: 4, marginTop: 4 }}>
{p.tags.map(t => <span key={t} className="ws-tag-pill">{t}</span>)}
</div>
)}
</div>
<span className="adm-col-sm" style={{ color: 'var(--red)' }}> {p.likesCount}</span>
<span className="adm-col-sm">
{p.isDeleted
? <span className="adm-role-badge banned">🚫 Eliminado</span>
: p.isFlagged
? <span className="adm-role-badge" style={{ background: 'rgba(255,204,0,0.15)', color: 'var(--yellow)' }}> Reportado</span>
: <span className="adm-role-badge" style={{ background: 'rgba(68,255,136,0.15)', color: 'var(--green)' }}> Activo</span>
}
</span>
<div className="adm-col-xs adm-actions">
{p.isDeleted ? (
<button className="adm-act-btn green" onClick={() => moderate(p.id, 'restore')}>Restaurar</button>
) : (
<>
{p.isFlagged && <button className="adm-act-btn green" onClick={() => moderate(p.id, 'unflag')}>Aprobar</button>}
<button className="adm-act-btn red" onClick={() => moderate(p.id, 'delete')}>Eliminar</button>
</>
)}
</div>
</div>
))}
{patches.length === 0 && (
<p style={{ padding: 20, color: 'var(--text2)', textAlign: 'center' }}>No hay patches</p>
)}
</div>
</div>
);
}
function LevelsView() {
const [levels, setLevels] = useState([]);
const [editing, setEditing] = useState(null); // level being edited
const [showCreate, setShowCreate] = useState(false);
const fileRef = useRef(null);
const load = useCallback(async () => {
try {
const data = await levelsApi.list();
setLevels(data.levels || []);
} catch {}
}, []);
useEffect(() => { load(); }, [load]);
const handleCreate = async (form) => {
await levelsApi.create(form);
setShowCreate(false);
load();
};
const handleUpdate = async (id, form) => {
await levelsApi.update(id, form);
setEditing(null);
load();
};
const handleDelete = async (id) => {
if (!confirm('Eliminar este nivel?')) return;
await levelsApi.remove(id);
load();
};
const handleImportPatch = async (levelId) => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = async (e) => {
const file = e.target.files[0];
if (!file) return;
const text = await file.text();
try {
const data = JSON.parse(text);
await levelsApi.importPatch(levelId, {
modules: data.modules || [],
connections: data.connections || [],
});
load();
} catch (err) {
alert('Error importando: ' + err.message);
}
};
input.click();
};
return (
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: 12, marginBottom: 20 }}>
<h2 className="adm-page-title" style={{ margin: 0 }}>SynthQuest Niveles</h2>
<span style={{ fontSize: 11, color: 'var(--text2)', background: 'var(--surface)', padding: '4px 10px', borderRadius: 12 }}>
{levels.length} custom
</span>
<div style={{ flex: 1 }} />
<button className="ws-share-btn" onClick={() => setShowCreate(true)}>+ Nuevo Nivel</button>
</div>
{showCreate && (
<LevelForm onSave={handleCreate} onCancel={() => setShowCreate(false)} />
)}
{editing && (
<LevelForm level={editing} onSave={(form) => handleUpdate(editing.id, form)} onCancel={() => setEditing(null)} />
)}
<div className="adm-table">
<div className="adm-table-head">
<span className="adm-col-xs">MUNDO</span>
<span className="adm-col-sm">ID</span>
<span className="adm-col-grow">TITULO</span>
<span className="adm-col-sm">PATCH</span>
<span className="adm-col-xs">ACCIONES</span>
</div>
{levels.map(lvl => (
<div key={lvl.id} className="adm-table-row">
<span className="adm-col-xs" style={{ fontFamily: 'JetBrains Mono', fontSize: 11, color: 'var(--accent)' }}>
{lvl.worldId}
</span>
<span className="adm-col-sm" style={{ fontFamily: 'JetBrains Mono', fontSize: 11, color: 'var(--text2)' }}>
{lvl.levelId}
</span>
<div className="adm-col-grow">
<strong style={{ color: lvl.isBoss ? 'var(--yellow)' : 'var(--text)', fontSize: 13 }}>
{lvl.isBoss ? '👑 ' : ''}{lvl.title}
</strong>
{lvl.subtitle && <div style={{ fontSize: 11, color: 'var(--text2)' }}>{lvl.subtitle}</div>}
</div>
<span className="adm-col-sm">
{lvl.preplacedData ? (
<span style={{ fontSize: 10, color: 'var(--green)' }}>
{lvl.preplacedData.modules?.length || 0} modules
</span>
) : (
<button className="adm-act-btn green" onClick={() => handleImportPatch(lvl.id)}>
📥 Importar
</button>
)}
</span>
<div className="adm-col-xs adm-actions">
<button className="adm-act-btn" style={{ borderColor: 'var(--accent)', color: 'var(--accent)' }}
onClick={() => setEditing(lvl)}>Editar</button>
<button className="adm-act-btn red" onClick={() => handleDelete(lvl.id)}></button>
</div>
</div>
))}
{levels.length === 0 && (
<p style={{ padding: 20, color: 'var(--text2)', textAlign: 'center' }}>
No hay niveles custom. Los 96 niveles base estan hardcoded en el codigo.
</p>
)}
</div>
</div>
);
}
function LevelForm({ level, onSave, onCancel }) {
const [form, setForm] = useState({
worldId: level?.worldId || 'w1',
levelId: level?.levelId || '',
title: level?.title || '',
subtitle: level?.subtitle || '',
description: level?.description || '',
concept: level?.concept || '',
availableModules: level?.availableModules || [],
isBoss: level?.isBoss || false,
sortOrder: level?.sortOrder || 0,
});
const [modInput, setModInput] = useState('');
const set = (k, v) => setForm(f => ({ ...f, [k]: v }));
const addMod = () => {
if (modInput.trim() && !form.availableModules.includes(modInput.trim())) {
set('availableModules', [...form.availableModules, modInput.trim()]);
setModInput('');
}
};
const removeMod = (m) => set('availableModules', form.availableModules.filter(x => x !== m));
return (
<div style={{ background: 'var(--surface)', border: '1px solid var(--border)', borderRadius: 12, padding: 20, marginBottom: 16 }}>
<h3 style={{ color: 'var(--text)', margin: '0 0 16px', fontSize: 16 }}>
{level ? 'Editar Nivel' : 'Nuevo Nivel'}
</h3>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 }}>
<div>
<label className="auth-label">MUNDO</label>
<select className="adm-action-select" style={{ width: '100%', padding: 8 }} value={form.worldId} onChange={e => set('worldId', e.target.value)}>
{Array.from({ length: 12 }, (_, i) => <option key={i} value={`w${i + 1}`}>Mundo {i + 1}</option>)}
</select>
</div>
<div>
<label className="auth-label">LEVEL ID</label>
<input className="auth-input" value={form.levelId} onChange={e => set('levelId', e.target.value)}
placeholder="w1-9" disabled={!!level} />
</div>
<div>
<label className="auth-label">TITULO</label>
<input className="auth-input" value={form.title} onChange={e => set('title', e.target.value)} />
</div>
<div>
<label className="auth-label">SUBTITULO</label>
<input className="auth-input" value={form.subtitle} onChange={e => set('subtitle', e.target.value)} />
</div>
<div style={{ gridColumn: '1/-1' }}>
<label className="auth-label">DESCRIPCION (MISION)</label>
<textarea className="auth-input" rows={3} value={form.description} onChange={e => set('description', e.target.value)}
style={{ resize: 'vertical', fontFamily: 'inherit' }} />
</div>
<div style={{ gridColumn: '1/-1' }}>
<label className="auth-label">PISTA (CONCEPTO)</label>
<textarea className="auth-input" rows={2} value={form.concept} onChange={e => set('concept', e.target.value)}
style={{ resize: 'vertical', fontFamily: 'inherit' }} />
</div>
<div style={{ gridColumn: '1/-1' }}>
<label className="auth-label">MODULOS DISPONIBLES</label>
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 6 }}>
{form.availableModules.map(m => (
<span key={m} className="ws-tag active" onClick={() => removeMod(m)} style={{ cursor: 'pointer' }}>
{m}
</span>
))}
</div>
<div style={{ display: 'flex', gap: 6 }}>
<input className="auth-input" style={{ flex: 1 }} placeholder="oscillator, filter, vca..."
value={modInput} onChange={e => setModInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && (e.preventDefault(), addMod())} />
<button className="adm-act-btn green" onClick={addMod} type="button">+ Añadir</button>
</div>
</div>
<div style={{ display: 'flex', gap: 8, alignItems: 'center' }}>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, color: 'var(--text2)', fontSize: 12 }}>
<input type="checkbox" checked={form.isBoss} onChange={e => set('isBoss', e.target.checked)} />
Boss Level
</label>
<label className="auth-label" style={{ margin: 0 }}>ORDEN</label>
<input className="auth-input" type="number" style={{ width: 60 }} value={form.sortOrder}
onChange={e => set('sortOrder', parseInt(e.target.value) || 0)} />
</div>
</div>
<div style={{ display: 'flex', gap: 8, marginTop: 16 }}>
<button className="auth-submit" style={{ flex: 1 }} onClick={() => onSave(form)}>
{level ? 'Guardar' : 'Crear Nivel'}
</button>
<button className="adm-act-btn" style={{ padding: '10px 20px' }} onClick={onCancel}>Cancelar</button>
</div>
</div>
);
}
export default function AdminPanel2({ onBack }) {
const { isAdmin } = useAuth();
const [page, setPage] = useState('dashboard');
if (!isAdmin) {
return (
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100vh', background: 'var(--bg)' }}>
<div style={{ textAlign: 'center', color: 'var(--text2)' }}>
<p style={{ fontSize: 48 }}>🔒</p>
<p>Acceso restringido a administradores</p>
<button className="login-btn" onClick={onBack} style={{ marginTop: 16 }}>Volver</button>
</div>
</div>
);
}
return (
<div className="adm-layout">
<Sidebar active={page} onNavigate={setPage} onBack={onBack} />
<div className="adm-main">
{page === 'dashboard' && <DashboardView />}
{page === 'users' && <UsersView />}
{page === 'workshop' && <WorkshopModView />}
{page === 'levels' && <LevelsView />}
</div>
</div>
);
}

View File

@@ -0,0 +1,114 @@
import React, { useState } from 'react';
import { useAuth } from '../services/AuthContext.jsx';
export default function AuthModal() {
const { showAuth, closeAuth, login, register } = useAuth();
const [tab, setTab] = useState('login'); // 'login' | 'register'
const [email, setEmail] = useState('');
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [loading, setLoading] = useState(false);
if (!showAuth) return null;
const handleSubmit = async (e) => {
e.preventDefault();
setError('');
setLoading(true);
try {
if (tab === 'login') {
await login(email, password);
} else {
await register(email, username, password);
}
} catch (err) {
setError(err.message || 'Error');
}
setLoading(false);
};
const reset = () => {
setError('');
setEmail('');
setUsername('');
setPassword('');
};
return (
<div className="auth-overlay" onClick={closeAuth}>
<div className="auth-card" onClick={e => e.stopPropagation()}>
<div className="auth-logo">
<div className="auth-logo-box">~</div>
<span className="auth-logo-name">Reaktor</span>
</div>
<div className="auth-tabs">
<button
className={`auth-tab ${tab === 'login' ? 'active' : ''}`}
onClick={() => { setTab('login'); reset(); }}
>
Iniciar Sesion
</button>
<button
className={`auth-tab ${tab === 'register' ? 'active' : ''}`}
onClick={() => { setTab('register'); reset(); }}
>
Registrarse
</button>
</div>
<form onSubmit={handleSubmit} className="auth-form">
<label className="auth-label">EMAIL</label>
<input
type="email"
className="auth-input"
placeholder="tu@email.com"
value={email}
onChange={e => setEmail(e.target.value)}
required
/>
{tab === 'register' && (
<>
<label className="auth-label">USUARIO</label>
<input
type="text"
className="auth-input"
placeholder="username"
value={username}
onChange={e => setUsername(e.target.value)}
minLength={3}
maxLength={50}
required
/>
</>
)}
<label className="auth-label">CONTRASEÑA</label>
<input
type="password"
className="auth-input"
placeholder="••••••••"
value={password}
onChange={e => setPassword(e.target.value)}
minLength={6}
required
/>
{error && <div className="auth-error">{error}</div>}
<button type="submit" className="auth-submit" disabled={loading}>
{loading ? '...' : tab === 'login' ? 'Entrar' : 'Crear Cuenta'}
</button>
</form>
<button className="auth-skip" onClick={closeAuth}>
Continuar sin cuenta
</button>
<button className="auth-close" onClick={closeAuth}></button>
</div>
</div>
);
}

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

@@ -0,0 +1,181 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { triggerKeyboard } from '../engine/audioEngine.js';
import { state } from '../engine/state.js';
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
const KEY_MAP = {
'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,
'q': 12, '2': 13, 'w': 14, '3': 15, 'e': 16, 'r': 17,
'5': 18, 't': 19, '6': 20, 'y': 21, '7': 22, 'u': 23, 'i': 24,
};
function midiToFreq(midi) {
return 440 * Math.pow(2, (midi - 69) / 12);
}
// 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 octave = mod?.params?.octave ?? 4;
const activeKeys = useRef(new Set());
const playNote = useCallback((semitone) => {
const midi = (octave + 1) * 12 + semitone;
const freq = midiToFreq(midi);
triggerKeyboard(moduleId, freq, true);
}, [moduleId, octave]);
const stopNote = useCallback(() => {
triggerKeyboard(moduleId, 440, false);
}, [moduleId]);
useEffect(() => {
const handleDown = (e) => {
if (e.repeat) return;
const key = e.key.toLowerCase();
if (KEY_MAP[key] !== undefined && !activeKeys.current.has(key)) {
activeKeys.current.add(key);
playNote(KEY_MAP[key]);
}
};
const handleUp = (e) => {
const key = e.key.toLowerCase();
if (KEY_MAP[key] !== undefined) {
activeKeys.current.delete(key);
if (activeKeys.current.size === 0) stopNote();
}
};
window.addEventListener('keydown', handleDown);
window.addEventListener('keyup', handleUp);
return () => {
window.removeEventListener('keydown', handleDown);
window.removeEventListener('keyup', handleUp);
};
}, [playNote, stopNote]);
// Mini keyboard (1 octave)
const whites = [0, 2, 4, 5, 7, 9, 11];
const blacks = [1, 3, -1, 6, 8, 10];
return (
<>
<div style={{ padding: '2px 0' }}>
<svg viewBox="0 0 140 30" style={{ width: '100%', height: 30 }}>
{whites.map((note, i) => (
<rect key={`w${i}`} x={i * 20} y={0} width={19} height={28}
rx={1} fill="#222" stroke="#444" strokeWidth={0.5}
style={{ cursor: 'pointer' }}
onPointerDown={() => playNote(note)}
onPointerUp={stopNote}
/>
))}
{blacks.filter(n => n >= 0).map((note, i) => {
const pos = [1, 2, 4, 5, 6][i];
return (
<rect key={`b${i}`} x={pos * 20 - 6} y={0} width={12} height={18}
rx={1} fill="#111" stroke="#333" strokeWidth={0.5}
style={{ cursor: 'pointer' }}
onPointerDown={() => playNote(note)}
onPointerUp={stopNote}
/>
);
})}
</svg>
<div style={{ fontSize: 9, color: 'var(--text2)', textAlign: 'center', marginTop: 2 }}>
Z-M / Q-I keys · Oct {octave}
</div>
</div>
{fullscreen && createPortal(
<FullscreenPiano
moduleId={moduleId}
initialOctave={octave}
onClose={onCloseFullscreen}
/>,
document.body
)}
</>
);
}

View File

@@ -0,0 +1,150 @@
import React, { useRef, useCallback, useState } from 'react';
const SIZE = 32;
const RADIUS = 12;
const STROKE = 3;
const START_ANGLE = 225;
const END_ANGLE = -45;
const RANGE = 270; // degrees
function polarToCart(cx, cy, r, deg) {
const rad = (deg - 90) * Math.PI / 180;
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
function describeArc(cx, cy, r, startDeg, endDeg) {
const start = polarToCart(cx, cy, r, endDeg);
const end = polarToCart(cx, cy, r, startDeg);
const large = endDeg - startDeg <= 180 ? '0' : '1';
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${large} 0 ${end.x} ${end.y}`;
}
export default function Knob({ value, min, max, onChange, color = 'var(--accent)', label, unit, formatValue, modulated = false, liveValue }) {
const ref = useRef(null);
const dragRef = useRef(null);
const inputRef = useRef(null);
const [editing, setEditing] = useState(false);
const [editText, setEditText] = useState('');
// Use liveValue for visual display when being modulated, base value for interaction
const displayNum = liveValue !== undefined ? liveValue : value;
const clampedDisplay = Math.max(min, Math.min(max, displayNum));
const norm = Math.max(0, Math.min(1, (clampedDisplay - min) / (max - min)));
const angleDeg = START_ANGLE - norm * RANGE;
const cx = SIZE / 2, cy = SIZE / 2;
const trackPath = describeArc(cx, cy, RADIUS, END_ANGLE, START_ANGLE);
const fillAngle = START_ANGLE - norm * RANGE;
const fillPath = norm > 0.001 ? describeArc(cx, cy, RADIUS, fillAngle, START_ANGLE) : '';
const dotPos = polarToCart(cx, cy, RADIUS - 4, angleDeg);
// Also show base value indicator when modulated
const baseNorm = Math.max(0, Math.min(1, (value - min) / (max - min)));
const baseAngle = START_ANGLE - baseNorm * RANGE;
const baseDotPos = polarToCart(cx, cy, RADIUS - 4, baseAngle);
const displayVal = formatValue ? formatValue(displayNum) :
displayNum >= 1000 ? `${(displayNum / 1000).toFixed(1)}k` :
displayNum >= 100 ? Math.round(displayNum) :
displayNum >= 1 ? displayNum.toFixed(1) :
displayNum.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
const handlePointerDown = useCallback((e) => {
if (editing) return;
e.preventDefault(); e.stopPropagation();
dragRef.current = { startY: e.clientY, startValue: value };
const handleMove = (me) => {
const dy = dragRef.current.startY - me.clientY;
const sensitivity = (max - min) / 200;
let newVal = dragRef.current.startValue + dy * sensitivity;
newVal = Math.max(min, Math.min(max, newVal));
if (max - min > 10 && Number.isInteger(min) && Number.isInteger(max)) {
newVal = Math.round(newVal);
}
onChange(newVal);
};
const handleUp = () => {
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleUp);
dragRef.current = null;
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleUp);
}, [value, min, max, onChange, editing]);
const handleWheel = useCallback((e) => {
if (editing) return;
e.preventDefault(); e.stopPropagation();
const step = (max - min) / 100;
const newVal = Math.max(min, Math.min(max, value - Math.sign(e.deltaY) * step));
onChange(newVal);
}, [value, min, max, onChange, editing]);
// Double-click: open inline text input
const handleDoubleClick = useCallback((e) => {
e.preventDefault(); e.stopPropagation();
setEditText(String(typeof displayVal === 'number' ? displayVal : value));
setEditing(true);
// Focus input after render
setTimeout(() => inputRef.current?.focus(), 0);
}, [value, displayVal]);
const commitEdit = useCallback(() => {
const parsed = parseFloat(editText);
if (!isNaN(parsed)) {
const clamped = Math.max(min, Math.min(max, parsed));
onChange(clamped);
}
setEditing(false);
}, [editText, min, max, onChange]);
const handleInputKeyDown = useCallback((e) => {
e.stopPropagation();
if (e.key === 'Enter') {
commitEdit();
} else if (e.key === 'Escape') {
setEditing(false);
}
}, [commitEdit]);
const handleInputBlur = useCallback(() => {
commitEdit();
}, [commitEdit]);
if (editing) {
return (
<div className="knob-container knob-editing" onWheel={(e) => e.stopPropagation()}>
<input
ref={inputRef}
className="knob-input"
type="text"
value={editText}
onChange={(e) => setEditText(e.target.value)}
onKeyDown={handleInputKeyDown}
onBlur={handleInputBlur}
onPointerDown={(e) => e.stopPropagation()}
/>
</div>
);
}
return (
<div className={`knob-container ${modulated ? 'knob-modulated' : ''}`} onWheel={handleWheel} onDoubleClick={handleDoubleClick}>
<svg className="knob-svg" viewBox={`0 0 ${SIZE} ${SIZE}`}
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} />
{fillPath && <path className="knob-fill" d={fillPath} style={{ stroke: color }} />}
{/* Ghost dot at base value position when modulated */}
{liveValue !== undefined && (
<circle className="knob-base-dot" cx={baseDotPos.x} cy={baseDotPos.y} r={1.5} />
)}
<circle className="knob-dot" cx={dotPos.x} cy={dotPos.y} r={2} />
</svg>
</div>
);
}

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

@@ -0,0 +1,330 @@
import React, { useCallback, useState, useEffect, useRef } from 'react';
import { getModuleDef } from '../engine/moduleRegistry.js';
import { state, removeModule, updateModuleParam, isPortConnected, emit } from '../engine/state.js';
import { updateParam, getAudioNode } from '../engine/audioEngine.js';
import Knob from './Knob.jsx';
import ScopeDisplay from './ScopeDisplay.jsx';
import KeyboardWidget from './KeyboardWidget.jsx';
import DrumPadWidget from './DrumPadWidget.jsx';
import SequencerWidget from './SequencerWidget.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 }) {
const def = getModuleDef(mod.type);
if (!def) return null;
const isSelected = state.selectedModuleId === mod.id;
const [fullscreen, setFullscreen] = useState(false);
// Merge default 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 (any source → any param) ====================
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 = {};
// Read current params fresh from state each tick (avoid stale closure)
const curMod = state.modules.find(m => m.id === mod.id);
if (!curMod) return;
const curDef = getModuleDef(curMod.type);
if (!curDef) return;
const curParams = { ...Object.fromEntries(Object.entries(curDef.params).map(([k, v]) => [k, v.default])), ...curMod.params };
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;
const baseValue = curParams[paramName];
// Modulation scale based on target parameter
const getScale = () => {
if (curMod.type === 'oscillator' && paramName === 'frequency') return baseValue * 0.5;
if (curMod.type === 'filter' && paramName === 'frequency') return baseValue;
if (curMod.type === 'vca' && paramName === 'gain') return 1;
return baseValue || 1;
};
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 phase = (t * lfoP.frequency) % 1;
const lfoVal = simulateLFO(lfoP.waveform, phase) * lfoP.amplitude;
newValues[paramName] = baseValue + lfoVal * getScale();
} else if (srcMod.type === 'envelope') {
// Envelope: read current level (0-1) from the source envelope node
const envEntry = getAudioNode(srcMod.id);
if (envEntry?.node) {
const envValue = typeof envEntry.node.value === 'number' ? envEntry.node.value : 0;
if (curMod.type === 'vca' && paramName === 'gain') {
newValues[paramName] = envValue; // Envelope directly drives gain (0→1)
} else {
newValues[paramName] = baseValue + envValue * getScale();
}
}
} else if (srcMod.type === 'oscillator') {
// Oscillator FM: simulate modulating oscillator waveform
const srcDef = getModuleDef('oscillator');
const srcP = { ...Object.fromEntries(Object.entries(srcDef.params).map(([k, v]) => [k, v.default])), ...srcMod.params };
// Clamp visual frequency to avoid aliasing — show a slow representation
const visFreq = Math.min(srcP.frequency, 8);
const phase = (t * visFreq) % 1;
const modVal = simulateLFO(srcP.waveform, phase) * 0.5;
newValues[paramName] = baseValue + modVal * getScale();
} else if (srcMod.type === 'noise') {
// Noise: random jitter
const noiseVal = (Math.random() * 2 - 1) * 0.3;
newValues[paramName] = baseValue + noiseVal * getScale();
} else {
// Generic fallback: subtle visual pulse so user sees modulation is active
const pulseVal = Math.sin(2 * Math.PI * t) * 0.2;
newValues[paramName] = baseValue + pulseVal * getScale();
}
}
setLiveValues(newValues);
};
rafRef.current = requestAnimationFrame(tick);
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
};
}, [mod.id, mod.type, modulatedParams.size]);
const handleParamChange = useCallback((name, value) => {
updateModuleParam(mod.id, name, value);
updateParam(mod.id, name, value);
}, [mod.id]);
const handleHeaderDown = useCallback((e) => {
if (e.button !== 0) return;
e.stopPropagation();
state.selectedModuleId = mod.id;
state.dragging = {
moduleId: mod.id,
offsetX: e.clientX / zoom - mod.x,
offsetY: e.clientY / zoom - mod.y,
};
emit();
}, [mod, zoom]);
const handleDelete = useCallback((e) => {
e.stopPropagation();
removeModule(mod.id);
}, [mod.id]);
const handlePortMouseDown = useCallback((e, portName, direction) => {
e.stopPropagation(); e.preventDefault();
const portDef = direction === 'output'
? def.outputs.find(p => p.name === portName)
: def.inputs.find(p => p.name === portName);
if (!portDef) return;
const rect = e.currentTarget.getBoundingClientRect();
onStartConnect({
moduleId: mod.id,
port: portName,
portType: portDef.type,
direction,
startX: rect.left + rect.width / 2,
startY: rect.top + rect.height / 2,
});
}, [mod.id, def, onStartConnect]);
// Report port positions for wire rendering
const portRef = useCallback((el, portName, direction) => {
if (el) {
onPortPosition(mod.id, portName, direction, el);
}
}, [mod.id, onPortPosition]);
return (
<div
className={`module ${isSelected ? 'selected' : ''}`}
style={{
left: mod.x * zoom, top: mod.y * zoom,
transform: `scale(${zoom})`, transformOrigin: 'top left',
...(mod.type === 'pianoroll' ? { width: getModuleWidth(mod, 'pianoroll') } : mod.type === 'sequencer' ? { width: getModuleWidth(mod, 'sequencer') } : {}),
}}
data-module-id={mod.id}
onPointerDown={(e) => {
// Don't deselect when clicking inside a module
e.stopPropagation();
state.selectedModuleId = mod.id; emit();
}}
>
<div className="module-header" onPointerDown={handleHeaderDown}>
<span className="type-icon">{def.icon}</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>
</div>
<div className="module-body">
{/* Input ports */}
{def.inputs.map(port => (
<div key={port.name} className="port-row input">
<div
className={`port-dot ${port.type} ${isPortConnected(mod.id, port.name, 'input') ? 'connected' : ''}`}
ref={el => portRef(el, port.name, 'input')}
data-module-id={mod.id}
data-port-name={port.name}
data-port-direction="input"
data-port-type={port.type}
onPointerDown={e => handlePortMouseDown(e, port.name, 'input')}
/>
<span className="port-label">{port.label}</span>
</div>
))}
{/* Parameters */}
{Object.entries(def.params).map(([name, paramDef]) => {
if (paramDef.type === 'knob') {
const color = paramDef.unit === 'Hz' ? 'var(--accent)' :
paramDef.unit === 'dB' ? 'var(--green)' :
paramDef.unit === 's' ? 'var(--purple)' : 'var(--accent)';
return (
<div key={name} className="param-row">
<span className="param-label">{paramDef.label}</span>
<Knob
value={params[name]}
min={paramDef.min}
max={paramDef.max}
onChange={v => handleParamChange(name, v)}
color={color}
modulated={modulatedParams.has(name)}
liveValue={liveValues[name]}
/>
<span className={`param-value ${liveValues[name] !== undefined ? 'param-value-live' : ''}`}>
{(() => {
const v = liveValues[name] !== undefined ? liveValues[name] : params[name];
const s = v >= 1000 ? `${(v / 1000).toFixed(1)}k` :
v >= 100 ? Math.round(v) :
v >= 1 ? Number(v).toFixed(1) :
Number(v).toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
return s;
})()}
{paramDef.unit ? ` ${paramDef.unit}` : ''}
</span>
</div>
);
}
if (paramDef.type === 'select') {
return (
<div key={name} className="param-row">
<span className="param-label">{paramDef.label}</span>
<select className="param-select" value={params[name]}
onChange={e => handleParamChange(name, e.target.value)}>
{paramDef.options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
</div>
);
}
return null;
})}
{/* Scope display */}
{mod.type === 'scope' && <ScopeDisplay moduleId={mod.id} />}
{/* Keyboard widget */}
{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 */}
{mod.type === 'sequencer' && <SequencerWidget moduleId={mod.id} />}
{/* Piano Roll widget */}
{mod.type === 'pianoroll' && <PianoRollWidget moduleId={mod.id} />}
{/* Output ports */}
{def.outputs.map(port => (
<div key={port.name} className="port-row output">
<div
className={`port-dot ${port.type} ${isPortConnected(mod.id, port.name, 'output') ? 'connected' : ''}`}
ref={el => portRef(el, port.name, 'output')}
data-module-id={mod.id}
data-port-name={port.name}
data-port-direction="output"
data-port-type={port.type}
onPointerDown={e => handlePortMouseDown(e, port.name, 'output')}
/>
<span className="port-label">{port.label}</span>
</div>
))}
</div>
</div>
);
}

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

@@ -0,0 +1,112 @@
import React, { useRef, useEffect, useState } from 'react';
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 }) {
const canvasRef = 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(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width = 160;
const h = canvas.height = 60;
let frameCount = 0;
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.fillRect(0, 0, w, h);
// Grid lines
ctx.strokeStyle = '#151530';
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2);
ctx.moveTo(0, h / 4); ctx.lineTo(w, h / 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();
const data = getAnalyserData(moduleId);
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.lineWidth = 1.5;
ctx.beginPath();
const count = end - offset;
const step = w / count;
for (let i = 0; i < count; i++) {
const y = h / 2 + data[offset + i] * h / 2 * -1;
if (i === 0) ctx.moveTo(0, y);
else ctx.lineTo(i * step, y);
}
ctx.stroke();
}
};
draw();
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
}, [moduleId]);
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

@@ -0,0 +1,265 @@
import React, { useState, useEffect, useCallback } from 'react';
import { workshop as workshopApi } from '../services/api.js';
import { useAuth } from '../services/AuthContext.jsx';
import { state, deserialize } from '../engine/state.js';
import { rebuildGraph } from '../engine/audioEngine.js';
import { getPresets } from '../engine/presets.js';
const TAGS = ['ambient', 'bass', 'drums', 'pad', 'lead', 'fx', 'chiptune', 'experimental'];
function ShareModal({ onClose, onShared }) {
const [title, setTitle] = useState('');
const [description, setDescription] = useState('');
const [selectedTags, setSelectedTags] = useState([]);
const [selectedPreset, setSelectedPreset] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const presets = getPresets();
const handleShare = async () => {
if (!title.trim()) { setError('Titulo requerido'); return; }
if (!selectedPreset) { setError('Selecciona un preset para compartir'); return; }
setLoading(true);
setError('');
try {
// Use the preset data directly (already serialized correctly)
const patchData = {
modules: selectedPreset.modules || [],
connections: selectedPreset.connections || [],
camera: selectedPreset.camera || { camX: 0, camY: 0, zoom: 1 },
masterVolume: selectedPreset.masterVolume ?? -6,
};
await workshopApi.share({
title: title.trim(),
description: description.trim(),
tags: selectedTags,
data: patchData,
});
onShared?.();
onClose();
} catch (err) {
setError(err.message);
}
setLoading(false);
};
return (
<div className="auth-overlay" onClick={onClose}>
<div className="auth-card" onClick={e => e.stopPropagation()} style={{ gap: 14, maxHeight: '80vh', overflow: 'auto' }}>
<h2 style={{ fontSize: 18, fontWeight: 700, color: 'var(--text)', margin: 0 }}>Compartir Patch</h2>
<div className="auth-form" style={{ gap: 10 }}>
<label className="auth-label">SELECCIONA UN PRESET</label>
{presets.length === 0 ? (
<p style={{ fontSize: 12, color: 'var(--text2)' }}>
No tienes presets guardados. Ve al Sandbox, crea algo y guardalo con "Save" primero.
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxHeight: 150, overflowY: 'auto' }}>
{presets.map((p, i) => (
<button key={i} type="button"
style={{
padding: '10px 12px', background: selectedPreset === p ? 'var(--surface2)' : 'var(--bg)',
border: `1px solid ${selectedPreset === p ? 'var(--accent)' : 'var(--border)'}`,
borderRadius: 6, cursor: 'pointer', textAlign: 'left',
color: 'var(--text)', fontSize: 13, fontFamily: 'inherit',
}}
onClick={() => { setSelectedPreset(p); if (!title) setTitle(p.name || ''); }}
>
<strong>{p.name}</strong>
<span style={{ color: 'var(--text2)', fontSize: 11, marginLeft: 8 }}>
{p.modules?.length || 0} modules · {p.connections?.length || 0} wires
</span>
</button>
))}
</div>
)}
<label className="auth-label">TITULO</label>
<input className="auth-input" placeholder="Nombre de tu patch"
value={title} onChange={e => setTitle(e.target.value)} />
<label className="auth-label">DESCRIPCION</label>
<textarea className="auth-input" placeholder="Describe tu creacion..."
value={description} onChange={e => setDescription(e.target.value)}
rows={3} style={{ resize: 'vertical', fontFamily: 'inherit' }} />
<label className="auth-label">TAGS</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
{TAGS.map(tag => (
<button key={tag} type="button"
className={`ws-tag ${selectedTags.includes(tag) ? 'active' : ''}`}
onClick={() => setSelectedTags(prev =>
prev.includes(tag) ? prev.filter(t => t !== tag) : [...prev, tag]
)}
>{tag}</button>
))}
</div>
{error && <div className="auth-error">{error}</div>}
<button className="auth-submit" onClick={handleShare}
disabled={loading || presets.length === 0}>
{loading ? 'Compartiendo...' : 'Compartir'}
</button>
</div>
<button className="auth-close" onClick={onClose}></button>
</div>
</div>
);
}
function PatchCard({ patch, onLoad, onLike }) {
const moduleCount = patch.data?.modules?.length || 0;
const wireCount = patch.data?.connections?.length || 0;
return (
<div className="ws-card">
<div className="ws-card-preview">
<span className="ws-card-wave">{moduleCount > 6 ? '~ ~ ~ ~' : '~ ~'}</span>
</div>
<div className="ws-card-body">
<h3 className="ws-card-title">{patch.title}</h3>
<p className="ws-card-author">por {patch.author?.username || 'Anonimo'}</p>
{patch.tags?.length > 0 && (
<div className="ws-card-tags">
{patch.tags.map(t => <span key={t} className="ws-tag-pill">{t}</span>)}
</div>
)}
<div className="ws-card-footer">
<button className="ws-like-btn" onClick={() => onLike(patch.id)}>
{patch.likesCount || 0}
</button>
<span className="ws-card-meta">{moduleCount} modules · {wireCount} wires</span>
<button className="ws-load-btn" onClick={() => onLoad(patch)}>Cargar</button>
</div>
</div>
</div>
);
}
export default function Workshop({ onSwitchToSandbox, onSwitchToGame, onSwitchToAdmin }) {
const { isLoggedIn, isAdmin, openAuth, logout, user } = useAuth();
const [patches, setPatches] = useState([]);
const [search, setSearch] = useState('');
const [activeTag, setActiveTag] = useState('');
const [sort, setSort] = useState('recent');
const [loading, setLoading] = useState(true);
const [showShare, setShowShare] = useState(false);
const loadPatches = useCallback(async () => {
setLoading(true);
try {
const params = new URLSearchParams();
if (search) params.set('q', search);
if (activeTag) params.set('tags', activeTag);
params.set('sort', sort);
const data = await workshopApi.browse(params.toString());
setPatches(data.patches || []);
} catch (err) {
console.warn('Workshop load failed:', err);
}
setLoading(false);
}, [search, activeTag, sort]);
useEffect(() => { loadPatches(); }, [loadPatches]);
const handleLoad = (patch) => {
if (!patch.data) return;
// Deep clone and load — same pattern as loadPreset()
// Don't stop audio first: rebuildGraph destroys and recreates all nodes
const cleanData = JSON.parse(JSON.stringify(patch.data));
deserialize(cleanData);
if (state.isRunning) rebuildGraph();
onSwitchToSandbox?.();
};
const handleLike = async (patchId) => {
if (!isLoggedIn) { openAuth(); return; }
try {
await workshopApi.like(patchId);
loadPatches();
} catch {}
};
const handleShare = () => {
if (!isLoggedIn) { openAuth(); return; }
setShowShare(true);
};
return (
<div className="ws-page">
<nav className="ws-nav">
<button className="ws-back-btn" onClick={onSwitchToSandbox}> Volver</button>
<span style={{ fontSize: 16, fontWeight: 700, color: 'var(--text)' }}>Workshop</span>
<div style={{ flex: 1 }} />
{isAdmin && onSwitchToAdmin && (
<button className="ws-nav-tab" onClick={onSwitchToAdmin} style={{ color: 'var(--yellow)' }}>🛠 Admin</button>
)}
{isLoggedIn ? (
<div className="user-badge" onClick={logout} title="Cerrar sesion">
<div className="user-avatar">{user?.username?.[0]?.toUpperCase()}</div>
<span className="user-name">{user?.username}</span>
</div>
) : (
<button className="login-btn" onClick={openAuth}>Entrar</button>
)}
</nav>
<div className="ws-header">
<h1 className="ws-title">Workshop</h1>
<p className="ws-subtitle">Explora, comparte y descubre sonidos de la comunidad</p>
</div>
<div className="ws-toolbar">
<div className="ws-search">
<span>🔍</span>
<input placeholder="Buscar patches..." value={search}
onChange={e => setSearch(e.target.value)} />
</div>
<div className="ws-tags">
<button className={`ws-tag ${!activeTag ? 'active' : ''}`}
onClick={() => setActiveTag('')}>Todos</button>
{TAGS.slice(0, 5).map(tag => (
<button key={tag} className={`ws-tag ${activeTag === tag ? 'active' : ''}`}
onClick={() => setActiveTag(activeTag === tag ? '' : tag)}>{tag}</button>
))}
</div>
<select className="ws-sort" value={sort} onChange={e => setSort(e.target.value)}>
<option value="recent">Recientes</option>
<option value="popular">Popular</option>
</select>
<button className="ws-share-btn" onClick={handleShare}>
+ Compartir Patch
</button>
</div>
<div className="ws-grid">
{loading ? (
<p style={{ color: 'var(--text2)', gridColumn: '1/-1', textAlign: 'center', padding: 40 }}>
Cargando...
</p>
) : patches.length === 0 ? (
<p style={{ color: 'var(--text2)', gridColumn: '1/-1', textAlign: 'center', padding: 40 }}>
No hay patches aun. Se el primero en compartir!
</p>
) : (
patches.map(p => (
<PatchCard key={p.id} patch={p} onLoad={handleLoad} onLike={handleLike} />
))
)}
</div>
{showShare && <ShareModal onClose={() => setShowShare(false)} onShared={loadPatches} />}
</div>
);
}

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) {

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);
}

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

@@ -0,0 +1,94 @@
import React, { useState, useCallback } from 'react';
import WorldMap from './WorldMap.jsx';
import PuzzleView from './PuzzleView.jsx';
import AdminPanel from './AdminPanel.jsx';
import { WORLD_1 } from './levels/world1.js';
import { WORLD_2 } from './levels/world2.js';
import { WORLD_3 } from './levels/world3.js';
import { WORLD_4 } from './levels/world4.js';
import { WORLD_5 } from './levels/world5.js';
import { WORLD_6 } from './levels/world6.js';
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, onSwitchToWorkshop }) {
const [view, setView] = useState('map');
const [currentLevel, setCurrentLevel] = useState(null);
const [currentLevelIndex, setCurrentLevelIndex] = useState(0);
const [currentWorld, setCurrentWorld] = useState(null);
const [showAdmin, setShowAdmin] = useState(false);
const [adminMode, setAdminMode] = useState(false);
const handleSelectLevel = useCallback((level, world) => {
const idx = world.levels.findIndex(l => l.id === level.id);
setCurrentLevel(level);
setCurrentLevelIndex(idx);
setCurrentWorld(world);
setView('puzzle');
}, []);
const handleBack = useCallback(() => {
setView('map');
setCurrentLevel(null);
setCurrentWorld(null);
}, []);
const handleNextLevel = useCallback(() => {
if (!currentWorld) return;
const nextIdx = currentLevelIndex + 1;
if (nextIdx < currentWorld.levels.length) {
setCurrentLevel(currentWorld.levels[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 {
setView('map');
}
}
}, [currentLevelIndex, currentWorld]);
if (view === 'puzzle' && currentLevel && currentWorld) {
return (
<PuzzleView
key={currentLevel.id}
level={currentLevel}
levelIndex={currentLevelIndex}
worldLevels={currentWorld.levels}
onBack={handleBack}
onNextLevel={handleNextLevel}
adminMode={adminMode}
/>
);
}
return (
<>
<WorldMap
onSelectLevel={handleSelectLevel}
onSandbox={onSwitchToSandbox}
onWorkshop={onSwitchToWorkshop}
onAdmin={() => setShowAdmin(true)}
/>
{showAdmin && (
<AdminPanel
worlds={allWorlds}
onClose={() => setShowAdmin(false)}
adminMode={adminMode}
onToggleAdmin={() => setAdminMode(a => !a)}
/>
)}
</>
);
}

View File

@@ -1,21 +1,24 @@
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 }) { export default function LevelComplete({ stars, checks, levelTitle, onNext, onRetry, onMap, isLastLevel, hintPenalty }) {
const [showStars, setShowStars] = useState(0); const [showStars, setShowStars] = useState(0);
useEffect(() => { useEffect(() => {
// Animate stars appearing one by one
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]);
const messages = [ const messages = [
'', // 0 stars '',
'Has dado el primer paso...', 'Has dado el primer paso...',
'Buen trabajo — casi perfecto.', hintPenalty ? 'Pista usada — tercera estrella bloqueada permanentemente.' : 'Buen trabajo — casi perfecto.',
'Ejecucion impecable.', 'Ejecucion impecable.',
]; ];
@@ -27,21 +30,25 @@ export default function LevelComplete({ stars, checks, levelTitle, onNext, onRet
</h2> </h2>
<p className="gm-complete-level">{levelTitle}</p> <p className="gm-complete-level">{levelTitle}</p>
{/* Stars */}
<div className="gm-complete-stars"> <div className="gm-complete-stars">
{[1, 2, 3].map(i => ( {[1, 2, 3].map(i => (
<span <span
key={i} key={i}
className={`gm-big-star ${i <= showStars ? 'earned' : 'empty'}`} className={`gm-big-star ${i <= showStars ? 'earned' : 'empty'} ${i === 3 && hintPenalty ? 'locked' : ''}`}
> >
{i === 3 && hintPenalty ? '🔒' : '★'}
</span> </span>
))} ))}
</div> </div>
<p className="gm-complete-msg">{messages[stars] || ''}</p> <p className="gm-complete-msg">{messages[stars] || ''}</p>
{/* Checks */} {hintPenalty && (
<p className="gm-hint-penalty-msg">
Usaste la pista tercera estrella bloqueada
</p>
)}
<div className="gm-checks"> <div className="gm-checks">
{checks.map((check, i) => ( {checks.map((check, i) => (
<div key={i} className={`gm-check ${check.passed ? 'passed' : 'failed'}`}> <div key={i} className={`gm-check ${check.passed ? 'passed' : 'failed'}`}>
@@ -52,10 +59,11 @@ export default function LevelComplete({ stars, checks, levelTitle, onNext, onRet
))} ))}
</div> </div>
{/* Actions */}
<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}>Reintentar</button> <button className="gm-btn secondary" onClick={onRetry}>
Reintentar
</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

@@ -0,0 +1,625 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { state, subscribe, addModule, emit, addConnection, removeModule, updateModulePosition, deserialize } from '../engine/state.js';
import { startAudio, stopAudio, connectWire, rebuildGraph } from '../engine/audioEngine.js';
import { getModuleDef } from '../engine/moduleRegistry.js';
import ModuleNode from '../components/ModuleNode.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 LevelComplete from './LevelComplete.jsx';
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, adminMode }) {
const [, forceUpdate] = useState(0);
const containerRef = useRef(null);
const portPositions = useRef({});
const [tempWire, setTempWire] = useState(null);
const connectingRef = useRef(null);
const [hintUsed, setHintUsed] = useState(false);
const [showHint, setShowHint] = useState(false);
const [result, setResult] = useState(null);
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(() => {
const unsub = subscribe(() => {
forceUpdate(n => n + 1);
// Auto-save patch on every state change (debounced below)
scheduleSave();
});
return unsub;
}, [level.id]);
// Debounced auto-save of the current patch
const saveTimerRef = useRef(null);
const scheduleSave = useCallback(() => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
saveTimerRef.current = setTimeout(() => {
if (state.modules.length > 0) {
saveLevelPatch(level.id, state.modules, state.connections);
}
}, 1000);
}, [level.id]);
useEffect(() => {
return () => {
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
};
}, []);
useEffect(() => {
loadLevel();
// Center view on modules after level loads and DOM settles
const timer = setTimeout(() => handleCenterView(), 100);
return () => {
clearTimeout(timer);
stopAudio();
stopTarget();
};
}, [level.id]);
const loadLevel = useCallback((forceReset = false) => {
// Check for a saved patch first (unless explicitly resetting)
const saved = !forceReset ? getLevelPatch(level.id) : null;
if (saved) {
const data = {
modules: saved.modules.map(m => ({
id: m.id, type: m.type, x: m.x, y: m.y, params: { ...m.params },
})),
connections: saved.connections.map(c => ({ ...c })),
camera: { camX: 0, camY: 0, zoom: 1 },
};
deserialize(data);
} else {
const data = {
modules: (level.preplacedModules || []).map(m => ({
id: m.id, type: m.type, x: m.x, y: m.y, params: { ...m.params },
})),
connections: [],
camera: { camX: 0, camY: 0, zoom: 1 },
};
deserialize(data);
}
setResult(null);
// Restore persisted hint state — no cheating by reloading!
const hintPersisted = wasHintUsed(level.id);
setHintUsed(hintPersisted);
setShowHint(hintPersisted); // If they used it before, show it again
if (state.isRunning) stopAudio();
}, [level]);
const handlePortPosition = useCallback((moduleId, portName, direction, el) => {
const key = `${moduleId}-${portName}-${direction}`;
portPositions.current[key] = el;
}, []);
const handleStartConnect = useCallback((info) => {
connectingRef.current = info;
const containerRect = containerRef.current.getBoundingClientRect();
setTempWire({
portType: info.portType,
startX: info.startX - containerRect.left,
startY: info.startY - containerRect.top,
endX: info.startX - containerRect.left,
endY: info.startY - containerRect.top,
});
}, []);
// Robust port detection — searches all port-dots by bounding rect distance
// instead of elementFromPoint (which gets blocked by SVG wire overlay)
const findPortAtPoint = (clientX, clientY) => {
const portDots = document.querySelectorAll('.port-dot[data-module-id]');
let closest = null;
let closestDist = 18;
for (const dot of portDots) {
const rect = dot.getBoundingClientRect();
const cx = rect.left + rect.width / 2;
const cy = rect.top + rect.height / 2;
const dist = Math.sqrt((clientX - cx) ** 2 + (clientY - cy) ** 2);
if (dist < closestDist) {
closestDist = dist;
closest = dot;
}
}
return closest;
};
const handlePointerDown = useCallback((e) => {
if (e.button === 1 || (e.button === 0 && e.altKey) || e.button === 2) {
state.panning = true;
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
e.preventDefault();
} 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;
emit();
}
}, [isMobile]);
const handlePointerMove = useCallback((e) => {
if (state.panning && state.panStart) {
state.camX = e.clientX - state.panStart.x;
state.camY = e.clientY - state.panStart.y;
emit();
return;
}
if (state.dragging) {
const newX = e.clientX / state.zoom - state.dragging.offsetX;
const newY = e.clientY / state.zoom - state.dragging.offsetY;
updateModulePosition(state.dragging.moduleId, newX, newY);
return;
}
if (connectingRef.current && containerRef.current) {
const containerRect = containerRef.current.getBoundingClientRect();
setTempWire(prev => prev ? {
...prev,
endX: e.clientX - containerRect.left,
endY: e.clientY - containerRect.top,
} : null);
}
}, []);
const handlePointerUp = useCallback((e) => {
if (state.panning) {
state.panning = false;
state.panStart = null;
}
if (state.dragging) {
state.dragging = null;
emit();
}
if (connectingRef.current) {
const portEl = findPortAtPoint(e.clientX, e.clientY);
if (portEl) finishConnection(portEl);
connectingRef.current = null;
setTempWire(null);
}
}, []);
const finishConnection = (portEl) => {
const from = connectingRef.current;
if (!from) return;
const targetModuleId = parseInt(portEl.dataset.moduleId);
const targetPort = portEl.dataset.portName;
const targetDirection = portEl.dataset.portDirection;
if (!targetModuleId || !targetPort || !targetDirection) return;
if (targetModuleId === from.moduleId && targetPort === from.port) return;
let fromMod, fromPort, toMod, toPort;
if (from.direction === 'output' && targetDirection === 'input') {
fromMod = from.moduleId; fromPort = from.port;
toMod = targetModuleId; toPort = targetPort;
} else if (from.direction === 'input' && targetDirection === 'output') {
fromMod = targetModuleId; fromPort = targetPort;
toMod = from.moduleId; toPort = from.port;
} else return;
const connId = addConnection(fromMod, fromPort, toMod, toPort);
if (connId && state.isRunning) {
const conn = state.connections.find(c => c.id === connId);
if (conn) connectWire(conn);
}
};
const handleWheel = useCallback((e) => {
e.preventDefault();
const delta = -e.deltaY * 0.001;
state.zoom = Math.max(0.3, Math.min(3, state.zoom + delta));
emit();
}, []);
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 x = (-state.camX + 250) / state.zoom + Math.random() * 30;
const y = (-state.camY + 150) / state.zoom + Math.random() * 30;
addModule(type, x, y);
if (state.isRunning) rebuildGraph();
};
const handleToggleAudio = async () => {
if (state.isRunning) {
stopAudio();
playEngineStop();
} else {
await startAudio();
playEngineStart();
}
emit();
};
const handlePlayTarget = async () => {
if (isTargetPlaying()) {
stopTarget();
setTargetPlaying(false);
} else {
setTargetPlaying(true);
await playTarget(level.target);
setTimeout(() => setTargetPlaying(false), (level.target.duration || 2) * 1000 + 100);
}
};
// 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 = () => {
setHintUsed(true);
setShowHint(true);
markHintUsed(level.id);
playHint();
};
const handleCheck = () => {
const mods = state.modules;
const conns = state.connections;
const checks = level.checks.map(check => ({
...check,
passed: check.test(mods, conns),
}));
let stars = 0;
for (const check of checks) {
if (check.passed) stars = check.star;
else break;
}
// Cap at 2 stars if hint was used
if (hintUsed && stars > 2) stars = 2;
setResult({ stars, checks, hintPenalty: hintUsed && stars >= 2 });
if (stars >= 1) {
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;
return (
<div className="gm-puzzle">
{/* Top bar */}
<div className="gm-puzzle-bar">
<button className="gm-btn icon" onClick={() => { playNav(); onBack(); }}>{isMobile ? '←' : '← Mapa'}</button>
<div className="gm-puzzle-title">
<span className="gm-puzzle-num">{levelIndex + 1}/{worldLevels.length}</span>
<span className="gm-puzzle-name">{level.title}</span>
</div>
<div className="gm-puzzle-actions">
<button
className={`gm-btn ${targetPlaying ? 'active' : 'target'}`}
onClick={handlePlayTarget}
>
{targetPlaying ? '⏹' : '🎯'}{!isMobile && <span className="btn-label">{targetPlaying ? ' Parar' : ' Objetivo'}</span>}
</button>
<button
className={`gm-btn ${state.isRunning ? 'active' : ''}`}
onClick={handleToggleAudio}
>
{state.isRunning ? '⏹' : '▶'}{!isMobile && <span className="btn-label">{state.isRunning ? ' Parar' : ' Mi Sonido'}</span>}
</button>
{!isMobile && (
<button className="gm-btn clear" onClick={handleClearCanvas} title="Limpiar canvas">
🗑 Limpiar
</button>
)}
<button className="gm-btn check" onClick={handleCheck}>
{!isMobile && <span className="btn-label"> Comprobar</span>}
</button>
{adminMode && (
<button className="gm-btn admin-solve" onClick={handleAutoSolve} title="Admin: resolver nivel">
🛠 Resolver
</button>
)}
</div>
</div>
<div className="gm-puzzle-content">
{/* Left sidebar (desktop only — hidden on mobile via CSS) */}
<div className="gm-puzzle-sidebar">
{/* Description — always visible */}
<div className="gm-concept-panel">
<div className="gm-concept-header">
<span>📖 Mision</span>
</div>
<div className="gm-concept-body">
<p className="gm-concept-desc">{level.description}</p>
</div>
</div>
{/* Hint — hidden, reveals with penalty */}
<div className="gm-hint-panel">
{!showHint ? (
<button className="gm-hint-btn" onClick={handleRevealHint}>
<span className="gm-hint-icon">💡</span>
<span className="gm-hint-label">Mostrar Pista</span>
<span className="gm-hint-penalty">max </span>
</button>
) : (
<div className="gm-hint-revealed">
<div className="gm-hint-header">
<span>💡 Pista</span>
<span className="gm-hint-penalty-tag">max </span>
</div>
<p className="gm-hint-text">{level.concept}</p>
</div>
)}
</div>
{/* Objectives */}
<div className="gm-objectives">
<div className="gm-obj-title">Objetivos</div>
{level.checks.map((check, i) => {
const passed = result?.checks?.[i]?.passed;
const cappedByStar = hintUsed && check.star === 3;
return (
<div key={i} className={`gm-obj ${passed === true ? (cappedByStar ? 'capped' : 'passed') : passed === false ? 'failed' : ''}`}>
<span className="gm-obj-star">{'★'.repeat(check.star)}</span>
<span className="gm-obj-name">
{check.desc}
{cappedByStar && <span className="gm-obj-locked"> 🔒</span>}
</span>
{passed === true && !cappedByStar && <span className="gm-obj-check"></span>}
{passed === false && <span className="gm-obj-x"></span>}
</div>
);
})}
{hintUsed && (
<div className="gm-hint-warning">
Pista usada maximo 2 estrellas en este nivel (permanente).
</div>
)}
</div>
{/* Module palette */}
{level.availableModules.length > 0 && (
<div className="gm-module-palette">
<div className="gm-palette-title">Modulos Disponibles</div>
{level.availableModules.map(type => {
const def = getModuleDef(type);
if (!def) return null;
return (
<div key={type} className="gm-palette-item" onClick={() => handleAddModule(type)}>
<span className="gm-palette-icon">{def.icon}</span>
<span className="gm-palette-name">{def.name}</span>
<span className="gm-palette-add">+</span>
</div>
);
})}
</div>
)}
<button className="gm-btn danger" onClick={() => loadLevel(true)} style={{ marginTop: 'auto' }}>
Reiniciar Nivel
</button>
</div>
{/* Main canvas */}
<div className="gm-puzzle-canvas-wrap">
<div
ref={containerRef}
className={`node-canvas ${state.panning ? 'grabbing' : ''} ${connectingRef.current ? 'connecting' : ''}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onWheel={handleWheel}
onContextMenu={handleContextMenu}
>
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', zIndex: 0 }}>
<defs>
<pattern id="puzzle-grid" width={20 * state.zoom} height={20 * state.zoom}
patternUnits="userSpaceOnUse"
x={state.camX % (20 * state.zoom)} y={state.camY % (20 * state.zoom)}>
<circle cx={1} cy={1} r={0.8} fill="#1a1a30" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#puzzle-grid)" />
</svg>
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} zoom={state.zoom} camX={state.camX} camY={state.camY} />
<div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
{state.modules.map(mod => (
<ModuleNode
key={mod.id}
mod={mod}
zoom={state.zoom}
onStartConnect={handleStartConnect}
onPortPosition={handlePortPosition}
/>
))}
</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 && (
<div className="gm-canvas-hint">
Arrastra de un puerto (circulo) a otro para conectar modulos
</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 */}
{result && result.stars >= 1 && (
<LevelComplete
stars={result.stars}
checks={result.checks}
levelTitle={level.title}
isLastLevel={isLastLevel}
hintPenalty={result.hintPenalty}
onRetry={() => setResult(null)}
onMap={onBack}
onNext={onNextLevel}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,247 @@
import React, { useState, useRef } from 'react';
import MobileTabBar from '../components/MobileTabBar.jsx';
import { useIsMobile } from '../hooks/useIsMobile.js';
import { useAuth } from '../services/AuthContext.jsx';
import { WORLD_1 } from './levels/world1.js';
import { WORLD_2 } from './levels/world2.js';
import { WORLD_3 } from './levels/world3.js';
import { WORLD_4 } from './levels/world4.js';
import { WORLD_5 } from './levels/world5.js';
import { WORLD_6 } from './levels/world6.js';
import { WORLD_7 } from './levels/world7.js';
import { WORLD_8 } from './levels/world8.js';
import { WORLD_9 } from './levels/world9.js';
import { WORLD_10 } from './levels/world10.js';
import { WORLD_11 } from './levels/world11.js';
import { WORLD_12 } from './levels/world12.js';
import { getLevelProgress, isLevelUnlocked, loadProgress } from './gameState.js';
const worlds = [WORLD_1, WORLD_2, WORLD_3, WORLD_4, WORLD_5, WORLD_6, WORLD_7, WORLD_8, WORLD_9, WORLD_10, WORLD_11, WORLD_12];
function Stars({ count, max = 3 }) {
return (
<span className="gm-stars">
{Array.from({ length: max }, (_, i) => (
<span key={i} className={i < count ? 'star filled' : 'star empty'}></span>
))}
</span>
);
}
function getTotalStars() {
const p = loadProgress();
return Object.values(p.completedLevels).reduce((s, l) => s + (l.stars || 0), 0);
}
function getMaxStars() {
return worlds.reduce((s, w) => s + w.levels.length * 3, 0);
}
function isWorldUnlocked(world) {
if (!world.unlockStars) return true; // World 1 always unlocked
return getTotalStars() >= world.unlockStars;
}
const MOBILE_TABS = [
{ id: 'game', label: 'JUEGO', icon: '🎮' },
{ id: 'sandbox', label: 'SANDBOX', icon: '🎛' },
{ id: 'workshop', label: 'WORKSHOP', icon: '🎵' },
{ id: 'config', label: 'CONFIG', icon: '⚙' },
];
export default function WorldMap({ onSelectLevel, onSandbox, onAdmin, onWorkshop }) {
const totalStars = getTotalStars();
const maxStars = getMaxStars();
const [search, setSearch] = useState('');
const searchRef = useRef(null);
const isMobile = useIsMobile();
const { user, isLoggedIn, openAuth, logout } = useAuth();
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 (
<div className="gm-worldmap">
{/* Header */}
<div className="gm-header">
<div className="gm-logo">
<span className="gm-logo-icon">~</span>
<div>
<h1 className="gm-title">SynthQuest</h1>
<p className="gm-tagline">Aprende sintesis modular resolviendo puzzles</p>
</div>
</div>
<div className="gm-header-right">
<div className="gm-total-stars">
<span className="star filled"></span> {totalStars}/{maxStars}
</div>
<button className="gm-sandbox-btn" onClick={onSandbox}>
🎛 Sandbox
</button>
{onAdmin && (
<button className="gm-admin-btn" onClick={onAdmin} title="Admin Mode">
🛠
</button>
)}
{isLoggedIn ? (
<div className="user-badge" onClick={logout} title="Cerrar sesion">
<div className="user-avatar">{user.username?.[0]?.toUpperCase()}</div>
<span className="user-name">{user.username}</span>
</div>
) : (
<button className="login-btn" onClick={openAuth}>Entrar</button>
)}
</div>
</div>
{/* Search bar */}
<div className="gm-search-bar">
<span className="gm-search-icon">🔍</span>
<input
ref={searchRef}
className="gm-search-input"
type="text"
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>
{/* 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">
{searchResults.map(({ level, world, worldIdx, idx }) => {
const progress = getLevelProgress(level.id);
const levelUnlocked = isLevelUnlocked(level.id, world.levels) && isWorldUnlocked(world);
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" 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-info">
<h3 className="gm-level-title">{level.title}</h3>
<p className="gm-level-subtitle">{level.subtitle}</p>
</div>
{levelUnlocked ? (
<Stars count={stars} />
) : (
<span className="gm-lock">🔒</span>
)}
{!levelUnlocked && <div className="gm-lock-overlay" />}
</div>
);
})}
</div>
</div>
);
})
)}
{/* Mobile tab bar */}
{isMobile && (
<MobileTabBar
tabs={MOBILE_TABS}
activeTab="game"
onTabChange={(id) => {
if (id === 'sandbox') onSandbox?.();
if (id === 'workshop') onWorkshop?.();
if (id === 'config') onAdmin?.();
}}
/>
)}
</div>
);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,149 @@
/**
* gameState.js — Game progress persistence
* Tracks completed levels, stars earned, unlocks, and saved patches per level
*/
const STORAGE_KEY = 'synthquest-progress';
const PATCHES_KEY = 'synthquest-patches';
const defaultProgress = {
currentWorld: 'w1',
completedLevels: {}, // { levelId: { stars: 3 } }
unlockedWorlds: ['w1'],
totalStars: 0,
};
let _progress = null;
let _patches = null; // { levelId: { modules, connections } }
export function loadProgress() {
if (_progress) return _progress;
try {
const raw = localStorage.getItem(STORAGE_KEY);
_progress = raw ? { ...defaultProgress, ...JSON.parse(raw) } : { ...defaultProgress };
} catch {
_progress = { ...defaultProgress };
}
return _progress;
}
export function saveProgress() {
if (!_progress) return;
_progress.totalStars = Object.values(_progress.completedLevels)
.reduce((sum, l) => sum + (l.stars || 0), 0);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(_progress));
} catch {}
}
export function completeLevel(levelId, stars) {
const p = loadProgress();
const existing = p.completedLevels[levelId];
if (!existing || stars > existing.stars) {
p.completedLevels[levelId] = { stars, completedAt: Date.now() };
}
saveProgress();
}
export function getLevelProgress(levelId) {
const p = loadProgress();
return p.completedLevels[levelId] || null;
}
export function isLevelUnlocked(levelId, worldLevels) {
const p = loadProgress();
const idx = worldLevels.findIndex(l => l.id === levelId);
if (idx === 0) return true;
const prevId = worldLevels[idx - 1]?.id;
return prevId && p.completedLevels[prevId]?.stars >= 1;
}
export function resetProgress() {
_progress = { ...defaultProgress };
_patches = {};
_hints = {};
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();
}

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;
},
},
],
},
],
};

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;
},
},
],
},
],
};

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;
},
},
],
},
],
};

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;
},
},
],
},
],
};

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;
},
},
],
},
],
};

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');
},
},
],
},
],
};

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);
},
},
],
},
],
};

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;
},
},
],
},
],
};

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;
},
},
],
},
],
};

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;
},
},
],
},
],
};

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

@@ -0,0 +1,193 @@
/**
* targetAudio.js — Plays the "target" sound for a puzzle level
* 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';
let _activeNodes = [];
let _isPlaying = false;
let _stopTimeout = null;
let _loops = []; // Track Tone.Loop instances for cleanup
export function isTargetPlaying() {
return _isPlaying;
}
export async function playTarget(target) {
if (_isPlaying) {
stopTarget();
return;
}
await Tone.start();
_isPlaying = true;
const nodes = [];
const output = new Tone.Gain(0.5).toDestination();
nodes.push(output);
// 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) {
if (spec.type === 'oscillator') {
const osc = new Tone.Oscillator({
type: spec.params.waveform || 'sine',
frequency: spec.params.frequency || 440,
detune: spec.params.detune || 0,
});
osc.connect(destination);
// Connect LFO to frequency if specified
if (lfo && target.lfo?.target === 'frequency') {
lfo.connect(osc.frequency);
}
osc.start();
nodes.push(osc);
} else if (spec.type === 'noise') {
const noise = new Tone.Noise(spec.params.type || 'white');
noise.connect(destination);
noise.start();
nodes.push(noise);
}
}
// 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;
// Auto-stop after duration
const dur = (target.duration || 2) * 1000;
_stopTimeout = setTimeout(() => stopTarget(), dur);
}
export function stopTarget() {
if (_stopTimeout) {
clearTimeout(_stopTimeout);
_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) {
try {
if (node.stop) node.stop();
if (node.disconnect) node.disconnect();
if (node.dispose) node.dispose();
} catch {}
}
_activeNodes = [];
_isPlaying = false;
}

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;
}

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]);
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,52 @@
import React, { useState } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import GameApp from './game/GameApp.jsx';
import Workshop from './components/Workshop.jsx';
import AdminPanel2 from './components/AdminPanel2.jsx';
import { AuthProvider } from './services/AuthContext.jsx';
import AuthModal from './components/AuthModal.jsx';
import './index.css';
function Root() {
const [mode, setMode] = useState('game'); // 'game' | 'sandbox' | 'workshop' | 'admin'
const nav = {
toGame: () => setMode('game'),
toSandbox: () => setMode('sandbox'),
toWorkshop: () => setMode('workshop'),
toAdmin: () => setMode('admin'),
};
return (
<AuthProvider>
{mode === 'sandbox' && <App onSwitchToGame={nav.toGame} onSwitchToWorkshop={nav.toWorkshop} onSwitchToAdmin={nav.toAdmin} />}
{mode === 'game' && <GameApp onSwitchToSandbox={nav.toSandbox} onSwitchToWorkshop={nav.toWorkshop} />}
{mode === 'workshop' && <Workshop onSwitchToSandbox={nav.toSandbox} onSwitchToGame={nav.toGame} onSwitchToAdmin={nav.toAdmin} />}
{mode === 'admin' && <AdminPanel2 onBack={nav.toGame} />}
<AuthModal />
</AuthProvider>
);
}
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(() => {});
});
}

View File

@@ -0,0 +1,79 @@
import React, { createContext, useContext, useState, useEffect, useCallback } from 'react';
import { auth as authApi, users as usersApi, setAccessToken, setOnUnauthorized } from './api.js';
import { startAutoSync, stopAutoSync } from './syncService.js';
const AuthContext = createContext(null);
export function useAuth() {
return useContext(AuthContext);
}
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [showAuth, setShowAuth] = useState(false);
const logout = useCallback(async () => {
stopAutoSync();
try { await authApi.logout(); } catch {}
setAccessToken(null);
setUser(null);
}, []);
// On mount, try to refresh session
useEffect(() => {
setOnUnauthorized(() => {
setUser(null);
setAccessToken(null);
});
authApi.refresh().then(async (ok) => {
if (ok) {
try {
const me = await usersApi.me();
setUser(me);
startAutoSync();
} catch {}
}
setLoading(false);
});
}, []);
const login = useCallback(async (email, password) => {
const data = await authApi.login(email, password);
setAccessToken(data.accessToken);
setUser(data.user);
setShowAuth(false);
startAutoSync();
return data.user;
}, []);
const register = useCallback(async (email, username, password) => {
const data = await authApi.register(email, username, password);
setAccessToken(data.accessToken);
setUser(data.user);
setShowAuth(false);
startAutoSync();
return data.user;
}, []);
const value = {
user,
loading,
isLoggedIn: !!user,
isAdmin: user?.role === 'admin',
isPremium: user?.role === 'premium' || user?.role === 'admin',
login,
register,
logout,
showAuth,
openAuth: () => setShowAuth(true),
closeAuth: () => setShowAuth(false),
};
return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
);
}

View File

@@ -0,0 +1,103 @@
const API_BASE = '/api/v1';
let _accessToken = null;
let _onUnauthorized = null;
export function setAccessToken(token) { _accessToken = token; }
export function getAccessToken() { return _accessToken; }
export function setOnUnauthorized(fn) { _onUnauthorized = fn; }
async function request(method, path, body = null, opts = {}) {
const headers = { 'Content-Type': 'application/json' };
if (_accessToken) headers['Authorization'] = `Bearer ${_accessToken}`;
const res = await fetch(`${API_BASE}${path}`, {
method,
headers,
credentials: 'include', // send cookies (refresh token)
body: body ? JSON.stringify(body) : null,
...opts,
});
if (res.status === 401 && !path.includes('/auth/')) {
// Try to refresh
const refreshed = await refreshToken();
if (refreshed) {
headers['Authorization'] = `Bearer ${_accessToken}`;
const retry = await fetch(`${API_BASE}${path}`, {
method, headers, credentials: 'include',
body: body ? JSON.stringify(body) : null,
});
if (retry.ok) return retry.json();
}
_onUnauthorized?.();
throw new Error('Unauthorized');
}
if (!res.ok) {
const data = await res.json().catch(() => ({}));
throw new Error(data.error || `HTTP ${res.status}`);
}
return res.json();
}
async function refreshToken() {
try {
const res = await fetch(`${API_BASE}/auth/refresh`, {
method: 'POST',
credentials: 'include',
});
if (!res.ok) return false;
const data = await res.json();
_accessToken = data.accessToken;
return true;
} catch {
return false;
}
}
// Auth
export const auth = {
register: (email, username, password) =>
request('POST', '/auth/register', { email, username, password }),
login: (email, password) =>
request('POST', '/auth/login', { email, password }),
logout: () => request('POST', '/auth/logout'),
refresh: refreshToken,
};
// Users
export const users = {
me: () => request('GET', '/users/me'),
update: (data) => request('PATCH', '/users/me', data),
};
// Workshop
export const workshop = {
browse: (params = '') => request('GET', `/workshop${params ? '?' + params : ''}`),
get: (id) => request('GET', `/workshop/${id}`),
share: (data) => request('POST', '/workshop', data),
remove: (id) => request('DELETE', `/workshop/${id}`),
like: (id) => request('POST', `/workshop/${id}/like`),
unlike: (id) => request('DELETE', `/workshop/${id}/like`),
report: (id) => request('POST', `/workshop/${id}/report`),
};
// Admin Levels
export const levels = {
list: () => request('GET', '/admin/levels'),
create: (data) => request('POST', '/admin/levels', data),
update: (id, data) => request('PATCH', `/admin/levels/${id}`, data),
remove: (id) => request('DELETE', `/admin/levels/${id}`),
importPatch: (id, patchData) => request('POST', `/admin/levels/${id}/import-patch`, patchData),
};
// Admin
export const admin = {
stats: () => request('GET', '/admin/stats'),
users: (params = '') => request('GET', `/admin/users${params ? '?' + params : ''}`),
updateUser: (id, data) => request('PATCH', `/admin/users/${id}`, data),
patches: (params = '') => request('GET', `/admin/patches${params ? '?' + params : ''}`),
updatePatch: (id, data) => request('PATCH', `/admin/patches/${id}`, data),
};

View File

@@ -0,0 +1,134 @@
import { getAccessToken } from './api.js';
const API_BASE = '/api/v1/sync';
const SYNC_INTERVAL = 30000; // 30 seconds
let _syncTimer = null;
async function apiFetch(method, path, body = null) {
const token = getAccessToken();
if (!token) return null;
const res = await fetch(`${API_BASE}${path}`, {
method,
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`,
},
credentials: 'include',
body: body ? JSON.stringify(body) : null,
});
if (!res.ok) return null;
return res.json();
}
// ==================== Preset Sync ====================
export async function syncPresets() {
if (!getAccessToken()) return;
try {
// Get local presets
const localRaw = localStorage.getItem('reaktor_presets');
const localPresets = localRaw ? JSON.parse(localRaw) : [];
// Get server presets
const serverData = await apiFetch('GET', '/presets');
if (!serverData) return;
// Push local presets to server
if (localPresets.length > 0) {
await apiFetch('PUT', '/presets', {
presets: localPresets.map(p => ({
name: p.name,
data: p.data,
updatedAt: p.savedAt || new Date().toISOString(),
})),
});
}
// Merge server presets into local (add any missing)
const serverPresets = serverData.presets || [];
const localNames = new Set(localPresets.map(p => p.name));
let merged = [...localPresets];
for (const sp of serverPresets) {
if (!localNames.has(sp.name)) {
merged.push({
name: sp.name,
data: sp.data,
savedAt: sp.updatedAt,
});
}
}
localStorage.setItem('reaktor_presets', JSON.stringify(merged));
} catch (err) {
console.warn('[sync] preset sync failed:', err.message);
}
}
// ==================== Game Progress Sync ====================
export async function syncProgress() {
if (!getAccessToken()) return;
try {
const localRaw = localStorage.getItem('synthquest-progress');
const localProgress = localRaw ? JSON.parse(localRaw) : null;
// Get server progress
const serverData = await apiFetch('GET', '/progress');
if (localProgress) {
// Push local to server
await apiFetch('PUT', '/progress', {
data: localProgress,
updatedAt: new Date().toISOString(),
});
}
// If server has progress and local doesn't, pull it
if (serverData?.progress && !localProgress) {
localStorage.setItem('synthquest-progress', JSON.stringify(serverData.progress));
}
// Also sync hint data
const hintsRaw = localStorage.getItem('synthquest-hints');
// Hints are local-only for now (anti-cheat integrity)
} catch (err) {
console.warn('[sync] progress sync failed:', err.message);
}
}
// ==================== Auto Sync ====================
export function startAutoSync() {
if (_syncTimer) return;
// Initial sync
syncPresets();
syncProgress();
// Periodic sync
_syncTimer = setInterval(() => {
syncPresets();
syncProgress();
}, SYNC_INTERVAL);
// Sync on tab focus
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') {
syncPresets();
syncProgress();
}
});
}
export function stopAutoSync() {
if (_syncTimer) {
clearInterval(_syncTimer);
_syncTimer = null;
}
}

View File

@@ -0,0 +1,13 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
'/api': 'http://localhost:3001',
},
},
build: { outDir: '../../dist', emptyOutDir: true }
});

View File

@@ -0,0 +1,5 @@
DATABASE_URL=postgres://reaktor:reaktor_dev@localhost:5432/reaktor
JWT_SECRET=change-this-to-a-random-secret
PORT=3001
CORS_ORIGIN=http://localhost:3000
NODE_ENV=development

View File

@@ -0,0 +1,8 @@
export default {
schema: './src/db/schema.js',
out: './src/db/migrations',
dialect: 'postgresql',
dbCredentials: {
url: process.env.DATABASE_URL || 'postgres://reaktor:reaktor_dev@localhost:5432/reaktor',
},
};

View File

@@ -0,0 +1,29 @@
{
"name": "@reaktor/server",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"start": "node src/index.js",
"dev": "node --watch src/index.js",
"db:generate": "drizzle-kit generate",
"db:push": "drizzle-kit push"
},
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"@fastify/multipart": "^9.4.0",
"@fastify/rate-limit": "^10.3.0",
"@fastify/static": "^9.0.0",
"argon2": "^0.44.0",
"dotenv": "^17.3.1",
"drizzle-orm": "^0.45.1",
"fastify": "^5.8.2",
"postgres": "^3.4.8",
"uuid": "^13.0.0"
},
"devDependencies": {
"drizzle-kit": "^0.31.10"
}
}

View File

@@ -4,7 +4,7 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const PORT = process.env.PORT || 80; const PORT = process.env.PORT || 80;
const STATIC_DIR = path.join(__dirname, 'dist'); const STATIC_DIR = path.join(__dirname, '..', '..', 'dist');
const MIME = { const MIME = {
'.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript', '.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',

View File

@@ -0,0 +1,9 @@
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from './schema.js';
const connectionString = process.env.DATABASE_URL || 'postgres://reaktor:reaktor_dev@localhost:5432/reaktor';
const client = postgres(connectionString);
export const db = drizzle(client, { schema });
export { schema };

View File

@@ -0,0 +1,80 @@
import { pgTable, uuid, varchar, text, boolean, integer, timestamp, jsonb, primaryKey } from 'drizzle-orm/pg-core';
export const users = pgTable('users', {
id: uuid('id').defaultRandom().primaryKey(),
email: varchar('email', { length: 255 }).unique().notNull(),
username: varchar('username', { length: 50 }).unique().notNull(),
passwordHash: varchar('password_hash', { length: 255 }).notNull(),
avatarUrl: varchar('avatar_url', { length: 500 }),
bio: text('bio'),
role: varchar('role', { length: 20 }).default('user').notNull(), // user | premium | admin
authProvider: varchar('auth_provider', { length: 20 }).default('local'),
providerId: varchar('provider_id', { length: 255 }),
stripeCustomerId: varchar('stripe_customer_id', { length: 255 }),
subscriptionStatus: varchar('subscription_status', { length: 20 }),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const presets = pgTable('presets', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
name: varchar('name', { length: 200 }).notNull(),
data: jsonb('data').notNull(),
isAutosave: boolean('is_autosave').default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const gameProgress = pgTable('game_progress', {
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).primaryKey(),
data: jsonb('data').notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const sharedPatches = pgTable('shared_patches', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'set null' }),
title: varchar('title', { length: 200 }).notNull(),
description: text('description'),
tags: text('tags').array(),
data: jsonb('data').notNull(),
previewUrl: varchar('preview_url', { length: 500 }),
likesCount: integer('likes_count').default(0).notNull(),
isFlagged: boolean('is_flagged').default(false).notNull(),
isDeleted: boolean('is_deleted').default(false).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
export const likes = pgTable('likes', {
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
patchId: uuid('patch_id').references(() => sharedPatches.id, { onDelete: 'cascade' }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => ({
pk: primaryKey({ columns: [table.userId, table.patchId] }),
}));
export const customLevels = pgTable('custom_levels', {
id: uuid('id').defaultRandom().primaryKey(),
worldId: varchar('world_id', { length: 20 }).notNull(),
levelId: varchar('level_id', { length: 50 }).unique().notNull(),
title: varchar('title', { length: 200 }).notNull(),
subtitle: varchar('subtitle', { length: 200 }),
description: text('description'),
concept: text('concept'),
availableModules: text('available_modules').array(),
preplacedData: jsonb('preplaced_data'), // imported from sandbox export
targetData: jsonb('target_data'),
sortOrder: integer('sort_order').default(0),
isBoss: boolean('is_boss').default(false),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
});
export const refreshTokens = pgTable('refresh_tokens', {
id: uuid('id').defaultRandom().primaryKey(),
userId: uuid('user_id').references(() => users.id, { onDelete: 'cascade' }).notNull(),
tokenHash: varchar('token_hash', { length: 255 }).notNull(),
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});

View File

@@ -0,0 +1,80 @@
import 'dotenv/config';
import Fastify from 'fastify';
import cors from '@fastify/cors';
import cookie from '@fastify/cookie';
import jwt from '@fastify/jwt';
import rateLimit from '@fastify/rate-limit';
import fastifyStatic from '@fastify/static';
import path from 'path';
import { fileURLToPath } from 'url';
import authRoutes from './routes/auth.js';
import userRoutes from './routes/users.js';
import adminRoutes from './routes/admin.js';
import syncRoutes from './routes/sync.js';
import workshopRoutes from './routes/workshop.js';
import levelRoutes from './routes/levels.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const PORT = process.env.PORT || 3001;
const JWT_SECRET = process.env.JWT_SECRET || 'dev-secret-change-in-production';
const fastify = Fastify({ logger: true });
// Plugins
await fastify.register(cors, {
origin: process.env.CORS_ORIGIN || 'http://localhost:3000',
credentials: true,
});
await fastify.register(cookie);
await fastify.register(jwt, {
secret: JWT_SECRET,
});
await fastify.register(rateLimit, {
max: 100,
timeWindow: '1 minute',
});
// API routes
await fastify.register(authRoutes, { prefix: '/api/v1/auth' });
await fastify.register(userRoutes, { prefix: '/api/v1/users' });
await fastify.register(adminRoutes, { prefix: '/api/v1/admin' });
await fastify.register(syncRoutes, { prefix: '/api/v1/sync' });
await fastify.register(workshopRoutes, { prefix: '/api/v1/workshop' });
await fastify.register(levelRoutes, { prefix: '/api/v1/admin/levels' });
// Rate limit auth endpoints more aggressively
fastify.addHook('onRoute', (routeOptions) => {
if (routeOptions.url?.startsWith('/api/v1/auth')) {
routeOptions.config = { ...routeOptions.config, rateLimit: { max: 10, timeWindow: '1 minute' } };
}
});
// Health check
fastify.get('/api/health', async () => ({ status: 'ok', timestamp: new Date().toISOString() }));
// In production, serve static files (SPA)
if (process.env.NODE_ENV === 'production') {
const distDir = path.join(__dirname, '..', '..', '..', 'dist');
await fastify.register(fastifyStatic, { root: distDir });
// SPA fallback
fastify.setNotFoundHandler((request, reply) => {
if (request.url.startsWith('/api/')) {
reply.code(404).send({ error: 'Not found' });
} else {
reply.sendFile('index.html');
}
});
}
// Start
try {
await fastify.listen({ port: PORT, host: '0.0.0.0' });
} catch (err) {
fastify.log.error(err);
process.exit(1);
}

View File

@@ -0,0 +1,18 @@
export async function authenticate(request, reply) {
try {
await request.jwtVerify();
} catch (err) {
reply.code(401).send({ error: 'Unauthorized' });
}
}
export async function requireAdmin(request, reply) {
try {
await request.jwtVerify();
if (request.user.role !== 'admin') {
reply.code(403).send({ error: 'Forbidden' });
}
} catch (err) {
reply.code(401).send({ error: 'Unauthorized' });
}
}

View File

@@ -0,0 +1,103 @@
import { eq, sql, desc, ilike } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { requireAdmin } from '../middleware/auth.js';
export default async function adminRoutes(fastify) {
// Dashboard stats
fastify.get('/stats', { preHandler: [requireAdmin] }, async () => {
const [userCount] = await db.select({ count: sql`count(*)::int` }).from(schema.users);
const [patchCount] = await db.select({ count: sql`count(*)::int` }).from(schema.sharedPatches).where(eq(schema.sharedPatches.isDeleted, false));
const [premiumCount] = await db.select({ count: sql`count(*)::int` }).from(schema.users).where(eq(schema.users.role, 'premium'));
const [flaggedCount] = await db.select({ count: sql`count(*)::int` }).from(schema.sharedPatches).where(eq(schema.sharedPatches.isFlagged, true));
return {
users: userCount.count,
patches: patchCount.count,
premium: premiumCount.count,
flagged: flaggedCount.count,
};
});
// List users
fastify.get('/users', { preHandler: [requireAdmin] }, async (request) => {
const { q, page = 1, role } = request.query;
const limit = 20;
const offset = (page - 1) * limit;
let query = db.select({
id: schema.users.id,
email: schema.users.email,
username: schema.users.username,
role: schema.users.role,
avatarUrl: schema.users.avatarUrl,
createdAt: schema.users.createdAt,
}).from(schema.users);
if (q) {
query = query.where(
sql`${schema.users.username} ILIKE ${'%' + q + '%'} OR ${schema.users.email} ILIKE ${'%' + q + '%'}`
);
}
if (role) {
query = query.where(eq(schema.users.role, role));
}
const users = await query.orderBy(desc(schema.users.createdAt)).limit(limit).offset(offset);
return { users, page, limit };
});
// Update user role / ban
fastify.patch('/users/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
const { role } = request.body || {};
if (!role || !['user', 'premium', 'admin', 'banned'].includes(role)) {
return reply.code(400).send({ error: 'Invalid role' });
}
const [user] = await db.update(schema.users)
.set({ role, updatedAt: new Date() })
.where(eq(schema.users.id, request.params.id))
.returning({ id: schema.users.id, username: schema.users.username, role: schema.users.role });
if (!user) return reply.code(404).send({ error: 'User not found' });
return user;
});
// List shared patches (moderation)
fastify.get('/patches', { preHandler: [requireAdmin] }, async (request) => {
const { flagged, deleted, page = 1 } = request.query;
const limit = 20;
const offset = (page - 1) * limit;
let query = db.select().from(schema.sharedPatches);
if (flagged === 'true') {
query = query.where(eq(schema.sharedPatches.isFlagged, true));
}
if (deleted === 'true') {
query = query.where(eq(schema.sharedPatches.isDeleted, true));
}
const patches = await query.orderBy(desc(schema.sharedPatches.createdAt)).limit(limit).offset(offset);
return { patches, page, limit };
});
// Moderate patch (delete, unflag, restore)
fastify.patch('/patches/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
const updates = {};
const { action } = request.body || {};
if (action === 'delete') updates.isDeleted = true;
else if (action === 'restore') updates.isDeleted = false;
else if (action === 'unflag') updates.isFlagged = false;
else return reply.code(400).send({ error: 'Invalid action' });
const [patch] = await db.update(schema.sharedPatches)
.set(updates)
.where(eq(schema.sharedPatches.id, request.params.id))
.returning();
if (!patch) return reply.code(404).send({ error: 'Patch not found' });
return patch;
});
}

View File

@@ -0,0 +1,206 @@
import argon2 from 'argon2';
import { v4 as uuidv4 } from 'uuid';
import { eq } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
const REFRESH_EXPIRY_DAYS = 30;
async function hashRefreshToken(token) {
return argon2.hash(token, { type: argon2.argon2id });
}
export default async function authRoutes(fastify) {
// Register
fastify.post('/register', {
schema: {
body: {
type: 'object',
required: ['email', 'username', 'password'],
properties: {
email: { type: 'string', format: 'email' },
username: { type: 'string', minLength: 3, maxLength: 50 },
password: { type: 'string', minLength: 6 },
},
},
},
}, async (request, reply) => {
const { email, username, password } = request.body;
// Check existing
const existing = await db.select().from(schema.users)
.where(eq(schema.users.email, email)).limit(1);
if (existing.length > 0) {
return reply.code(409).send({ error: 'Email already registered' });
}
const existingUsername = await db.select().from(schema.users)
.where(eq(schema.users.username, username)).limit(1);
if (existingUsername.length > 0) {
return reply.code(409).send({ error: 'Username already taken' });
}
const passwordHash = await argon2.hash(password, { type: argon2.argon2id });
const [user] = await db.insert(schema.users).values({
email,
username,
passwordHash,
}).returning({ id: schema.users.id, email: schema.users.email, username: schema.users.username, role: schema.users.role });
// Generate tokens
const accessToken = fastify.jwt.sign(
{ id: user.id, email: user.email, username: user.username, role: user.role },
{ expiresIn: '15m' }
);
const refreshToken = uuidv4();
const refreshHash = await hashRefreshToken(refreshToken);
const expiresAt = new Date(Date.now() + REFRESH_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
await db.insert(schema.refreshTokens).values({
userId: user.id,
tokenHash: refreshHash,
expiresAt,
});
reply.setCookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/api/v1/auth',
maxAge: REFRESH_EXPIRY_DAYS * 24 * 60 * 60,
});
return { user, accessToken };
});
// Login
fastify.post('/login', {
schema: {
body: {
type: 'object',
required: ['email', 'password'],
properties: {
email: { type: 'string' },
password: { type: 'string' },
},
},
},
}, async (request, reply) => {
const { email, password } = request.body;
const [user] = await db.select().from(schema.users)
.where(eq(schema.users.email, email)).limit(1);
if (!user) {
return reply.code(401).send({ error: 'Invalid credentials' });
}
const valid = await argon2.verify(user.passwordHash, password);
if (!valid) {
return reply.code(401).send({ error: 'Invalid credentials' });
}
const accessToken = fastify.jwt.sign(
{ id: user.id, email: user.email, username: user.username, role: user.role },
{ expiresIn: '15m' }
);
const refreshToken = uuidv4();
const refreshHash = await hashRefreshToken(refreshToken);
const expiresAt = new Date(Date.now() + REFRESH_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
await db.insert(schema.refreshTokens).values({
userId: user.id,
tokenHash: refreshHash,
expiresAt,
});
reply.setCookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/api/v1/auth',
maxAge: REFRESH_EXPIRY_DAYS * 24 * 60 * 60,
});
return {
user: { id: user.id, email: user.email, username: user.username, role: user.role, avatarUrl: user.avatarUrl },
accessToken,
};
});
// Refresh token
fastify.post('/refresh', async (request, reply) => {
const token = request.cookies.refreshToken;
if (!token) {
return reply.code(401).send({ error: 'No refresh token' });
}
// Find all non-expired tokens and verify
const candidates = await db.select().from(schema.refreshTokens)
.where(eq(schema.refreshTokens.expiresAt, new Date())); // will fix below
// Actually, find all tokens and check hash
const allTokens = await db.select().from(schema.refreshTokens);
let matchedToken = null;
for (const t of allTokens) {
if (t.expiresAt < new Date()) continue;
try {
if (await argon2.verify(t.tokenHash, token)) {
matchedToken = t;
break;
}
} catch {}
}
if (!matchedToken) {
return reply.code(401).send({ error: 'Invalid refresh token' });
}
// Delete old token (rotation)
await db.delete(schema.refreshTokens).where(eq(schema.refreshTokens.id, matchedToken.id));
// Get user
const [user] = await db.select().from(schema.users)
.where(eq(schema.users.id, matchedToken.userId)).limit(1);
if (!user) {
return reply.code(401).send({ error: 'User not found' });
}
// Issue new tokens
const accessToken = fastify.jwt.sign(
{ id: user.id, email: user.email, username: user.username, role: user.role },
{ expiresIn: '15m' }
);
const newRefreshToken = uuidv4();
const refreshHash = await hashRefreshToken(newRefreshToken);
const expiresAt = new Date(Date.now() + REFRESH_EXPIRY_DAYS * 24 * 60 * 60 * 1000);
await db.insert(schema.refreshTokens).values({
userId: user.id,
tokenHash: refreshHash,
expiresAt,
});
reply.setCookie('refreshToken', newRefreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/api/v1/auth',
maxAge: REFRESH_EXPIRY_DAYS * 24 * 60 * 60,
});
return { accessToken };
});
// Logout
fastify.post('/logout', async (request, reply) => {
reply.clearCookie('refreshToken', { path: '/api/v1/auth' });
return { ok: true };
});
}

View File

@@ -0,0 +1,95 @@
import { eq, asc } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { requireAdmin } from '../middleware/auth.js';
export default async function levelRoutes(fastify) {
// List all custom levels
fastify.get('/', { preHandler: [requireAdmin] }, async () => {
const levels = await db.select().from(schema.customLevels)
.orderBy(asc(schema.customLevels.worldId), asc(schema.customLevels.sortOrder));
return { levels };
});
// Create a new level (import from sandbox JSON)
fastify.post('/', { preHandler: [requireAdmin] }, async (request, reply) => {
const { worldId, levelId, title, subtitle, description, concept,
availableModules, preplacedData, targetData, sortOrder, isBoss } = request.body;
if (!worldId || !levelId || !title) {
return reply.code(400).send({ error: 'worldId, levelId and title required' });
}
// Check for duplicate levelId
const [existing] = await db.select().from(schema.customLevels)
.where(eq(schema.customLevels.levelId, levelId)).limit(1);
if (existing) {
return reply.code(409).send({ error: `Level ${levelId} already exists` });
}
const [level] = await db.insert(schema.customLevels).values({
worldId,
levelId,
title,
subtitle: subtitle || '',
description: description || '',
concept: concept || '',
availableModules: availableModules || [],
preplacedData: preplacedData || null,
targetData: targetData || null,
sortOrder: sortOrder || 0,
isBoss: isBoss || false,
}).returning();
return level;
});
// Update a level
fastify.patch('/:id', { preHandler: [requireAdmin] }, async (request, reply) => {
const updates = { ...request.body, updatedAt: new Date() };
delete updates.id;
delete updates.createdAt;
const [level] = await db.update(schema.customLevels)
.set(updates)
.where(eq(schema.customLevels.id, request.params.id))
.returning();
if (!level) return reply.code(404).send({ error: 'Level not found' });
return level;
});
// Delete a level
fastify.delete('/:id', { preHandler: [requireAdmin] }, async (request) => {
await db.delete(schema.customLevels)
.where(eq(schema.customLevels.id, request.params.id));
return { ok: true };
});
// Import preplaced modules from sandbox JSON export
fastify.post('/:id/import-patch', { preHandler: [requireAdmin] }, async (request, reply) => {
const { modules, connections } = request.body;
if (!modules) return reply.code(400).send({ error: 'modules required' });
// Convert sandbox export to preplacedModules format
const preplacedData = {
modules: modules.map(m => ({
id: m.id,
type: m.type,
x: m.x,
y: m.y,
params: m.params || {},
locked: true,
})),
connections: connections || [],
};
const [level] = await db.update(schema.customLevels)
.set({ preplacedData, updatedAt: new Date() })
.where(eq(schema.customLevels.id, request.params.id))
.returning();
if (!level) return reply.code(404).send({ error: 'Level not found' });
return level;
});
}

View File

@@ -0,0 +1,104 @@
import { eq } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { authenticate } from '../middleware/auth.js';
export default async function syncRoutes(fastify) {
// Get all user presets
fastify.get('/presets', { preHandler: [authenticate] }, async (request) => {
const presets = await db.select({
id: schema.presets.id,
name: schema.presets.name,
data: schema.presets.data,
isAutosave: schema.presets.isAutosave,
updatedAt: schema.presets.updatedAt,
}).from(schema.presets)
.where(eq(schema.presets.userId, request.user.id))
.orderBy(schema.presets.updatedAt);
return { presets };
});
// Upsert presets (merge from client)
fastify.put('/presets', { preHandler: [authenticate] }, async (request) => {
const { presets } = request.body;
if (!Array.isArray(presets)) return { error: 'presets must be array' };
const results = [];
for (const p of presets) {
if (!p.name || !p.data) continue;
if (p.id) {
// Update existing
const [existing] = await db.select().from(schema.presets)
.where(eq(schema.presets.id, p.id)).limit(1);
if (existing && existing.userId === request.user.id) {
// Only update if client is newer
const clientTime = p.updatedAt ? new Date(p.updatedAt) : new Date();
if (clientTime >= new Date(existing.updatedAt)) {
const [updated] = await db.update(schema.presets)
.set({ name: p.name, data: p.data, isAutosave: p.isAutosave || false, updatedAt: new Date() })
.where(eq(schema.presets.id, p.id))
.returning();
results.push(updated);
} else {
results.push(existing); // Server is newer, keep it
}
continue;
}
}
// Insert new
const [inserted] = await db.insert(schema.presets).values({
userId: request.user.id,
name: p.name,
data: p.data,
isAutosave: p.isAutosave || false,
}).returning();
results.push(inserted);
}
return { presets: results };
});
// Delete a preset
fastify.delete('/presets/:id', { preHandler: [authenticate] }, async (request, reply) => {
await db.delete(schema.presets)
.where(eq(schema.presets.id, request.params.id));
return { ok: true };
});
// Get game progress
fastify.get('/progress', { preHandler: [authenticate] }, async (request) => {
const [progress] = await db.select().from(schema.gameProgress)
.where(eq(schema.gameProgress.userId, request.user.id)).limit(1);
return { progress: progress?.data || null, updatedAt: progress?.updatedAt || null };
});
// Upsert game progress (last-write-wins)
fastify.put('/progress', { preHandler: [authenticate] }, async (request) => {
const { data, updatedAt } = request.body;
if (!data) return { error: 'data required' };
const [existing] = await db.select().from(schema.gameProgress)
.where(eq(schema.gameProgress.userId, request.user.id)).limit(1);
if (existing) {
const clientTime = updatedAt ? new Date(updatedAt) : new Date();
if (clientTime >= new Date(existing.updatedAt)) {
await db.update(schema.gameProgress)
.set({ data, updatedAt: new Date() })
.where(eq(schema.gameProgress.userId, request.user.id));
}
} else {
await db.insert(schema.gameProgress).values({
userId: request.user.id,
data,
});
}
return { ok: true };
});
}

View File

@@ -0,0 +1,56 @@
import { eq } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { authenticate } from '../middleware/auth.js';
export default async function userRoutes(fastify) {
// Get my profile
fastify.get('/me', { preHandler: [authenticate] }, async (request) => {
const [user] = await db.select({
id: schema.users.id,
email: schema.users.email,
username: schema.users.username,
avatarUrl: schema.users.avatarUrl,
bio: schema.users.bio,
role: schema.users.role,
createdAt: schema.users.createdAt,
}).from(schema.users).where(eq(schema.users.id, request.user.id)).limit(1);
if (!user) return { error: 'User not found' };
return user;
});
// Update my profile
fastify.patch('/me', {
preHandler: [authenticate],
schema: {
body: {
type: 'object',
properties: {
username: { type: 'string', minLength: 3, maxLength: 50 },
bio: { type: 'string', maxLength: 500 },
},
},
},
}, async (request, reply) => {
const updates = {};
if (request.body.username) updates.username = request.body.username;
if (request.body.bio !== undefined) updates.bio = request.body.bio;
updates.updatedAt = new Date();
if (Object.keys(updates).length === 1) {
return reply.code(400).send({ error: 'Nothing to update' });
}
const [user] = await db.update(schema.users)
.set(updates)
.where(eq(schema.users.id, request.user.id))
.returning({
id: schema.users.id,
username: schema.users.username,
bio: schema.users.bio,
});
return user;
});
}

View File

@@ -0,0 +1,175 @@
import { eq, sql, desc, and, ilike, or, inArray } from 'drizzle-orm';
import { db, schema } from '../db/index.js';
import { authenticate } from '../middleware/auth.js';
export default async function workshopRoutes(fastify) {
// Browse patches (public)
fastify.get('/', async (request) => {
const { q, tags, sort = 'recent', page = 1 } = request.query;
const limit = 20;
const offset = (page - 1) * limit;
const conditions = [eq(schema.sharedPatches.isDeleted, false)];
if (q) {
conditions.push(
or(
ilike(schema.sharedPatches.title, `%${q}%`),
ilike(schema.sharedPatches.description, `%${q}%`)
)
);
}
if (tags) {
const tagList = tags.split(',').map(t => t.trim());
conditions.push(sql`${schema.sharedPatches.tags} && ARRAY[${sql.join(tagList.map(t => sql`${t}`), sql`, `)}]::text[]`);
}
const orderBy = sort === 'popular'
? desc(schema.sharedPatches.likesCount)
: desc(schema.sharedPatches.createdAt);
const patches = await db.select({
id: schema.sharedPatches.id,
title: schema.sharedPatches.title,
description: schema.sharedPatches.description,
tags: schema.sharedPatches.tags,
data: schema.sharedPatches.data,
likesCount: schema.sharedPatches.likesCount,
createdAt: schema.sharedPatches.createdAt,
userId: schema.sharedPatches.userId,
}).from(schema.sharedPatches)
.where(and(...conditions))
.orderBy(orderBy)
.limit(limit)
.offset(offset);
// Get usernames for patches
const userIds = [...new Set(patches.filter(p => p.userId).map(p => p.userId))];
let userMap = {};
if (userIds.length > 0) {
const users = await db.select({
id: schema.users.id,
username: schema.users.username,
avatarUrl: schema.users.avatarUrl,
}).from(schema.users).where(inArray(schema.users.id, userIds));
userMap = Object.fromEntries(users.map(u => [u.id, u]));
}
const result = patches.map(p => ({
...p,
author: userMap[p.userId] || null,
userId: undefined,
}));
return { patches: result, page: +page, limit };
});
// Get single patch
fastify.get('/:id', async (request, reply) => {
const [patch] = await db.select().from(schema.sharedPatches)
.where(and(
eq(schema.sharedPatches.id, request.params.id),
eq(schema.sharedPatches.isDeleted, false)
)).limit(1);
if (!patch) return reply.code(404).send({ error: 'Not found' });
let author = null;
if (patch.userId) {
const [user] = await db.select({
username: schema.users.username,
avatarUrl: schema.users.avatarUrl,
}).from(schema.users).where(eq(schema.users.id, patch.userId)).limit(1);
author = user || null;
}
return { ...patch, author };
});
// Share a patch (requires auth)
fastify.post('/', { preHandler: [authenticate] }, async (request) => {
const { title, description, tags, data } = request.body;
if (!title || !data) return { error: 'title and data required' };
const [patch] = await db.insert(schema.sharedPatches).values({
userId: request.user.id,
title,
description: description || '',
tags: tags || [],
data,
}).returning();
return patch;
});
// Delete own patch
fastify.delete('/:id', { preHandler: [authenticate] }, async (request, reply) => {
const [patch] = await db.select().from(schema.sharedPatches)
.where(eq(schema.sharedPatches.id, request.params.id)).limit(1);
if (!patch) return reply.code(404).send({ error: 'Not found' });
// Owner or admin can delete
if (patch.userId !== request.user.id && request.user.role !== 'admin') {
return reply.code(403).send({ error: 'Forbidden' });
}
await db.update(schema.sharedPatches)
.set({ isDeleted: true })
.where(eq(schema.sharedPatches.id, request.params.id));
return { ok: true };
});
// Like a patch
fastify.post('/:id/like', { preHandler: [authenticate] }, async (request, reply) => {
const patchId = request.params.id;
// Check if already liked
const [existing] = await db.select().from(schema.likes)
.where(and(
eq(schema.likes.userId, request.user.id),
eq(schema.likes.patchId, patchId)
)).limit(1);
if (existing) return { liked: true, message: 'Already liked' };
await db.insert(schema.likes).values({
userId: request.user.id,
patchId,
});
await db.update(schema.sharedPatches)
.set({ likesCount: sql`${schema.sharedPatches.likesCount} + 1` })
.where(eq(schema.sharedPatches.id, patchId));
return { liked: true };
});
// Unlike a patch
fastify.delete('/:id/like', { preHandler: [authenticate] }, async (request) => {
const patchId = request.params.id;
const result = await db.delete(schema.likes)
.where(and(
eq(schema.likes.userId, request.user.id),
eq(schema.likes.patchId, patchId)
));
await db.update(schema.sharedPatches)
.set({ likesCount: sql`GREATEST(${schema.sharedPatches.likesCount} - 1, 0)` })
.where(eq(schema.sharedPatches.id, patchId));
return { liked: false };
});
// Report/flag a patch
fastify.post('/:id/report', { preHandler: [authenticate] }, async (request) => {
await db.update(schema.sharedPatches)
.set({ isFlagged: true })
.where(eq(schema.sharedPatches.id, request.params.id));
return { ok: true };
});
}

180
producto.md Normal file
View File

@@ -0,0 +1,180 @@
# Reaktor — Product Document
## Vision
Reaktor es una plataforma web de sintesis modular que combina un **sandbox creativo** con un **sistema de aprendizaje gamificado (SynthQuest)**. La plataforma permitira a los usuarios crear, aprender, compartir y descubrir sonidos sintetizados.
---
## Producto Actual (v1 — Live)
### Sandbox Mode
- Sintetizador modular completo en el navegador (Tone.js)
- 15+ tipos de modulos: Oscillator, Filter, Envelope, VCA, LFO, Mixer, Sequencer, Piano Roll, Keyboard, Drum Pad, CV→Gate, Delay, Reverb, Distortion, Scope, Output, Noise
- Conexion visual de modulos con cables de audio/control/trigger
- Canvas con zoom, pan, grid
- Guardado/carga de presets (localStorage)
- Export/import de patches como JSON
- Demo Chiptune incluido
### SynthQuest (Game Mode)
- 12 mundos tematicos, 96 niveles progresivos
- Sistema de 3 estrellas por nivel
- Hints con penalizacion (max 2 estrellas)
- Boss levels por mundo
- Progreso persistente (localStorage)
### Mobile
- UI responsiva completa (bottom sheet, tab bar, touch panning, pinch zoom)
- Keyboard y Drum Pad a pantalla completa
- PWA instalable
---
## Roadmap
### Phase 0 — Estructura (monorepo)
**Objetivo:** Preparar la base tecnica para el backend sin romper nada.
- [ ] Reestructurar a monorepo (`packages/client` + `packages/server`)
- [ ] Actualizar Dockerfile (multi-stage: client build + server)
- [ ] Docker Compose con PostgreSQL
- [ ] Verificar que deploy funciona igual que antes
### Phase 1 — Usuarios y Auth
**Objetivo:** Sistema de cuentas de usuario.
- [ ] Backend API (Fastify + PostgreSQL + Drizzle ORM)
- [ ] Registro por email + password (argon2)
- [ ] Login con JWT (access token 15min + refresh cookie 30d)
- [ ] Perfil de usuario (username, avatar, bio)
- [ ] Roles: `user`, `premium`, `admin`
- [ ] UI: modal de login/registro en el frontend
- [ ] Auth context en React (user, isLoggedIn, role)
- [ ] OAuth con Google/GitHub (opcional, puede ir en Phase 1.5)
### Phase 2 — Sincronizacion de Datos
**Objetivo:** Los datos del usuario viajan con su cuenta, no con el dispositivo.
- [ ] Sync de presets a la nube (offline-first, localStorage primary)
- [ ] Sync de progreso de SynthQuest
- [ ] Merge inteligente: last-write-wins por timestamp
- [ ] Cola de sincronizacion offline (flush al reconectar)
- [ ] Multi-dispositivo: login en otro dispositivo y tener todo
### Phase 3 — Workshop / Comunidad
**Objetivo:** Compartir creaciones y descubrir sonidos de otros usuarios.
- [ ] Publicar patches con titulo, descripcion, tags
- [ ] Preview de audio (generado client-side con Tone.js Recorder)
- [ ] Galeria publica: buscar, filtrar por tags, ordenar (popular/reciente)
- [ ] Sistema de likes/favoritos
- [ ] Cargar patch compartido directamente en el Sandbox
- [ ] Perfil publico de usuario con sus patches compartidos
- [ ] Comentarios en patches (v2, opcional)
### Phase 4 — Panel de Administracion
**Objetivo:** Control total sobre la plataforma.
- [ ] Dashboard con KPIs:
- Usuarios totales, DAU, MAU
- Patches compartidos (total, por dia)
- Usuarios premium vs free
- Niveles completados (metricas del juego)
- [ ] Gestion de usuarios:
- Lista, busqueda, filtros
- Ver detalle de usuario (patches, progreso, rol)
- Cambiar rol (user → premium → admin)
- Banear/desbanear
- [ ] Moderacion del Workshop:
- Ver patches reportados/flagged
- Eliminar contenido (soft delete)
- Editar titulo/descripcion
- Ver historial de moderacion
### Phase 5 — Monetizacion (futuro)
**Objetivo:** Cursos premium y sostenibilidad.
- [ ] Definir proveedor de pagos (Stripe, LemonSqueezy, Paddle)
- [ ] Plan Premium: acceso a cursos avanzados de sintesis
- [ ] Checkout flow
- [ ] Gestion de suscripciones (portal del usuario)
- [ ] Metricas de revenue en admin dashboard
- [ ] Sandbox permanece gratuito
### Phase 6 — Cursos (futuro)
**Objetivo:** Contenido educativo estructurado de pago.
- [ ] Sistema de cursos con lecciones
- [ ] Lecciones interactivas (como SynthQuest pero mas profundo)
- [ ] Certificados de completado
- [ ] Tracks tematicos: "Sound Design", "Beat Making", "Ambient Textures"
---
## Stack Tecnico
| Capa | Tecnologia |
|------|-----------|
| Frontend | React 18, Vite, Tone.js |
| Backend | Fastify v5 (Node.js) |
| Base de datos | PostgreSQL 16 + Drizzle ORM |
| Auth | JWT + httpOnly refresh cookies + argon2 |
| Storage | Filesystem (Docker volume) |
| Deploy | Docker + Docker Compose |
| Hosting | montlab.dev (self-hosted) |
| Git | Gitea (git.montlab.dev) |
---
## Principios de Diseno
1. **Offline-first** — La app funciona sin internet. El backend es un extra, no una dependencia.
2. **Opt-in** — Todo funciona sin cuenta. Login desbloquea sync + comunidad.
3. **Mobile-first** — Cada feature se disena primero para movil.
4. **Progresivo** — Cada phase se puede deployar independientemente.
5. **Simple** — Preferir soluciones simples sobre arquitecturas complejas.
---
## Metricas de Exito
- **Phase 1:** 100 usuarios registrados en el primer mes
- **Phase 3:** 50 patches compartidos en el Workshop
- **Phase 5:** 10 suscriptores premium
- **Long-term:** Reaktor como referencia en educacion de sintesis modular web
---
## Notas Tecnicas
### SynthQuest: Niveles base vs niveles custom
Los **96 niveles base** (12 mundos × 8 niveles) estan hardcoded en ficheros JS (`packages/client/src/game/levels/world1.js` ... `world12.js`). Estos niveles **no se pueden editar desde el admin panel** porque contienen funciones `test()` en JavaScript que validan si el jugador ha completado el objetivo:
```javascript
checks: [
{
star: 1,
desc: 'Conecta el oscilador a la salida',
test: (mods, conns) => {
// Logica JS que inspecciona los modulos y conexiones
return conns.some(c => c.from.moduleId === osc.id && ...);
},
},
]
```
Estas funciones `test()` no se pueden serializar en una base de datos — son codigo ejecutable que depende del contexto del engine. Para editar los niveles base hay que modificar directamente los ficheros JS y hacer deploy.
Los **niveles custom** creados desde el admin panel se almacenan en PostgreSQL y permiten definir titulo, descripcion, modulos disponibles y patch base (importado del Sandbox). Sin embargo, **no soportan checks/objetivos custom** porque requeririan escribir funciones JS. Los niveles custom se pueden usar para:
- Tutoriales simples tipo "monta este circuito"
- Challenges de la comunidad
- Contenido adicional sin sistema de estrellas
Para añadir niveles con sistema de estrellas completo, hay que crear un fichero `worldN.js` con los checks en JS.
---
*Documento vivo — actualizar conforme avanza el desarrollo.*

View File

@@ -1,293 +0,0 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { state, subscribe, addModule, emit, addConnection, updateModulePosition, deserialize } from './engine/state.js';
import { startAudio, stopAudio, connectWire, rebuildGraph } from './engine/audioEngine.js';
import { getModuleDef } from './engine/moduleRegistry.js';
import { autoSave, autoLoad, exportPatch, importPatch } from './engine/presets.js';
import ModuleNode from './components/ModuleNode.jsx';
import WireLayer from './components/WireLayer.jsx';
import ModulePalette from './components/ModulePalette.jsx';
import PresetModal from './components/PresetModal.jsx';
import { CHIPTUNE_PRESET } from './presets/chiptune.js';
export default function App({ onSwitchToGame }) {
const [, forceUpdate] = useState(0);
const containerRef = useRef(null);
const portPositions = useRef({});
const [tempWire, setTempWire] = useState(null);
const connectingRef = useRef(null);
const [presetModal, setPresetModal] = useState(null);
const importRef = useRef(null);
// Subscribe to state changes
useEffect(() => {
const unsub = subscribe(() => forceUpdate(n => n + 1));
return unsub;
}, []);
// Auto-load on mount, or load chiptune demo if empty
useEffect(() => {
const loaded = autoLoad();
if (!loaded || state.modules.length === 0) {
// Load chiptune demo preset
deserialize(CHIPTUNE_PRESET);
}
}, []);
// Auto-save interval
useEffect(() => {
const interval = setInterval(autoSave, 3000);
return () => clearInterval(interval);
}, []);
// Port position reporting
const handlePortPosition = useCallback((moduleId, portName, direction, el) => {
const key = `${moduleId}-${portName}-${direction}`;
portPositions.current[key] = el;
}, []);
// Start connecting a wire
const handleStartConnect = useCallback((info) => {
connectingRef.current = info;
const containerRect = containerRef.current.getBoundingClientRect();
setTempWire({
portType: info.portType,
startX: info.startX - containerRect.left,
startY: info.startY - containerRect.top,
endX: info.startX - containerRect.left,
endY: info.startY - containerRect.top,
});
}, []);
// Find port-dot element at pointer position (including nearby)
const findPortAtPoint = (x, y) => {
// First try exact hit
const el = document.elementFromPoint(x, y);
if (el && el.classList.contains('port-dot') && el.dataset.moduleId) {
return el;
}
// Try a small radius around the point
for (const [dx, dy] of [[0,0],[-8,0],[8,0],[0,-8],[0,8],[-6,-6],[6,-6],[-6,6],[6,6]]) {
const hit = document.elementFromPoint(x + dx, y + dy);
if (hit && hit.classList.contains('port-dot') && hit.dataset.moduleId) {
return hit;
}
}
return null;
};
// Canvas pointer events
const handlePointerDown = useCallback((e) => {
if (e.button === 1 || (e.button === 0 && e.altKey) || e.button === 2) {
state.panning = true;
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
e.preventDefault();
} else if (e.button === 0 && !connectingRef.current) {
state.selectedModuleId = null;
emit();
}
}, []);
const handlePointerMove = useCallback((e) => {
if (state.panning && state.panStart) {
state.camX = e.clientX - state.panStart.x;
state.camY = e.clientY - state.panStart.y;
emit();
return;
}
if (state.dragging) {
const newX = e.clientX / state.zoom - state.dragging.offsetX;
const newY = e.clientY / state.zoom - state.dragging.offsetY;
updateModulePosition(state.dragging.moduleId, newX, newY);
return;
}
if (connectingRef.current && containerRef.current) {
const containerRect = containerRef.current.getBoundingClientRect();
setTempWire(prev => prev ? {
...prev,
endX: e.clientX - containerRect.left,
endY: e.clientY - containerRect.top,
} : null);
}
}, []);
const handlePointerUp = useCallback((e) => {
if (state.panning) {
state.panning = false;
state.panStart = null;
}
if (state.dragging) {
state.dragging = null;
emit();
}
// End connecting
if (connectingRef.current) {
const portEl = findPortAtPoint(e.clientX, e.clientY);
if (portEl) {
finishConnection(portEl);
}
connectingRef.current = null;
setTempWire(null);
}
}, []);
const finishConnection = (portEl) => {
const from = connectingRef.current;
if (!from) return;
// Read data attributes directly — clean and reliable
const targetModuleId = parseInt(portEl.dataset.moduleId);
const targetPort = portEl.dataset.portName;
const targetDirection = portEl.dataset.portDirection;
if (!targetModuleId || !targetPort || !targetDirection) return;
if (targetModuleId === from.moduleId && targetPort === from.port) return;
// Determine from/to
let fromMod, fromPort, toMod, toPort;
if (from.direction === 'output' && targetDirection === 'input') {
fromMod = from.moduleId; fromPort = from.port;
toMod = targetModuleId; toPort = targetPort;
} else if (from.direction === 'input' && targetDirection === 'output') {
fromMod = targetModuleId; fromPort = targetPort;
toMod = from.moduleId; toPort = from.port;
} else {
return; // same direction — invalid
}
const connId = addConnection(fromMod, fromPort, toMod, toPort);
if (connId && state.isRunning) {
const conn = state.connections.find(c => c.id === connId);
if (conn) connectWire(conn);
}
};
const handleWheel = useCallback((e) => {
e.preventDefault();
const delta = -e.deltaY * 0.001;
state.zoom = Math.max(0.3, Math.min(3, state.zoom + delta));
emit();
}, []);
const handleContextMenu = useCallback((e) => e.preventDefault(), []);
const handleToggleAudio = async () => {
if (state.isRunning) {
stopAudio();
} else {
await startAudio();
}
emit();
};
const handleAddModule = (type) => {
const x = (-state.camX + 300) / state.zoom + Math.random() * 50;
const y = (-state.camY + 200) / state.zoom + Math.random() * 50;
addModule(type, x, y);
if (state.isRunning) rebuildGraph();
};
const handleImport = async (e) => {
const file = e.target.files[0];
if (!file) return;
await importPatch(file);
emit();
e.target.value = '';
};
const handleLoadDemo = () => {
deserialize(CHIPTUNE_PRESET);
if (state.isRunning) rebuildGraph();
emit();
};
return (
<div className="app">
{/* Toolbar */}
<div className="toolbar">
{onSwitchToGame && (
<button className="toolbar-btn" onClick={onSwitchToGame} style={{ color: 'var(--yellow)' }}>
🎮 Game
</button>
)}
<span className="toolbar-title">Reaktor</span>
<div className="toolbar-sep" />
<button className={`toolbar-btn ${state.isRunning ? 'active' : ''}`} onClick={handleToggleAudio}>
{state.isRunning ? '⏹ Stop' : '▶ Start'}
</button>
<div className="toolbar-sep" />
<div className="toolbar-group">
<button className="toolbar-btn" onClick={() => setPresetModal('save')}>💾 Save</button>
<button className="toolbar-btn" onClick={() => setPresetModal('load')}>📂 Load</button>
<button className="toolbar-btn" onClick={exportPatch}>📤 Export</button>
<button className="toolbar-btn" onClick={() => importRef.current?.click()}>📥 Import</button>
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
</div>
<div className="toolbar-sep" />
<button className="toolbar-btn" onClick={handleLoadDemo} style={{ color: 'var(--yellow)' }}>
🎮 Chiptune Demo
</button>
<div className="toolbar-sep" />
<span className="toolbar-label" style={{ color: state.isRunning ? 'var(--green)' : 'var(--text2)' }}>
{state.isRunning ? '● LIVE' : '○ OFF'}
</span>
<span className="toolbar-label" style={{ marginLeft: 'auto' }}>
{state.modules.length} modules · {state.connections.length} wires
</span>
</div>
{/* Main canvas area */}
<div className="main-area">
<div
ref={containerRef}
className={`node-canvas ${state.panning ? 'grabbing' : ''} ${connectingRef.current ? 'connecting' : ''}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onWheel={handleWheel}
onContextMenu={handleContextMenu}
>
{/* Grid background */}
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', zIndex: 0 }}>
<defs>
<pattern id="grid" width={20 * state.zoom} height={20 * state.zoom}
patternUnits="userSpaceOnUse"
x={state.camX % (20 * state.zoom)} y={state.camY % (20 * state.zoom)}>
<circle cx={1} cy={1} r={0.8} fill="#1a1a30" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#grid)" />
</svg>
{/* Wire layer (behind modules, uses getBoundingClientRect) */}
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} zoom={state.zoom} camX={state.camX} camY={state.camY} />
{/* Modules container (offset by camera) */}
<div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
{state.modules.map(mod => (
<ModuleNode
key={mod.id}
mod={mod}
zoom={state.zoom}
onStartConnect={handleStartConnect}
onPortPosition={handlePortPosition}
/>
))}
</div>
</div>
{/* Module palette */}
<ModulePalette onAddModule={handleAddModule} />
</div>
{/* Status bar */}
<div className="status-bar">
<span className="status-accent">Reaktor MontLab Modular Synth</span>
<span>Zoom: {(state.zoom * 100).toFixed(0)}%</span>
<span>RClick: pan · Wheel: zoom · Drag port: wire · Click wire: delete</span>
</div>
{presetModal && <PresetModal mode={presetModal} onClose={() => setPresetModal(null)} />}
</div>
);
}

View File

@@ -1,91 +0,0 @@
import React, { useCallback, useEffect, useRef } from 'react';
import { triggerKeyboard } from '../engine/audioEngine.js';
import { state } from '../engine/state.js';
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 = {
'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,
'q': 12, '2': 13, 'w': 14, '3': 15, 'e': 16, 'r': 17,
'5': 18, 't': 19, '6': 20, 'y': 21, '7': 22, 'u': 23, 'i': 24,
};
function midiToFreq(midi) {
return 440 * Math.pow(2, (midi - 69) / 12);
}
export default function KeyboardWidget({ moduleId }) {
const mod = state.modules.find(m => m.id === moduleId);
const octave = mod?.params?.octave ?? 4;
const activeKeys = useRef(new Set());
const playNote = useCallback((semitone) => {
const midi = (octave + 1) * 12 + semitone;
const freq = midiToFreq(midi);
triggerKeyboard(moduleId, freq, true);
}, [moduleId, octave]);
const stopNote = useCallback(() => {
triggerKeyboard(moduleId, 440, false);
}, [moduleId]);
useEffect(() => {
const handleDown = (e) => {
if (e.repeat) return;
const key = e.key.toLowerCase();
if (KEY_MAP[key] !== undefined && !activeKeys.current.has(key)) {
activeKeys.current.add(key);
playNote(KEY_MAP[key]);
}
};
const handleUp = (e) => {
const key = e.key.toLowerCase();
if (KEY_MAP[key] !== undefined) {
activeKeys.current.delete(key);
if (activeKeys.current.size === 0) stopNote();
}
};
window.addEventListener('keydown', handleDown);
window.addEventListener('keyup', handleUp);
return () => {
window.removeEventListener('keydown', handleDown);
window.removeEventListener('keyup', handleUp);
};
}, [playNote, stopNote]);
// Draw mini keyboard (1 octave)
const whites = [0, 2, 4, 5, 7, 9, 11];
const blacks = [1, 3, -1, 6, 8, 10];
return (
<div style={{ padding: '2px 0' }}>
<svg viewBox="0 0 140 30" style={{ width: '100%', height: 30 }}>
{whites.map((note, i) => (
<rect key={`w${i}`} x={i * 20} y={0} width={19} height={28}
rx={1} fill="#222" stroke="#444" strokeWidth={0.5}
style={{ cursor: 'pointer' }}
onPointerDown={() => playNote(note)}
onPointerUp={stopNote}
/>
))}
{blacks.filter(n => n >= 0).map((note, i) => {
const pos = [1, 2, 4, 5, 6][i];
return (
<rect key={`b${i}`} x={pos * 20 - 6} y={0} width={12} height={18}
rx={1} fill="#111" stroke="#333" strokeWidth={0.5}
style={{ cursor: 'pointer' }}
onPointerDown={() => playNote(note)}
onPointerUp={stopNote}
/>
);
})}
</svg>
<div style={{ fontSize: 9, color: 'var(--text2)', textAlign: 'center', marginTop: 2 }}>
Z-M / Q-I keys · Oct {octave}
</div>
</div>
);
}

View File

@@ -1,82 +0,0 @@
import React, { useRef, useCallback } from 'react';
const SIZE = 32;
const RADIUS = 12;
const STROKE = 3;
const START_ANGLE = 225;
const END_ANGLE = -45;
const RANGE = 270; // degrees
function polarToCart(cx, cy, r, deg) {
const rad = (deg - 90) * Math.PI / 180;
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
}
function describeArc(cx, cy, r, startDeg, endDeg) {
const start = polarToCart(cx, cy, r, endDeg);
const end = polarToCart(cx, cy, r, startDeg);
const large = endDeg - startDeg <= 180 ? '0' : '1';
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 }) {
const ref = useRef(null);
const dragRef = useRef(null);
const norm = Math.max(0, Math.min(1, (value - min) / (max - min)));
const angleDeg = START_ANGLE - norm * RANGE;
const cx = SIZE / 2, cy = SIZE / 2;
const trackPath = describeArc(cx, cy, RADIUS, END_ANGLE, START_ANGLE);
const fillAngle = START_ANGLE - norm * RANGE;
const fillPath = norm > 0.001 ? describeArc(cx, cy, RADIUS, fillAngle, START_ANGLE) : '';
const dotPos = polarToCart(cx, cy, RADIUS - 4, angleDeg);
const displayVal = formatValue ? formatValue(value) :
value >= 1000 ? `${(value / 1000).toFixed(1)}k` :
value >= 100 ? Math.round(value) :
value >= 1 ? value.toFixed(1) :
value.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
const handlePointerDown = useCallback((e) => {
e.preventDefault(); e.stopPropagation();
dragRef.current = { startY: e.clientY, startValue: value };
const handleMove = (me) => {
const dy = dragRef.current.startY - me.clientY;
const sensitivity = (max - min) / 200;
let newVal = dragRef.current.startValue + dy * sensitivity;
newVal = Math.max(min, Math.min(max, newVal));
// Snap to nice values for integer ranges
if (max - min > 10 && Number.isInteger(min) && Number.isInteger(max)) {
newVal = Math.round(newVal);
}
onChange(newVal);
};
const handleUp = () => {
window.removeEventListener('pointermove', handleMove);
window.removeEventListener('pointerup', handleUp);
dragRef.current = null;
};
window.addEventListener('pointermove', handleMove);
window.addEventListener('pointerup', handleUp);
}, [value, min, max, onChange]);
const handleWheel = useCallback((e) => {
e.preventDefault(); e.stopPropagation();
const step = (max - min) / 100;
const newVal = Math.max(min, Math.min(max, value - Math.sign(e.deltaY) * step));
onChange(newVal);
}, [value, min, max, onChange]);
return (
<div className="knob-container" onWheel={handleWheel}>
<svg className="knob-svg" viewBox={`0 0 ${SIZE} ${SIZE}`}
onPointerDown={handlePointerDown} ref={ref}>
<path className="knob-track" d={trackPath} />
{fillPath && <path className="knob-fill" d={fillPath} style={{ stroke: color }} />}
<circle className="knob-dot" cx={dotPos.x} cy={dotPos.y} r={2} />
</svg>
</div>
);
}

View File

@@ -1,175 +0,0 @@
import React, { useCallback } from 'react';
import { getModuleDef } from '../engine/moduleRegistry.js';
import { state, removeModule, updateModuleParam, isPortConnected, emit } from '../engine/state.js';
import { updateParam } from '../engine/audioEngine.js';
import Knob from './Knob.jsx';
import ScopeDisplay from './ScopeDisplay.jsx';
import KeyboardWidget from './KeyboardWidget.jsx';
import SequencerWidget from './SequencerWidget.jsx';
import PianoRollWidget from './PianoRollWidget.jsx';
export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }) {
const def = getModuleDef(mod.type);
if (!def) return null;
const isSelected = state.selectedModuleId === mod.id;
// Merge default params
const params = { ...Object.fromEntries(Object.entries(def.params).map(([k, v]) => [k, v.default])), ...mod.params };
const handleParamChange = useCallback((name, value) => {
updateModuleParam(mod.id, name, value);
updateParam(mod.id, name, value);
}, [mod.id]);
const handleHeaderDown = useCallback((e) => {
if (e.button !== 0) return;
e.stopPropagation();
state.selectedModuleId = mod.id;
state.dragging = {
moduleId: mod.id,
offsetX: e.clientX / zoom - mod.x,
offsetY: e.clientY / zoom - mod.y,
};
emit();
}, [mod, zoom]);
const handleDelete = useCallback((e) => {
e.stopPropagation();
removeModule(mod.id);
}, [mod.id]);
const handlePortMouseDown = useCallback((e, portName, direction) => {
e.stopPropagation(); e.preventDefault();
const portDef = direction === 'output'
? def.outputs.find(p => p.name === portName)
: def.inputs.find(p => p.name === portName);
if (!portDef) return;
const rect = e.currentTarget.getBoundingClientRect();
onStartConnect({
moduleId: mod.id,
port: portName,
portType: portDef.type,
direction,
startX: rect.left + rect.width / 2,
startY: rect.top + rect.height / 2,
});
}, [mod.id, def, onStartConnect]);
// Report port positions for wire rendering
const portRef = useCallback((el, portName, direction) => {
if (el) {
onPortPosition(mod.id, portName, direction, el);
}
}, [mod.id, onPortPosition]);
return (
<div
className={`module ${isSelected ? 'selected' : ''}`}
style={{
left: mod.x * zoom, top: mod.y * zoom,
transform: `scale(${zoom})`, transformOrigin: 'top left',
...(mod.type === 'pianoroll' ? { width: 520 } : mod.type === 'sequencer' ? { width: 310 } : {}),
}}
data-module-id={mod.id}
onPointerDown={(e) => {
// Don't deselect when clicking inside a module
e.stopPropagation();
state.selectedModuleId = mod.id; emit();
}}
>
<div className="module-header" onPointerDown={handleHeaderDown}>
<span className="type-icon">{def.icon}</span>
<span className="type-name">{def.name}</span>
<button className="close-btn" onClick={handleDelete}></button>
</div>
<div className="module-body">
{/* Input ports */}
{def.inputs.map(port => (
<div key={port.name} className="port-row input">
<div
className={`port-dot ${port.type} ${isPortConnected(mod.id, port.name, 'input') ? 'connected' : ''}`}
ref={el => portRef(el, port.name, 'input')}
data-module-id={mod.id}
data-port-name={port.name}
data-port-direction="input"
data-port-type={port.type}
onPointerDown={e => handlePortMouseDown(e, port.name, 'input')}
/>
<span className="port-label">{port.label}</span>
</div>
))}
{/* Parameters */}
{Object.entries(def.params).map(([name, paramDef]) => {
if (paramDef.type === 'knob') {
const color = paramDef.unit === 'Hz' ? 'var(--accent)' :
paramDef.unit === 'dB' ? 'var(--green)' :
paramDef.unit === 's' ? 'var(--purple)' : 'var(--accent)';
return (
<div key={name} className="param-row">
<span className="param-label">{paramDef.label}</span>
<Knob
value={params[name]}
min={paramDef.min}
max={paramDef.max}
onChange={v => handleParamChange(name, v)}
color={color}
/>
<span className="param-value">
{params[name] >= 1000 ? `${(params[name] / 1000).toFixed(1)}k` :
params[name] >= 100 ? Math.round(params[name]) :
params[name] >= 1 ? Number(params[name]).toFixed(1) :
Number(params[name]).toFixed(3).replace(/0+$/, '').replace(/\.$/, '')}
{paramDef.unit ? ` ${paramDef.unit}` : ''}
</span>
</div>
);
}
if (paramDef.type === 'select') {
return (
<div key={name} className="param-row">
<span className="param-label">{paramDef.label}</span>
<select className="param-select" value={params[name]}
onChange={e => handleParamChange(name, e.target.value)}>
{paramDef.options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
</select>
</div>
);
}
return null;
})}
{/* Scope display */}
{mod.type === 'scope' && <ScopeDisplay moduleId={mod.id} />}
{/* Keyboard widget */}
{mod.type === 'keyboard' && <KeyboardWidget moduleId={mod.id} />}
{/* Sequencer widget */}
{mod.type === 'sequencer' && <SequencerWidget moduleId={mod.id} />}
{/* Piano Roll widget */}
{mod.type === 'pianoroll' && <PianoRollWidget moduleId={mod.id} />}
{/* Output ports */}
{def.outputs.map(port => (
<div key={port.name} className="port-row output">
<div
className={`port-dot ${port.type} ${isPortConnected(mod.id, port.name, 'output') ? 'connected' : ''}`}
ref={el => portRef(el, port.name, 'output')}
data-module-id={mod.id}
data-port-name={port.name}
data-port-direction="output"
data-port-type={port.type}
onPointerDown={e => handlePortMouseDown(e, port.name, 'output')}
/>
<span className="port-label">{port.label}</span>
</div>
))}
</div>
</div>
);
}

View File

@@ -1,50 +0,0 @@
import React, { useRef, useEffect } from 'react';
import { getAnalyserData } from '../engine/audioEngine.js';
export default function ScopeDisplay({ moduleId }) {
const canvasRef = useRef(null);
const rafRef = useRef(null);
useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const ctx = canvas.getContext('2d');
const w = canvas.width = 160;
const h = canvas.height = 60;
const draw = () => {
ctx.fillStyle = '#050510';
ctx.fillRect(0, 0, w, h);
// Grid lines
ctx.strokeStyle = '#151530';
ctx.lineWidth = 0.5;
ctx.beginPath();
ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2);
ctx.moveTo(0, h / 4); ctx.lineTo(w, h / 4);
ctx.moveTo(0, h * 3 / 4); ctx.lineTo(w, h * 3 / 4);
ctx.stroke();
const data = getAnalyserData(moduleId);
if (data && data.length > 0) {
ctx.strokeStyle = '#00e5ff';
ctx.lineWidth = 1.5;
ctx.beginPath();
const step = w / data.length;
for (let i = 0; i < data.length; i++) {
const y = h / 2 + data[i] * h / 2 * -1;
if (i === 0) ctx.moveTo(0, y);
else ctx.lineTo(i * step, y);
}
ctx.stroke();
}
rafRef.current = requestAnimationFrame(draw);
};
draw();
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
}, [moduleId]);
return <canvas ref={canvasRef} className="scope-canvas" />;
}

View File

@@ -1,54 +0,0 @@
import React, { useState, useCallback } from 'react';
import WorldMap from './WorldMap.jsx';
import PuzzleView from './PuzzleView.jsx';
import { WORLD_1 } from './levels/world1.js';
export default function GameApp({ onSwitchToSandbox }) {
const [view, setView] = useState('map'); // 'map' | 'puzzle'
const [currentLevel, setCurrentLevel] = useState(null);
const [currentLevelIndex, setCurrentLevelIndex] = useState(0);
const worldLevels = WORLD_1.levels;
const handleSelectLevel = useCallback((level) => {
const idx = worldLevels.findIndex(l => l.id === level.id);
setCurrentLevel(level);
setCurrentLevelIndex(idx);
setView('puzzle');
}, [worldLevels]);
const handleBack = useCallback(() => {
setView('map');
setCurrentLevel(null);
}, []);
const handleNextLevel = useCallback(() => {
const nextIdx = currentLevelIndex + 1;
if (nextIdx < worldLevels.length) {
setCurrentLevel(worldLevels[nextIdx]);
setCurrentLevelIndex(nextIdx);
} else {
setView('map');
}
}, [currentLevelIndex, worldLevels]);
if (view === 'puzzle' && currentLevel) {
return (
<PuzzleView
key={currentLevel.id}
level={currentLevel}
levelIndex={currentLevelIndex}
worldLevels={worldLevels}
onBack={handleBack}
onNextLevel={handleNextLevel}
/>
);
}
return (
<WorldMap
onSelectLevel={handleSelectLevel}
onSandbox={onSwitchToSandbox}
/>
);
}

View File

@@ -1,379 +0,0 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import { state, subscribe, addModule, emit, addConnection, removeModule, updateModulePosition, deserialize } from '../engine/state.js';
import { startAudio, stopAudio, connectWire, rebuildGraph } from '../engine/audioEngine.js';
import { getModuleDef } from '../engine/moduleRegistry.js';
import ModuleNode from '../components/ModuleNode.jsx';
import WireLayer from '../components/WireLayer.jsx';
import { playTarget, stopTarget, isTargetPlaying } from './targetAudio.js';
import LevelComplete from './LevelComplete.jsx';
import { completeLevel } from './gameState.js';
export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onNextLevel }) {
const [, forceUpdate] = useState(0);
const containerRef = useRef(null);
const portPositions = useRef({});
const [tempWire, setTempWire] = useState(null);
const connectingRef = useRef(null);
const [showConcept, setShowConcept] = useState(true);
const [result, setResult] = useState(null); // { stars, checks }
const [targetPlaying, setTargetPlaying] = useState(false);
// Subscribe to state changes
useEffect(() => {
const unsub = subscribe(() => forceUpdate(n => n + 1));
return unsub;
}, []);
// Load level on mount
useEffect(() => {
loadLevel();
return () => {
stopAudio();
stopTarget();
};
}, [level.id]);
const loadLevel = useCallback(() => {
// Clear state and load preplaced modules
const data = {
modules: (level.preplacedModules || []).map(m => ({
id: m.id,
type: m.type,
x: m.x,
y: m.y,
params: { ...m.params },
})),
connections: [],
camera: { camX: 0, camY: 0, zoom: 1 },
};
deserialize(data);
setResult(null);
setShowConcept(true);
if (state.isRunning) stopAudio();
}, [level]);
// Port position reporting
const handlePortPosition = useCallback((moduleId, portName, direction, el) => {
const key = `${moduleId}-${portName}-${direction}`;
portPositions.current[key] = el;
}, []);
// Start connecting wire
const handleStartConnect = useCallback((info) => {
connectingRef.current = info;
const containerRect = containerRef.current.getBoundingClientRect();
setTempWire({
portType: info.portType,
startX: info.startX - containerRect.left,
startY: info.startY - containerRect.top,
endX: info.startX - containerRect.left,
endY: info.startY - containerRect.top,
});
}, []);
const findPortAtPoint = (x, y) => {
for (const [dx, dy] of [[0,0],[-8,0],[8,0],[0,-8],[0,8],[-6,-6],[6,-6],[-6,6],[6,6]]) {
const hit = document.elementFromPoint(x + dx, y + dy);
if (hit && hit.classList.contains('port-dot') && hit.dataset.moduleId) {
return hit;
}
}
return null;
};
const handlePointerDown = useCallback((e) => {
if (e.button === 1 || (e.button === 0 && e.altKey) || e.button === 2) {
state.panning = true;
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
e.preventDefault();
} else if (e.button === 0 && !connectingRef.current) {
state.selectedModuleId = null;
emit();
}
}, []);
const handlePointerMove = useCallback((e) => {
if (state.panning && state.panStart) {
state.camX = e.clientX - state.panStart.x;
state.camY = e.clientY - state.panStart.y;
emit();
return;
}
if (state.dragging) {
const newX = e.clientX / state.zoom - state.dragging.offsetX;
const newY = e.clientY / state.zoom - state.dragging.offsetY;
updateModulePosition(state.dragging.moduleId, newX, newY);
return;
}
if (connectingRef.current && containerRef.current) {
const containerRect = containerRef.current.getBoundingClientRect();
setTempWire(prev => prev ? {
...prev,
endX: e.clientX - containerRect.left,
endY: e.clientY - containerRect.top,
} : null);
}
}, []);
const handlePointerUp = useCallback((e) => {
if (state.panning) {
state.panning = false;
state.panStart = null;
}
if (state.dragging) {
state.dragging = null;
emit();
}
if (connectingRef.current) {
const portEl = findPortAtPoint(e.clientX, e.clientY);
if (portEl) finishConnection(portEl);
connectingRef.current = null;
setTempWire(null);
}
}, []);
const finishConnection = (portEl) => {
const from = connectingRef.current;
if (!from) return;
const targetModuleId = parseInt(portEl.dataset.moduleId);
const targetPort = portEl.dataset.portName;
const targetDirection = portEl.dataset.portDirection;
if (!targetModuleId || !targetPort || !targetDirection) return;
if (targetModuleId === from.moduleId && targetPort === from.port) return;
let fromMod, fromPort, toMod, toPort;
if (from.direction === 'output' && targetDirection === 'input') {
fromMod = from.moduleId; fromPort = from.port;
toMod = targetModuleId; toPort = targetPort;
} else if (from.direction === 'input' && targetDirection === 'output') {
fromMod = targetModuleId; fromPort = targetPort;
toMod = from.moduleId; toPort = from.port;
} else return;
const connId = addConnection(fromMod, fromPort, toMod, toPort);
if (connId && state.isRunning) {
const conn = state.connections.find(c => c.id === connId);
if (conn) connectWire(conn);
}
};
const handleWheel = useCallback((e) => {
e.preventDefault();
const delta = -e.deltaY * 0.001;
state.zoom = Math.max(0.3, Math.min(3, state.zoom + delta));
emit();
}, []);
const handleContextMenu = useCallback((e) => e.preventDefault(), []);
// Add module from palette
const handleAddModule = (type) => {
const x = (-state.camX + 250) / state.zoom + Math.random() * 30;
const y = (-state.camY + 150) / state.zoom + Math.random() * 30;
addModule(type, x, y);
if (state.isRunning) rebuildGraph();
};
// Toggle player audio
const handleToggleAudio = async () => {
if (state.isRunning) {
stopAudio();
} else {
await startAudio();
}
emit();
};
// Play target sound
const handlePlayTarget = async () => {
if (isTargetPlaying()) {
stopTarget();
setTargetPlaying(false);
} else {
setTargetPlaying(true);
await playTarget(level.target);
// Auto-update when target stops
setTimeout(() => setTargetPlaying(false), (level.target.duration || 2) * 1000 + 100);
}
};
// Validate solution
const handleCheck = () => {
const mods = state.modules;
const conns = state.connections;
const checks = level.checks.map(check => ({
...check,
passed: check.test(mods, conns),
}));
// Stars: sequential — need all previous stars to earn next
let stars = 0;
for (const check of checks) {
if (check.passed) stars = check.star;
else break;
}
setResult({ stars, checks });
if (stars >= 1) {
completeLevel(level.id, stars);
}
};
const isLastLevel = levelIndex >= worldLevels.length - 1;
return (
<div className="gm-puzzle">
{/* Top bar */}
<div className="gm-puzzle-bar">
<button className="gm-btn icon" onClick={onBack}> Mapa</button>
<div className="gm-puzzle-title">
<span className="gm-puzzle-num">{levelIndex + 1}/{worldLevels.length}</span>
<span className="gm-puzzle-name">{level.title}</span>
</div>
<div className="gm-puzzle-actions">
<button
className={`gm-btn ${targetPlaying ? 'active' : 'target'}`}
onClick={handlePlayTarget}
>
{targetPlaying ? '⏹ Parar' : '🎯 Objetivo'}
</button>
<button
className={`gm-btn ${state.isRunning ? 'active' : ''}`}
onClick={handleToggleAudio}
>
{state.isRunning ? '⏹ Parar' : '▶ Mi Sonido'}
</button>
<button className="gm-btn check" onClick={handleCheck}>
Comprobar
</button>
</div>
</div>
<div className="gm-puzzle-content">
{/* Left sidebar: concept + module palette */}
<div className="gm-puzzle-sidebar">
{/* Concept panel */}
<div className="gm-concept-panel">
<div className="gm-concept-header" onClick={() => setShowConcept(!showConcept)}>
<span>💡 Concepto</span>
<span>{showConcept ? '▼' : '▶'}</span>
</div>
{showConcept && (
<div className="gm-concept-body">
<p className="gm-concept-desc">{level.description}</p>
<div className="gm-concept-tip">
<strong>Pista:</strong> {level.concept}
</div>
</div>
)}
</div>
{/* Objectives */}
<div className="gm-objectives">
<div className="gm-obj-title">Objetivos</div>
{level.checks.map((check, i) => {
const passed = result?.checks?.[i]?.passed;
return (
<div key={i} className={`gm-obj ${passed === true ? 'passed' : passed === false ? 'failed' : ''}`}>
<span className="gm-obj-star">{'★'.repeat(check.star)}</span>
<span className="gm-obj-name">{check.desc}</span>
{passed === true && <span className="gm-obj-check"></span>}
{passed === false && <span className="gm-obj-x"></span>}
</div>
);
})}
</div>
{/* Module palette for this level */}
{level.availableModules.length > 0 && (
<div className="gm-module-palette">
<div className="gm-palette-title">Modulos Disponibles</div>
{level.availableModules.map(type => {
const def = getModuleDef(type);
if (!def) return null;
return (
<div
key={type}
className="gm-palette-item"
onClick={() => handleAddModule(type)}
>
<span className="gm-palette-icon">{def.icon}</span>
<span className="gm-palette-name">{def.name}</span>
<span className="gm-palette-add">+</span>
</div>
);
})}
</div>
)}
{/* Reset button */}
<button className="gm-btn danger" onClick={loadLevel} style={{ marginTop: 'auto' }}>
Reiniciar Nivel
</button>
</div>
{/* Main canvas */}
<div className="gm-puzzle-canvas-wrap">
<div
ref={containerRef}
className={`node-canvas ${state.panning ? 'grabbing' : ''} ${connectingRef.current ? 'connecting' : ''}`}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp}
onWheel={handleWheel}
onContextMenu={handleContextMenu}
>
{/* Grid */}
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', zIndex: 0 }}>
<defs>
<pattern id="puzzle-grid" width={20 * state.zoom} height={20 * state.zoom}
patternUnits="userSpaceOnUse"
x={state.camX % (20 * state.zoom)} y={state.camY % (20 * state.zoom)}>
<circle cx={1} cy={1} r={0.8} fill="#1a1a30" />
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#puzzle-grid)" />
</svg>
{/* Wires */}
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} zoom={state.zoom} camX={state.camX} camY={state.camY} />
{/* Modules */}
<div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
{state.modules.map(mod => (
<ModuleNode
key={mod.id}
mod={mod}
zoom={state.zoom}
onStartConnect={handleStartConnect}
onPortPosition={handlePortPosition}
/>
))}
</div>
</div>
{/* Canvas hints */}
{state.modules.length > 0 && state.connections.length === 0 && (
<div className="gm-canvas-hint">
Arrastra de un puerto (circulo) a otro para conectar modulos
</div>
)}
</div>
</div>
{/* Level complete overlay */}
{result && result.stars >= 1 && (
<LevelComplete
stars={result.stars}
checks={result.checks}
levelTitle={level.title}
isLastLevel={isLastLevel}
onRetry={loadLevel}
onMap={onBack}
onNext={onNextLevel}
/>
)}
</div>
);
}

View File

@@ -1,111 +0,0 @@
import React from 'react';
import { WORLD_1 } from './levels/world1.js';
import { getLevelProgress, isLevelUnlocked } from './gameState.js';
const worlds = [WORLD_1];
function Stars({ count, max = 3 }) {
return (
<span className="gm-stars">
{Array.from({ length: max }, (_, i) => (
<span key={i} className={i < count ? 'star filled' : 'star empty'}></span>
))}
</span>
);
}
export default function WorldMap({ onSelectLevel, onSandbox }) {
const world = WORLD_1;
const totalStars = world.levels.reduce((s, l) => {
const p = getLevelProgress(l.id);
return s + (p?.stars || 0);
}, 0);
const maxStars = world.levels.length * 3;
return (
<div className="gm-worldmap">
{/* Header */}
<div className="gm-header">
<div className="gm-logo">
<span className="gm-logo-icon">~</span>
<div>
<h1 className="gm-title">SynthQuest</h1>
<p className="gm-tagline">Aprende sintesis modular resolviendo puzzles</p>
</div>
</div>
<div className="gm-header-right">
<div className="gm-total-stars">
<span className="star filled"></span> {totalStars}/{maxStars}
</div>
<button className="gm-sandbox-btn" onClick={onSandbox}>
🎛 Sandbox
</button>
</div>
</div>
{/* World section */}
<div 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 1: {world.name}</h2>
<p className="gm-world-sub">{world.subtitle}</p>
</div>
</div>
{/* Level grid */}
<div className="gm-level-grid">
{world.levels.map((level, idx) => {
const progress = getLevelProgress(level.id);
const unlocked = isLevelUnlocked(level.id, world.levels);
const stars = progress?.stars || 0;
const isBoss = idx === world.levels.length - 1;
return (
<div
key={level.id}
className={`gm-level-card ${unlocked ? 'unlocked' : 'locked'} ${isBoss ? 'boss' : ''} ${stars === 3 ? 'perfect' : ''}`}
onClick={() => unlocked && onSelectLevel(level)}
>
<div className="gm-level-number">{idx + 1}</div>
<div className="gm-level-info">
<h3 className="gm-level-title">{level.title}</h3>
<p className="gm-level-subtitle">{level.subtitle}</p>
</div>
{unlocked ? (
<Stars count={stars} />
) : (
<span className="gm-lock">🔒</span>
)}
{!unlocked && <div className="gm-lock-overlay" />}
</div>
);
})}
</div>
</div>
{/* Future worlds teaser */}
<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 2: Filtros</h2>
<p className="gm-world-sub">Proximamente... Consigue {Math.ceil(maxStars * 0.6)} estrellas para desbloquear</p>
</div>
<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>
);
}

View File

@@ -1,64 +0,0 @@
/**
* gameState.js — Game progress persistence
* Tracks completed levels, stars earned, and unlocks
*/
const STORAGE_KEY = 'synthquest-progress';
const defaultProgress = {
currentWorld: 'w1',
completedLevels: {}, // { levelId: { stars: 3, bestTime: 12.5 } }
unlockedWorlds: ['w1'],
totalStars: 0,
};
let _progress = null;
export function loadProgress() {
if (_progress) return _progress;
try {
const raw = localStorage.getItem(STORAGE_KEY);
_progress = raw ? { ...defaultProgress, ...JSON.parse(raw) } : { ...defaultProgress };
} catch {
_progress = { ...defaultProgress };
}
return _progress;
}
export function saveProgress() {
if (!_progress) return;
_progress.totalStars = Object.values(_progress.completedLevels)
.reduce((sum, l) => sum + (l.stars || 0), 0);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(_progress));
} catch {}
}
export function completeLevel(levelId, stars) {
const p = loadProgress();
const existing = p.completedLevels[levelId];
if (!existing || stars > existing.stars) {
p.completedLevels[levelId] = { stars, completedAt: Date.now() };
}
saveProgress();
}
export function getLevelProgress(levelId) {
const p = loadProgress();
return p.completedLevels[levelId] || null;
}
export function isLevelUnlocked(levelId, worldLevels) {
const p = loadProgress();
// First level is always unlocked
const idx = worldLevels.findIndex(l => l.id === levelId);
if (idx === 0) return true;
// Previous level must have at least 1 star
const prevId = worldLevels[idx - 1]?.id;
return prevId && p.completedLevels[prevId]?.stars >= 1;
}
export function resetProgress() {
_progress = { ...defaultProgress };
saveProgress();
}

View File

@@ -1,63 +0,0 @@
/**
* targetAudio.js — Plays the "target" sound for a puzzle level
* Builds a temporary Tone.js graph from the level's target config
*/
import * as Tone from 'tone';
let _activeNodes = [];
let _isPlaying = false;
let _stopTimeout = null;
export function isTargetPlaying() {
return _isPlaying;
}
export async function playTarget(target) {
if (_isPlaying) {
stopTarget();
return;
}
await Tone.start();
_isPlaying = true;
const nodes = [];
const output = new Tone.Gain(0.5).toDestination();
nodes.push(output);
// Build oscillators from target.build
for (const spec of target.build) {
if (spec.type === 'oscillator') {
const osc = new Tone.Oscillator({
type: spec.params.waveform || 'sine',
frequency: spec.params.frequency || 440,
detune: spec.params.detune || 0,
});
osc.connect(output);
osc.start();
nodes.push(osc);
}
}
_activeNodes = nodes;
// Auto-stop after duration
const dur = (target.duration || 2) * 1000;
_stopTimeout = setTimeout(() => stopTarget(), dur);
}
export function stopTarget() {
if (_stopTimeout) {
clearTimeout(_stopTimeout);
_stopTimeout = null;
}
for (const node of _activeNodes) {
try {
if (node.stop) node.stop();
if (node.disconnect) node.disconnect();
if (node.dispose) node.dispose();
} catch {}
}
_activeNodes = [];
_isPlaying = false;
}

View File

@@ -1,511 +0,0 @@
/* ===== Reset & Base ===== */
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
:root {
--bg: #08080f;
--panel: #0e0e1a;
--surface: #14142a;
--surface2: #1a1a35;
--border: #252545;
--text: #c8cce0;
--text2: #6668a0;
--accent: #00e5ff;
--accent2: #ff6644;
--green: #44ff88;
--yellow: #ffcc00;
--purple: #aa55ff;
--red: #ff4466;
--wire-audio: #00e5ff;
--wire-control: #ff6644;
--wire-trigger: #ffcc00;
--knob-track: #333;
--knob-fill: #00e5ff;
--module-w: 180;
--port-r: 6;
}
html, body, #root {
width: 100%; height: 100%; overflow: hidden;
background: var(--bg); color: var(--text);
font-family: 'Inter', 'SF Pro', -apple-system, system-ui, sans-serif;
font-size: 12px;
-webkit-font-smoothing: antialiased;
}
/* ===== Layout ===== */
.app { display: flex; flex-direction: column; height: 100vh; }
.toolbar {
height: 40px; background: var(--panel); border-bottom: 1px solid var(--border);
display: flex; align-items: center; padding: 0 12px; gap: 8px; flex-shrink: 0;
z-index: 10;
}
.toolbar-title {
font-weight: 700; font-size: 14px; color: var(--accent);
letter-spacing: 1px; text-transform: uppercase; margin-right: 16px;
}
.toolbar-btn {
padding: 4px 10px; border: 1px solid var(--border); border-radius: 4px;
background: var(--surface); color: var(--text2); cursor: pointer;
font-size: 11px; font-weight: 500; transition: all 0.15s;
font-family: inherit;
}
.toolbar-btn:hover { border-color: var(--accent); color: var(--text); }
.toolbar-btn.active { background: var(--accent); color: #000; border-color: var(--accent); }
.toolbar-btn.danger { border-color: var(--red); color: var(--red); }
.toolbar-btn.danger:hover { background: var(--red); color: #000; }
.toolbar-sep { width: 1px; height: 20px; background: var(--border); margin: 0 4px; }
.toolbar-group { display: flex; gap: 4px; align-items: center; }
.toolbar-label { color: var(--text2); font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
.main-area { flex: 1; position: relative; overflow: hidden; }
/* ===== Node Canvas ===== */
.node-canvas {
position: absolute; inset: 0; cursor: grab;
}
.node-canvas.grabbing { cursor: grabbing; }
.node-canvas.connecting { cursor: crosshair; }
.wires-svg {
position: absolute; inset: 0; pointer-events: none; z-index: 3;
overflow: visible;
}
.wires-svg path {
fill: none; stroke-width: 2.5; stroke-linecap: round;
pointer-events: stroke; cursor: pointer;
filter: drop-shadow(0 0 3px rgba(0,229,255,0.3));
}
.wires-svg path.audio { stroke: var(--wire-audio); opacity: 0.8; }
.wires-svg path.control { stroke: var(--wire-control); opacity: 0.8; }
.wires-svg path.trigger { stroke: var(--wire-trigger); opacity: 0.8; }
.wires-svg path.temp { stroke-dasharray: 6 4; opacity: 0.5; filter: none; }
.wires-svg path:hover { stroke-width: 4; opacity: 1; filter: drop-shadow(0 0 6px rgba(0,229,255,0.6)); }
/* ===== Modules ===== */
.module {
position: absolute; width: 180px; min-width: 180px;
background: var(--surface); border: 1px solid var(--border);
border-radius: 8px; user-select: none; z-index: 2;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
transition: box-shadow 0.15s;
}
.module.selected { border-color: var(--accent); box-shadow: 0 0 20px rgba(0,229,255,0.15); }
.module:hover { box-shadow: 0 6px 24px rgba(0,0,0,0.5); }
.module-header {
display: flex; align-items: center; gap: 6px;
padding: 6px 10px; border-bottom: 1px solid var(--border);
cursor: grab; border-radius: 8px 8px 0 0;
background: var(--surface2);
}
.module-header .type-icon { font-size: 14px; }
.module-header .type-name {
font-size: 11px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.5px; color: var(--text);
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.module-header .close-btn {
width: 18px; height: 18px; border: none; background: transparent;
color: var(--text2); cursor: pointer; font-size: 12px; border-radius: 3px;
display: flex; align-items: center; justify-content: center;
}
.module-header .close-btn:hover { background: var(--red); color: #fff; }
.module-body { padding: 8px 10px; display: flex; flex-direction: column; gap: 6px; }
/* Ports */
.port-row {
display: flex; align-items: center; gap: 6px;
position: relative; height: 20px;
}
.port-row.input { flex-direction: row; }
.port-row.output { flex-direction: row-reverse; }
.port-dot {
width: 12px; height: 12px; border-radius: 50%;
border: 2px solid var(--border); background: var(--surface);
cursor: pointer; flex-shrink: 0; transition: all 0.15s;
position: relative; z-index: 5;
}
.port-dot.audio { border-color: var(--wire-audio); }
.port-dot.control { border-color: var(--wire-control); }
.port-dot.trigger { border-color: var(--wire-trigger); }
.port-dot:hover { transform: scale(1.3); }
.port-dot.connected { background: currentColor; }
.port-dot.audio.connected { background: var(--wire-audio); }
.port-dot.control.connected { background: var(--wire-control); }
.port-dot.trigger.connected { background: var(--wire-trigger); }
.port-dot.compatible { animation: pulse-port 0.6s infinite alternate; }
@keyframes pulse-port {
from { box-shadow: 0 0 2px currentColor; }
to { box-shadow: 0 0 8px currentColor; }
}
.port-label {
font-size: 10px; color: var(--text2); text-transform: uppercase;
letter-spacing: 0.3px; white-space: nowrap;
}
/* Knobs */
.param-row {
display: flex; align-items: center; gap: 6px;
}
.param-label {
font-size: 10px; color: var(--text2); width: 48px;
text-transform: uppercase; letter-spacing: 0.3px; flex-shrink: 0;
}
.knob-container { position: relative; width: 32px; height: 32px; flex-shrink: 0; }
.knob-svg { width: 32px; height: 32px; cursor: pointer; }
.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-dot { fill: var(--text); }
.param-value {
font-size: 10px; color: var(--accent); font-family: 'JetBrains Mono', monospace;
min-width: 40px; text-align: right;
}
/* Select param */
.param-select {
flex: 1; background: var(--bg); border: 1px solid var(--border);
border-radius: 3px; padding: 2px 4px; color: var(--text);
font-size: 10px; font-family: inherit; cursor: pointer;
}
.param-select:focus { outline: none; border-color: var(--accent); }
/* Scope canvas */
.scope-canvas {
width: 100%; height: 60px; border-radius: 4px;
background: #050510; border: 1px solid var(--border);
}
/* ===== Module Palette (sidebar) ===== */
.palette {
position: absolute; left: 8px; top: 8px; z-index: 20;
background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
padding: 8px; display: flex; flex-direction: column; gap: 4px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
max-height: calc(100% - 16px); overflow-y: auto;
}
.palette-title {
font-size: 9px; font-weight: 700; color: var(--text2);
text-transform: uppercase; letter-spacing: 1px; padding: 2px 4px;
}
.palette-item {
display: flex; align-items: center; gap: 6px;
padding: 5px 8px; border-radius: 4px; cursor: pointer;
font-size: 11px; color: var(--text); transition: all 0.1s;
}
.palette-item:hover { background: var(--surface2); }
.palette-item .p-icon { font-size: 14px; width: 20px; text-align: center; }
.palette-item .p-name { font-weight: 500; }
.palette-item .p-cat { font-size: 9px; color: var(--text2); margin-left: auto; }
/* ===== Status Bar ===== */
.status-bar {
height: 24px; background: var(--panel); border-top: 1px solid var(--border);
display: flex; align-items: center; padding: 0 12px; gap: 16px;
font-size: 10px; color: var(--text2); flex-shrink: 0; z-index: 10;
}
.status-bar .status-accent { color: var(--accent); }
/* ===== Preset Modal ===== */
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.7);
display: flex; align-items: center; justify-content: center; z-index: 100;
}
.modal {
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
padding: 20px; min-width: 360px; max-width: 500px;
box-shadow: 0 24px 64px rgba(0,0,0,0.6);
}
.modal h2 { font-size: 15px; color: var(--accent); margin-bottom: 12px; }
.modal input {
width: 100%; padding: 8px 10px; background: var(--bg);
border: 1px solid var(--border); border-radius: 4px;
color: var(--text); font-size: 13px; font-family: inherit;
}
.modal input:focus { outline: none; border-color: var(--accent); }
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; }
.modal-actions button { padding: 6px 14px; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 600; border: 1px solid var(--border); background: var(--surface); color: var(--text); font-family: inherit; }
.modal-actions .primary { background: var(--accent); color: #000; border-color: var(--accent); }
.preset-list { max-height: 200px; overflow-y: auto; margin: 8px 0; }
.preset-item {
padding: 6px 10px; cursor: pointer; border-radius: 4px;
display: flex; align-items: center; justify-content: space-between;
font-size: 12px;
}
.preset-item:hover { background: var(--surface2); }
.preset-item .preset-date { color: var(--text2); font-size: 10px; }
/* ======================================================
GAME MODE — SynthQuest
====================================================== */
/* ===== World Map ===== */
.gm-worldmap {
height: 100vh; overflow-y: auto;
background: linear-gradient(180deg, #08080f 0%, #0a0a1a 50%, #08080f 100%);
padding: 0 24px 40px;
}
.gm-header {
display: flex; align-items: center; justify-content: space-between;
padding: 20px 0; border-bottom: 1px solid var(--border); margin-bottom: 32px;
}
.gm-logo { display: flex; align-items: center; gap: 12px; }
.gm-logo-icon {
font-size: 36px; color: var(--accent);
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);
}
.gm-title { font-size: 22px; font-weight: 800; color: var(--text); letter-spacing: 1px; }
.gm-tagline { font-size: 12px; color: var(--text2); margin-top: 2px; }
.gm-header-right { display: flex; align-items: center; gap: 16px; }
.gm-total-stars { font-size: 16px; color: var(--yellow); font-weight: 700; }
.gm-sandbox-btn {
padding: 8px 16px; border: 1px solid var(--border); border-radius: 6px;
background: var(--surface); color: var(--text2); cursor: pointer;
font-size: 12px; font-weight: 600; font-family: inherit; transition: all 0.15s;
}
.gm-sandbox-btn:hover { border-color: var(--accent); color: var(--text); }
/* World sections */
.gm-world-section { margin-bottom: 32px; }
.gm-locked-world { opacity: 0.4; }
.gm-world-header {
display: flex; align-items: center; gap: 12px; margin-bottom: 16px;
}
.gm-world-icon { font-size: 28px; }
.gm-world-name { font-size: 16px; font-weight: 700; color: var(--text); }
.gm-world-sub { font-size: 11px; color: var(--text2); margin-top: 2px; }
/* Level grid */
.gm-level-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.gm-level-card {
display: flex; align-items: center; gap: 12px;
padding: 14px 16px; border-radius: 10px; cursor: pointer;
background: var(--surface); border: 1px solid var(--border);
transition: all 0.2s; position: relative; overflow: hidden;
}
.gm-level-card.unlocked:hover {
border-color: var(--accent); transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(0,229,255,0.1);
}
.gm-level-card.locked { cursor: default; opacity: 0.5; }
.gm-level-card.boss { border-color: var(--yellow); }
.gm-level-card.boss .gm-level-number { background: var(--yellow); color: #000; }
.gm-level-card.perfect { border-color: var(--green); }
.gm-level-number {
width: 36px; height: 36px; border-radius: 50%;
background: var(--surface2); display: flex; align-items: center; justify-content: center;
font-weight: 800; font-size: 14px; color: var(--accent); flex-shrink: 0;
border: 1px solid var(--border);
}
.gm-level-info { flex: 1; min-width: 0; }
.gm-level-title { font-size: 13px; font-weight: 600; color: var(--text); }
.gm-level-subtitle { font-size: 10px; color: var(--text2); margin-top: 2px; }
.gm-stars { display: flex; gap: 2px; }
.gm-stars .star { font-size: 16px; }
.gm-stars .star.filled { color: var(--yellow); }
.gm-stars .star.empty { color: var(--border); }
.gm-lock { font-size: 18px; }
.gm-lock-overlay {
position: absolute; inset: 0; background: rgba(8,8,15,0.3);
pointer-events: none;
}
/* ===== Puzzle View ===== */
.gm-puzzle { display: flex; flex-direction: column; height: 100vh; }
.gm-puzzle-bar {
height: 48px; background: var(--panel); border-bottom: 1px solid var(--border);
display: flex; align-items: center; padding: 0 16px; gap: 12px;
flex-shrink: 0; z-index: 10;
}
.gm-puzzle-title { display: flex; align-items: center; gap: 8px; }
.gm-puzzle-num {
font-size: 10px; color: var(--text2); background: var(--surface);
padding: 2px 8px; border-radius: 4px; font-weight: 600;
}
.gm-puzzle-name { font-size: 14px; font-weight: 700; color: var(--text); }
.gm-puzzle-actions { margin-left: auto; display: flex; gap: 8px; }
/* Buttons */
.gm-btn {
padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px;
background: var(--surface); color: var(--text); cursor: pointer;
font-size: 12px; font-weight: 600; font-family: inherit; transition: all 0.15s;
white-space: nowrap;
}
.gm-btn:hover { border-color: var(--accent); }
.gm-btn.icon { padding: 6px 10px; }
.gm-btn.primary { background: var(--accent); color: #000; border-color: var(--accent); }
.gm-btn.primary:hover { background: #33ecff; }
.gm-btn.secondary { background: var(--surface2); }
.gm-btn.target { border-color: var(--yellow); color: var(--yellow); }
.gm-btn.target:hover { background: rgba(255,204,0,0.1); }
.gm-btn.check { border-color: var(--green); color: var(--green); }
.gm-btn.check:hover { background: rgba(68,255,136,0.1); }
.gm-btn.active { background: var(--accent); color: #000; border-color: var(--accent); }
.gm-btn.danger { border-color: var(--red); color: var(--red); }
.gm-btn.danger:hover { background: rgba(255,68,102,0.1); }
/* Puzzle layout */
.gm-puzzle-content { flex: 1; display: flex; overflow: hidden; }
.gm-puzzle-sidebar {
width: 280px; flex-shrink: 0; background: var(--panel);
border-right: 1px solid var(--border); overflow-y: auto;
padding: 12px; display: flex; flex-direction: column; gap: 12px;
}
.gm-puzzle-canvas-wrap {
flex: 1; position: relative; overflow: hidden;
}
/* Concept panel */
.gm-concept-panel {
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
overflow: hidden;
}
.gm-concept-header {
padding: 10px 12px; cursor: pointer; display: flex; justify-content: space-between;
align-items: center; font-size: 12px; font-weight: 600; color: var(--yellow);
}
.gm-concept-body { padding: 0 12px 12px; }
.gm-concept-desc { font-size: 11px; color: var(--text); line-height: 1.5; margin-bottom: 8px; }
.gm-concept-tip {
font-size: 10px; color: var(--text2); line-height: 1.5;
padding: 8px; background: var(--bg); border-radius: 4px;
border-left: 3px solid var(--accent);
}
/* Objectives */
.gm-objectives {
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
padding: 10px 12px;
}
.gm-obj-title {
font-size: 10px; font-weight: 700; color: var(--text2);
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px;
}
.gm-obj {
display: flex; align-items: center; gap: 8px; padding: 6px 0;
border-bottom: 1px solid var(--border); font-size: 11px;
}
.gm-obj:last-child { border-bottom: none; }
.gm-obj-star { color: var(--yellow); font-size: 12px; flex-shrink: 0; width: 30px; }
.gm-obj-name { flex: 1; color: var(--text2); }
.gm-obj.passed .gm-obj-name { color: var(--green); }
.gm-obj.failed .gm-obj-name { color: var(--text2); }
.gm-obj-check { color: var(--green); font-weight: 700; }
.gm-obj-x { color: var(--red); font-weight: 700; }
/* Module palette (game) */
.gm-module-palette {
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
padding: 10px 12px;
}
.gm-palette-title {
font-size: 10px; font-weight: 700; color: var(--text2);
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px;
}
.gm-palette-item {
display: flex; align-items: center; gap: 8px;
padding: 8px; border-radius: 6px; cursor: pointer;
transition: all 0.15s; font-size: 12px; color: var(--text);
}
.gm-palette-item:hover { background: var(--surface2); }
.gm-palette-icon { font-size: 16px; width: 24px; text-align: center; }
.gm-palette-name { flex: 1; font-weight: 500; }
.gm-palette-add {
width: 22px; height: 22px; border-radius: 50%;
background: var(--surface2); display: flex; align-items: center; justify-content: center;
font-size: 14px; color: var(--accent); font-weight: 700;
}
/* Canvas hint */
.gm-canvas-hint {
position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
padding: 8px 16px; background: rgba(0,0,0,0.7); border-radius: 8px;
font-size: 11px; color: var(--text2); pointer-events: none;
border: 1px solid var(--border); z-index: 10;
}
/* ===== Level Complete Overlay ===== */
.gm-complete-overlay {
position: fixed; inset: 0; background: rgba(8,8,15,0.85);
display: flex; align-items: center; justify-content: center;
z-index: 200; animation: fadeIn 0.3s;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.gm-complete-card {
background: var(--panel); border: 1px solid var(--border); border-radius: 16px;
padding: 32px 40px; text-align: center; min-width: 400px;
box-shadow: 0 32px 64px rgba(0,0,0,0.5);
animation: slideUp 0.4s ease-out;
}
@keyframes slideUp {
from { transform: translateY(40px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.gm-complete-title { font-size: 20px; font-weight: 800; color: var(--green); margin-bottom: 4px; }
.gm-complete-level { font-size: 13px; color: var(--text2); margin-bottom: 20px; }
.gm-complete-stars { display: flex; justify-content: center; gap: 12px; margin-bottom: 12px; }
.gm-big-star {
font-size: 48px; transition: all 0.4s ease-out;
}
.gm-big-star.empty { color: var(--border); transform: scale(0.8); }
.gm-big-star.earned {
color: var(--yellow); transform: scale(1);
filter: drop-shadow(0 0 12px rgba(255,204,0,0.4));
animation: starPop 0.4s ease-out;
}
@keyframes starPop {
0% { transform: scale(0.3); opacity: 0; }
60% { transform: scale(1.3); }
100% { transform: scale(1); opacity: 1; }
}
.gm-complete-msg { font-size: 13px; color: var(--text2); margin-bottom: 20px; font-style: italic; }
.gm-checks {
margin-bottom: 24px; text-align: left;
background: var(--surface); border-radius: 8px; padding: 12px;
}
.gm-check {
display: flex; align-items: center; gap: 8px; padding: 6px 0;
font-size: 12px; border-bottom: 1px solid var(--border);
}
.gm-check:last-child { border-bottom: none; }
.gm-check-icon { font-size: 14px; width: 20px; text-align: center; }
.gm-check.passed .gm-check-icon { color: var(--green); }
.gm-check.failed .gm-check-icon { color: var(--red); }
.gm-check-name { flex: 1; color: var(--text); }
.gm-check-star { color: var(--yellow); }
.gm-complete-actions { display: flex; gap: 8px; justify-content: center; }

View File

@@ -1,17 +0,0 @@
import React, { useState } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import GameApp from './game/GameApp.jsx';
import './index.css';
function Root() {
const [mode, setMode] = useState('game'); // 'game' | 'sandbox'
if (mode === 'sandbox') {
return <App onSwitchToGame={() => setMode('game')} />;
}
return <GameApp onSwitchToSandbox={() => setMode('sandbox')} />;
}
createRoot(document.getElementById('root')).render(<Root />);

View File

@@ -1,8 +0,0 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
plugins: [react()],
server: { port: 3000 },
build: { outDir: 'dist' }
});