Compare commits

48 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
85 changed files with 11269 additions and 1348 deletions

1
.gitignore vendored
View File

@@ -1,3 +1,4 @@
node_modules
dist
.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
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
RUN npm run build
COPY packages/client/package.json packages/client/
COPY packages/server/package.json packages/server/
RUN npm install --include=dev
COPY packages/client packages/client
RUN npm run build -w packages/client
# Stage 2: Production
FROM node:20-alpine
RUN apk add --no-cache python3 make g++
WORKDIR /app
COPY --from=build /app/dist ./dist
COPY server.js .
COPY package.json package-lock.json* ./
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
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",
"version": "1.0.0",
"name": "reaktor",
"private": true,
"type": "module",
"workspaces": ["packages/*"],
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview",
"start": "node server.js"
},
"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"
"dev": "npm run dev -w packages/client",
"dev:server": "npm run dev -w packages/server",
"build": "npm run build -w packages/client",
"start": "node packages/server/src/index.js",
"db:push": "npm run db:push -w packages/server"
}
}

View File

@@ -2,9 +2,14 @@
<html lang="en">
<head>
<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>
<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>
<body>
<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;
})
);
});

View File

@@ -8,16 +8,29 @@ 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 }) {
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(() => {
@@ -25,11 +38,11 @@ export default function App({ onSwitchToGame }) {
return unsub;
}, []);
// Auto-load on mount, or load chiptune demo if empty
// 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) {
// Load chiptune demo preset
deserialize(CHIPTUNE_PRESET);
}
}, []);
@@ -86,10 +99,17 @@ export default function App({ onSwitchToGame }) {
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) {
@@ -191,6 +211,26 @@ export default function App({ onSwitchToGame }) {
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();
@@ -223,40 +263,111 @@ export default function App({ onSwitchToGame }) {
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 && (
{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>
<div className="toolbar-sep" />
<button className={`toolbar-btn ${state.isRunning ? 'active' : ''}`} onClick={handleToggleAudio}>
{!isMobile && <div className="toolbar-sep" />}
{!isMobile && (
<button className={`toolbar-btn start-btn ${state.isRunning ? 'active' : ''}`} onClick={handleToggleAudio}>
{state.isRunning ? '⏹ Stop' : '▶ Start'}
</button>
<div className="toolbar-sep" />
)}
{!isMobile && <div className="toolbar-sep" />}
{!isMobile && (
<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>
<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>
<div className="toolbar-sep" />
<button className="toolbar-btn" onClick={handleLoadDemo} style={{ color: 'var(--yellow)' }}>
)}
{!isMobile && <div className="toolbar-sep" />}
{!isMobile && (
<>
<button className="toolbar-btn demo-btn" onClick={handleLoadDemo} style={{ color: 'var(--yellow)' }}>
🎮 Chiptune Demo
</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)' }}>
</>
)}
<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">
@@ -281,10 +392,10 @@ export default function App({ onSwitchToGame }) {
<rect width="100%" height="100%" fill="url(#grid)" />
</svg>
{/* Wire layer (behind modules, uses getBoundingClientRect) */}
{/* Wire layer */}
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} zoom={state.zoom} camX={state.camX} camY={state.camY} />
{/* Modules container (offset by camera) */}
{/* Modules container */}
<div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
{state.modules.map(mod => (
<ModuleNode
@@ -298,20 +409,50 @@ export default function App({ onSwitchToGame }) {
</div>
</div>
{/* Zoom controls — top right of canvas */}
{/* 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>
{/* Module palette */}
<ModulePalette onAddModule={handleAddModule} />
{/* Desktop palette */}
{!isMobile && <ModulePalette onAddModule={handleAddModule} />}
</div>
{/* Status bar */}
{/* 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>

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

View File

@@ -1,13 +1,28 @@
import React, { useCallback, useState, useEffect, useRef } from 'react';
import { getModuleDef } from '../engine/moduleRegistry.js';
import { state, removeModule, updateModuleParam, isPortConnected, emit } from '../engine/state.js';
import { updateParam } from '../engine/audioEngine.js';
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' },
@@ -31,6 +46,7 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
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 };
@@ -44,7 +60,7 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
}
}
// ==================== Live LFO modulation visualization ====================
// ==================== Live modulation visualization (any source any param) ====================
const [liveValues, setLiveValues] = useState({});
const rafRef = useRef(null);
const startTimeRef = useRef(performance.now() / 1000);
@@ -55,40 +71,83 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
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 || srcMod.type !== 'lfo') continue;
if (!srcMod) continue;
// Read LFO params from state
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 freq = lfoP.frequency;
const amp = lfoP.amplitude;
const waveform = lfoP.waveform;
const phase = (t * freq) % 1;
const lfoVal = simulateLFO(waveform, phase) * amp;
const phase = (t * lfoP.frequency) % 1;
const lfoVal = simulateLFO(lfoP.waveform, phase) * lfoP.amplitude;
newValues[paramName] = baseValue + lfoVal * getScale();
// Compute modulated value (same scaling as audioEngine)
const baseValue = params[paramName];
let scale;
if (mod.type === 'oscillator' && paramName === 'frequency') scale = baseValue * 0.5;
else if (mod.type === 'filter' && paramName === 'frequency') scale = baseValue;
else if (mod.type === 'vca' && paramName === 'gain') scale = 1;
else scale = baseValue || 1;
} 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 (01)
} else {
newValues[paramName] = baseValue + envValue * getScale();
}
}
newValues[paramName] = baseValue + lfoVal * scale;
} 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);
};
rafRef.current = requestAnimationFrame(tick);
@@ -150,7 +209,7 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
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 } : {}),
...(mod.type === 'pianoroll' ? { width: getModuleWidth(mod, 'pianoroll') } : mod.type === 'sequencer' ? { width: getModuleWidth(mod, 'sequencer') } : {}),
}}
data-module-id={mod.id}
onPointerDown={(e) => {
@@ -162,6 +221,13 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
<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>
@@ -232,7 +298,10 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
{mod.type === 'scope' && <ScopeDisplay moduleId={mod.id} />}
{/* Keyboard widget */}
{mod.type === 'keyboard' && <KeyboardWidget moduleId={mod.id} />}
{mod.type === 'keyboard' && <KeyboardWidget moduleId={mod.id} fullscreen={fullscreen} onCloseFullscreen={() => setFullscreen(false)} />}
{/* Drum Pad widget */}
{mod.type === 'drumpad' && <DrumPadWidget moduleId={mod.id} fullscreen={fullscreen} onCloseFullscreen={() => setFullscreen(false)} />}
{/* Sequencer widget */}
{mod.type === 'sequencer' && <SequencerWidget moduleId={mod.id} />}

View File

@@ -1,7 +1,7 @@
import React, { useState, useEffect, useRef, useCallback } from 'react';
import * as Tone from 'tone';
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';
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
];
const ROLL_W = 500;
const BEAT_PX = 30; // pixels per beat constant density regardless of bar count
const ROLL_H = 200;
const KEY_W = 24;
const MIN_NOTE = 48; // C3
@@ -90,11 +90,10 @@ const ROW_H = ROLL_H / NOTE_RANGE;
export default function PianoRollWidget({ moduleId }) {
const mod = state.modules.find(m => m.id === moduleId);
const canvasRef = useRef(null);
const partRef = useRef(null);
const [playPos, setPlayPos] = useState(-1);
const [tool, setTool] = useState('draw'); // 'draw' | 'erase'
const drawingRef = useRef(null);
const rafRef = useRef(null);
const playPosRef = useRef(-1);
const midiInputRef = useRef(null);
const bpm = mod?.params?.bpm ?? 140;
@@ -110,7 +109,8 @@ export default function PianoRollWidget({ moduleId }) {
const notesRef = useRef(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
const draw = useCallback(() => {
@@ -195,8 +195,9 @@ export default function PianoRollWidget({ moduleId }) {
}
// Playhead
if (playPos >= 0 && playPos < totalBeats) {
const px = KEY_W + playPos * beatW;
const currentPlayPos = playPosRef.current;
if (currentPlayPos >= 0 && currentPlayPos < totalBeats) {
const px = KEY_W + currentPlayPos * beatW;
ctx.strokeStyle = '#ff6644';
ctx.lineWidth = 2;
ctx.beginPath();
@@ -220,7 +221,7 @@ export default function PianoRollWidget({ moduleId }) {
ctx.fillStyle = 'rgba(0,229,255,0.3)';
ctx.fillRect(x, row * ROW_H, Math.max(nw, 2), ROW_H);
}
}, [totalBeats, beatW, playPos]);
}, [totalBeats, beatW, rollW]);
// Animation loop
useEffect(() => {
@@ -232,66 +233,76 @@ export default function PianoRollWidget({ moduleId }) {
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
}, [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(() => {
if (!state.isRunning) {
if (partRef.current) { partRef.current.stop(); partRef.current.dispose(); partRef.current = null; }
setPlayPos(-1);
unsubscribeTick(`pr-${moduleId}`);
playPosRef.current = -1;
return;
}
Tone.getTransport().bpm.value = bpm;
let currentNote = null;
let lastQuantPos = -1;
// Build Tone.Part from notes using musical time (bars:quarters:sixteenths)
// This lets the Transport BPM control actual playback speed
const events = notesRef.current.map(n => {
// Convert beats to bars:quarters:sixteenths notation
const totalSixteenths = Math.round(n.start * 4);
const barNum = Math.floor(totalSixteenths / 16);
const remainder = totalSixteenths % 16;
const quarterNum = Math.floor(remainder / 4);
const sixteenthNum = remainder % 4;
return {
time: `${barNum}:${quarterNum}:${sixteenthNum}`,
note: n.note,
dur: n.duration,
};
subscribeTick(`pr-${moduleId}`, (time, ticks) => {
const currentBpm = bpmRef.current;
const currentLoop = loopRef.current;
const currentTotalBeats = totalBeatsRef.current;
// Convert ticks to beats: each beat = MASTER_TICK_RATE * 60 / bpm ticks
// Position in sixteenths: ticks / (ticksPerSixteenth)
const ticksPerBeat = MASTER_TICK_RATE * 60 / currentBpm;
const rawPos = ticks / ticksPerBeat; // in beats
const pos = currentLoop ? rawPos % currentTotalBeats : rawPos;
const quantPos = Math.floor(pos * 4) / 4;
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 () => {
clearInterval(posInterval);
if (partRef.current) { partRef.current.stop(); partRef.current.dispose(); partRef.current = null; }
unsubscribeTick(`pr-${moduleId}`);
};
}, [state.isRunning, moduleId, bpm, bars, loop]);
}, [state.isRunning, moduleId]);
// Mouse interaction for drawing/erasing notes
const handleMouseDown = useCallback((e) => {
@@ -398,7 +409,7 @@ export default function PianoRollWidget({ moduleId }) {
}, [mod]);
return (
<div style={{ width: ROLL_W }}>
<div style={{ width: rollW }}>
{/* Mini toolbar */}
<div style={{ display: 'flex', gap: 4, marginBottom: 3 }}>
<button
@@ -436,9 +447,9 @@ export default function PianoRollWidget({ moduleId }) {
</div>
<canvas
ref={canvasRef}
width={ROLL_W}
width={rollW}
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}
/>
</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 * as Tone from 'tone';
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'];
function midiToFreq(midi) { return 440 * Math.pow(2, (midi - 69) / 12); }
function noteLabel(midi) { return NOTE_NAMES[midi % 12] + Math.floor(midi / 12 - 1); }
// Default notes: C minor pentatonic pattern
const DEFAULT_STEPS = [
{ 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 },
@@ -18,17 +17,24 @@ const DEFAULT_STEPS = [
export default function SequencerWidget({ moduleId }) {
const mod = state.modules.find(m => m.id === moduleId);
const [currentStep, setCurrentStep] = useState(-1);
const seqRef = useRef(null);
const currentStepRef = useRef(-1);
const [visualStep, setVisualStep] = useState(-1);
const stepsRef = useRef(null);
const rafRef = useRef(null);
// Init steps data
const numSteps = parseInt(mod?.params?.steps || '16');
if (!mod?.params?._steps) {
if (mod) {
if (!mod.params._steps) {
const initial = DEFAULT_STEPS.slice(0, numSteps);
while (initial.length < numSteps) initial.push({ midi: 60, gate: false });
if (mod) {
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;
@@ -36,46 +42,69 @@ export default function SequencerWidget({ moduleId }) {
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(() => {
if (!state.isRunning) {
if (seqRef.current) { seqRef.current.stop(); seqRef.current.dispose(); seqRef.current = null; }
setCurrentStep(-1);
unsubscribeTick(`seq-${moduleId}`);
currentStepRef.current = -1;
setVisualStep(-1);
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];
if (!s) return;
setCurrentStep(stepIdx);
currentStepRef.current = stepIdx;
if (s.gate) {
setSequencerSignals(moduleId, midiToFreq(s.midi), true);
Tone.getTransport().scheduleOnce(() => {
setSequencerSignals(moduleId, midiToFreq(s.midi), false);
}, time + Tone.Time('16n').toSeconds() * 0.8);
} else {
setSequencerSignals(moduleId, midiToFreq(s.midi), false);
lastGateOn = true;
}
}, Array.from({ length: numSteps }, (_, i) => i), '16n');
seq.start(0);
if (Tone.getTransport().state !== 'started') {
Tone.getTransport().start();
}
seqRef.current = seq;
});
return () => {
if (seqRef.current) { seqRef.current.stop(); seqRef.current.dispose(); seqRef.current = null; }
unsubscribeTick(`seq-${moduleId}`);
};
}, [state.isRunning, moduleId, numSteps]);
// Update BPM live
useEffect(() => {
if (state.isRunning) Tone.getTransport().bpm.value = bpm;
}, [bpm]);
}, [state.isRunning, moduleId]);
const toggleGate = (idx) => {
steps[idx] = { ...steps[idx], gate: !steps[idx].gate };
@@ -99,20 +128,17 @@ export default function SequencerWidget({ moduleId }) {
return (
<div style={{ width: W + 4, overflow: 'hidden' }}>
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: W, height: H, display: 'block' }}>
{/* Steps */}
{steps.slice(0, numSteps).map((s, i) => {
const x = i * CELL_W;
const isActive = i === currentStep;
const isActive = i === visualStep;
const barHeight = ((s.midi - 36) / 60) * (CELL_H - 4);
return (
<g key={i}>
{/* Background */}
<rect x={x + 1} y={0} width={CELL_W - 2} height={CELL_H}
rx={2} fill={isActive ? '#1a2a40' : '#0c0c18'}
stroke={isActive ? '#00e5ff' : '#222'} strokeWidth={isActive ? 1.5 : 0.5}
/>
{/* Note bar */}
{s.gate && (
<rect x={x + 3} y={CELL_H - barHeight - 2} width={CELL_W - 6} height={barHeight}
rx={1}
@@ -120,17 +146,14 @@ export default function SequencerWidget({ moduleId }) {
opacity={0.9}
/>
)}
{/* Inactive marker */}
{!s.gate && (
<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} />
)}
{/* Note name */}
<text x={x + CELL_W / 2} y={CELL_H + 10} textAnchor="middle"
fontSize={6} fill={s.gate ? '#88ccff' : '#333'} fontFamily="monospace">
{noteLabel(s.midi)}
</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}
fill="transparent" style={{ cursor: 'pointer' }}
onClick={() => changeNote(i, 1)}
@@ -146,11 +169,10 @@ export default function SequencerWidget({ moduleId }) {
</g>
);
})}
{/* Playhead line */}
{currentStep >= 0 && (
{visualStep >= 0 && (
<line
x1={currentStep * CELL_W + CELL_W / 2} y1={0}
x2={currentStep * CELL_W + CELL_W / 2} y2={CELL_H}
x1={visualStep * CELL_W + CELL_W / 2} y1={0}
x2={visualStep * CELL_W + CELL_W / 2} y2={CELL_H}
stroke="#00e5ff" strokeWidth={1} opacity={0.4}
/>
)}

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
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 ====================
function createNode(mod) {
@@ -84,13 +126,17 @@ function createNode(mod) {
};
}
case 'vca': {
// Use a Multiply node: in × cv
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 {
node: gain,
inputs: { in: gain, cv: gain.gain },
_cvMod: cvMod,
inputs: { in: gain, cv: cvMod },
outputs: { out: gain },
dispose: () => gain.dispose(),
dispose: () => { cvMod.disconnect(); cvMod.dispose(); gain.dispose(); },
};
}
case 'delay': {
@@ -136,7 +182,7 @@ function createNode(mod) {
};
}
case 'scope': {
const analyser = new Tone.Analyser('waveform', 256);
const analyser = new Tone.Analyser('waveform', 2048);
return {
node: analyser,
inputs: { in: analyser },
@@ -145,6 +191,20 @@ function createNode(mod) {
dispose: () => analyser.dispose(),
};
}
case 'cv2gate': {
// Converts a continuous CV signal to gate on/off based on threshold.
// 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 {
node: analyser,
_gateSig: gateSig,
_gateState: false,
inputs: { in: analyser },
outputs: { gate: gateSig },
dispose: () => { analyser.dispose(); gateSig.dispose(); },
};
}
case 'output': {
// True stereo output: separate left/right channels → merge → master gain → destination
const leftGain = new Tone.Gain(1);
@@ -170,7 +230,8 @@ function createNode(mod) {
},
};
}
case 'keyboard': {
case 'keyboard':
case 'drumpad': {
const freqSig = new Tone.Signal(440);
const gateSig = new Tone.Signal(0);
return {
@@ -245,6 +306,17 @@ export function connectWire(conn) {
const toEntry = ensureNode(conn.to.moduleId);
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 input = toEntry.inputs[conn.to.port];
if (!output || input === undefined || input === null) return;
@@ -256,6 +328,11 @@ export function connectWire(conn) {
} catch (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) {
@@ -274,6 +351,12 @@ export function disconnectWire(conn) {
} catch (e) {
// 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) {
@@ -318,7 +401,12 @@ export function updateParam(moduleId, paramName, value) {
else if (paramName === 'release') entry.node.release = value;
break;
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;
case 'delay':
if (paramName === 'delayTime') entry.node.delayTime.value = value;
@@ -343,6 +431,8 @@ export function updateParam(moduleId, paramName, value) {
if (paramName === 'volume') entry.node.gain.value = Tone.dbToGain(value);
break;
case 'keyboard':
case 'drumpad':
case 'cv2gate':
case 'sequencer':
case 'pianoroll':
// All params stored in state, managed by widgets
@@ -350,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) {
const entry = audioNodes[moduleId];
if (!entry) return;
if (entry._freqSig) entry._freqSig.value = freq;
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
for (const conn of state.connections) {
if (conn.from.moduleId === moduleId && conn.from.port === 'gate') {
for (const conn of getConnectionsFrom(moduleId, 'gate')) {
const envEntry = audioNodes[conn.to.moduleId];
if (envEntry && envEntry.node instanceof Tone.Envelope) {
if (gate) envEntry.node.triggerAttack();
else envEntry.node.triggerRelease();
}
}
}
}
export function triggerKeyboard(moduleId, freq, gate) {
@@ -374,27 +492,43 @@ export function triggerKeyboard(moduleId, freq, gate) {
if (entry._freqSig) entry._freqSig.value = freq;
if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0;
// Also trigger any connected envelopes
for (const conn of state.connections) {
if (conn.from.moduleId === moduleId && conn.from.port === 'gate') {
// 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
for (const conn of getConnectionsFrom(moduleId, 'gate')) {
const envEntry = audioNodes[conn.to.moduleId];
if (envEntry && envEntry.node instanceof Tone.Envelope) {
if (gate) envEntry.node.triggerAttack();
else envEntry.node.triggerRelease();
}
}
}
}
export async function startAudio() {
await Tone.start();
state.isRunning = true;
startMasterClock();
// Rebuild entire audio graph
rebuildGraph();
}
export function stopAudio() {
stopMasterClock();
// Stop and reset Transport
try {
Tone.getTransport().stop();
Tone.getTransport().cancel();
Tone.getTransport().position = 0;
} catch (e) {}
// Destroy all nodes
for (const id of Object.keys(audioNodes)) {
destroyNode(parseInt(id));
@@ -417,6 +551,55 @@ export function rebuildGraph() {
for (const conn of state.connections) {
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) {

View File

@@ -117,7 +117,7 @@ defineModule('envelope', {
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' },
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: {},
});
// ==================== 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 ====================
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 ====================
defineModule('sequencer', {

View File

@@ -4,6 +4,7 @@
*/
import { playConnect, playDisconnect, playModuleAdd, playModuleDelete } from './uiSounds.js';
import { getModuleDef } from './moduleRegistry.js';
import { invalidateConnectionCache } from './audioEngine.js';
let _listeners = new Set();
let _nextModuleId = 1;
@@ -93,6 +94,7 @@ export function addConnection(fromModuleId, fromPort, toModuleId, toPort) {
const id = _nextConnectionId++;
state.connections.push({ id, from: { moduleId: fromModuleId, port: fromPort }, to: { moduleId: toModuleId, port: toPort } });
invalidateConnectionCache();
emit();
playConnect();
return id;
@@ -100,6 +102,7 @@ export function addConnection(fromModuleId, fromPort, toModuleId, toPort) {
export function removeConnection(id, _silent = false) {
state.connections = state.connections.filter(c => c.id !== id);
invalidateConnectionCache();
emit();
if (!_silent) playDisconnect();
}

View File

@@ -17,7 +17,7 @@ import { WORLD_12 } from './levels/world12.js';
const allWorlds = [WORLD_1, WORLD_2, WORLD_3, WORLD_4, WORLD_5, WORLD_6, WORLD_7, WORLD_8, WORLD_9, WORLD_10, WORLD_11, WORLD_12];
export default function GameApp({ onSwitchToSandbox }) {
export default function GameApp({ onSwitchToSandbox, onSwitchToWorkshop }) {
const [view, setView] = useState('map');
const [currentLevel, setCurrentLevel] = useState(null);
const [currentLevelIndex, setCurrentLevelIndex] = useState(0);
@@ -78,6 +78,7 @@ export default function GameApp({ onSwitchToSandbox }) {
<WorldMap
onSelectLevel={handleSelectLevel}
onSandbox={onSwitchToSandbox}
onWorkshop={onSwitchToWorkshop}
onAdmin={() => setShowAdmin(true)}
/>
{showAdmin && (

View File

@@ -4,10 +4,14 @@ import { startAudio, stopAudio, connectWire, rebuildGraph } from '../engine/audi
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, markHintUsed, wasHintUsed } from './gameState.js';
import { completeLevel, saveLevelPatch, getLevelPatch, clearLevelPatch, markHintUsed, wasHintUsed } from './gameState.js';
import { playLevelComplete, playFail, playHint, playEngineStart, playEngineStop, playNav } from '../engine/uiSounds.js';
import { SOLUTIONS } from './autoSolver.js';
export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onNextLevel, adminMode }) {
const [, forceUpdate] = useState(0);
@@ -19,6 +23,13 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
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(() => {
@@ -48,7 +59,10 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
useEffect(() => {
loadLevel();
// Center view on modules after level loads and DOM settles
const timer = setTimeout(() => handleCenterView(), 100);
return () => {
clearTimeout(timer);
stopAudio();
stopTarget();
};
@@ -126,10 +140,17 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
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) {
@@ -221,6 +242,26 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
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;
@@ -250,6 +291,13 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
}
};
// 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);
@@ -285,15 +333,20 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
}
};
// Admin auto-solve gives 3 stars instantly
// Admin auto-solve loads the actual solution modules/connections and validates naturally
const handleAutoSolve = () => {
const checks = level.checks.map(check => ({
...check,
passed: true,
}));
completeLevel(level.id, 3);
setResult({ stars: 3, checks, hintPenalty: false });
playLevelComplete();
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;
@@ -302,7 +355,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
<div className="gm-puzzle">
{/* Top bar */}
<div className="gm-puzzle-bar">
<button className="gm-btn icon" onClick={() => { playNav(); onBack(); }}> Mapa</button>
<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>
@@ -312,16 +365,21 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
className={`gm-btn ${targetPlaying ? 'active' : 'target'}`}
onClick={handlePlayTarget}
>
{targetPlaying ? '⏹ Parar' : '🎯 Objetivo'}
{targetPlaying ? '⏹' : '🎯'}{!isMobile && <span className="btn-label">{targetPlaying ? ' Parar' : ' Objetivo'}</span>}
</button>
<button
className={`gm-btn ${state.isRunning ? 'active' : ''}`}
onClick={handleToggleAudio}
>
{state.isRunning ? '⏹ Parar' : ' Mi Sonido'}
{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}>
Comprobar
{!isMobile && <span className="btn-label"> Comprobar</span>}
</button>
{adminMode && (
<button className="gm-btn admin-solve" onClick={handleAutoSolve} title="Admin: resolver nivel">
@@ -332,7 +390,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
</div>
<div className="gm-puzzle-content">
{/* Left sidebar */}
{/* Left sidebar (desktop only — hidden on mobile via CSS) */}
<div className="gm-puzzle-sidebar">
{/* Description — always visible */}
<div className="gm-concept-panel">
@@ -455,6 +513,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
{(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 && (
@@ -465,6 +524,89 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
</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

View File

@@ -1,4 +1,7 @@
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';
@@ -39,11 +42,20 @@ function isWorldUnlocked(world) {
return getTotalStars() >= world.unlockStars;
}
export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
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();
@@ -81,6 +93,14 @@ export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
🛠
</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>
@@ -209,6 +229,19 @@ export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
);
})
)}
{/* 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

@@ -24,7 +24,14 @@ export const WORLD_10 = {
preplacedModules: [
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 3 },
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,
@@ -87,7 +94,14 @@ export const WORLD_10 = {
preplacedModules: [
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 3 },
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,
@@ -148,7 +162,16 @@ export const WORLD_10 = {
preplacedModules: [
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 3 },
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,
@@ -205,7 +228,16 @@ export const WORLD_10 = {
preplacedModules: [
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 3 },
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,
@@ -262,7 +294,16 @@ export const WORLD_10 = {
preplacedModules: [
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 4 },
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,
@@ -317,7 +358,16 @@ export const WORLD_10 = {
preplacedModules: [
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 3 },
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,
@@ -374,7 +424,19 @@ export const WORLD_10 = {
preplacedModules: [
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 4 },
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,
@@ -442,7 +504,21 @@ export const WORLD_10 = {
preplacedModules: [
{ id: 1, type: 'output', x: 950, y: 160, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 5 },
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,

View File

@@ -25,7 +25,15 @@ export const WORLD_11 = {
preplacedModules: [
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
],
target: { build: [], duration: 3 },
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,
@@ -80,7 +88,14 @@ export const WORLD_11 = {
preplacedModules: [
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 3 },
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,
@@ -140,7 +155,21 @@ export const WORLD_11 = {
preplacedModules: [
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 5 },
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,
@@ -196,7 +225,17 @@ export const WORLD_11 = {
preplacedModules: [
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 4 },
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,
@@ -266,7 +305,15 @@ export const WORLD_11 = {
preplacedModules: [
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 3 },
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,
@@ -319,7 +366,18 @@ export const WORLD_11 = {
preplacedModules: [
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -10 }, locked: true },
],
target: { build: [], duration: 4 },
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,
@@ -375,7 +433,18 @@ export const WORLD_11 = {
preplacedModules: [
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 3 },
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,
@@ -440,7 +509,26 @@ export const WORLD_11 = {
preplacedModules: [
{ id: 1, type: 'output', x: 950, y: 160, params: { volume: -10 }, locked: true },
],
target: { build: [], duration: 5 },
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,

View File

@@ -25,7 +25,19 @@ export const WORLD_12 = {
preplacedModules: [
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 5 },
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,
@@ -86,7 +98,16 @@ export const WORLD_12 = {
preplacedModules: [
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -6 }, locked: true },
],
target: { build: [], duration: 4 },
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,
@@ -146,7 +167,18 @@ export const WORLD_12 = {
preplacedModules: [
{ id: 1, type: 'output', x: 850, y: 140, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 5 },
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,
@@ -206,7 +238,19 @@ export const WORLD_12 = {
preplacedModules: [
{ id: 1, type: 'output', x: 850, y: 140, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 5 },
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,
@@ -265,7 +309,20 @@ export const WORLD_12 = {
preplacedModules: [
{ id: 1, type: 'output', x: 850, y: 140, params: { volume: -6 }, locked: true },
],
target: { build: [], duration: 5 },
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,
@@ -323,7 +380,22 @@ export const WORLD_12 = {
preplacedModules: [
{ id: 1, type: 'output', x: 950, y: 160, params: { volume: -10 }, locked: true },
],
target: { build: [], duration: 6 },
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,
@@ -380,7 +452,20 @@ export const WORLD_12 = {
preplacedModules: [
{ id: 1, type: 'output', x: 850, y: 140, params: { volume: -10 }, locked: true },
],
target: { build: [], duration: 5 },
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,
@@ -441,7 +526,27 @@ export const WORLD_12 = {
preplacedModules: [
{ id: 1, type: 'output', x: 1000, y: 160, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 8 },
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,

View File

@@ -80,7 +80,13 @@ export const WORLD_3 = {
{ id: 2, type: 'vca', x: 400, y: 60, params: { gain: 0 }, locked: false },
{ id: 3, type: 'output', x: 620, y: 80, params: { volume: -6 }, locked: true },
],
target: { build: [], duration: 2 },
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,
@@ -135,7 +141,13 @@ export const WORLD_3 = {
{ id: 2, type: 'vca', x: 400, y: 60, params: { gain: 0 }, locked: false },
{ id: 3, type: 'output', x: 620, y: 80, params: { volume: -6 }, locked: true },
],
target: { build: [], duration: 2 },
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,
@@ -191,7 +203,13 @@ export const WORLD_3 = {
{ id: 2, type: 'vca', x: 400, y: 60, params: { gain: 0 }, locked: false },
{ id: 3, type: 'output', x: 620, y: 80, params: { volume: -6 }, locked: true },
],
target: { build: [], duration: 2 },
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,
@@ -243,7 +261,14 @@ export const WORLD_3 = {
{ id: 2, type: 'vca', x: 500, y: 60, params: { gain: 0 }, locked: false },
{ id: 3, type: 'output', x: 760, y: 80, params: { volume: -6 }, locked: true },
],
target: { build: [], duration: 2 },
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,
@@ -300,7 +325,14 @@ export const WORLD_3 = {
{ id: 2, type: 'vca', x: 520, y: 40, params: { gain: 0 }, locked: false },
{ id: 3, type: 'output', x: 760, y: 80, params: { volume: -6 }, locked: true },
],
target: { build: [], duration: 2 },
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,
@@ -357,7 +389,13 @@ export const WORLD_3 = {
{ id: 2, type: 'vca', x: 340, y: 60, params: { gain: 0.7 }, locked: false },
{ id: 3, type: 'output', x: 580, y: 80, params: { volume: -6 }, locked: true },
],
target: { build: [], duration: 3 },
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,
@@ -408,7 +446,14 @@ export const WORLD_3 = {
preplacedModules: [
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -6 }, locked: true },
],
target: { build: [], duration: 3 },
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,

View File

@@ -25,7 +25,13 @@ export const WORLD_4 = {
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sine', frequency: 440, detune: 0 }, locked: true },
{ id: 2, type: 'output', x: 500, y: 100, params: { volume: -6 }, locked: true },
],
target: { build: [], duration: 3 },
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,
@@ -74,7 +80,13 @@ export const WORLD_4 = {
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'square', frequency: 440, detune: 0 }, locked: false },
{ id: 2, type: 'output', x: 500, y: 100, params: { volume: -6 }, locked: true },
],
target: { build: [], duration: 4 },
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,
@@ -124,7 +136,14 @@ export const WORLD_4 = {
{ id: 2, type: 'filter', x: 300, y: 60, params: { type: 'lowpass', frequency: 600, Q: 4 }, locked: false },
{ id: 3, type: 'output', x: 560, y: 80, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 3 },
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,
@@ -178,7 +197,13 @@ export const WORLD_4 = {
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sine', frequency: 440, detune: 0 }, locked: true },
{ id: 2, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
],
target: { build: [], duration: 3 },
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,
@@ -232,7 +257,14 @@ export const WORLD_4 = {
{ id: 3, type: 'vca', x: 480, y: 40, params: { gain: 0.6 }, locked: false },
{ id: 4, type: 'output', x: 680, y: 60, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 3 },
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,
@@ -288,7 +320,13 @@ export const WORLD_4 = {
preplacedModules: [
{ id: 1, type: 'output', x: 600, y: 120, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 3 },
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 220 } },
{ type: 'oscillator', params: { waveform: 'sine', frequency: 880 } },
],
duration: 3,
},
checks: [
{
star: 1,
@@ -354,7 +392,15 @@ export const WORLD_4 = {
{ id: 3, type: 'vca', x: 480, y: 40, params: { gain: 0 }, locked: false },
{ id: 4, type: 'output', x: 680, y: 60, params: { volume: -6 }, locked: true },
],
target: { build: [], duration: 3 },
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,
@@ -418,7 +464,15 @@ export const WORLD_4 = {
preplacedModules: [
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 4 },
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,

View File

@@ -26,7 +26,15 @@ export const WORLD_5 = {
{ id: 2, type: 'delay', x: 340, y: 80, params: { time: 0.3, feedback: 0.3, mix: 0.5 }, locked: false },
{ id: 3, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
],
target: { build: [], duration: 3 },
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,
@@ -77,7 +85,15 @@ export const WORLD_5 = {
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'square', frequency: 330, detune: 0 }, locked: true },
{ id: 2, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
],
target: { build: [], duration: 2 },
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,
@@ -129,7 +145,15 @@ export const WORLD_5 = {
{ id: 2, type: 'reverb', x: 340, y: 80, params: { decay: 2, mix: 0.4 }, locked: false },
{ id: 3, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
],
target: { build: [], duration: 3 },
target: {
build: [
{ type: 'oscillator', params: { waveform: 'triangle', frequency: 440 } },
],
effects: [
{ type: 'reverb', decay: 5.5, wet: 0.55 },
],
duration: 3,
},
checks: [
{
star: 1,
@@ -179,7 +203,15 @@ export const WORLD_5 = {
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sine', frequency: 220, detune: 0 }, locked: true },
{ id: 2, type: 'output', x: 600, y: 100, params: { volume: -10 }, locked: true },
],
target: { build: [], duration: 2.5 },
target: {
build: [
{ type: 'oscillator', params: { waveform: 'sine', frequency: 220 } },
],
effects: [
{ type: 'distortion', amount: 6 },
],
duration: 2.5,
},
checks: [
{
star: 1,
@@ -229,7 +261,16 @@ export const WORLD_5 = {
{ id: 1, type: 'oscillator', x: 60, y: 80, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: true },
{ id: 2, type: 'output', x: 740, y: 100, params: { volume: -10 }, locked: true },
],
target: { build: [], duration: 3 },
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,
@@ -289,7 +330,16 @@ export const WORLD_5 = {
{ id: 1, type: 'oscillator', x: 60, y: 80, params: { waveform: 'square', frequency: 330, detune: 0 }, locked: true },
{ id: 2, type: 'output', x: 720, y: 100, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 3 },
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,
@@ -343,7 +393,16 @@ export const WORLD_5 = {
{ id: 1, type: 'oscillator', x: 60, y: 80, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: false },
{ id: 2, type: 'output', x: 720, y: 100, params: { volume: -10 }, locked: true },
],
target: { build: [], duration: 4 },
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,
@@ -398,7 +457,19 @@ export const WORLD_5 = {
preplacedModules: [
{ id: 1, type: 'output', x: 900, y: 120, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 5 },
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,

View File

@@ -24,7 +24,13 @@ export const WORLD_6 = {
preplacedModules: [
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
],
target: { build: [], duration: 2 },
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,
@@ -84,7 +90,14 @@ export const WORLD_6 = {
preplacedModules: [
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -10 }, locked: true },
],
target: { build: [], duration: 2 },
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,
@@ -135,7 +148,15 @@ export const WORLD_6 = {
preplacedModules: [
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 2 },
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,
@@ -194,7 +215,19 @@ export const WORLD_6 = {
preplacedModules: [
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 5 },
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,
@@ -250,7 +283,16 @@ export const WORLD_6 = {
preplacedModules: [
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 4 },
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,
@@ -303,7 +345,13 @@ export const WORLD_6 = {
preplacedModules: [
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 2 },
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,
@@ -368,7 +416,19 @@ export const WORLD_6 = {
preplacedModules: [
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 4 },
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,
@@ -425,7 +485,22 @@ export const WORLD_6 = {
preplacedModules: [
{ id: 1, type: 'output', x: 950, y: 160, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 5 },
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,

View File

@@ -25,7 +25,14 @@ export const WORLD_7 = {
preplacedModules: [
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
],
target: { build: [], duration: 2 },
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,
@@ -81,7 +88,17 @@ export const WORLD_7 = {
preplacedModules: [
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 3 },
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,
@@ -137,7 +154,16 @@ export const WORLD_7 = {
preplacedModules: [
{ id: 1, type: 'output', x: 750, y: 120, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 2.5 },
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,
@@ -194,7 +220,15 @@ export const WORLD_7 = {
preplacedModules: [
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 2 },
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,
@@ -250,7 +284,14 @@ export const WORLD_7 = {
preplacedModules: [
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 2 },
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,
@@ -308,7 +349,16 @@ export const WORLD_7 = {
preplacedModules: [
{ id: 1, type: 'output', x: 750, y: 120, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 2.5 },
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,
@@ -362,7 +412,18 @@ export const WORLD_7 = {
preplacedModules: [
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 3 },
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,
@@ -417,7 +478,22 @@ export const WORLD_7 = {
preplacedModules: [
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 5 },
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,

View File

@@ -26,7 +26,13 @@ export const WORLD_8 = {
preplacedModules: [
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 1.5 },
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,
@@ -79,7 +85,14 @@ export const WORLD_8 = {
preplacedModules: [
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 1.5 },
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,
@@ -135,7 +148,15 @@ export const WORLD_8 = {
preplacedModules: [
{ id: 1, type: 'output', x: 750, y: 120, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 2 },
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,
@@ -191,7 +212,14 @@ export const WORLD_8 = {
preplacedModules: [
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -10 }, locked: true },
],
target: { build: [], duration: 1.5 },
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,
@@ -246,7 +274,16 @@ export const WORLD_8 = {
preplacedModules: [
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -12 }, locked: true },
],
target: { build: [], duration: 1.5 },
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,
@@ -299,7 +336,13 @@ export const WORLD_8 = {
preplacedModules: [
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 1.5 },
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,
@@ -351,7 +394,17 @@ export const WORLD_8 = {
preplacedModules: [
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 2.5 },
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,
@@ -406,7 +459,25 @@ export const WORLD_8 = {
preplacedModules: [
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 6 },
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,

View File

@@ -24,7 +24,14 @@ export const WORLD_9 = {
preplacedModules: [
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
],
target: { build: [], duration: 3 },
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,
@@ -83,7 +90,15 @@ export const WORLD_9 = {
preplacedModules: [
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
],
target: { build: [], duration: 3 },
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,
@@ -139,7 +154,14 @@ export const WORLD_9 = {
preplacedModules: [
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 3 },
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,
@@ -197,7 +219,14 @@ export const WORLD_9 = {
preplacedModules: [
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
],
target: { build: [], duration: 3 },
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,
@@ -256,7 +285,16 @@ export const WORLD_9 = {
preplacedModules: [
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 4 },
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,
@@ -316,7 +354,16 @@ export const WORLD_9 = {
preplacedModules: [
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 3 },
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,
@@ -372,7 +419,15 @@ export const WORLD_9 = {
preplacedModules: [
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 4 },
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,
@@ -427,7 +482,19 @@ export const WORLD_9 = {
preplacedModules: [
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
],
target: { build: [], duration: 5 },
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,

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 PORT = process.env.PORT || 80;
const STATIC_DIR = path.join(__dirname, 'dist');
const STATIC_DIR = path.join(__dirname, '..', '..', 'dist');
const MIME = {
'.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,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,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,81 +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);
// Optional filter in the chain
let destination = output;
if (target.filter) {
const filter = new Tone.Filter({
type: target.filter.type || 'lowpass',
frequency: target.filter.frequency || 1000,
Q: target.filter.Q || 1,
});
filter.connect(output);
destination = filter;
nodes.push(filter);
}
// Build oscillators / noise from target.build
for (const spec of target.build) {
if (spec.type === 'oscillator') {
const osc = new Tone.Oscillator({
type: spec.params.waveform || 'sine',
frequency: spec.params.frequency || 440,
detune: spec.params.detune || 0,
});
osc.connect(destination);
osc.start();
nodes.push(osc);
} else if (spec.type === 'noise') {
const noise = new Tone.Noise(spec.params.type || 'white');
noise.connect(destination);
noise.start();
nodes.push(noise);
}
}
_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,787 +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); }
/* Modulation indicator: pulsing ring around modulated knobs */
.knob-mod-ring {
fill: none;
stroke-width: 1.5;
stroke-dasharray: 3 2;
opacity: 0.7;
animation: knob-mod-pulse 1.2s ease-in-out infinite alternate, knob-mod-spin 4s linear infinite;
}
@keyframes knob-mod-pulse {
from { opacity: 0.3; stroke-width: 1; }
to { opacity: 0.9; stroke-width: 2; }
}
@keyframes knob-mod-spin {
from { stroke-dashoffset: 0; }
to { stroke-dashoffset: 30; }
}
.knob-modulated .param-label,
.knob-container.knob-modulated + .param-value {
color: var(--accent2);
}
/* Ghost dot showing base value position while modulated */
.knob-base-dot {
fill: var(--text2);
opacity: 0.4;
}
/* Live modulation number — highlight color + subtle glow */
.param-value-live {
color: var(--accent) !important;
text-shadow: 0 0 6px rgba(0, 229, 255, 0.5);
font-variant-numeric: tabular-nums;
transition: none;
}
.knob-editing { display: flex; align-items: center; justify-content: center; }
.knob-input {
width: 48px; height: 22px; padding: 0 4px;
background: var(--bg); border: 1px solid var(--accent); border-radius: 3px;
color: var(--accent); font-size: 11px; font-family: 'JetBrains Mono', monospace;
text-align: center; outline: none;
}
.knob-input:focus { box-shadow: 0 0 6px rgba(0,229,255,0.3); }
.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); }
/* Level search bar */
.gm-search-bar {
display: flex; align-items: center; gap: 8px;
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
padding: 8px 14px; margin-bottom: 24px; transition: border-color 0.15s;
}
.gm-search-bar:focus-within { border-color: var(--accent); }
.gm-search-icon { font-size: 14px; opacity: 0.5; flex-shrink: 0; }
.gm-search-input {
flex: 1; background: none; border: none; outline: none;
color: var(--text); font-size: 13px; font-family: inherit;
}
.gm-search-input::placeholder { color: var(--text2); }
.gm-search-clear {
background: none; border: none; color: var(--text2); cursor: pointer;
font-size: 14px; padding: 2px 4px; border-radius: 4px;
}
.gm-search-clear:hover { color: var(--text); background: var(--surface2); }
.gm-search-results { margin-bottom: 24px; }
.gm-search-empty { color: var(--text2); font-size: 13px; padding: 16px 0; text-align: center; }
.gm-search-count { color: var(--text2); font-size: 11px; margin-bottom: 12px; }
/* World sections */
.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; min-height: 0; }
.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;
min-height: 0; /* Allow flex item to shrink below content — enables scrolling */
}
/* Prevent sidebar children from shrinking — forces overflow → scroll */
.gm-puzzle-sidebar > * { flex-shrink: 0; }
.gm-puzzle-canvas-wrap {
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; }
.gm-obj.capped .gm-obj-name { color: var(--text2); text-decoration: line-through; }
.gm-obj-locked { color: var(--text2); font-size: 10px; }
.gm-hint-warning {
margin-top: 8px; padding: 6px 8px; background: rgba(255,204,0,0.08);
border-radius: 4px; font-size: 10px; color: var(--yellow); line-height: 1.4;
}
/* Hint panel */
.gm-hint-panel { }
.gm-hint-btn {
width: 100%; display: flex; align-items: center; gap: 8px;
padding: 10px 12px; border: 1px dashed var(--yellow); border-radius: 8px;
background: rgba(255,204,0,0.04); cursor: pointer;
font-family: inherit; transition: all 0.15s;
}
.gm-hint-btn:hover { background: rgba(255,204,0,0.1); border-style: solid; }
.gm-hint-icon { font-size: 16px; }
.gm-hint-label { font-size: 12px; font-weight: 600; color: var(--yellow); flex: 1; text-align: left; }
.gm-hint-penalty {
font-size: 9px; color: var(--red); background: rgba(255,68,102,0.15);
padding: 2px 6px; border-radius: 3px; font-weight: 700;
}
.gm-hint-revealed {
background: var(--surface); border: 1px solid var(--yellow); border-radius: 8px;
overflow: hidden;
}
.gm-hint-header {
padding: 8px 12px; display: flex; justify-content: space-between; align-items: center;
font-size: 12px; font-weight: 600; color: var(--yellow);
background: rgba(255,204,0,0.06);
}
.gm-hint-penalty-tag {
font-size: 9px; color: var(--red); background: rgba(255,68,102,0.15);
padding: 2px 6px; border-radius: 3px; font-weight: 700;
}
.gm-hint-text {
padding: 8px 12px 12px; font-size: 11px; color: var(--text); line-height: 1.5;
}
.gm-hint-penalty-msg {
font-size: 11px; color: var(--yellow); margin-bottom: 16px;
}
.gm-big-star.locked {
color: var(--border); font-size: 36px;
}
/* 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; }
/* World stars counter */
.gm-world-stars {
display: flex; align-items: center; gap: 4px;
font-size: 14px; color: var(--text2);
background: var(--surface); border-radius: 12px; padding: 4px 10px;
}
.gm-world-stars .star.filled { color: var(--yellow); }
/* ===== Zoom Controls (Google Maps style) ===== */
.zoom-controls {
position: absolute;
top: 12px;
right: 12px;
z-index: 50;
display: flex;
flex-direction: column;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
}
.zoom-btn {
width: 36px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
background: transparent;
color: var(--text);
border: none;
cursor: pointer;
font-size: 18px;
font-weight: 600;
transition: background 0.15s;
}
.zoom-btn:hover {
background: var(--surface2);
}
.zoom-btn:active {
background: var(--border);
}
.zoom-btn.zoom-label {
font-size: 10px;
font-weight: 500;
color: var(--text2);
border-top: 1px solid var(--border);
border-bottom: 1px solid var(--border);
height: 26px;
width: 36px;
}
/* Zoom positioning inside puzzle canvas */
.gm-puzzle-canvas-wrap .zoom-controls {
top: 12px;
right: 12px;
}
/* Position zoom inside sandbox main-area (offset for palette sidebar) */
.main-area .zoom-controls {
top: 12px;
right: 220px;
}
/* ===== Admin Panel ===== */
.gm-admin-btn {
background: var(--surface);
border: 1px solid var(--border);
color: var(--text2);
padding: 6px 10px;
border-radius: 6px;
cursor: pointer;
font-size: 16px;
transition: all 0.15s;
}
.gm-admin-btn:hover { background: var(--surface2); color: var(--text); }
.admin-overlay {
position: fixed; inset: 0; z-index: 1000;
background: rgba(0,0,0,0.7);
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px);
}
.admin-panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
width: 90%; max-width: 700px; max-height: 85vh;
overflow-y: auto;
padding: 20px;
}
.admin-header {
display: flex; align-items: center; gap: 12px;
margin-bottom: 16px; padding-bottom: 12px;
border-bottom: 1px solid var(--border);
}
.admin-header h2 { font-size: 18px; color: var(--yellow); flex: 1; }
.admin-total { color: var(--yellow); font-size: 14px; font-weight: 600; }
.admin-close {
background: none; border: none; color: var(--text2);
cursor: pointer; font-size: 18px; padding: 4px 8px;
}
.admin-close:hover { color: var(--text); }
.admin-actions {
display: flex; gap: 8px; margin-bottom: 16px;
}
.admin-action-btn {
padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border);
background: var(--surface); color: var(--text); cursor: pointer;
font-size: 12px; font-weight: 500; transition: all 0.15s;
}
.admin-action-btn.gold { border-color: var(--yellow); color: var(--yellow); }
.admin-action-btn.gold:hover { background: var(--yellow); color: var(--bg); }
.admin-action-btn.active { border-color: var(--green); color: var(--green); background: rgba(68, 255, 136, 0.1); }
.admin-action-btn.active:hover { background: var(--green); color: var(--bg); }
.admin-action-btn.danger { border-color: var(--red); color: var(--red); }
.admin-action-btn.danger:hover { background: var(--red); color: #fff; }
/* Admin auto-solve button in puzzle bar */
.gm-btn.admin-solve {
background: rgba(170, 85, 255, 0.15); border-color: var(--purple); color: var(--purple);
}
.gm-btn.admin-solve:hover { background: var(--purple); color: #fff; }
.admin-world { margin-bottom: 16px; }
.admin-world-header {
display: flex; align-items: center; gap: 8px;
padding: 8px 0; border-bottom: 1px solid var(--border);
margin-bottom: 6px;
}
.admin-world-icon { font-size: 18px; }
.admin-world-name { flex: 1; font-size: 13px; font-weight: 600; color: var(--text); }
.admin-world-stars { font-size: 12px; color: var(--yellow); }
.admin-unlock-btn {
padding: 3px 10px; border-radius: 4px; border: 1px solid var(--green);
background: transparent; color: var(--green); cursor: pointer;
font-size: 11px; transition: all 0.15s;
}
.admin-unlock-btn:hover { background: var(--green); color: var(--bg); }
.admin-levels { display: flex; flex-direction: column; gap: 2px; }
.admin-level {
display: flex; align-items: center; gap: 8px;
padding: 4px 8px; border-radius: 4px;
transition: background 0.1s;
}
.admin-level:hover { background: var(--surface); }
.admin-level-num { font-size: 11px; color: var(--text2); width: 24px; }
.admin-level-name { flex: 1; font-size: 12px; color: var(--text); }
.admin-star-btns { display: flex; gap: 3px; }
.admin-star-btn {
padding: 2px 6px; border-radius: 3px; border: 1px solid var(--border);
background: transparent; cursor: pointer; font-size: 11px;
color: var(--text2); transition: all 0.1s;
}
.admin-star-btn:hover { border-color: var(--yellow); color: var(--yellow); }
.admin-star-btn.active { background: var(--yellow); color: var(--bg); border-color: var(--yellow); }
.admin-star-btn.zero { color: var(--red); }
.admin-star-btn.zero:hover { border-color: var(--red); color: var(--red); }

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