Compare commits
58 Commits
08206e996e
...
feat/produ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
925043e055 | ||
|
|
a0a3b58b49 | ||
|
|
13612bfa99 | ||
|
|
acbe4257ae | ||
|
|
12569dba76 | ||
|
|
f43a315047 | ||
|
|
b0522d8b0f | ||
|
|
e53ec600ad | ||
|
|
c673745b09 | ||
|
|
3b80070c9a | ||
|
|
982654c3ef | ||
|
|
64ffa36c09 | ||
|
|
3523111019 | ||
|
|
e129fd3739 | ||
|
|
6a4a308fd9 | ||
|
|
b058997889 | ||
|
|
4baa86eed0 | ||
|
|
4f4d2bfae5 | ||
|
|
02db83b896 | ||
|
|
49c016d0a6 | ||
|
|
2a2b3b3341 | ||
|
|
38dca9402f | ||
|
|
7e6c960b0b | ||
|
|
7596aea491 | ||
|
|
7d3a19ec35 | ||
|
|
8bdb953b52 | ||
|
|
18661961a1 | ||
|
|
1f941d7e39 | ||
|
|
9dba156961 | ||
|
|
b91b35f23d | ||
|
|
1cf39f9b13 | ||
|
|
cf6e912905 | ||
|
|
52045897e5 | ||
|
|
8b193126f7 | ||
|
|
f0e7f7f37a | ||
|
|
892195410b | ||
|
|
816e7270ed | ||
|
|
323f30cfb9 | ||
|
|
8b66944e52 | ||
|
|
cd88fb5444 | ||
|
|
4517e49ea6 | ||
|
|
589fbcf533 | ||
|
|
73532074b1 | ||
|
|
fce0bcdace | ||
|
|
64280874ea | ||
|
|
36eb31a652 | ||
|
|
58d567c671 | ||
|
|
888b88e748 | ||
|
|
9123bf8c5c | ||
|
|
23ac673e51 | ||
|
|
be66d9a7cf | ||
|
|
1e3652f3b0 | ||
|
|
a1be6df355 | ||
|
|
f0100eb64f | ||
|
|
c4a2cb3cef | ||
|
|
41d993183f | ||
|
|
00c4ec8e00 | ||
|
|
e077e7f553 |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,3 +1,4 @@
|
||||
node_modules
|
||||
dist
|
||||
.vite
|
||||
.env
|
||||
|
||||
23
Dockerfile
23
Dockerfile
@@ -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
24
docker-compose.yml
Normal 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
3000
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
20
packages/client/package.json
Normal file
20
packages/client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 496 B After Width: | Height: | Size: 496 B |
BIN
packages/client/public/icon-192.png
Normal file
BIN
packages/client/public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
BIN
packages/client/public/icon-512.png
Normal file
BIN
packages/client/public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
15
packages/client/public/manifest.json
Normal file
15
packages/client/public/manifest.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "Reaktor — MontLab Modular Synth",
|
||||
"short_name": "Reaktor",
|
||||
"description": "Modular synthesizer & SynthQuest puzzle game",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#08080f",
|
||||
"theme_color": "#00e5ff",
|
||||
"icons": [
|
||||
{ "src": "/favicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
|
||||
{ "src": "/icon-192.png", "sizes": "192x192", "type": "image/png" },
|
||||
{ "src": "/icon-512.png", "sizes": "512x512", "type": "image/png" }
|
||||
]
|
||||
}
|
||||
33
packages/client/public/sw.js
Normal file
33
packages/client/public/sw.js
Normal file
@@ -0,0 +1,33 @@
|
||||
const CACHE_NAME = 'reaktor-v1';
|
||||
|
||||
self.addEventListener('install', (e) => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener('activate', (e) => {
|
||||
e.waitUntil(
|
||||
caches.keys().then(keys =>
|
||||
Promise.all(keys.filter(k => k !== CACHE_NAME).map(k => caches.delete(k)))
|
||||
)
|
||||
);
|
||||
self.clients.claim();
|
||||
});
|
||||
|
||||
self.addEventListener('fetch', (e) => {
|
||||
// Only cache GET requests, skip API calls
|
||||
if (e.request.method !== 'GET') return;
|
||||
|
||||
e.respondWith(
|
||||
caches.match(e.request).then(cached => {
|
||||
const fetching = fetch(e.request).then(response => {
|
||||
if (response.ok) {
|
||||
const clone = response.clone();
|
||||
caches.open(CACHE_NAME).then(cache => cache.put(e.request, clone));
|
||||
}
|
||||
return response;
|
||||
}).catch(() => cached);
|
||||
|
||||
return cached || fetching;
|
||||
})
|
||||
);
|
||||
});
|
||||
465
packages/client/src/App.jsx
Normal file
465
packages/client/src/App.jsx
Normal file
@@ -0,0 +1,465 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { state, subscribe, addModule, emit, addConnection, updateModulePosition, deserialize } from './engine/state.js';
|
||||
import { startAudio, stopAudio, connectWire, rebuildGraph } from './engine/audioEngine.js';
|
||||
import { getModuleDef } from './engine/moduleRegistry.js';
|
||||
import { autoSave, autoLoad, exportPatch, importPatch } from './engine/presets.js';
|
||||
import { playEngineStart, playEngineStop } from './engine/uiSounds.js';
|
||||
import ModuleNode from './components/ModuleNode.jsx';
|
||||
import WireLayer from './components/WireLayer.jsx';
|
||||
import ModulePalette from './components/ModulePalette.jsx';
|
||||
import PresetModal from './components/PresetModal.jsx';
|
||||
import BottomSheet from './components/BottomSheet.jsx';
|
||||
import { CHIPTUNE_PRESET } from './presets/chiptune.js';
|
||||
import { useIsMobile } from './hooks/useIsMobile.js';
|
||||
import { usePinchZoom } from './hooks/usePinchZoom.js';
|
||||
import { getModulesByCategory } from './engine/moduleRegistry.js';
|
||||
import { useAuth } from './services/AuthContext.jsx';
|
||||
|
||||
export default function App({ onSwitchToGame, onSwitchToWorkshop, onSwitchToAdmin }) {
|
||||
const [, forceUpdate] = useState(0);
|
||||
const containerRef = useRef(null);
|
||||
const portPositions = useRef({});
|
||||
const [tempWire, setTempWire] = useState(null);
|
||||
const connectingRef = useRef(null);
|
||||
const [presetModal, setPresetModal] = useState(null);
|
||||
const { user, isLoggedIn, isAdmin, openAuth, logout } = useAuth();
|
||||
const importRef = useRef(null);
|
||||
const isMobile = useIsMobile();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
// Pinch-to-zoom on mobile
|
||||
const getZoom = useCallback(() => state.zoom, []);
|
||||
const setZoom = useCallback((z) => { state.zoom = z; emit(); }, []);
|
||||
usePinchZoom(containerRef, getZoom, setZoom);
|
||||
|
||||
// Subscribe to state changes
|
||||
useEffect(() => {
|
||||
const unsub = subscribe(() => forceUpdate(n => n + 1));
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
// Auto-load on mount, but skip if modules already loaded (e.g. from Workshop)
|
||||
useEffect(() => {
|
||||
if (state.modules.length > 0) return; // Already loaded (Workshop, etc.)
|
||||
const loaded = autoLoad();
|
||||
if (!loaded || state.modules.length === 0) {
|
||||
deserialize(CHIPTUNE_PRESET);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-save interval
|
||||
useEffect(() => {
|
||||
const interval = setInterval(autoSave, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Port position reporting
|
||||
const handlePortPosition = useCallback((moduleId, portName, direction, el) => {
|
||||
const key = `${moduleId}-${portName}-${direction}`;
|
||||
portPositions.current[key] = el;
|
||||
}, []);
|
||||
|
||||
// Start connecting a wire
|
||||
const handleStartConnect = useCallback((info) => {
|
||||
connectingRef.current = info;
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
setTempWire({
|
||||
portType: info.portType,
|
||||
startX: info.startX - containerRect.left,
|
||||
startY: info.startY - containerRect.top,
|
||||
endX: info.startX - containerRect.left,
|
||||
endY: info.startY - containerRect.top,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Find port-dot element at pointer position (including nearby)
|
||||
// Robust port detection — searches all port-dots by bounding rect distance
|
||||
// instead of elementFromPoint (which gets blocked by SVG wire overlay)
|
||||
const findPortAtPoint = (clientX, clientY) => {
|
||||
const portDots = document.querySelectorAll('.port-dot[data-module-id]');
|
||||
let closest = null;
|
||||
let closestDist = 18;
|
||||
for (const dot of portDots) {
|
||||
const rect = dot.getBoundingClientRect();
|
||||
const cx = rect.left + rect.width / 2;
|
||||
const cy = rect.top + rect.height / 2;
|
||||
const dist = Math.sqrt((clientX - cx) ** 2 + (clientY - cy) ** 2);
|
||||
if (dist < closestDist) {
|
||||
closestDist = dist;
|
||||
closest = dot;
|
||||
}
|
||||
}
|
||||
return closest;
|
||||
};
|
||||
|
||||
// Canvas pointer events
|
||||
const handlePointerDown = useCallback((e) => {
|
||||
if (e.button === 1 || (e.button === 0 && e.altKey) || e.button === 2) {
|
||||
state.panning = true;
|
||||
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
|
||||
e.preventDefault();
|
||||
} else if (e.button === 0 && !connectingRef.current) {
|
||||
// On mobile (touch), single finger on empty canvas = pan
|
||||
if (isMobile && e.pointerType === 'touch') {
|
||||
state.panning = true;
|
||||
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
state.selectedModuleId = null;
|
||||
emit();
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
const handlePointerMove = useCallback((e) => {
|
||||
if (state.panning && state.panStart) {
|
||||
state.camX = e.clientX - state.panStart.x;
|
||||
state.camY = e.clientY - state.panStart.y;
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
if (state.dragging) {
|
||||
const newX = e.clientX / state.zoom - state.dragging.offsetX;
|
||||
const newY = e.clientY / state.zoom - state.dragging.offsetY;
|
||||
updateModulePosition(state.dragging.moduleId, newX, newY);
|
||||
return;
|
||||
}
|
||||
if (connectingRef.current && containerRef.current) {
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
setTempWire(prev => prev ? {
|
||||
...prev,
|
||||
endX: e.clientX - containerRect.left,
|
||||
endY: e.clientY - containerRect.top,
|
||||
} : null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePointerUp = useCallback((e) => {
|
||||
if (state.panning) {
|
||||
state.panning = false;
|
||||
state.panStart = null;
|
||||
}
|
||||
if (state.dragging) {
|
||||
state.dragging = null;
|
||||
emit();
|
||||
}
|
||||
|
||||
// End connecting
|
||||
if (connectingRef.current) {
|
||||
const portEl = findPortAtPoint(e.clientX, e.clientY);
|
||||
if (portEl) {
|
||||
finishConnection(portEl);
|
||||
}
|
||||
connectingRef.current = null;
|
||||
setTempWire(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const finishConnection = (portEl) => {
|
||||
const from = connectingRef.current;
|
||||
if (!from) return;
|
||||
|
||||
// Read data attributes directly — clean and reliable
|
||||
const targetModuleId = parseInt(portEl.dataset.moduleId);
|
||||
const targetPort = portEl.dataset.portName;
|
||||
const targetDirection = portEl.dataset.portDirection;
|
||||
|
||||
if (!targetModuleId || !targetPort || !targetDirection) return;
|
||||
if (targetModuleId === from.moduleId && targetPort === from.port) return;
|
||||
|
||||
// Determine from/to
|
||||
let fromMod, fromPort, toMod, toPort;
|
||||
if (from.direction === 'output' && targetDirection === 'input') {
|
||||
fromMod = from.moduleId; fromPort = from.port;
|
||||
toMod = targetModuleId; toPort = targetPort;
|
||||
} else if (from.direction === 'input' && targetDirection === 'output') {
|
||||
fromMod = targetModuleId; fromPort = targetPort;
|
||||
toMod = from.moduleId; toPort = from.port;
|
||||
} else {
|
||||
return; // same direction — invalid
|
||||
}
|
||||
|
||||
const connId = addConnection(fromMod, fromPort, toMod, toPort);
|
||||
if (connId && state.isRunning) {
|
||||
const conn = state.connections.find(c => c.id === connId);
|
||||
if (conn) connectWire(conn);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWheel = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
const delta = -e.deltaY * 0.001;
|
||||
state.zoom = Math.max(0.3, Math.min(3, state.zoom + delta));
|
||||
emit();
|
||||
}, []);
|
||||
|
||||
const handleContextMenu = useCallback((e) => e.preventDefault(), []);
|
||||
|
||||
// Zoom controls (Google Maps style)
|
||||
const handleZoomIn = useCallback(() => {
|
||||
state.zoom = Math.min(3, state.zoom * 1.25);
|
||||
emit();
|
||||
}, []);
|
||||
const handleZoomOut = useCallback(() => {
|
||||
state.zoom = Math.max(0.3, state.zoom / 1.25);
|
||||
emit();
|
||||
}, []);
|
||||
const handleZoomReset = useCallback(() => {
|
||||
state.zoom = 1;
|
||||
state.camX = 0;
|
||||
state.camY = 0;
|
||||
emit();
|
||||
}, []);
|
||||
|
||||
// Center view on all modules
|
||||
const handleCenterView = useCallback(() => {
|
||||
if (state.modules.length === 0) { state.camX = 0; state.camY = 0; emit(); return; }
|
||||
const container = containerRef.current;
|
||||
const cw = container?.clientWidth || 800;
|
||||
const ch = container?.clientHeight || 600;
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const m of state.modules) {
|
||||
minX = Math.min(minX, m.x);
|
||||
minY = Math.min(minY, m.y);
|
||||
maxX = Math.max(maxX, m.x + 200);
|
||||
maxY = Math.max(maxY, m.y + 150);
|
||||
}
|
||||
const cx = (minX + maxX) / 2 * state.zoom;
|
||||
const cy = (minY + maxY) / 2 * state.zoom;
|
||||
state.camX = cw / 2 - cx;
|
||||
state.camY = ch / 2 - cy;
|
||||
emit();
|
||||
}, []);
|
||||
|
||||
const handleToggleAudio = async () => {
|
||||
if (state.isRunning) {
|
||||
stopAudio();
|
||||
playEngineStop();
|
||||
} else {
|
||||
await startAudio();
|
||||
playEngineStart();
|
||||
}
|
||||
emit();
|
||||
};
|
||||
|
||||
const handleAddModule = (type) => {
|
||||
const x = (-state.camX + 300) / state.zoom + Math.random() * 50;
|
||||
const y = (-state.camY + 200) / state.zoom + Math.random() * 50;
|
||||
addModule(type, x, y);
|
||||
if (state.isRunning) rebuildGraph();
|
||||
};
|
||||
|
||||
const handleImport = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
await importPatch(file);
|
||||
emit();
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleLoadDemo = () => {
|
||||
deserialize(CHIPTUNE_PRESET);
|
||||
if (state.isRunning) rebuildGraph();
|
||||
emit();
|
||||
};
|
||||
|
||||
const handleClearCanvas = () => {
|
||||
if (state.isRunning) stopAudio();
|
||||
deserialize({ modules: [], connections: [] });
|
||||
emit();
|
||||
};
|
||||
|
||||
// Flatten all modules for mobile grid
|
||||
const allModuleDefs = Object.values(getModulesByCategory()).flat();
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{/* Toolbar */}
|
||||
<div className="toolbar">
|
||||
{onSwitchToGame && !isMobile && (
|
||||
<button className="toolbar-btn" onClick={onSwitchToGame} style={{ color: 'var(--yellow)' }}>
|
||||
🎮 Game
|
||||
</button>
|
||||
)}
|
||||
{onSwitchToWorkshop && !isMobile && (
|
||||
<button className="toolbar-btn" onClick={onSwitchToWorkshop}>
|
||||
🎵 Workshop
|
||||
</button>
|
||||
)}
|
||||
<span className="toolbar-title">Reaktor</span>
|
||||
{!isMobile && <div className="toolbar-sep" />}
|
||||
{!isMobile && (
|
||||
<button className={`toolbar-btn start-btn ${state.isRunning ? 'active' : ''}`} onClick={handleToggleAudio}>
|
||||
{state.isRunning ? '⏹ Stop' : '▶ Start'}
|
||||
</button>
|
||||
)}
|
||||
{!isMobile && <div className="toolbar-sep" />}
|
||||
{!isMobile && (
|
||||
<div className="toolbar-group">
|
||||
<button className="toolbar-btn save-btn" onClick={() => setPresetModal('save')}>💾 Save</button>
|
||||
<button className="toolbar-btn load-btn" onClick={() => setPresetModal('load')}>📂 Load</button>
|
||||
<button className="toolbar-btn export-btn" onClick={exportPatch}>📤 Export</button>
|
||||
<button className="toolbar-btn import-btn" onClick={() => importRef.current?.click()}>📥 Import</button>
|
||||
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
|
||||
</div>
|
||||
)}
|
||||
{!isMobile && <div className="toolbar-sep" />}
|
||||
{!isMobile && (
|
||||
<>
|
||||
<button className="toolbar-btn demo-btn" onClick={handleLoadDemo} style={{ color: 'var(--yellow)' }}>
|
||||
🎮 Chiptune Demo
|
||||
</button>
|
||||
<button className="toolbar-btn clear-btn" onClick={handleClearCanvas}>
|
||||
🗑 Limpiar
|
||||
</button>
|
||||
<div className="toolbar-sep" />
|
||||
</>
|
||||
)}
|
||||
<span className="toolbar-label" style={{ color: state.isRunning ? 'var(--green)' : 'var(--text2)', marginLeft: isMobile ? 'auto' : undefined }}>
|
||||
{state.isRunning ? '● LIVE' : '○ OFF'}
|
||||
</span>
|
||||
{!isMobile && (
|
||||
<span className="toolbar-label" style={{ marginLeft: 'auto' }}>
|
||||
{state.modules.length} modules · {state.connections.length} wires
|
||||
</span>
|
||||
)}
|
||||
{isLoggedIn ? (
|
||||
<div className="user-dropdown">
|
||||
<div className="user-badge">
|
||||
<div className="user-avatar">{user.username?.[0]?.toUpperCase()}</div>
|
||||
<span className="user-name">{user.username}</span>
|
||||
</div>
|
||||
<div className="user-dropdown-menu">
|
||||
{isAdmin && onSwitchToAdmin && <button onClick={onSwitchToAdmin}>🛠 Admin</button>}
|
||||
<button onClick={logout}>Cerrar sesion</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="login-btn" onClick={openAuth}>Entrar</button>
|
||||
)}
|
||||
{isMobile && (
|
||||
<button className="mobile-menu-btn" onClick={() => setMenuOpen(true)}>≡</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile menu overlay */}
|
||||
{isMobile && menuOpen && (
|
||||
<div className="mobile-menu-overlay" onClick={() => setMenuOpen(false)}>
|
||||
<div className="mobile-menu-panel" onClick={e => e.stopPropagation()}>
|
||||
{onSwitchToGame && (
|
||||
<button className="toolbar-btn" onClick={() => { setMenuOpen(false); onSwitchToGame(); }} style={{ color: 'var(--yellow)' }}>
|
||||
🎮 Game
|
||||
</button>
|
||||
)}
|
||||
<button className="toolbar-btn" onClick={() => { setPresetModal('save'); setMenuOpen(false); }}>💾 Save</button>
|
||||
<button className="toolbar-btn" onClick={() => { setPresetModal('load'); setMenuOpen(false); }}>📂 Load</button>
|
||||
<button className="toolbar-btn" onClick={() => { exportPatch(); setMenuOpen(false); }}>📤 Export</button>
|
||||
<button className="toolbar-btn" onClick={() => { importRef.current?.click(); setMenuOpen(false); }}>📥 Import</button>
|
||||
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
|
||||
<button className="toolbar-btn" onClick={() => { handleLoadDemo(); setMenuOpen(false); }} style={{ color: 'var(--yellow)' }}>
|
||||
🎮 Chiptune Demo
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={() => { handleClearCanvas(); setMenuOpen(false); }}>🗑 Limpiar</button>
|
||||
{onSwitchToWorkshop && (
|
||||
<button className="toolbar-btn" onClick={() => { setMenuOpen(false); onSwitchToWorkshop(); }} style={{ color: 'var(--accent)' }}>
|
||||
🎵 Workshop
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main canvas area */}
|
||||
<div className="main-area">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`node-canvas ${state.panning ? 'grabbing' : ''} ${connectingRef.current ? 'connecting' : ''}`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onWheel={handleWheel}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{/* Grid background */}
|
||||
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', zIndex: 0 }}>
|
||||
<defs>
|
||||
<pattern id="grid" width={20 * state.zoom} height={20 * state.zoom}
|
||||
patternUnits="userSpaceOnUse"
|
||||
x={state.camX % (20 * state.zoom)} y={state.camY % (20 * state.zoom)}>
|
||||
<circle cx={1} cy={1} r={0.8} fill="#1a1a30" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
</svg>
|
||||
|
||||
{/* Wire layer */}
|
||||
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} zoom={state.zoom} camX={state.camX} camY={state.camY} />
|
||||
|
||||
{/* Modules container */}
|
||||
<div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
|
||||
{state.modules.map(mod => (
|
||||
<ModuleNode
|
||||
key={mod.id}
|
||||
mod={mod}
|
||||
zoom={state.zoom}
|
||||
onStartConnect={handleStartConnect}
|
||||
onPortPosition={handlePortPosition}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zoom controls */}
|
||||
<div className="zoom-controls">
|
||||
<button className="zoom-btn" onClick={handleZoomIn} title="Zoom in">+</button>
|
||||
<button className="zoom-btn zoom-label" onClick={handleZoomReset} title="Reset zoom">
|
||||
{(state.zoom * 100).toFixed(0)}%
|
||||
</button>
|
||||
<button className="zoom-btn" onClick={handleZoomOut} title="Zoom out">−</button>
|
||||
<button className="zoom-btn" onClick={handleCenterView} title="Centrar vista">⌂</button>
|
||||
</div>
|
||||
|
||||
{/* Desktop palette */}
|
||||
{!isMobile && <ModulePalette onAddModule={handleAddModule} />}
|
||||
</div>
|
||||
|
||||
{/* Mobile action bar */}
|
||||
{isMobile && (
|
||||
<div className="mobile-action-bar">
|
||||
<button
|
||||
className={`start-btn-mobile ${state.isRunning ? 'active' : ''}`}
|
||||
onClick={handleToggleAudio}
|
||||
>
|
||||
{state.isRunning ? '⏹ STOP' : '▶ START'}
|
||||
</button>
|
||||
<button className="action-icon-btn" onClick={() => setPresetModal('save')}>💾</button>
|
||||
<button className="action-icon-btn" onClick={exportPatch}>📤</button>
|
||||
<button className="action-icon-btn" onClick={handleClearCanvas}>🗑</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile bottom sheet with modules */}
|
||||
{isMobile && (
|
||||
<BottomSheet>
|
||||
<div className="mobile-module-grid">
|
||||
{allModuleDefs.map(def => (
|
||||
<div key={def.type} className="mobile-module-tile" onClick={() => handleAddModule(def.type)}>
|
||||
<span className="tile-icon">{def.icon}</span>
|
||||
<span className="tile-name">{def.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</BottomSheet>
|
||||
)}
|
||||
|
||||
{/* Status bar (hidden on mobile via CSS) */}
|
||||
<div className="status-bar">
|
||||
<span className="status-accent">Reaktor — MontLab Modular Synth</span>
|
||||
<span>Zoom: {(state.zoom * 100).toFixed(0)}%</span>
|
||||
<span>RClick: pan · Wheel: zoom · Drag port: wire · Click wire: delete</span>
|
||||
</div>
|
||||
|
||||
{presetModal && <PresetModal mode={presetModal} onClose={() => setPresetModal(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
466
packages/client/src/components/AdminPanel2.jsx
Normal file
466
packages/client/src/components/AdminPanel2.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
packages/client/src/components/AuthModal.jsx
Normal file
114
packages/client/src/components/AuthModal.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
packages/client/src/components/BottomSheet.jsx
Normal file
52
packages/client/src/components/BottomSheet.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
packages/client/src/components/DrumPadWidget.jsx
Normal file
109
packages/client/src/components/DrumPadWidget.jsx
Normal 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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
181
packages/client/src/components/KeyboardWidget.jsx
Normal file
181
packages/client/src/components/KeyboardWidget.jsx
Normal 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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
150
packages/client/src/components/Knob.jsx
Normal file
150
packages/client/src/components/Knob.jsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useRef, useCallback, useState } from 'react';
|
||||
|
||||
const SIZE = 32;
|
||||
const RADIUS = 12;
|
||||
const STROKE = 3;
|
||||
const START_ANGLE = 225;
|
||||
const END_ANGLE = -45;
|
||||
const RANGE = 270; // degrees
|
||||
|
||||
function polarToCart(cx, cy, r, deg) {
|
||||
const rad = (deg - 90) * Math.PI / 180;
|
||||
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
|
||||
}
|
||||
|
||||
function describeArc(cx, cy, r, startDeg, endDeg) {
|
||||
const start = polarToCart(cx, cy, r, endDeg);
|
||||
const end = polarToCart(cx, cy, r, startDeg);
|
||||
const large = endDeg - startDeg <= 180 ? '0' : '1';
|
||||
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${large} 0 ${end.x} ${end.y}`;
|
||||
}
|
||||
|
||||
export default function Knob({ value, min, max, onChange, color = 'var(--accent)', label, unit, formatValue, modulated = false, liveValue }) {
|
||||
const ref = useRef(null);
|
||||
const dragRef = useRef(null);
|
||||
const inputRef = useRef(null);
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editText, setEditText] = useState('');
|
||||
|
||||
// Use liveValue for visual display when being modulated, base value for interaction
|
||||
const displayNum = liveValue !== undefined ? liveValue : value;
|
||||
const clampedDisplay = Math.max(min, Math.min(max, displayNum));
|
||||
const norm = Math.max(0, Math.min(1, (clampedDisplay - min) / (max - min)));
|
||||
const angleDeg = START_ANGLE - norm * RANGE;
|
||||
|
||||
const cx = SIZE / 2, cy = SIZE / 2;
|
||||
const trackPath = describeArc(cx, cy, RADIUS, END_ANGLE, START_ANGLE);
|
||||
const fillAngle = START_ANGLE - norm * RANGE;
|
||||
const fillPath = norm > 0.001 ? describeArc(cx, cy, RADIUS, fillAngle, START_ANGLE) : '';
|
||||
|
||||
const dotPos = polarToCart(cx, cy, RADIUS - 4, angleDeg);
|
||||
|
||||
// Also show base value indicator when modulated
|
||||
const baseNorm = Math.max(0, Math.min(1, (value - min) / (max - min)));
|
||||
const baseAngle = START_ANGLE - baseNorm * RANGE;
|
||||
const baseDotPos = polarToCart(cx, cy, RADIUS - 4, baseAngle);
|
||||
|
||||
const displayVal = formatValue ? formatValue(displayNum) :
|
||||
displayNum >= 1000 ? `${(displayNum / 1000).toFixed(1)}k` :
|
||||
displayNum >= 100 ? Math.round(displayNum) :
|
||||
displayNum >= 1 ? displayNum.toFixed(1) :
|
||||
displayNum.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
|
||||
|
||||
const handlePointerDown = useCallback((e) => {
|
||||
if (editing) return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
dragRef.current = { startY: e.clientY, startValue: value };
|
||||
const handleMove = (me) => {
|
||||
const dy = dragRef.current.startY - me.clientY;
|
||||
const sensitivity = (max - min) / 200;
|
||||
let newVal = dragRef.current.startValue + dy * sensitivity;
|
||||
newVal = Math.max(min, Math.min(max, newVal));
|
||||
if (max - min > 10 && Number.isInteger(min) && Number.isInteger(max)) {
|
||||
newVal = Math.round(newVal);
|
||||
}
|
||||
onChange(newVal);
|
||||
};
|
||||
const handleUp = () => {
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', handleUp);
|
||||
dragRef.current = null;
|
||||
};
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
window.addEventListener('pointerup', handleUp);
|
||||
}, [value, min, max, onChange, editing]);
|
||||
|
||||
const handleWheel = useCallback((e) => {
|
||||
if (editing) return;
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const step = (max - min) / 100;
|
||||
const newVal = Math.max(min, Math.min(max, value - Math.sign(e.deltaY) * step));
|
||||
onChange(newVal);
|
||||
}, [value, min, max, onChange, editing]);
|
||||
|
||||
// Double-click: open inline text input
|
||||
const handleDoubleClick = useCallback((e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
setEditText(String(typeof displayVal === 'number' ? displayVal : value));
|
||||
setEditing(true);
|
||||
// Focus input after render
|
||||
setTimeout(() => inputRef.current?.focus(), 0);
|
||||
}, [value, displayVal]);
|
||||
|
||||
const commitEdit = useCallback(() => {
|
||||
const parsed = parseFloat(editText);
|
||||
if (!isNaN(parsed)) {
|
||||
const clamped = Math.max(min, Math.min(max, parsed));
|
||||
onChange(clamped);
|
||||
}
|
||||
setEditing(false);
|
||||
}, [editText, min, max, onChange]);
|
||||
|
||||
const handleInputKeyDown = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
if (e.key === 'Enter') {
|
||||
commitEdit();
|
||||
} else if (e.key === 'Escape') {
|
||||
setEditing(false);
|
||||
}
|
||||
}, [commitEdit]);
|
||||
|
||||
const handleInputBlur = useCallback(() => {
|
||||
commitEdit();
|
||||
}, [commitEdit]);
|
||||
|
||||
if (editing) {
|
||||
return (
|
||||
<div className="knob-container knob-editing" onWheel={(e) => e.stopPropagation()}>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="knob-input"
|
||||
type="text"
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
onKeyDown={handleInputKeyDown}
|
||||
onBlur={handleInputBlur}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`knob-container ${modulated ? 'knob-modulated' : ''}`} onWheel={handleWheel} onDoubleClick={handleDoubleClick}>
|
||||
<svg className="knob-svg" viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
onPointerDown={handlePointerDown} ref={ref}>
|
||||
{/* Modulation glow ring */}
|
||||
{modulated && (
|
||||
<circle className="knob-mod-ring" cx={cx} cy={cy} r={RADIUS + 1} style={{ stroke: color }} />
|
||||
)}
|
||||
<path className="knob-track" d={trackPath} />
|
||||
{fillPath && <path className="knob-fill" d={fillPath} style={{ stroke: color }} />}
|
||||
{/* Ghost dot at base value position when modulated */}
|
||||
{liveValue !== undefined && (
|
||||
<circle className="knob-base-dot" cx={baseDotPos.x} cy={baseDotPos.y} r={1.5} />
|
||||
)}
|
||||
<circle className="knob-dot" cx={dotPos.x} cy={dotPos.y} r={2} />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
16
packages/client/src/components/MobileTabBar.jsx
Normal file
16
packages/client/src/components/MobileTabBar.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
330
packages/client/src/components/ModuleNode.jsx
Normal file
330
packages/client/src/components/ModuleNode.jsx
Normal file
@@ -0,0 +1,330 @@
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { getModuleDef } from '../engine/moduleRegistry.js';
|
||||
import { state, removeModule, updateModuleParam, isPortConnected, emit } from '../engine/state.js';
|
||||
import { updateParam, getAudioNode } from '../engine/audioEngine.js';
|
||||
import Knob from './Knob.jsx';
|
||||
import ScopeDisplay from './ScopeDisplay.jsx';
|
||||
import KeyboardWidget from './KeyboardWidget.jsx';
|
||||
import DrumPadWidget from './DrumPadWidget.jsx';
|
||||
import SequencerWidget from './SequencerWidget.jsx';
|
||||
import PianoRollWidget from './PianoRollWidget.jsx';
|
||||
|
||||
// Dynamic module widths for sequencer/pianoroll based on step/bar count
|
||||
function getModuleWidth(mod, type) {
|
||||
if (type === 'sequencer') {
|
||||
const numSteps = parseInt(mod?.params?.steps || '16');
|
||||
return Math.max(200, numSteps * 18 + 20); // CELL_W=18 + padding
|
||||
}
|
||||
if (type === 'pianoroll') {
|
||||
const bars = parseInt(mod?.params?.bars || '4');
|
||||
const totalBeats = bars * 4;
|
||||
return 24 + totalBeats * 30 + 20; // KEY_W + beats*BEAT_PX + padding
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Map input port names → the param name they modulate (for visual feedback)
|
||||
const PORT_TO_PARAM = {
|
||||
filter: { cutoff: 'frequency' },
|
||||
oscillator: { freq: 'frequency', detune: 'detune' },
|
||||
vca: { cv: 'gain' },
|
||||
};
|
||||
|
||||
// Compute a simulated LFO waveform value at time t (seconds)
|
||||
function simulateLFO(waveform, phase) {
|
||||
switch (waveform) {
|
||||
case 'sine': return Math.sin(2 * Math.PI * phase);
|
||||
case 'triangle': return 1 - 4 * Math.abs((phase % 1) - 0.5);
|
||||
case 'sawtooth': return 2 * (phase % 1) - 1;
|
||||
case 'square': return (phase % 1) < 0.5 ? 1 : -1;
|
||||
default: return Math.sin(2 * Math.PI * phase);
|
||||
}
|
||||
}
|
||||
|
||||
export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }) {
|
||||
const def = getModuleDef(mod.type);
|
||||
if (!def) return null;
|
||||
|
||||
const isSelected = state.selectedModuleId === mod.id;
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
|
||||
// Merge default params
|
||||
const params = { ...Object.fromEntries(Object.entries(def.params).map(([k, v]) => [k, v.default])), ...mod.params };
|
||||
|
||||
// Find which params are being modulated (have an incoming connection on their corresponding port)
|
||||
const modulatedParams = new Set();
|
||||
const portMap = PORT_TO_PARAM[mod.type] || {};
|
||||
for (const conn of state.connections) {
|
||||
if (conn.to.moduleId === mod.id && portMap[conn.to.port]) {
|
||||
modulatedParams.add(portMap[conn.to.port]);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Live modulation visualization (any source → any param) ====================
|
||||
const [liveValues, setLiveValues] = useState({});
|
||||
const rafRef = useRef(null);
|
||||
const startTimeRef = useRef(performance.now() / 1000);
|
||||
|
||||
useEffect(() => {
|
||||
if (modulatedParams.size === 0) {
|
||||
setLiveValues({});
|
||||
return;
|
||||
}
|
||||
|
||||
let frameCount = 0;
|
||||
const tick = () => {
|
||||
frameCount++;
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
if (frameCount % 4 !== 0) return;
|
||||
|
||||
const t = performance.now() / 1000 - startTimeRef.current;
|
||||
const newValues = {};
|
||||
|
||||
// Read current params fresh from state each tick (avoid stale closure)
|
||||
const curMod = state.modules.find(m => m.id === mod.id);
|
||||
if (!curMod) return;
|
||||
const curDef = getModuleDef(curMod.type);
|
||||
if (!curDef) return;
|
||||
const curParams = { ...Object.fromEntries(Object.entries(curDef.params).map(([k, v]) => [k, v.default])), ...curMod.params };
|
||||
|
||||
for (const conn of state.connections) {
|
||||
if (conn.to.moduleId !== mod.id) continue;
|
||||
const paramName = portMap[conn.to.port];
|
||||
if (!paramName) continue;
|
||||
|
||||
const srcMod = state.modules.find(m => m.id === conn.from.moduleId);
|
||||
if (!srcMod) continue;
|
||||
|
||||
const baseValue = curParams[paramName];
|
||||
|
||||
// Modulation scale based on target parameter
|
||||
const getScale = () => {
|
||||
if (curMod.type === 'oscillator' && paramName === 'frequency') return baseValue * 0.5;
|
||||
if (curMod.type === 'filter' && paramName === 'frequency') return baseValue;
|
||||
if (curMod.type === 'vca' && paramName === 'gain') return 1;
|
||||
return baseValue || 1;
|
||||
};
|
||||
|
||||
if (srcMod.type === 'lfo') {
|
||||
// LFO: simulate waveform for smooth visual
|
||||
const lfoDef = getModuleDef('lfo');
|
||||
const lfoP = { ...Object.fromEntries(Object.entries(lfoDef.params).map(([k, v]) => [k, v.default])), ...srcMod.params };
|
||||
const phase = (t * lfoP.frequency) % 1;
|
||||
const lfoVal = simulateLFO(lfoP.waveform, phase) * lfoP.amplitude;
|
||||
newValues[paramName] = baseValue + lfoVal * getScale();
|
||||
|
||||
} else if (srcMod.type === 'envelope') {
|
||||
// Envelope: read current level (0-1) from the source envelope node
|
||||
const envEntry = getAudioNode(srcMod.id);
|
||||
if (envEntry?.node) {
|
||||
const envValue = typeof envEntry.node.value === 'number' ? envEntry.node.value : 0;
|
||||
if (curMod.type === 'vca' && paramName === 'gain') {
|
||||
newValues[paramName] = envValue; // Envelope directly drives gain (0→1)
|
||||
} else {
|
||||
newValues[paramName] = baseValue + envValue * getScale();
|
||||
}
|
||||
}
|
||||
|
||||
} else if (srcMod.type === 'oscillator') {
|
||||
// Oscillator FM: simulate modulating oscillator waveform
|
||||
const srcDef = getModuleDef('oscillator');
|
||||
const srcP = { ...Object.fromEntries(Object.entries(srcDef.params).map(([k, v]) => [k, v.default])), ...srcMod.params };
|
||||
// Clamp visual frequency to avoid aliasing — show a slow representation
|
||||
const visFreq = Math.min(srcP.frequency, 8);
|
||||
const phase = (t * visFreq) % 1;
|
||||
const modVal = simulateLFO(srcP.waveform, phase) * 0.5;
|
||||
newValues[paramName] = baseValue + modVal * getScale();
|
||||
|
||||
} else if (srcMod.type === 'noise') {
|
||||
// Noise: random jitter
|
||||
const noiseVal = (Math.random() * 2 - 1) * 0.3;
|
||||
newValues[paramName] = baseValue + noiseVal * getScale();
|
||||
|
||||
} else {
|
||||
// Generic fallback: subtle visual pulse so user sees modulation is active
|
||||
const pulseVal = Math.sin(2 * Math.PI * t) * 0.2;
|
||||
newValues[paramName] = baseValue + pulseVal * getScale();
|
||||
}
|
||||
}
|
||||
|
||||
setLiveValues(newValues);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return () => {
|
||||
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||
};
|
||||
}, [mod.id, mod.type, modulatedParams.size]);
|
||||
|
||||
const handleParamChange = useCallback((name, value) => {
|
||||
updateModuleParam(mod.id, name, value);
|
||||
updateParam(mod.id, name, value);
|
||||
}, [mod.id]);
|
||||
|
||||
const handleHeaderDown = useCallback((e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
state.selectedModuleId = mod.id;
|
||||
state.dragging = {
|
||||
moduleId: mod.id,
|
||||
offsetX: e.clientX / zoom - mod.x,
|
||||
offsetY: e.clientY / zoom - mod.y,
|
||||
};
|
||||
emit();
|
||||
}, [mod, zoom]);
|
||||
|
||||
const handleDelete = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
removeModule(mod.id);
|
||||
}, [mod.id]);
|
||||
|
||||
const handlePortMouseDown = useCallback((e, portName, direction) => {
|
||||
e.stopPropagation(); e.preventDefault();
|
||||
const portDef = direction === 'output'
|
||||
? def.outputs.find(p => p.name === portName)
|
||||
: def.inputs.find(p => p.name === portName);
|
||||
if (!portDef) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
onStartConnect({
|
||||
moduleId: mod.id,
|
||||
port: portName,
|
||||
portType: portDef.type,
|
||||
direction,
|
||||
startX: rect.left + rect.width / 2,
|
||||
startY: rect.top + rect.height / 2,
|
||||
});
|
||||
}, [mod.id, def, onStartConnect]);
|
||||
|
||||
// Report port positions for wire rendering
|
||||
const portRef = useCallback((el, portName, direction) => {
|
||||
if (el) {
|
||||
onPortPosition(mod.id, portName, direction, el);
|
||||
}
|
||||
}, [mod.id, onPortPosition]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`module ${isSelected ? 'selected' : ''}`}
|
||||
style={{
|
||||
left: mod.x * zoom, top: mod.y * zoom,
|
||||
transform: `scale(${zoom})`, transformOrigin: 'top left',
|
||||
...(mod.type === 'pianoroll' ? { width: getModuleWidth(mod, 'pianoroll') } : mod.type === 'sequencer' ? { width: getModuleWidth(mod, 'sequencer') } : {}),
|
||||
}}
|
||||
data-module-id={mod.id}
|
||||
onPointerDown={(e) => {
|
||||
// Don't deselect when clicking inside a module
|
||||
e.stopPropagation();
|
||||
state.selectedModuleId = mod.id; emit();
|
||||
}}
|
||||
>
|
||||
<div className="module-header" onPointerDown={handleHeaderDown}>
|
||||
<span className="type-icon">{def.icon}</span>
|
||||
<span className="type-name">{def.name}</span>
|
||||
{(mod.type === 'keyboard' || mod.type === 'drumpad') && (
|
||||
<button
|
||||
className="expand-btn"
|
||||
onClick={(e) => { e.stopPropagation(); setFullscreen(true); }}
|
||||
title="Pantalla completa"
|
||||
>⤢</button>
|
||||
)}
|
||||
<button className="close-btn" onClick={handleDelete}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="module-body">
|
||||
{/* Input ports */}
|
||||
{def.inputs.map(port => (
|
||||
<div key={port.name} className="port-row input">
|
||||
<div
|
||||
className={`port-dot ${port.type} ${isPortConnected(mod.id, port.name, 'input') ? 'connected' : ''}`}
|
||||
ref={el => portRef(el, port.name, 'input')}
|
||||
data-module-id={mod.id}
|
||||
data-port-name={port.name}
|
||||
data-port-direction="input"
|
||||
data-port-type={port.type}
|
||||
onPointerDown={e => handlePortMouseDown(e, port.name, 'input')}
|
||||
/>
|
||||
<span className="port-label">{port.label}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Parameters */}
|
||||
{Object.entries(def.params).map(([name, paramDef]) => {
|
||||
if (paramDef.type === 'knob') {
|
||||
const color = paramDef.unit === 'Hz' ? 'var(--accent)' :
|
||||
paramDef.unit === 'dB' ? 'var(--green)' :
|
||||
paramDef.unit === 's' ? 'var(--purple)' : 'var(--accent)';
|
||||
return (
|
||||
<div key={name} className="param-row">
|
||||
<span className="param-label">{paramDef.label}</span>
|
||||
<Knob
|
||||
value={params[name]}
|
||||
min={paramDef.min}
|
||||
max={paramDef.max}
|
||||
onChange={v => handleParamChange(name, v)}
|
||||
color={color}
|
||||
modulated={modulatedParams.has(name)}
|
||||
liveValue={liveValues[name]}
|
||||
/>
|
||||
<span className={`param-value ${liveValues[name] !== undefined ? 'param-value-live' : ''}`}>
|
||||
{(() => {
|
||||
const v = liveValues[name] !== undefined ? liveValues[name] : params[name];
|
||||
const s = v >= 1000 ? `${(v / 1000).toFixed(1)}k` :
|
||||
v >= 100 ? Math.round(v) :
|
||||
v >= 1 ? Number(v).toFixed(1) :
|
||||
Number(v).toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
|
||||
return s;
|
||||
})()}
|
||||
{paramDef.unit ? ` ${paramDef.unit}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (paramDef.type === 'select') {
|
||||
return (
|
||||
<div key={name} className="param-row">
|
||||
<span className="param-label">{paramDef.label}</span>
|
||||
<select className="param-select" value={params[name]}
|
||||
onChange={e => handleParamChange(name, e.target.value)}>
|
||||
{paramDef.options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
{/* Scope display */}
|
||||
{mod.type === 'scope' && <ScopeDisplay moduleId={mod.id} />}
|
||||
|
||||
{/* Keyboard widget */}
|
||||
{mod.type === 'keyboard' && <KeyboardWidget moduleId={mod.id} fullscreen={fullscreen} onCloseFullscreen={() => setFullscreen(false)} />}
|
||||
|
||||
{/* Drum Pad widget */}
|
||||
{mod.type === 'drumpad' && <DrumPadWidget moduleId={mod.id} fullscreen={fullscreen} onCloseFullscreen={() => setFullscreen(false)} />}
|
||||
|
||||
{/* Sequencer widget */}
|
||||
{mod.type === 'sequencer' && <SequencerWidget moduleId={mod.id} />}
|
||||
|
||||
{/* Piano Roll widget */}
|
||||
{mod.type === 'pianoroll' && <PianoRollWidget moduleId={mod.id} />}
|
||||
|
||||
{/* Output ports */}
|
||||
{def.outputs.map(port => (
|
||||
<div key={port.name} className="port-row output">
|
||||
<div
|
||||
className={`port-dot ${port.type} ${isPortConnected(mod.id, port.name, 'output') ? 'connected' : ''}`}
|
||||
ref={el => portRef(el, port.name, 'output')}
|
||||
data-module-id={mod.id}
|
||||
data-port-name={port.name}
|
||||
data-port-direction="output"
|
||||
data-port-type={port.type}
|
||||
onPointerDown={e => handlePortMouseDown(e, port.name, 'output')}
|
||||
/>
|
||||
<span className="port-label">{port.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
112
packages/client/src/components/ScopeDisplay.jsx
Normal file
112
packages/client/src/components/ScopeDisplay.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
@@ -1,10 +1,19 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { wirePath } from '../utils/bezier.js';
|
||||
import { state, removeConnection } from '../engine/state.js';
|
||||
import { disconnectWire } from '../engine/audioEngine.js';
|
||||
import { getModuleDef, PORT_TYPE } from '../engine/moduleRegistry.js';
|
||||
|
||||
export default function WireLayer({ portPositions, tempWire, containerRef, zoom, camX, camY }) {
|
||||
// Force a second render after DOM commit so getBoundingClientRect reads correct positions
|
||||
// This fixes wires lagging behind after zoom, pan, or level re-entry
|
||||
const [, refreshWires] = useState(0);
|
||||
const connCount = state.connections.length;
|
||||
const modCount = state.modules.length;
|
||||
useEffect(() => {
|
||||
const id = requestAnimationFrame(() => refreshWires(n => n + 1));
|
||||
return () => cancelAnimationFrame(id);
|
||||
}, [zoom, camX, camY, connCount, modCount]);
|
||||
const getPortPos = (moduleId, portName, direction) => {
|
||||
const key = `${moduleId}-${portName}-${direction}`;
|
||||
const el = portPositions.current[key];
|
||||
265
packages/client/src/components/Workshop.jsx
Normal file
265
packages/client/src/components/Workshop.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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) {
|
||||
@@ -24,11 +66,16 @@ function createNode(mod) {
|
||||
case 'oscillator': {
|
||||
const osc = new Tone.Oscillator({ type: p.waveform, frequency: p.frequency, detune: p.detune });
|
||||
osc.start();
|
||||
// Modulation scaler for freq input: LFO (-1..1) × scale → added to osc.frequency
|
||||
// Scale = half the current frequency so modulation is musically meaningful
|
||||
const freqMod = new Tone.Gain(p.frequency * 0.5);
|
||||
freqMod.connect(osc.frequency);
|
||||
return {
|
||||
node: osc,
|
||||
inputs: { freq: osc.frequency, detune: osc.detune },
|
||||
_freqMod: freqMod,
|
||||
inputs: { freq: freqMod, detune: osc.detune },
|
||||
outputs: { out: osc },
|
||||
dispose: () => { osc.stop(); osc.dispose(); },
|
||||
dispose: () => { osc.stop(); freqMod.disconnect(); freqMod.dispose(); osc.dispose(); },
|
||||
};
|
||||
}
|
||||
case 'lfo': {
|
||||
@@ -53,11 +100,16 @@ function createNode(mod) {
|
||||
}
|
||||
case 'filter': {
|
||||
const filter = new Tone.Filter({ type: p.type, frequency: p.frequency, Q: p.Q });
|
||||
// Modulation scaler for cutoff input: LFO (-1..1) × scale → added to filter.frequency
|
||||
// Scale = cutoff value so full LFO sweep covers 0 to 2× the cutoff
|
||||
const cutoffMod = new Tone.Gain(p.frequency);
|
||||
cutoffMod.connect(filter.frequency);
|
||||
return {
|
||||
node: filter,
|
||||
inputs: { in: filter, cutoff: filter.frequency },
|
||||
_cutoffMod: cutoffMod,
|
||||
inputs: { in: filter, cutoff: cutoffMod },
|
||||
outputs: { out: filter },
|
||||
dispose: () => filter.dispose(),
|
||||
dispose: () => { cutoffMod.disconnect(); cutoffMod.dispose(); filter.dispose(); },
|
||||
};
|
||||
}
|
||||
case 'envelope': {
|
||||
@@ -74,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': {
|
||||
@@ -126,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 },
|
||||
@@ -135,17 +191,47 @@ function createNode(mod) {
|
||||
dispose: () => analyser.dispose(),
|
||||
};
|
||||
}
|
||||
case 'output': {
|
||||
const gain = new Tone.Gain(Tone.dbToGain(p.volume));
|
||||
gain.toDestination();
|
||||
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: gain,
|
||||
inputs: { left: gain, right: gain },
|
||||
outputs: {},
|
||||
dispose: () => { gain.disconnect(); gain.dispose(); },
|
||||
node: analyser,
|
||||
_gateSig: gateSig,
|
||||
_gateState: false,
|
||||
inputs: { in: analyser },
|
||||
outputs: { gate: gateSig },
|
||||
dispose: () => { analyser.dispose(); gateSig.dispose(); },
|
||||
};
|
||||
}
|
||||
case 'keyboard': {
|
||||
case 'output': {
|
||||
// True stereo output: separate left/right channels → merge → master gain → destination
|
||||
const leftGain = new Tone.Gain(1);
|
||||
const rightGain = new Tone.Gain(1);
|
||||
const merge = new Tone.Merge();
|
||||
const master = new Tone.Gain(Tone.dbToGain(p.volume));
|
||||
leftGain.connect(merge, 0, 0);
|
||||
rightGain.connect(merge, 0, 1);
|
||||
merge.connect(master);
|
||||
master.toDestination();
|
||||
return {
|
||||
node: master,
|
||||
_merge: merge,
|
||||
_leftGain: leftGain,
|
||||
_rightGain: rightGain,
|
||||
inputs: { left: leftGain, right: rightGain },
|
||||
outputs: {},
|
||||
dispose: () => {
|
||||
leftGain.disconnect(); leftGain.dispose();
|
||||
rightGain.disconnect(); rightGain.dispose();
|
||||
merge.disconnect(); merge.dispose();
|
||||
master.disconnect(); master.dispose();
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'keyboard':
|
||||
case 'drumpad': {
|
||||
const freqSig = new Tone.Signal(440);
|
||||
const gateSig = new Tone.Signal(0);
|
||||
return {
|
||||
@@ -220,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;
|
||||
@@ -231,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) {
|
||||
@@ -249,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) {
|
||||
@@ -262,7 +370,11 @@ export function updateParam(moduleId, paramName, value) {
|
||||
switch (mod.type) {
|
||||
case 'oscillator':
|
||||
if (paramName === 'waveform') entry.node.type = value;
|
||||
else if (paramName === 'frequency') entry.node.frequency.value = value;
|
||||
else if (paramName === 'frequency') {
|
||||
entry.node.frequency.value = value;
|
||||
// Update mod scaler proportionally
|
||||
if (entry._freqMod) entry._freqMod.gain.value = value * 0.5;
|
||||
}
|
||||
else if (paramName === 'detune') entry.node.detune.value = value;
|
||||
break;
|
||||
case 'lfo':
|
||||
@@ -275,7 +387,11 @@ export function updateParam(moduleId, paramName, value) {
|
||||
break;
|
||||
case 'filter':
|
||||
if (paramName === 'type') entry.node.type = value;
|
||||
else if (paramName === 'frequency') entry.node.frequency.value = value;
|
||||
else if (paramName === 'frequency') {
|
||||
entry.node.frequency.value = value;
|
||||
// Update mod scaler proportionally
|
||||
if (entry._cutoffMod) entry._cutoffMod.gain.value = value;
|
||||
}
|
||||
else if (paramName === 'Q') entry.node.Q.value = value;
|
||||
break;
|
||||
case 'envelope':
|
||||
@@ -285,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;
|
||||
@@ -310,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
|
||||
@@ -317,15 +440,44 @@ 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();
|
||||
@@ -333,7 +485,6 @@ export function setSequencerSignals(moduleId, freq, gate) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function triggerKeyboard(moduleId, freq, gate) {
|
||||
const entry = audioNodes[moduleId];
|
||||
@@ -341,9 +492,16 @@ 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();
|
||||
@@ -351,17 +509,26 @@ export function triggerKeyboard(moduleId, freq, gate) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
@@ -384,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) {
|
||||
@@ -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', {
|
||||
@@ -2,6 +2,9 @@
|
||||
* state.js — Centralized reactive state for the modular synth
|
||||
* Uses a simple pub/sub pattern for React integration
|
||||
*/
|
||||
import { playConnect, playDisconnect, playModuleAdd, playModuleDelete } from './uiSounds.js';
|
||||
import { getModuleDef } from './moduleRegistry.js';
|
||||
import { invalidateConnectionCache } from './audioEngine.js';
|
||||
|
||||
let _listeners = new Set();
|
||||
let _nextModuleId = 1;
|
||||
@@ -40,9 +43,15 @@ export function emit() {
|
||||
|
||||
export function addModule(type, x, y) {
|
||||
const id = _nextModuleId++;
|
||||
state.modules.push({ id, type, x, y, params: {}, collapsed: false });
|
||||
// Populate ALL default params so level checkers can read them immediately
|
||||
const def = getModuleDef(type);
|
||||
const defaults = def
|
||||
? Object.fromEntries(Object.entries(def.params).map(([k, v]) => [k, v.default]))
|
||||
: {};
|
||||
state.modules.push({ id, type, x, y, params: defaults, collapsed: false });
|
||||
state.selectedModuleId = id;
|
||||
emit();
|
||||
playModuleAdd();
|
||||
return id;
|
||||
}
|
||||
|
||||
@@ -53,6 +62,7 @@ export function removeModule(id) {
|
||||
);
|
||||
if (state.selectedModuleId === id) state.selectedModuleId = null;
|
||||
emit();
|
||||
playModuleDelete();
|
||||
}
|
||||
|
||||
export function updateModulePosition(id, x, y) {
|
||||
@@ -78,19 +88,23 @@ export function addConnection(fromModuleId, fromPort, toModuleId, toPort) {
|
||||
c.to.moduleId === toModuleId && c.to.port === toPort
|
||||
);
|
||||
if (inputTaken) {
|
||||
// Remove old connection to this input
|
||||
removeConnection(inputTaken.id);
|
||||
// Remove old connection to this input (silent — connect sound will play)
|
||||
removeConnection(inputTaken.id, true);
|
||||
}
|
||||
|
||||
const id = _nextConnectionId++;
|
||||
state.connections.push({ id, from: { moduleId: fromModuleId, port: fromPort }, to: { moduleId: toModuleId, port: toPort } });
|
||||
invalidateConnectionCache();
|
||||
emit();
|
||||
playConnect();
|
||||
return id;
|
||||
}
|
||||
|
||||
export function removeConnection(id) {
|
||||
export function removeConnection(id, _silent = false) {
|
||||
state.connections = state.connections.filter(c => c.id !== id);
|
||||
invalidateConnectionCache();
|
||||
emit();
|
||||
if (!_silent) playDisconnect();
|
||||
}
|
||||
|
||||
export function getModule(id) {
|
||||
223
packages/client/src/engine/uiSounds.js
Normal file
223
packages/client/src/engine/uiSounds.js
Normal file
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* uiSounds.js — Procedural UI sound effects using Tone.js
|
||||
* All sounds are synthesized on-the-fly — no audio files needed.
|
||||
* Sounds are short, subtle, and "synth-themed" to match the app.
|
||||
*/
|
||||
import * as Tone from 'tone';
|
||||
|
||||
let _enabled = true;
|
||||
let _volume = -18; // dB, subtle
|
||||
let _initialized = false;
|
||||
let _masterGain = null;
|
||||
|
||||
// Lazy init — only create audio nodes after user interaction (Tone.start)
|
||||
function ensureInit() {
|
||||
if (_initialized) return true;
|
||||
if (Tone.context.state !== 'running') return false;
|
||||
_masterGain = new Tone.Gain(Tone.dbToGain(_volume)).toDestination();
|
||||
_initialized = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
export function setUISoundsEnabled(enabled) { _enabled = enabled; }
|
||||
export function isUISoundsEnabled() { return _enabled; }
|
||||
export function setUIVolume(db) {
|
||||
_volume = db;
|
||||
if (_masterGain) _masterGain.gain.value = Tone.dbToGain(db);
|
||||
}
|
||||
|
||||
// ==================== Sound definitions ====================
|
||||
|
||||
/** Cable connected — short bright "click" with rising pitch */
|
||||
export function playConnect() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.001, decay: 0.08, sustain: 0, release: 0.05 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('C6', 0.06);
|
||||
setTimeout(() => {
|
||||
const synth2 = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.001, decay: 0.06, sustain: 0, release: 0.04 },
|
||||
}).connect(_masterGain);
|
||||
synth2.triggerAttackRelease('E6', 0.05);
|
||||
setTimeout(() => synth2.dispose(), 200);
|
||||
}, 40);
|
||||
setTimeout(() => synth.dispose(), 300);
|
||||
}
|
||||
|
||||
/** Cable disconnected — short descending blip */
|
||||
export function playDisconnect() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'triangle' },
|
||||
envelope: { attack: 0.001, decay: 0.1, sustain: 0, release: 0.05 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('E5', 0.06);
|
||||
setTimeout(() => {
|
||||
const synth2 = new Tone.Synth({
|
||||
oscillator: { type: 'triangle' },
|
||||
envelope: { attack: 0.001, decay: 0.08, sustain: 0, release: 0.04 },
|
||||
}).connect(_masterGain);
|
||||
synth2.triggerAttackRelease('C5', 0.05);
|
||||
setTimeout(() => synth2.dispose(), 200);
|
||||
}, 50);
|
||||
setTimeout(() => synth.dispose(), 300);
|
||||
}
|
||||
|
||||
/** Module added — soft metallic "pop" */
|
||||
export function playModuleAdd() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.MembraneSynth({
|
||||
pitchDecay: 0.01,
|
||||
octaves: 4,
|
||||
envelope: { attack: 0.001, decay: 0.15, sustain: 0, release: 0.1 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('C4', 0.08);
|
||||
setTimeout(() => synth.dispose(), 400);
|
||||
}
|
||||
|
||||
/** Module deleted — reverse "zap" */
|
||||
export function playModuleDelete() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sawtooth' },
|
||||
envelope: { attack: 0.001, decay: 0.12, sustain: 0, release: 0.05 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('A3', 0.08);
|
||||
setTimeout(() => synth.dispose(), 300);
|
||||
}
|
||||
|
||||
/** Button click — tiny tick */
|
||||
export function playClick() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.001, decay: 0.03, sustain: 0, release: 0.02 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('A5', 0.02);
|
||||
setTimeout(() => synth.dispose(), 150);
|
||||
}
|
||||
|
||||
/** Star earned — bright ascending arpeggio */
|
||||
export function playStar(starNumber = 1) {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const notes = ['C5', 'E5', 'G5'];
|
||||
const note = notes[Math.min(starNumber - 1, 2)];
|
||||
const delay = (starNumber - 1) * 300;
|
||||
setTimeout(() => {
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.005, decay: 0.3, sustain: 0.1, release: 0.3 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease(note, 0.25);
|
||||
// Shimmer harmonic
|
||||
const shimmer = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.01, decay: 0.2, sustain: 0, release: 0.2 },
|
||||
volume: -6,
|
||||
}).connect(_masterGain);
|
||||
shimmer.triggerAttackRelease(
|
||||
Tone.Frequency(note).transpose(12).toNote(), 0.15
|
||||
);
|
||||
setTimeout(() => { synth.dispose(); shimmer.dispose(); }, 800);
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/** Level complete — triumphant chord */
|
||||
export function playLevelComplete() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const chord = ['C4', 'E4', 'G4', 'C5'];
|
||||
chord.forEach((note, i) => {
|
||||
setTimeout(() => {
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'triangle' },
|
||||
envelope: { attack: 0.01, decay: 0.5, sustain: 0.2, release: 0.5 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease(note, 0.4);
|
||||
setTimeout(() => synth.dispose(), 1200);
|
||||
}, i * 60);
|
||||
});
|
||||
}
|
||||
|
||||
/** Level failed / check failed — low "bonk" */
|
||||
export function playFail() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'square' },
|
||||
envelope: { attack: 0.001, decay: 0.15, sustain: 0, release: 0.1 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('D#3', 0.1);
|
||||
setTimeout(() => {
|
||||
const synth2 = new Tone.Synth({
|
||||
oscillator: { type: 'square' },
|
||||
envelope: { attack: 0.001, decay: 0.2, sustain: 0, release: 0.1 },
|
||||
}).connect(_masterGain);
|
||||
synth2.triggerAttackRelease('C3', 0.12);
|
||||
setTimeout(() => synth2.dispose(), 400);
|
||||
}, 100);
|
||||
setTimeout(() => synth.dispose(), 400);
|
||||
}
|
||||
|
||||
/** Hint revealed — mysterious "whoosh" */
|
||||
export function playHint() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const noise = new Tone.Noise('pink');
|
||||
const filter = new Tone.Filter({ type: 'bandpass', frequency: 2000, Q: 2 });
|
||||
const env = new Tone.AmplitudeEnvelope({ attack: 0.05, decay: 0.2, sustain: 0, release: 0.1 });
|
||||
noise.connect(filter).connect(env).connect(_masterGain);
|
||||
noise.start();
|
||||
env.triggerAttack();
|
||||
setTimeout(() => { env.triggerRelease(); }, 150);
|
||||
setTimeout(() => { noise.stop(); noise.dispose(); filter.dispose(); env.dispose(); }, 600);
|
||||
}
|
||||
|
||||
/** Audio engine start — power-on sweep */
|
||||
export function playEngineStart() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.1, decay: 0.2, sustain: 0, release: 0.1 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('C4', 0.15);
|
||||
setTimeout(() => {
|
||||
const synth2 = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.05, decay: 0.15, sustain: 0, release: 0.1 },
|
||||
}).connect(_masterGain);
|
||||
synth2.triggerAttackRelease('G4', 0.12);
|
||||
setTimeout(() => synth2.dispose(), 400);
|
||||
}, 100);
|
||||
setTimeout(() => synth.dispose(), 400);
|
||||
}
|
||||
|
||||
/** Audio engine stop — power-down */
|
||||
export function playEngineStop() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.01, decay: 0.2, sustain: 0, release: 0.1 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('G4', 0.1);
|
||||
setTimeout(() => {
|
||||
const synth2 = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.01, decay: 0.25, sustain: 0, release: 0.15 },
|
||||
}).connect(_masterGain);
|
||||
synth2.triggerAttackRelease('C4', 0.15);
|
||||
setTimeout(() => synth2.dispose(), 500);
|
||||
}, 80);
|
||||
setTimeout(() => synth.dispose(), 500);
|
||||
}
|
||||
|
||||
/** Navigation click (map, back buttons) — soft "tick" */
|
||||
export function playNav() {
|
||||
if (!_enabled || !ensureInit()) return;
|
||||
const synth = new Tone.Synth({
|
||||
oscillator: { type: 'sine' },
|
||||
envelope: { attack: 0.001, decay: 0.04, sustain: 0, release: 0.03 },
|
||||
}).connect(_masterGain);
|
||||
synth.triggerAttackRelease('E5', 0.03);
|
||||
setTimeout(() => synth.dispose(), 150);
|
||||
}
|
||||
124
packages/client/src/game/AdminPanel.jsx
Normal file
124
packages/client/src/game/AdminPanel.jsx
Normal file
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* AdminPanel.jsx — Debug/admin panel for SynthQuest
|
||||
* Allows adding/removing stars and unlocking levels for testing
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { loadProgress, saveProgress, resetProgress } from './gameState.js';
|
||||
|
||||
export default function AdminPanel({ worlds, onClose, adminMode, onToggleAdmin }) {
|
||||
const [, refresh] = useState(0);
|
||||
const p = loadProgress();
|
||||
const totalStars = Object.values(p.completedLevels).reduce((s, l) => s + (l.stars || 0), 0);
|
||||
|
||||
const setStars = (levelId, stars) => {
|
||||
if (stars <= 0) {
|
||||
delete p.completedLevels[levelId];
|
||||
} else {
|
||||
p.completedLevels[levelId] = { stars: Math.min(3, stars), completedAt: Date.now() };
|
||||
}
|
||||
saveProgress();
|
||||
refresh(n => n + 1);
|
||||
};
|
||||
|
||||
const unlockWorld = (world) => {
|
||||
// Give 1 star to each level in all previous worlds up to the requirement
|
||||
let needed = world.unlockStars || 0;
|
||||
for (const w of worlds) {
|
||||
if (w.id === world.id) break;
|
||||
for (const level of w.levels) {
|
||||
if (needed <= 0) break;
|
||||
const existing = p.completedLevels[level.id]?.stars || 0;
|
||||
if (existing < 1) {
|
||||
p.completedLevels[level.id] = { stars: 1, completedAt: Date.now() };
|
||||
needed -= 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
saveProgress();
|
||||
refresh(n => n + 1);
|
||||
};
|
||||
|
||||
const giveAllStars = () => {
|
||||
for (const w of worlds) {
|
||||
for (const level of w.levels) {
|
||||
p.completedLevels[level.id] = { stars: 3, completedAt: Date.now() };
|
||||
}
|
||||
}
|
||||
saveProgress();
|
||||
refresh(n => n + 1);
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
resetProgress();
|
||||
refresh(n => n + 1);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="admin-overlay" onClick={onClose}>
|
||||
<div className="admin-panel" onClick={e => e.stopPropagation()}>
|
||||
<div className="admin-header">
|
||||
<h2>🛠 Admin Mode</h2>
|
||||
<span className="admin-total">Total: ★ {totalStars}</span>
|
||||
<button className="admin-close" onClick={onClose}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-actions">
|
||||
<button
|
||||
className={`admin-action-btn ${adminMode ? 'active' : ''}`}
|
||||
onClick={onToggleAdmin}
|
||||
>
|
||||
{adminMode ? '🛠 Admin ON' : '🛠 Admin OFF'}
|
||||
</button>
|
||||
<button className="admin-action-btn gold" onClick={giveAllStars}>★★★ Todo</button>
|
||||
<button className="admin-action-btn danger" onClick={handleReset}>Reset Progreso</button>
|
||||
</div>
|
||||
|
||||
<div className="admin-worlds">
|
||||
{worlds.map((world, wi) => {
|
||||
const worldStars = world.levels.reduce((s, l) => {
|
||||
return s + (p.completedLevels[l.id]?.stars || 0);
|
||||
}, 0);
|
||||
const isUnlocked = !world.unlockStars || totalStars >= world.unlockStars;
|
||||
|
||||
return (
|
||||
<div key={world.id} className="admin-world">
|
||||
<div className="admin-world-header">
|
||||
<span className="admin-world-icon" style={{ color: world.color }}>{world.icon}</span>
|
||||
<span className="admin-world-name">M{wi + 1}: {world.name}</span>
|
||||
<span className="admin-world-stars">★ {worldStars}/{world.levels.length * 3}</span>
|
||||
{!isUnlocked && (
|
||||
<button className="admin-unlock-btn" onClick={() => unlockWorld(world)}>
|
||||
🔓 Desbloquear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-levels">
|
||||
{world.levels.map((level, li) => {
|
||||
const stars = p.completedLevels[level.id]?.stars || 0;
|
||||
return (
|
||||
<div key={level.id} className="admin-level">
|
||||
<span className="admin-level-num">{wi + 1}.{li + 1}</span>
|
||||
<span className="admin-level-name">{level.title}</span>
|
||||
<div className="admin-star-btns">
|
||||
{[0, 1, 2, 3].map(s => (
|
||||
<button
|
||||
key={s}
|
||||
className={`admin-star-btn ${stars >= s && s > 0 ? 'active' : ''} ${s === 0 ? 'zero' : ''}`}
|
||||
onClick={() => setStars(level.id, s)}
|
||||
>
|
||||
{s === 0 ? '✕' : '★'.repeat(s)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
packages/client/src/game/GameApp.jsx
Normal file
94
packages/client/src/game/GameApp.jsx
Normal file
@@ -0,0 +1,94 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import WorldMap from './WorldMap.jsx';
|
||||
import PuzzleView from './PuzzleView.jsx';
|
||||
import AdminPanel from './AdminPanel.jsx';
|
||||
import { WORLD_1 } from './levels/world1.js';
|
||||
import { WORLD_2 } from './levels/world2.js';
|
||||
import { WORLD_3 } from './levels/world3.js';
|
||||
import { WORLD_4 } from './levels/world4.js';
|
||||
import { WORLD_5 } from './levels/world5.js';
|
||||
import { WORLD_6 } from './levels/world6.js';
|
||||
import { WORLD_7 } from './levels/world7.js';
|
||||
import { WORLD_8 } from './levels/world8.js';
|
||||
import { WORLD_9 } from './levels/world9.js';
|
||||
import { WORLD_10 } from './levels/world10.js';
|
||||
import { WORLD_11 } from './levels/world11.js';
|
||||
import { WORLD_12 } from './levels/world12.js';
|
||||
|
||||
const allWorlds = [WORLD_1, WORLD_2, WORLD_3, WORLD_4, WORLD_5, WORLD_6, WORLD_7, WORLD_8, WORLD_9, WORLD_10, WORLD_11, WORLD_12];
|
||||
|
||||
export default function GameApp({ onSwitchToSandbox, onSwitchToWorkshop }) {
|
||||
const [view, setView] = useState('map');
|
||||
const [currentLevel, setCurrentLevel] = useState(null);
|
||||
const [currentLevelIndex, setCurrentLevelIndex] = useState(0);
|
||||
const [currentWorld, setCurrentWorld] = useState(null);
|
||||
const [showAdmin, setShowAdmin] = useState(false);
|
||||
const [adminMode, setAdminMode] = useState(false);
|
||||
|
||||
const handleSelectLevel = useCallback((level, world) => {
|
||||
const idx = world.levels.findIndex(l => l.id === level.id);
|
||||
setCurrentLevel(level);
|
||||
setCurrentLevelIndex(idx);
|
||||
setCurrentWorld(world);
|
||||
setView('puzzle');
|
||||
}, []);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setView('map');
|
||||
setCurrentLevel(null);
|
||||
setCurrentWorld(null);
|
||||
}, []);
|
||||
|
||||
const handleNextLevel = useCallback(() => {
|
||||
if (!currentWorld) return;
|
||||
const nextIdx = currentLevelIndex + 1;
|
||||
if (nextIdx < currentWorld.levels.length) {
|
||||
setCurrentLevel(currentWorld.levels[nextIdx]);
|
||||
setCurrentLevelIndex(nextIdx);
|
||||
} else {
|
||||
// Move to next world's first level if unlocked
|
||||
const worldIdx = allWorlds.findIndex(w => w.id === currentWorld.id);
|
||||
if (worldIdx < allWorlds.length - 1) {
|
||||
const nextWorld = allWorlds[worldIdx + 1];
|
||||
setCurrentWorld(nextWorld);
|
||||
setCurrentLevel(nextWorld.levels[0]);
|
||||
setCurrentLevelIndex(0);
|
||||
} else {
|
||||
setView('map');
|
||||
}
|
||||
}
|
||||
}, [currentLevelIndex, currentWorld]);
|
||||
|
||||
if (view === 'puzzle' && currentLevel && currentWorld) {
|
||||
return (
|
||||
<PuzzleView
|
||||
key={currentLevel.id}
|
||||
level={currentLevel}
|
||||
levelIndex={currentLevelIndex}
|
||||
worldLevels={currentWorld.levels}
|
||||
onBack={handleBack}
|
||||
onNextLevel={handleNextLevel}
|
||||
adminMode={adminMode}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<WorldMap
|
||||
onSelectLevel={handleSelectLevel}
|
||||
onSandbox={onSwitchToSandbox}
|
||||
onWorkshop={onSwitchToWorkshop}
|
||||
onAdmin={() => setShowAdmin(true)}
|
||||
/>
|
||||
{showAdmin && (
|
||||
<AdminPanel
|
||||
worlds={allWorlds}
|
||||
onClose={() => setShowAdmin(false)}
|
||||
adminMode={adminMode}
|
||||
onToggleAdmin={() => setAdminMode(a => !a)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,21 +1,24 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { playStar, playNav } from '../engine/uiSounds.js';
|
||||
|
||||
export default function LevelComplete({ stars, checks, levelTitle, onNext, onRetry, onMap, isLastLevel }) {
|
||||
export default function LevelComplete({ stars, checks, levelTitle, onNext, onRetry, onMap, isLastLevel, hintPenalty }) {
|
||||
const [showStars, setShowStars] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
// Animate stars appearing one by one
|
||||
const timers = [];
|
||||
for (let i = 1; i <= stars; i++) {
|
||||
timers.push(setTimeout(() => setShowStars(i), i * 400));
|
||||
timers.push(setTimeout(() => {
|
||||
setShowStars(i);
|
||||
playStar(i);
|
||||
}, i * 400));
|
||||
}
|
||||
return () => timers.forEach(clearTimeout);
|
||||
}, [stars]);
|
||||
|
||||
const messages = [
|
||||
'', // 0 stars
|
||||
'',
|
||||
'Has dado el primer paso...',
|
||||
'Buen trabajo — casi perfecto.',
|
||||
hintPenalty ? 'Pista usada — tercera estrella bloqueada permanentemente.' : 'Buen trabajo — casi perfecto.',
|
||||
'Ejecucion impecable.',
|
||||
];
|
||||
|
||||
@@ -27,21 +30,25 @@ export default function LevelComplete({ stars, checks, levelTitle, onNext, onRet
|
||||
</h2>
|
||||
<p className="gm-complete-level">{levelTitle}</p>
|
||||
|
||||
{/* Stars */}
|
||||
<div className="gm-complete-stars">
|
||||
{[1, 2, 3].map(i => (
|
||||
<span
|
||||
key={i}
|
||||
className={`gm-big-star ${i <= showStars ? 'earned' : 'empty'}`}
|
||||
className={`gm-big-star ${i <= showStars ? 'earned' : 'empty'} ${i === 3 && hintPenalty ? 'locked' : ''}`}
|
||||
>
|
||||
★
|
||||
{i === 3 && hintPenalty ? '🔒' : '★'}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="gm-complete-msg">{messages[stars] || ''}</p>
|
||||
|
||||
{/* Checks */}
|
||||
{hintPenalty && (
|
||||
<p className="gm-hint-penalty-msg">
|
||||
Usaste la pista — tercera estrella bloqueada
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="gm-checks">
|
||||
{checks.map((check, i) => (
|
||||
<div key={i} className={`gm-check ${check.passed ? 'passed' : 'failed'}`}>
|
||||
@@ -52,10 +59,11 @@ export default function LevelComplete({ stars, checks, levelTitle, onNext, onRet
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="gm-complete-actions">
|
||||
<button className="gm-btn secondary" onClick={onMap}>Mapa</button>
|
||||
<button className="gm-btn secondary" onClick={onRetry}>Reintentar</button>
|
||||
<button className="gm-btn secondary" onClick={() => { playNav(); onMap(); }}>Mapa</button>
|
||||
<button className="gm-btn secondary" onClick={onRetry}>
|
||||
Reintentar
|
||||
</button>
|
||||
{stars >= 1 && !isLastLevel && (
|
||||
<button className="gm-btn primary" onClick={onNext}>Siguiente →</button>
|
||||
)}
|
||||
625
packages/client/src/game/PuzzleView.jsx
Normal file
625
packages/client/src/game/PuzzleView.jsx
Normal file
@@ -0,0 +1,625 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { state, subscribe, addModule, emit, addConnection, removeModule, updateModulePosition, deserialize } from '../engine/state.js';
|
||||
import { startAudio, stopAudio, connectWire, rebuildGraph } from '../engine/audioEngine.js';
|
||||
import { getModuleDef } from '../engine/moduleRegistry.js';
|
||||
import ModuleNode from '../components/ModuleNode.jsx';
|
||||
import WireLayer from '../components/WireLayer.jsx';
|
||||
import BottomSheet from '../components/BottomSheet.jsx';
|
||||
import { useIsMobile } from '../hooks/useIsMobile.js';
|
||||
import { usePinchZoom } from '../hooks/usePinchZoom.js';
|
||||
import { playTarget, stopTarget, isTargetPlaying } from './targetAudio.js';
|
||||
import LevelComplete from './LevelComplete.jsx';
|
||||
import { completeLevel, saveLevelPatch, getLevelPatch, clearLevelPatch, markHintUsed, wasHintUsed } from './gameState.js';
|
||||
import { playLevelComplete, playFail, playHint, playEngineStart, playEngineStop, playNav } from '../engine/uiSounds.js';
|
||||
import { SOLUTIONS } from './autoSolver.js';
|
||||
|
||||
export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onNextLevel, adminMode }) {
|
||||
const [, forceUpdate] = useState(0);
|
||||
const containerRef = useRef(null);
|
||||
const portPositions = useRef({});
|
||||
const [tempWire, setTempWire] = useState(null);
|
||||
const connectingRef = useRef(null);
|
||||
const [hintUsed, setHintUsed] = useState(false);
|
||||
const [showHint, setShowHint] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [targetPlaying, setTargetPlaying] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const [mobileTab, setMobileTab] = useState('mission');
|
||||
|
||||
// Pinch-to-zoom on mobile
|
||||
const getZoom = useCallback(() => state.zoom, []);
|
||||
const setZoom = useCallback((z) => { state.zoom = z; emit(); }, []);
|
||||
usePinchZoom(containerRef, getZoom, setZoom);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = subscribe(() => {
|
||||
forceUpdate(n => n + 1);
|
||||
// Auto-save patch on every state change (debounced below)
|
||||
scheduleSave();
|
||||
});
|
||||
return unsub;
|
||||
}, [level.id]);
|
||||
|
||||
// Debounced auto-save of the current patch
|
||||
const saveTimerRef = useRef(null);
|
||||
const scheduleSave = useCallback(() => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
saveTimerRef.current = setTimeout(() => {
|
||||
if (state.modules.length > 0) {
|
||||
saveLevelPatch(level.id, state.modules, state.connections);
|
||||
}
|
||||
}, 1000);
|
||||
}, [level.id]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (saveTimerRef.current) clearTimeout(saveTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadLevel();
|
||||
// Center view on modules after level loads and DOM settles
|
||||
const timer = setTimeout(() => handleCenterView(), 100);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
stopAudio();
|
||||
stopTarget();
|
||||
};
|
||||
}, [level.id]);
|
||||
|
||||
const loadLevel = useCallback((forceReset = false) => {
|
||||
// Check for a saved patch first (unless explicitly resetting)
|
||||
const saved = !forceReset ? getLevelPatch(level.id) : null;
|
||||
if (saved) {
|
||||
const data = {
|
||||
modules: saved.modules.map(m => ({
|
||||
id: m.id, type: m.type, x: m.x, y: m.y, params: { ...m.params },
|
||||
})),
|
||||
connections: saved.connections.map(c => ({ ...c })),
|
||||
camera: { camX: 0, camY: 0, zoom: 1 },
|
||||
};
|
||||
deserialize(data);
|
||||
} else {
|
||||
const data = {
|
||||
modules: (level.preplacedModules || []).map(m => ({
|
||||
id: m.id, type: m.type, x: m.x, y: m.y, params: { ...m.params },
|
||||
})),
|
||||
connections: [],
|
||||
camera: { camX: 0, camY: 0, zoom: 1 },
|
||||
};
|
||||
deserialize(data);
|
||||
}
|
||||
setResult(null);
|
||||
// Restore persisted hint state — no cheating by reloading!
|
||||
const hintPersisted = wasHintUsed(level.id);
|
||||
setHintUsed(hintPersisted);
|
||||
setShowHint(hintPersisted); // If they used it before, show it again
|
||||
if (state.isRunning) stopAudio();
|
||||
}, [level]);
|
||||
|
||||
const handlePortPosition = useCallback((moduleId, portName, direction, el) => {
|
||||
const key = `${moduleId}-${portName}-${direction}`;
|
||||
portPositions.current[key] = el;
|
||||
}, []);
|
||||
|
||||
const handleStartConnect = useCallback((info) => {
|
||||
connectingRef.current = info;
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
setTempWire({
|
||||
portType: info.portType,
|
||||
startX: info.startX - containerRect.left,
|
||||
startY: info.startY - containerRect.top,
|
||||
endX: info.startX - containerRect.left,
|
||||
endY: info.startY - containerRect.top,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Robust port detection — searches all port-dots by bounding rect distance
|
||||
// instead of elementFromPoint (which gets blocked by SVG wire overlay)
|
||||
const findPortAtPoint = (clientX, clientY) => {
|
||||
const portDots = document.querySelectorAll('.port-dot[data-module-id]');
|
||||
let closest = null;
|
||||
let closestDist = 18;
|
||||
for (const dot of portDots) {
|
||||
const rect = dot.getBoundingClientRect();
|
||||
const cx = rect.left + rect.width / 2;
|
||||
const cy = rect.top + rect.height / 2;
|
||||
const dist = Math.sqrt((clientX - cx) ** 2 + (clientY - cy) ** 2);
|
||||
if (dist < closestDist) {
|
||||
closestDist = dist;
|
||||
closest = dot;
|
||||
}
|
||||
}
|
||||
return closest;
|
||||
};
|
||||
|
||||
const handlePointerDown = useCallback((e) => {
|
||||
if (e.button === 1 || (e.button === 0 && e.altKey) || e.button === 2) {
|
||||
state.panning = true;
|
||||
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
|
||||
e.preventDefault();
|
||||
} else if (e.button === 0 && !connectingRef.current) {
|
||||
// On mobile (touch), single finger on empty canvas = pan
|
||||
if (isMobile && e.pointerType === 'touch') {
|
||||
state.panning = true;
|
||||
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
state.selectedModuleId = null;
|
||||
emit();
|
||||
}
|
||||
}, [isMobile]);
|
||||
|
||||
const handlePointerMove = useCallback((e) => {
|
||||
if (state.panning && state.panStart) {
|
||||
state.camX = e.clientX - state.panStart.x;
|
||||
state.camY = e.clientY - state.panStart.y;
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
if (state.dragging) {
|
||||
const newX = e.clientX / state.zoom - state.dragging.offsetX;
|
||||
const newY = e.clientY / state.zoom - state.dragging.offsetY;
|
||||
updateModulePosition(state.dragging.moduleId, newX, newY);
|
||||
return;
|
||||
}
|
||||
if (connectingRef.current && containerRef.current) {
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
setTempWire(prev => prev ? {
|
||||
...prev,
|
||||
endX: e.clientX - containerRect.left,
|
||||
endY: e.clientY - containerRect.top,
|
||||
} : null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePointerUp = useCallback((e) => {
|
||||
if (state.panning) {
|
||||
state.panning = false;
|
||||
state.panStart = null;
|
||||
}
|
||||
if (state.dragging) {
|
||||
state.dragging = null;
|
||||
emit();
|
||||
}
|
||||
if (connectingRef.current) {
|
||||
const portEl = findPortAtPoint(e.clientX, e.clientY);
|
||||
if (portEl) finishConnection(portEl);
|
||||
connectingRef.current = null;
|
||||
setTempWire(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const finishConnection = (portEl) => {
|
||||
const from = connectingRef.current;
|
||||
if (!from) return;
|
||||
const targetModuleId = parseInt(portEl.dataset.moduleId);
|
||||
const targetPort = portEl.dataset.portName;
|
||||
const targetDirection = portEl.dataset.portDirection;
|
||||
if (!targetModuleId || !targetPort || !targetDirection) return;
|
||||
if (targetModuleId === from.moduleId && targetPort === from.port) return;
|
||||
|
||||
let fromMod, fromPort, toMod, toPort;
|
||||
if (from.direction === 'output' && targetDirection === 'input') {
|
||||
fromMod = from.moduleId; fromPort = from.port;
|
||||
toMod = targetModuleId; toPort = targetPort;
|
||||
} else if (from.direction === 'input' && targetDirection === 'output') {
|
||||
fromMod = targetModuleId; fromPort = targetPort;
|
||||
toMod = from.moduleId; toPort = from.port;
|
||||
} else return;
|
||||
|
||||
const connId = addConnection(fromMod, fromPort, toMod, toPort);
|
||||
if (connId && state.isRunning) {
|
||||
const conn = state.connections.find(c => c.id === connId);
|
||||
if (conn) connectWire(conn);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWheel = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
const delta = -e.deltaY * 0.001;
|
||||
state.zoom = Math.max(0.3, Math.min(3, state.zoom + delta));
|
||||
emit();
|
||||
}, []);
|
||||
|
||||
const handleContextMenu = useCallback((e) => e.preventDefault(), []);
|
||||
|
||||
// Zoom controls (Google Maps style)
|
||||
const handleZoomIn = useCallback(() => {
|
||||
state.zoom = Math.min(3, state.zoom * 1.25);
|
||||
emit();
|
||||
}, []);
|
||||
const handleZoomOut = useCallback(() => {
|
||||
state.zoom = Math.max(0.3, state.zoom / 1.25);
|
||||
emit();
|
||||
}, []);
|
||||
const handleZoomReset = useCallback(() => {
|
||||
state.zoom = 1;
|
||||
state.camX = 0;
|
||||
state.camY = 0;
|
||||
emit();
|
||||
}, []);
|
||||
|
||||
// Center view on all modules
|
||||
const handleCenterView = useCallback(() => {
|
||||
if (state.modules.length === 0) { state.camX = 0; state.camY = 0; emit(); return; }
|
||||
const container = containerRef.current;
|
||||
const cw = container?.clientWidth || 800;
|
||||
const ch = container?.clientHeight || 600;
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const m of state.modules) {
|
||||
minX = Math.min(minX, m.x);
|
||||
minY = Math.min(minY, m.y);
|
||||
maxX = Math.max(maxX, m.x + 200);
|
||||
maxY = Math.max(maxY, m.y + 150);
|
||||
}
|
||||
const cx = (minX + maxX) / 2 * state.zoom;
|
||||
const cy = (minY + maxY) / 2 * state.zoom;
|
||||
state.camX = cw / 2 - cx;
|
||||
state.camY = ch / 2 - cy;
|
||||
emit();
|
||||
}, []);
|
||||
|
||||
const handleAddModule = (type) => {
|
||||
const x = (-state.camX + 250) / state.zoom + Math.random() * 30;
|
||||
const y = (-state.camY + 150) / state.zoom + Math.random() * 30;
|
||||
addModule(type, x, y);
|
||||
if (state.isRunning) rebuildGraph();
|
||||
};
|
||||
|
||||
const handleToggleAudio = async () => {
|
||||
if (state.isRunning) {
|
||||
stopAudio();
|
||||
playEngineStop();
|
||||
} else {
|
||||
await startAudio();
|
||||
playEngineStart();
|
||||
}
|
||||
emit();
|
||||
};
|
||||
|
||||
const handlePlayTarget = async () => {
|
||||
if (isTargetPlaying()) {
|
||||
stopTarget();
|
||||
setTargetPlaying(false);
|
||||
} else {
|
||||
setTargetPlaying(true);
|
||||
await playTarget(level.target);
|
||||
setTimeout(() => setTargetPlaying(false), (level.target.duration || 2) * 1000 + 100);
|
||||
}
|
||||
};
|
||||
|
||||
// Clear canvas — remove all user-added modules and reset to preplaced only
|
||||
const handleClearCanvas = () => {
|
||||
if (state.isRunning) stopAudio();
|
||||
clearLevelPatch(level.id);
|
||||
loadLevel(true);
|
||||
};
|
||||
|
||||
// Reveal hint — PERMANENTLY caps this level at 2 stars (persisted, survives reload)
|
||||
const handleRevealHint = () => {
|
||||
setHintUsed(true);
|
||||
setShowHint(true);
|
||||
markHintUsed(level.id);
|
||||
playHint();
|
||||
};
|
||||
|
||||
const handleCheck = () => {
|
||||
const mods = state.modules;
|
||||
const conns = state.connections;
|
||||
const checks = level.checks.map(check => ({
|
||||
...check,
|
||||
passed: check.test(mods, conns),
|
||||
}));
|
||||
|
||||
let stars = 0;
|
||||
for (const check of checks) {
|
||||
if (check.passed) stars = check.star;
|
||||
else break;
|
||||
}
|
||||
|
||||
// Cap at 2 stars if hint was used
|
||||
if (hintUsed && stars > 2) stars = 2;
|
||||
|
||||
setResult({ stars, checks, hintPenalty: hintUsed && stars >= 2 });
|
||||
|
||||
if (stars >= 1) {
|
||||
completeLevel(level.id, stars);
|
||||
playLevelComplete();
|
||||
} else {
|
||||
playFail();
|
||||
}
|
||||
};
|
||||
|
||||
// Admin auto-solve — loads the actual solution modules/connections and validates naturally
|
||||
const handleAutoSolve = () => {
|
||||
const solution = SOLUTIONS[level.id];
|
||||
if (!solution) {
|
||||
console.warn(`No auto-solve solution for level ${level.id}`);
|
||||
return;
|
||||
}
|
||||
// Load the solution patch into the engine state
|
||||
deserialize(solution);
|
||||
emit();
|
||||
// Now run the normal check logic against the loaded patch
|
||||
setTimeout(() => {
|
||||
handleCheck();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const isLastLevel = levelIndex >= worldLevels.length - 1;
|
||||
|
||||
return (
|
||||
<div className="gm-puzzle">
|
||||
{/* Top bar */}
|
||||
<div className="gm-puzzle-bar">
|
||||
<button className="gm-btn icon" onClick={() => { playNav(); onBack(); }}>{isMobile ? '←' : '← Mapa'}</button>
|
||||
<div className="gm-puzzle-title">
|
||||
<span className="gm-puzzle-num">{levelIndex + 1}/{worldLevels.length}</span>
|
||||
<span className="gm-puzzle-name">{level.title}</span>
|
||||
</div>
|
||||
<div className="gm-puzzle-actions">
|
||||
<button
|
||||
className={`gm-btn ${targetPlaying ? 'active' : 'target'}`}
|
||||
onClick={handlePlayTarget}
|
||||
>
|
||||
{targetPlaying ? '⏹' : '🎯'}{!isMobile && <span className="btn-label">{targetPlaying ? ' Parar' : ' Objetivo'}</span>}
|
||||
</button>
|
||||
<button
|
||||
className={`gm-btn ${state.isRunning ? 'active' : ''}`}
|
||||
onClick={handleToggleAudio}
|
||||
>
|
||||
{state.isRunning ? '⏹' : '▶'}{!isMobile && <span className="btn-label">{state.isRunning ? ' Parar' : ' Mi Sonido'}</span>}
|
||||
</button>
|
||||
{!isMobile && (
|
||||
<button className="gm-btn clear" onClick={handleClearCanvas} title="Limpiar canvas">
|
||||
🗑 Limpiar
|
||||
</button>
|
||||
)}
|
||||
<button className="gm-btn check" onClick={handleCheck}>
|
||||
✓{!isMobile && <span className="btn-label"> Comprobar</span>}
|
||||
</button>
|
||||
{adminMode && (
|
||||
<button className="gm-btn admin-solve" onClick={handleAutoSolve} title="Admin: resolver nivel">
|
||||
🛠 Resolver
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="gm-puzzle-content">
|
||||
{/* Left sidebar (desktop only — hidden on mobile via CSS) */}
|
||||
<div className="gm-puzzle-sidebar">
|
||||
{/* Description — always visible */}
|
||||
<div className="gm-concept-panel">
|
||||
<div className="gm-concept-header">
|
||||
<span>📖 Mision</span>
|
||||
</div>
|
||||
<div className="gm-concept-body">
|
||||
<p className="gm-concept-desc">{level.description}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Hint — hidden, reveals with penalty */}
|
||||
<div className="gm-hint-panel">
|
||||
{!showHint ? (
|
||||
<button className="gm-hint-btn" onClick={handleRevealHint}>
|
||||
<span className="gm-hint-icon">💡</span>
|
||||
<span className="gm-hint-label">Mostrar Pista</span>
|
||||
<span className="gm-hint-penalty">max ★★</span>
|
||||
</button>
|
||||
) : (
|
||||
<div className="gm-hint-revealed">
|
||||
<div className="gm-hint-header">
|
||||
<span>💡 Pista</span>
|
||||
<span className="gm-hint-penalty-tag">max ★★</span>
|
||||
</div>
|
||||
<p className="gm-hint-text">{level.concept}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Objectives */}
|
||||
<div className="gm-objectives">
|
||||
<div className="gm-obj-title">Objetivos</div>
|
||||
{level.checks.map((check, i) => {
|
||||
const passed = result?.checks?.[i]?.passed;
|
||||
const cappedByStar = hintUsed && check.star === 3;
|
||||
return (
|
||||
<div key={i} className={`gm-obj ${passed === true ? (cappedByStar ? 'capped' : 'passed') : passed === false ? 'failed' : ''}`}>
|
||||
<span className="gm-obj-star">{'★'.repeat(check.star)}</span>
|
||||
<span className="gm-obj-name">
|
||||
{check.desc}
|
||||
{cappedByStar && <span className="gm-obj-locked"> 🔒</span>}
|
||||
</span>
|
||||
{passed === true && !cappedByStar && <span className="gm-obj-check">✓</span>}
|
||||
{passed === false && <span className="gm-obj-x">✗</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{hintUsed && (
|
||||
<div className="gm-hint-warning">
|
||||
Pista usada — maximo 2 estrellas en este nivel (permanente).
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Module palette */}
|
||||
{level.availableModules.length > 0 && (
|
||||
<div className="gm-module-palette">
|
||||
<div className="gm-palette-title">Modulos Disponibles</div>
|
||||
{level.availableModules.map(type => {
|
||||
const def = getModuleDef(type);
|
||||
if (!def) return null;
|
||||
return (
|
||||
<div key={type} className="gm-palette-item" onClick={() => handleAddModule(type)}>
|
||||
<span className="gm-palette-icon">{def.icon}</span>
|
||||
<span className="gm-palette-name">{def.name}</span>
|
||||
<span className="gm-palette-add">+</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button className="gm-btn danger" onClick={() => loadLevel(true)} style={{ marginTop: 'auto' }}>
|
||||
↺ Reiniciar Nivel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main canvas */}
|
||||
<div className="gm-puzzle-canvas-wrap">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`node-canvas ${state.panning ? 'grabbing' : ''} ${connectingRef.current ? 'connecting' : ''}`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onWheel={handleWheel}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', zIndex: 0 }}>
|
||||
<defs>
|
||||
<pattern id="puzzle-grid" width={20 * state.zoom} height={20 * state.zoom}
|
||||
patternUnits="userSpaceOnUse"
|
||||
x={state.camX % (20 * state.zoom)} y={state.camY % (20 * state.zoom)}>
|
||||
<circle cx={1} cy={1} r={0.8} fill="#1a1a30" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#puzzle-grid)" />
|
||||
</svg>
|
||||
|
||||
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} zoom={state.zoom} camX={state.camX} camY={state.camY} />
|
||||
|
||||
<div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
|
||||
{state.modules.map(mod => (
|
||||
<ModuleNode
|
||||
key={mod.id}
|
||||
mod={mod}
|
||||
zoom={state.zoom}
|
||||
onStartConnect={handleStartConnect}
|
||||
onPortPosition={handlePortPosition}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zoom controls — top right */}
|
||||
<div className="zoom-controls">
|
||||
<button className="zoom-btn" onClick={handleZoomIn} title="Acercar">+</button>
|
||||
<button className="zoom-btn zoom-label" onClick={handleZoomReset} title="Resetear zoom">
|
||||
{(state.zoom * 100).toFixed(0)}%
|
||||
</button>
|
||||
<button className="zoom-btn" onClick={handleZoomOut} title="Alejar">−</button>
|
||||
<button className="zoom-btn" onClick={handleCenterView} title="Centrar vista">⌂</button>
|
||||
</div>
|
||||
|
||||
{state.modules.length > 0 && state.connections.length === 0 && (
|
||||
<div className="gm-canvas-hint">
|
||||
Arrastra de un puerto (circulo) a otro para conectar modulos
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile bottom sheet with tabs (replaces sidebar) */}
|
||||
{isMobile && (
|
||||
<BottomSheet
|
||||
tabs={[
|
||||
{ id: 'mission', label: 'MISION' },
|
||||
{ id: 'objectives', label: 'OBJETIVOS' },
|
||||
{ id: 'modules', label: 'MODULOS' },
|
||||
]}
|
||||
activeTab={mobileTab}
|
||||
onTabChange={setMobileTab}
|
||||
>
|
||||
{mobileTab === 'mission' && (
|
||||
<div>
|
||||
<p className="puzzle-mission-text">{level.description}</p>
|
||||
{!showHint ? (
|
||||
<button className="puzzle-hint-btn" onClick={handleRevealHint}>
|
||||
<span className="puzzle-hint-icon">💡</span>
|
||||
<span className="puzzle-hint-label">Mostrar Pista</span>
|
||||
<span className="puzzle-hint-penalty">max ★★</span>
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ marginTop: 8, padding: '10px 12px', background: 'var(--surface)', borderRadius: 8, border: '1px solid var(--yellow)' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--yellow)', marginBottom: 6 }}>💡 Pista <span className="puzzle-hint-penalty">max ★★</span></div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text)', lineHeight: 1.5 }}>{level.concept}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mobileTab === 'objectives' && (
|
||||
<div>
|
||||
{level.checks.map((check, i) => {
|
||||
const passed = result?.checks?.[i]?.passed;
|
||||
const cappedByStar = hintUsed && check.star === 3;
|
||||
return (
|
||||
<div key={i} className="puzzle-obj-item">
|
||||
<span className="puzzle-obj-star">{'★'.repeat(check.star)}</span>
|
||||
<span className="puzzle-obj-desc" style={passed === true ? { color: 'var(--green)' } : passed === false ? { color: 'var(--red)' } : {}}>
|
||||
{check.desc}
|
||||
{cappedByStar && ' 🔒'}
|
||||
</span>
|
||||
{passed === true && !cappedByStar && <span style={{ color: 'var(--green)', fontWeight: 700 }}>✓</span>}
|
||||
{passed === false && <span style={{ color: 'var(--red)', fontWeight: 700 }}>✗</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{hintUsed && (
|
||||
<div style={{ marginTop: 8, padding: '6px 8px', background: 'rgba(255,204,0,0.08)', borderRadius: 4, fontSize: 10, color: 'var(--yellow)' }}>
|
||||
Pista usada — maximo 2 estrellas (permanente).
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mobileTab === 'modules' && (
|
||||
<div>
|
||||
{level.availableModules.length > 0 ? (
|
||||
<div className="mobile-module-grid" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
|
||||
{level.availableModules.map(type => {
|
||||
const def = getModuleDef(type);
|
||||
if (!def) return null;
|
||||
return (
|
||||
<div key={type} className="mobile-module-tile" onClick={() => handleAddModule(type)}>
|
||||
<span className="tile-icon">{def.icon}</span>
|
||||
<span className="tile-name">{def.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: 12, color: 'var(--text2)' }}>No hay modulos extra disponibles para este nivel.</p>
|
||||
)}
|
||||
<button className="gm-btn danger" onClick={() => loadLevel(true)} style={{ width: '100%', marginTop: 12, justifyContent: 'center' }}>
|
||||
↺ Reiniciar Nivel
|
||||
</button>
|
||||
<button className="gm-btn clear" onClick={handleClearCanvas} style={{ width: '100%', marginTop: 6, justifyContent: 'center' }}>
|
||||
🗑 Limpiar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</BottomSheet>
|
||||
)}
|
||||
|
||||
{/* Level complete overlay */}
|
||||
{result && result.stars >= 1 && (
|
||||
<LevelComplete
|
||||
stars={result.stars}
|
||||
checks={result.checks}
|
||||
levelTitle={level.title}
|
||||
isLastLevel={isLastLevel}
|
||||
hintPenalty={result.hintPenalty}
|
||||
onRetry={() => setResult(null)}
|
||||
onMap={onBack}
|
||||
onNext={onNextLevel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
247
packages/client/src/game/WorldMap.jsx
Normal file
247
packages/client/src/game/WorldMap.jsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import MobileTabBar from '../components/MobileTabBar.jsx';
|
||||
import { useIsMobile } from '../hooks/useIsMobile.js';
|
||||
import { useAuth } from '../services/AuthContext.jsx';
|
||||
import { WORLD_1 } from './levels/world1.js';
|
||||
import { WORLD_2 } from './levels/world2.js';
|
||||
import { WORLD_3 } from './levels/world3.js';
|
||||
import { WORLD_4 } from './levels/world4.js';
|
||||
import { WORLD_5 } from './levels/world5.js';
|
||||
import { WORLD_6 } from './levels/world6.js';
|
||||
import { WORLD_7 } from './levels/world7.js';
|
||||
import { WORLD_8 } from './levels/world8.js';
|
||||
import { WORLD_9 } from './levels/world9.js';
|
||||
import { WORLD_10 } from './levels/world10.js';
|
||||
import { WORLD_11 } from './levels/world11.js';
|
||||
import { WORLD_12 } from './levels/world12.js';
|
||||
import { getLevelProgress, isLevelUnlocked, loadProgress } from './gameState.js';
|
||||
|
||||
const worlds = [WORLD_1, WORLD_2, WORLD_3, WORLD_4, WORLD_5, WORLD_6, WORLD_7, WORLD_8, WORLD_9, WORLD_10, WORLD_11, WORLD_12];
|
||||
|
||||
function Stars({ count, max = 3 }) {
|
||||
return (
|
||||
<span className="gm-stars">
|
||||
{Array.from({ length: max }, (_, i) => (
|
||||
<span key={i} className={i < count ? 'star filled' : 'star empty'}>★</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function getTotalStars() {
|
||||
const p = loadProgress();
|
||||
return Object.values(p.completedLevels).reduce((s, l) => s + (l.stars || 0), 0);
|
||||
}
|
||||
|
||||
function getMaxStars() {
|
||||
return worlds.reduce((s, w) => s + w.levels.length * 3, 0);
|
||||
}
|
||||
|
||||
function isWorldUnlocked(world) {
|
||||
if (!world.unlockStars) return true; // World 1 always unlocked
|
||||
return getTotalStars() >= world.unlockStars;
|
||||
}
|
||||
|
||||
const MOBILE_TABS = [
|
||||
{ id: 'game', label: 'JUEGO', icon: '🎮' },
|
||||
{ id: 'sandbox', label: 'SANDBOX', icon: '🎛' },
|
||||
{ id: 'workshop', label: 'WORKSHOP', icon: '🎵' },
|
||||
{ id: 'config', label: 'CONFIG', icon: '⚙' },
|
||||
];
|
||||
|
||||
export default function WorldMap({ onSelectLevel, onSandbox, onAdmin, onWorkshop }) {
|
||||
const totalStars = getTotalStars();
|
||||
const maxStars = getMaxStars();
|
||||
const [search, setSearch] = useState('');
|
||||
const searchRef = useRef(null);
|
||||
const isMobile = useIsMobile();
|
||||
const { user, isLoggedIn, openAuth, logout } = useAuth();
|
||||
|
||||
const query = search.trim().toLowerCase();
|
||||
|
||||
// Build flat search results when there's a query
|
||||
const searchResults = query ? worlds.flatMap((world, worldIdx) => {
|
||||
return world.levels.map((level, idx) => ({ level, world, worldIdx, idx }))
|
||||
.filter(({ level }) =>
|
||||
level.title.toLowerCase().includes(query) ||
|
||||
level.subtitle.toLowerCase().includes(query) ||
|
||||
level.id.toLowerCase().includes(query) ||
|
||||
world.name.toLowerCase().includes(query)
|
||||
);
|
||||
}) : [];
|
||||
|
||||
return (
|
||||
<div className="gm-worldmap">
|
||||
{/* Header */}
|
||||
<div className="gm-header">
|
||||
<div className="gm-logo">
|
||||
<span className="gm-logo-icon">~</span>
|
||||
<div>
|
||||
<h1 className="gm-title">SynthQuest</h1>
|
||||
<p className="gm-tagline">Aprende sintesis modular resolviendo puzzles</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="gm-header-right">
|
||||
<div className="gm-total-stars">
|
||||
<span className="star filled">★</span> {totalStars}/{maxStars}
|
||||
</div>
|
||||
<button className="gm-sandbox-btn" onClick={onSandbox}>
|
||||
🎛 Sandbox
|
||||
</button>
|
||||
{onAdmin && (
|
||||
<button className="gm-admin-btn" onClick={onAdmin} title="Admin Mode">
|
||||
🛠
|
||||
</button>
|
||||
)}
|
||||
{isLoggedIn ? (
|
||||
<div className="user-badge" onClick={logout} title="Cerrar sesion">
|
||||
<div className="user-avatar">{user.username?.[0]?.toUpperCase()}</div>
|
||||
<span className="user-name">{user.username}</span>
|
||||
</div>
|
||||
) : (
|
||||
<button className="login-btn" onClick={openAuth}>Entrar</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="gm-search-bar">
|
||||
<span className="gm-search-icon">🔍</span>
|
||||
<input
|
||||
ref={searchRef}
|
||||
className="gm-search-input"
|
||||
type="text"
|
||||
placeholder="Buscar nivel por nombre, mundo..."
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Escape' && (setSearch(''), searchRef.current?.blur())}
|
||||
/>
|
||||
{search && (
|
||||
<button className="gm-search-clear" onClick={() => { setSearch(''); searchRef.current?.focus(); }}>✕</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Search results */}
|
||||
{query ? (
|
||||
<div className="gm-search-results">
|
||||
{searchResults.length === 0 ? (
|
||||
<div className="gm-search-empty">No se encontraron niveles para "{search}"</div>
|
||||
) : (
|
||||
<div className="gm-search-count">{searchResults.length} nivel{searchResults.length !== 1 ? 'es' : ''} encontrado{searchResults.length !== 1 ? 's' : ''}</div>
|
||||
)}
|
||||
<div className="gm-level-grid">
|
||||
{searchResults.map(({ level, world, worldIdx, idx }) => {
|
||||
const progress = getLevelProgress(level.id);
|
||||
const levelUnlocked = isLevelUnlocked(level.id, world.levels) && isWorldUnlocked(world);
|
||||
const stars = progress?.stars || 0;
|
||||
const isBoss = idx === world.levels.length - 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={level.id}
|
||||
className={`gm-level-card ${levelUnlocked ? 'unlocked' : 'locked'} ${isBoss ? 'boss' : ''} ${stars === 3 ? 'perfect' : ''}`}
|
||||
onClick={() => levelUnlocked && onSelectLevel(level, world)}
|
||||
>
|
||||
<div className="gm-level-number" style={{ color: world.color }}>{worldIdx + 1}.{idx + 1}</div>
|
||||
<div className="gm-level-info">
|
||||
<h3 className="gm-level-title">{level.title}</h3>
|
||||
<p className="gm-level-subtitle">{world.name} — {level.subtitle}</p>
|
||||
</div>
|
||||
{levelUnlocked ? (
|
||||
<Stars count={stars} />
|
||||
) : (
|
||||
<span className="gm-lock">🔒</span>
|
||||
)}
|
||||
{!levelUnlocked && <div className="gm-lock-overlay" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
/* All worlds (normal view) */
|
||||
worlds.map((world, worldIdx) => {
|
||||
const unlocked = isWorldUnlocked(world);
|
||||
const worldStars = world.levels.reduce((s, l) => {
|
||||
const p = getLevelProgress(l.id);
|
||||
return s + (p?.stars || 0);
|
||||
}, 0);
|
||||
const worldMaxStars = world.levels.length * 3;
|
||||
|
||||
if (!unlocked) {
|
||||
return (
|
||||
<div key={world.id} className="gm-world-section gm-locked-world">
|
||||
<div className="gm-world-header">
|
||||
<span className="gm-world-icon" style={{ color: '#666' }}>{world.icon}</span>
|
||||
<div>
|
||||
<h2 className="gm-world-name" style={{ color: '#666' }}>Mundo {worldIdx + 1}: {world.name}</h2>
|
||||
<p className="gm-world-sub">Consigue {world.unlockStars} estrellas para desbloquear ({totalStars}/{world.unlockStars})</p>
|
||||
</div>
|
||||
<span className="gm-lock" style={{ fontSize: 24 }}>🔒</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={world.id} className="gm-world-section">
|
||||
<div className="gm-world-header">
|
||||
<span className="gm-world-icon" style={{ color: world.color }}>{world.icon}</span>
|
||||
<div>
|
||||
<h2 className="gm-world-name">Mundo {worldIdx + 1}: {world.name}</h2>
|
||||
<p className="gm-world-sub">{world.subtitle}</p>
|
||||
</div>
|
||||
<div className="gm-world-stars">
|
||||
<span className="star filled">★</span> {worldStars}/{worldMaxStars}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="gm-level-grid">
|
||||
{world.levels.map((level, idx) => {
|
||||
const progress = getLevelProgress(level.id);
|
||||
const levelUnlocked = isLevelUnlocked(level.id, world.levels);
|
||||
const stars = progress?.stars || 0;
|
||||
const isBoss = idx === world.levels.length - 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={level.id}
|
||||
className={`gm-level-card ${levelUnlocked ? 'unlocked' : 'locked'} ${isBoss ? 'boss' : ''} ${stars === 3 ? 'perfect' : ''}`}
|
||||
onClick={() => levelUnlocked && onSelectLevel(level, world)}
|
||||
>
|
||||
<div className="gm-level-number">{idx + 1}</div>
|
||||
<div className="gm-level-info">
|
||||
<h3 className="gm-level-title">{level.title}</h3>
|
||||
<p className="gm-level-subtitle">{level.subtitle}</p>
|
||||
</div>
|
||||
{levelUnlocked ? (
|
||||
<Stars count={stars} />
|
||||
) : (
|
||||
<span className="gm-lock">🔒</span>
|
||||
)}
|
||||
{!levelUnlocked && <div className="gm-lock-overlay" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
|
||||
{/* Mobile tab bar */}
|
||||
{isMobile && (
|
||||
<MobileTabBar
|
||||
tabs={MOBILE_TABS}
|
||||
activeTab="game"
|
||||
onTabChange={(id) => {
|
||||
if (id === 'sandbox') onSandbox?.();
|
||||
if (id === 'workshop') onWorkshop?.();
|
||||
if (id === 'config') onAdmin?.();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1913
packages/client/src/game/autoSolver.js
Normal file
1913
packages/client/src/game/autoSolver.js
Normal file
File diff suppressed because it is too large
Load Diff
149
packages/client/src/game/gameState.js
Normal file
149
packages/client/src/game/gameState.js
Normal file
@@ -0,0 +1,149 @@
|
||||
/**
|
||||
* gameState.js — Game progress persistence
|
||||
* Tracks completed levels, stars earned, unlocks, and saved patches per level
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'synthquest-progress';
|
||||
const PATCHES_KEY = 'synthquest-patches';
|
||||
|
||||
const defaultProgress = {
|
||||
currentWorld: 'w1',
|
||||
completedLevels: {}, // { levelId: { stars: 3 } }
|
||||
unlockedWorlds: ['w1'],
|
||||
totalStars: 0,
|
||||
};
|
||||
|
||||
let _progress = null;
|
||||
let _patches = null; // { levelId: { modules, connections } }
|
||||
|
||||
export function loadProgress() {
|
||||
if (_progress) return _progress;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
_progress = raw ? { ...defaultProgress, ...JSON.parse(raw) } : { ...defaultProgress };
|
||||
} catch {
|
||||
_progress = { ...defaultProgress };
|
||||
}
|
||||
return _progress;
|
||||
}
|
||||
|
||||
export function saveProgress() {
|
||||
if (!_progress) return;
|
||||
_progress.totalStars = Object.values(_progress.completedLevels)
|
||||
.reduce((sum, l) => sum + (l.stars || 0), 0);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(_progress));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function completeLevel(levelId, stars) {
|
||||
const p = loadProgress();
|
||||
const existing = p.completedLevels[levelId];
|
||||
if (!existing || stars > existing.stars) {
|
||||
p.completedLevels[levelId] = { stars, completedAt: Date.now() };
|
||||
}
|
||||
saveProgress();
|
||||
}
|
||||
|
||||
export function getLevelProgress(levelId) {
|
||||
const p = loadProgress();
|
||||
return p.completedLevels[levelId] || null;
|
||||
}
|
||||
|
||||
export function isLevelUnlocked(levelId, worldLevels) {
|
||||
const p = loadProgress();
|
||||
const idx = worldLevels.findIndex(l => l.id === levelId);
|
||||
if (idx === 0) return true;
|
||||
const prevId = worldLevels[idx - 1]?.id;
|
||||
return prevId && p.completedLevels[prevId]?.stars >= 1;
|
||||
}
|
||||
|
||||
export function resetProgress() {
|
||||
_progress = { ...defaultProgress };
|
||||
_patches = {};
|
||||
_hints = {};
|
||||
saveProgress();
|
||||
savePatches();
|
||||
saveHints();
|
||||
}
|
||||
|
||||
// ==================== Level patch persistence ====================
|
||||
|
||||
function loadPatches() {
|
||||
if (_patches) return _patches;
|
||||
try {
|
||||
const raw = localStorage.getItem(PATCHES_KEY);
|
||||
_patches = raw ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
_patches = {};
|
||||
}
|
||||
return _patches;
|
||||
}
|
||||
|
||||
function savePatches() {
|
||||
if (!_patches) return;
|
||||
try {
|
||||
localStorage.setItem(PATCHES_KEY, JSON.stringify(_patches));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function saveLevelPatch(levelId, modules, connections) {
|
||||
const patches = loadPatches();
|
||||
patches[levelId] = {
|
||||
modules: modules.map(m => ({ id: m.id, type: m.type, x: m.x, y: m.y, params: { ...m.params } })),
|
||||
connections: connections.map(c => ({ ...c })),
|
||||
savedAt: Date.now(),
|
||||
};
|
||||
savePatches();
|
||||
}
|
||||
|
||||
export function getLevelPatch(levelId) {
|
||||
const patches = loadPatches();
|
||||
return patches[levelId] || null;
|
||||
}
|
||||
|
||||
export function clearLevelPatch(levelId) {
|
||||
const patches = loadPatches();
|
||||
delete patches[levelId];
|
||||
savePatches();
|
||||
}
|
||||
|
||||
// ==================== Hint tracking (persisted, no cheating!) ====================
|
||||
|
||||
const HINTS_KEY = 'synthquest-hints';
|
||||
let _hints = null; // { levelId: true }
|
||||
|
||||
function loadHints() {
|
||||
if (_hints) return _hints;
|
||||
try {
|
||||
const raw = localStorage.getItem(HINTS_KEY);
|
||||
_hints = raw ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
_hints = {};
|
||||
}
|
||||
return _hints;
|
||||
}
|
||||
|
||||
function saveHints() {
|
||||
if (!_hints) return;
|
||||
try {
|
||||
localStorage.setItem(HINTS_KEY, JSON.stringify(_hints));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function markHintUsed(levelId) {
|
||||
const hints = loadHints();
|
||||
hints[levelId] = true;
|
||||
saveHints();
|
||||
}
|
||||
|
||||
export function wasHintUsed(levelId) {
|
||||
const hints = loadHints();
|
||||
return !!hints[levelId];
|
||||
}
|
||||
|
||||
export function clearHintForLevel(levelId) {
|
||||
const hints = loadHints();
|
||||
delete hints[levelId];
|
||||
saveHints();
|
||||
}
|
||||
576
packages/client/src/game/levels/world10.js
Normal file
576
packages/client/src/game/levels/world10.js
Normal file
@@ -0,0 +1,576 @@
|
||||
/**
|
||||
* World 10 — "Espacio y Stereo" (Space and Stereo)
|
||||
*
|
||||
* Teaches: Stereo imaging, spatial effects, delay for width, reverb placement
|
||||
* 8 levels, boss challenges with complete stereo mix
|
||||
*/
|
||||
|
||||
export const WORLD_10 = {
|
||||
id: 'w10',
|
||||
name: 'Espacio y Stereo',
|
||||
subtitle: 'Profundidad y dimensión',
|
||||
icon: '◉◉',
|
||||
color: '#44ddaa',
|
||||
unlockStars: 108,
|
||||
levels: [
|
||||
// ─────────────── LEVEL 10.1 ───────────────
|
||||
{
|
||||
id: 'w10-1',
|
||||
title: 'Pan Left-Right',
|
||||
subtitle: 'Los canales estéreo básicos',
|
||||
description: 'La estéreo más simple: coloca una fuente en el canal izquierdo y otra en el derecho. El output tiene dos entradas: "left" y "right". Conecta diferentes osciladores a cada uno.',
|
||||
concept: 'Osc 1 → Output (left). Osc 2 → Output (right). El output tiene dos canales separados. Juntos crean la ilusión de width — como si el sonido viniera de dos lugares diferentes.',
|
||||
availableModules: ['oscillator', 'vca', 'envelope', 'keyboard', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 220, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 330, detune: 0 } },
|
||||
],
|
||||
envelope: { attack: 0.05, decay: 0.3, sustain: 0.4, release: 0.2 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Estéreo básica',
|
||||
desc: 'Dos osciladores, uno al left, uno al right',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 2 || !out) return false;
|
||||
const leftConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
|
||||
const rightConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'right');
|
||||
return leftConn && rightConn;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Estéreo con VCA',
|
||||
desc: 'Cada oscilador con su VCA antes de output',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const vcas = mods.filter(m => m.type === 'vca');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 2 || vcas.length < 2 || !out) return false;
|
||||
const leftConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
|
||||
const rightConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'right');
|
||||
return leftConn && rightConn &&
|
||||
oscs.some(o => conns.some(c => c.from.moduleId === o.id && c.to.moduleId === vcas[0].id));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Estéreo Controlada',
|
||||
desc: 'Oscs left/right con envelopes separados gateados por keyboard',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const vcas = mods.filter(m => m.type === 'vca');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 2 || vcas.length < 2 || envs.length < 2 || !kb || !out) return false;
|
||||
const leftConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
|
||||
const rightConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'right');
|
||||
const gated = envs.filter(e =>
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === e.id && c.to.port === 'gate')
|
||||
);
|
||||
return leftConn && rightConn && gated.length >= 2;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 10.2 ───────────────
|
||||
{
|
||||
id: 'w10-2',
|
||||
title: 'Stereo Detune',
|
||||
subtitle: 'Ancho con osciladores diferentes',
|
||||
description: 'Coloca el mismo oscilador en ambos canales pero detuned: izquierda a la frecuencia exacta, derecha con un pequeño detune (+5 a +15 cents). Crea un "chorus" natural que te envuelve.',
|
||||
concept: 'Osc 1 (detune 0) → Left. Osc 2 (detune +7) a misma nota → Right. Cuando están cerca pero no iguales, el beating crea width. Es como tener dos cantantes cantando casi al unísono.',
|
||||
availableModules: ['oscillator', 'vca', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 220, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 220, detune: 8 } },
|
||||
],
|
||||
envelope: { attack: 0.06, decay: 0.35, sustain: 0.35, release: 0.25 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dos osciladores detuned',
|
||||
desc: 'Oscs a misma frecuencia pero con detune diferente',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 2 || !out) return false;
|
||||
const detunes = oscs.map(o => o.params.detune ?? 0);
|
||||
const freqs = oscs.map(o => o.params.frequency ?? 440);
|
||||
const sameFreq = Math.abs(freqs[0] - freqs[1]) < 10;
|
||||
const differentDetune = Math.abs(detunes[0] - detunes[1]) > 3;
|
||||
return sameFreq && differentDetune;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Stereo width audible',
|
||||
desc: 'Detune entre oscs > 5 cents para efecto chorus',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
if (oscs.length < 2) return false;
|
||||
const detunes = oscs.map(o => o.params.detune ?? 0);
|
||||
return Math.abs(detunes[0] - detunes[1]) > 5;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Chorus Estéreo',
|
||||
desc: 'Detuned oscs left/right con VCAs y envelopes',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const vcas = mods.filter(m => m.type === 'vca');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 2 || vcas.length < 2 || envs.length < 1 || !out) return false;
|
||||
const detunes = oscs.map(o => o.params.detune ?? 0);
|
||||
const freqs = oscs.map(o => o.params.frequency ?? 440);
|
||||
const leftConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
|
||||
const rightConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'right');
|
||||
return Math.abs(freqs[0] - freqs[1]) < 10 &&
|
||||
Math.abs(detunes[0] - detunes[1]) > 5 &&
|
||||
leftConn && rightConn;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 10.3 ───────────────
|
||||
{
|
||||
id: 'w10-3',
|
||||
title: 'Delay para Ancho',
|
||||
subtitle: 'La profundidad del eco',
|
||||
description: 'El delay es uno de los mejores trucos para width: copia la señal, la envía al otro canal con un pequeño delay (20-80ms). El cerebro interpreta esto como "la misma fuente reflejada en espacio".',
|
||||
concept: 'Osc → Left (seco). Osc → Delay (15-50ms) → Right. El delay crea la ilusión de distancia. Cuanto más delay, más separación. Mantén el feedback bajo para evitar caos.',
|
||||
availableModules: ['oscillator', 'vca', 'delay', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 165, detune: 0 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'delay', delayTime: 0.035, feedback: 0.15, wet: 0.8 },
|
||||
],
|
||||
envelope: { attack: 0.08, decay: 0.4, sustain: 0.3, release: 0.3 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Delay en señal',
|
||||
desc: 'Oscilador → Delay → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !del || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === del.id) &&
|
||||
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Delay corto',
|
||||
desc: 'Delay con tiempo entre 20-80ms',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
if (!del) return false;
|
||||
const time = del.params.delayTime ?? 0.3;
|
||||
return time >= 0.02 && time <= 0.08;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Delay Estéreo',
|
||||
desc: 'Osc left + Osc/Delay right con envelopes',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 1 || !del || !out) return false;
|
||||
const time = del.params.delayTime ?? 0.3;
|
||||
const leftConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
|
||||
const rightConn = conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out.id && c.to.port === 'right');
|
||||
return time >= 0.015 && time <= 0.1 &&
|
||||
(del.params.feedback ?? 0.4) < 0.5 &&
|
||||
leftConn && rightConn;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 10.4 ───────────────
|
||||
{
|
||||
id: 'w10-4',
|
||||
title: 'Reverb Corta',
|
||||
subtitle: 'La sala pequeña',
|
||||
description: 'Una reverb corta (decay 1-2s) simula una habitación pequeña. No es mucha cola, solo lo suficiente para darle "espacio" al sonido sin que desaparezca en la distancia. Perfecto para síntesis.',
|
||||
concept: 'Osc → VCA → Reverb (decay 1-2s, wet 0.3-0.5) → Output. La reverb enturbia ligeramente el sonido y lo coloca "en una sala". Mantén wet bajo para que no sea un sonido amortiguado.',
|
||||
availableModules: ['oscillator', 'vca', 'reverb', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 262, detune: 0 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 1.5, wet: 0.4 },
|
||||
],
|
||||
envelope: { attack: 0.07, decay: 0.4, sustain: 0.35, release: 0.25 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Reverb en la cadena',
|
||||
desc: 'Osc → Reverb → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !rev || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === rev.id) &&
|
||||
conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Decay corta',
|
||||
desc: 'Reverb con decay entre 1-2 segundos',
|
||||
test: (mods) => {
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (!rev) return false;
|
||||
const decay = rev.params.decay ?? 3;
|
||||
return decay >= 1 && decay <= 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Sala Perfecta',
|
||||
desc: 'Reverb (decay 1-2s, wet 0.3-0.5) + envelope al VCA',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!osc || !vca || !rev || !env) return false;
|
||||
const decay = rev.params.decay ?? 3;
|
||||
const wet = rev.params.wet ?? 0.4;
|
||||
return decay >= 1 && decay <= 2 &&
|
||||
wet >= 0.25 && wet <= 0.6 &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 10.5 ───────────────
|
||||
{
|
||||
id: 'w10-5',
|
||||
title: 'Catedral Reverb',
|
||||
subtitle: 'Los espacios enormes',
|
||||
description: 'Una catedral reverb es lo opuesto: decay largo (3+ segundos), wet alto. El sonido se desvanece lentamente, como si estuvieras en una basílica gigante. Crea atmósfera épica.',
|
||||
concept: 'Osc → VCA → Reverb (decay > 3s, wet > 0.5) → Output. El sonido se desmorona lentamente en el aire. Usa notas largas para aprovechar la cola reverb. ¡Es mágico!',
|
||||
availableModules: ['oscillator', 'vca', 'reverb', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 196, detune: 0 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 4.2, wet: 0.65 },
|
||||
],
|
||||
envelope: { attack: 0.06, decay: 0.8, sustain: 0.4, release: 0.5 },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Reverb larga',
|
||||
desc: 'Reverb con decay > 3 segundos',
|
||||
test: (mods) => {
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (!rev) return false;
|
||||
return (rev.params.decay ?? 3) > 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Reverb mojada',
|
||||
desc: 'Reverb con wet > 0.5 para efecto dramático',
|
||||
test: (mods) => {
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (!rev) return false;
|
||||
return (rev.params.decay ?? 3) > 3 &&
|
||||
(rev.params.wet ?? 0.4) > 0.5;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Catedral Épica',
|
||||
desc: 'Reverb (decay > 4s, wet > 0.6) con envelope lento al VCA',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!osc || !vca || !rev || !env || !kb) return false;
|
||||
return (rev.params.decay ?? 3) > 4 &&
|
||||
(rev.params.wet ?? 0.4) > 0.6 &&
|
||||
(env.params.attack ?? 0.01) < 0.1 &&
|
||||
(env.params.decay ?? 0.2) > 0.5 &&
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id && c.to.port === 'gate');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 10.6 ───────────────
|
||||
{
|
||||
id: 'w10-6',
|
||||
title: 'Slapback Echo',
|
||||
subtitle: 'Doblado rítmico',
|
||||
description: 'El slapback echo es un delay muy corto (100-200ms) sin feedback, que crea un efecto de "doblado" — como si hubiera una copia del sonido muy cerca. Popular en rockabilly y sintetizadores.',
|
||||
concept: 'Osc → Left (seco). Osc → Delay (100-200ms, feedback bajo) → Right. El delay corto mantiene la segunda "voz" identificable pero cercana. Es como tener un doblante.',
|
||||
availableModules: ['oscillator', 'vca', 'delay', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: 0 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'delay', delayTime: 0.15, feedback: 0.08, wet: 0.75 },
|
||||
],
|
||||
envelope: { attack: 0.05, decay: 0.35, sustain: 0.4, release: 0.2 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Delay rítmico',
|
||||
desc: 'Delay entre 80-250ms',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
if (!del) return false;
|
||||
const time = del.params.delayTime ?? 0.3;
|
||||
return time >= 0.08 && time <= 0.25;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sin feedback',
|
||||
desc: 'Delay con feedback < 0.2 para no crear repeticiones caóticas',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
if (!del) return false;
|
||||
const time = del.params.delayTime ?? 0.3;
|
||||
const fb = del.params.feedback ?? 0.4;
|
||||
return time >= 0.08 && time <= 0.25 && fb < 0.2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Doblante Perfecto',
|
||||
desc: 'Delay (100-200ms, feedback < 0.1) en stereo left/right',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !del || !out) return false;
|
||||
const time = del.params.delayTime ?? 0.3;
|
||||
const fb = del.params.feedback ?? 0.4;
|
||||
const leftConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
|
||||
const rightConn = conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out.id && c.to.port === 'right');
|
||||
return time >= 0.1 && time <= 0.2 &&
|
||||
fb < 0.1 &&
|
||||
leftConn && rightConn;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 10.7 ───────────────
|
||||
{
|
||||
id: 'w10-7',
|
||||
title: 'Orden de Efectos',
|
||||
subtitle: 'La cadena de procesamiento',
|
||||
description: 'El orden de los efectos es crítico: ¿delay antes o después de reverb? ¿Filtro antes que distortion? Aquí aprendes a construir cadenas de efectos que suenen coherentes y profesionales.',
|
||||
concept: 'Construye: Osc → Filter → Distortion → Delay → Reverb → Output. Cada efecto transforma el anterior. El filtro quita brillo, distortion añade armónicos, delay añade movimiento, reverb añade espacio.',
|
||||
availableModules: ['oscillator', 'filter', 'distortion', 'delay', 'reverb', 'vca', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 280, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3500, Q: 1.5 },
|
||||
effects: [
|
||||
{ type: 'distortion', distortion: 0.45, wet: 0.5 },
|
||||
{ type: 'delay', delayTime: 0.3, feedback: 0.35, wet: 0.55 },
|
||||
{ type: 'reverb', decay: 2.2, wet: 0.45 },
|
||||
],
|
||||
envelope: { attack: 0.08, decay: 0.45, sustain: 0.25, release: 0.3 },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena básica',
|
||||
desc: 'Osc → Filter → Delay → Reverb → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !del || !rev || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === del.id) &&
|
||||
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === rev.id) &&
|
||||
conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Con distortion',
|
||||
desc: 'Cadena con filtro + distortion + delay + reverb',
|
||||
test: (mods, conns) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (!flt || !dist || !del || !rev) return false;
|
||||
return conns.some(c => c.from.moduleId === dist.id && c.to.moduleId === del.id) ||
|
||||
conns.some(c => c.from.moduleId === dist.id && c.to.moduleId === rev.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Cadena Profesional',
|
||||
desc: 'Osc → Filter → Distortion → Delay → Reverb con envelope',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !dist || !del || !rev || !env || !out) return false;
|
||||
const fltOsc = conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id);
|
||||
const distFlt = conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === dist.id);
|
||||
const delDist = conns.some(c => c.from.moduleId === dist.id && c.to.moduleId === del.id);
|
||||
const revDel = conns.some(c => c.from.moduleId === del.id && c.to.moduleId === rev.id);
|
||||
const outRev = conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === out.id);
|
||||
return fltOsc && distFlt && delDist && revDel && outRev;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 10.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w10-8',
|
||||
title: 'Mix Espacial',
|
||||
subtitle: 'BOSS FINAL: Orquesta Estéreo',
|
||||
description: 'Construye una mezcla estéreo completa con múltiples fuentes, cada una con su propia posición en el espacio. Usa delay, reverb, y pan para colocar cada instrumento. Crea una orquesta de sintetizadores.',
|
||||
concept: 'Múltiples osciladores/fuentes, algunos en left/right, algunos con delay, algunos con reverb, todos controlados por keyboard/sequencer. La mezcla final debe sonar amplia, profunda, y multidimensional.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'mixer', 'lfo', 'envelope', 'keyboard', 'sequencer', 'delay', 'reverb', 'distortion'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 950, y: 160, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 110, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 110, detune: 10 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 4000, Q: 1.3 },
|
||||
lfo: { frequency: 0.6, type: 'sine', min: 2000, max: 5000, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'delay', delayTime: 0.25, feedback: 0.4, wet: 0.6 },
|
||||
{ type: 'reverb', decay: 3, wet: 0.55 },
|
||||
],
|
||||
envelope: { attack: 0.1, decay: 0.5, sustain: 0.3, release: 0.4 },
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Mezcla funcional',
|
||||
desc: 'Múltiples fuentes en left y right del output',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 2 || !out) return false;
|
||||
const leftConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
|
||||
const rightConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'right');
|
||||
return leftConn && rightConn && conns.length >= 8;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Con efectos espaciales',
|
||||
desc: 'Delay y Reverb en la mezcla creando profundidad',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 2 || !del || !rev || !out) return false;
|
||||
const delToOut = conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out.id);
|
||||
const revToOut = conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === out.id);
|
||||
return delToOut && revToOut;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Orquesta Completa',
|
||||
desc: '3+ oscs, stereo pan, delay + reverb, filter, envelope, keyboard/sequencer',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flts = mods.filter(m => m.type === 'filter');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 3 || flts.length < 1 || envs.length < 1 || !del || !rev || !out) return false;
|
||||
if (!kb && !seq) return false;
|
||||
const leftConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
|
||||
const rightConn = conns.some(c => c.to.moduleId === out.id && c.to.port === 'right');
|
||||
const delToOut = conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out.id);
|
||||
const revToOut = conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === out.id);
|
||||
return leftConn && rightConn && delToOut && revToOut && conns.length >= 15;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
596
packages/client/src/game/levels/world11.js
Normal file
596
packages/client/src/game/levels/world11.js
Normal file
@@ -0,0 +1,596 @@
|
||||
/**
|
||||
* World 11 — "Técnicas Avanzadas" (Advanced Techniques)
|
||||
*
|
||||
* Teaches: filter self-oscillation, ring modulation, drone textures,
|
||||
* polysynth, sidechain, feedback loops, cross-modulation
|
||||
* 8 levels, boss challenge with experimental patching
|
||||
*/
|
||||
|
||||
export const WORLD_11 = {
|
||||
id: 'w11',
|
||||
name: 'Técnicas Avanzadas',
|
||||
subtitle: 'Dominando el sintetizador',
|
||||
icon: '⚙',
|
||||
color: '#aa55ff',
|
||||
unlockStars: 120,
|
||||
levels: [
|
||||
// ─────────────── LEVEL 11.1 ───────────────
|
||||
{
|
||||
id: 'w11-1',
|
||||
title: 'Oscilación del Filtro',
|
||||
subtitle: 'El filtro se vuelve oscilador',
|
||||
description: 'Cuando subes la resonancia (Q) de un filtro lowpass al máximo, el filtro se auto-oscila y produce un tono puro. Es como un oscilador oculto dentro del filtro. Al modular la frecuencia de corte, obtienes un sintetizador completamente nuevo.',
|
||||
concept: 'Noise → Filter LP con Q muy alto (>8) → VCA → Output. Envelope al VCA. LFO o Keyboard al cutoff del filtro. La oscilación del filtro crea tonos puros sin necesidad de oscilador.',
|
||||
availableModules: ['noise', 'filter', 'vca', 'envelope', 'lfo', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1500, Q: 9.5 },
|
||||
lfo: { frequency: 1, type: 'sine', min: 600, max: 3500, target: 'frequency' },
|
||||
envelope: { attack: 0.1, decay: 0.4, sustain: 0.3, release: 0.25 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Filtro resonante',
|
||||
desc: 'Noise → Filter LP con Q alto (>5) → VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!noise || !flt || !vca || !out) return false;
|
||||
return flt.params.type === 'lowpass' && (flt.params.Q ?? 1) > 5 &&
|
||||
conns.some(c => c.from.moduleId === noise.id && c.to.moduleId === flt.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Auto-oscilación',
|
||||
desc: 'Filtro con Q > 8 para oscilación clara',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
return flt && flt.params.type === 'lowpass' && (flt.params.Q ?? 1) > 8;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Sintetizador por Filtro',
|
||||
desc: 'Q > 9, LFO o Keyboard al cutoff, envelope al VCA',
|
||||
test: (mods, conns) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!flt || !env) return false;
|
||||
const hasModulation = (lfo && conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff')) ||
|
||||
(kb && conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === flt.id && c.to.port === 'cutoff'));
|
||||
return (flt.params.Q ?? 1) > 9 && hasModulation &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 11.2 ───────────────
|
||||
{
|
||||
id: 'w11-2',
|
||||
title: 'Modulación en Anillo',
|
||||
subtitle: 'VCA como multiplicador',
|
||||
description: 'La modulación en anillo es un efecto clásico que surge de multiplicar dos señales de audio. Se simula aquí usando un VCA: una señal en "in" y un LFO/oscilador rápido en "cv". El resultado son frecuencias de suma y resta (sidebands).',
|
||||
concept: 'Osc1 → VCA. Osc2 rápido o LFO → cv del VCA. VCA → Mixer o directamente a Output. El VCA actúa como "multiplicador" creando tonos nuevos inarmónicos.',
|
||||
availableModules: ['oscillator', 'lfo', 'vca', 'mixer', 'filter', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 200, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 8, detune: 0 } },
|
||||
],
|
||||
envelope: { attack: 0.08, decay: 0.35, sustain: 0.35, release: 0.2 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dos osciladores',
|
||||
desc: 'Osc1 al in del VCA, Osc2/LFO al cv',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (oscs.length < 1 || !vca || (!lfo && oscs.length < 2)) return false;
|
||||
const hasInput = oscs.some(o => conns.some(c => c.from.moduleId === o.id && c.to.moduleId === vca.id && c.to.port === 'in'));
|
||||
const hasCV = (lfo && conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === vca.id && c.to.port === 'cv')) ||
|
||||
(oscs.length >= 2 && oscs.some(o => conns.some(c => c.from.moduleId === o.id && c.to.moduleId === vca.id && c.to.port === 'cv')));
|
||||
return hasInput && hasCV;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sonido ruidoso',
|
||||
desc: 'LFO/Osc rápido modulando el VCA (frecuencias inarmónicas)',
|
||||
test: (mods, conns) => {
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
if (!vca) return false;
|
||||
const hasRingMod = (lfo && conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === vca.id && c.to.port === 'cv')) ||
|
||||
(oscs.length >= 2);
|
||||
return hasRingMod && conns.some(c => c.to.moduleId === vca.id && c.to.port === 'in');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Modulación en Anillo completa',
|
||||
desc: 'Dos oscs con frecuencias diferentes, VCA como ring mod, sonidos inarmónicos claros',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (oscs.length < 2 || !vca) return false;
|
||||
const hasInput = oscs.some(o => conns.some(c => c.from.moduleId === o.id && c.to.moduleId === vca.id && c.to.port === 'in'));
|
||||
const hasCV = oscs.some(o => conns.some(c => c.from.moduleId === o.id && c.to.moduleId === vca.id && c.to.port === 'cv'));
|
||||
const freq1 = oscs[0].params.frequency ?? 440;
|
||||
const freq2 = oscs[1].params.frequency ?? 440;
|
||||
return hasInput && hasCV && Math.abs(freq1 - freq2) > 50;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 11.3 ───────────────
|
||||
{
|
||||
id: 'w11-3',
|
||||
title: 'Texturas de Drone',
|
||||
subtitle: 'Sonido que evoluciona lentamente',
|
||||
description: 'Un drone es un sonido constante que evoluciona gradualmente. Se crea con osciladores a tonos bajos, múltiples LFOs muy lentos modulando filtros y amplitud, creando texturas hipnóticas que cambian imperceptiblemente.',
|
||||
concept: 'Dos oscs sine bajos (~50-100 Hz) detuned. Mixer → Filter LP. LFOs muy lentos (~0.1-0.5 Hz) al cutoff, amplitud. Reverb largo. Sin gates ni envelopes percusivos — todo fluye continuamente.',
|
||||
availableModules: ['oscillator', 'filter', 'lfo', 'mixer', 'reverb', 'vca'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 60, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 60, detune: -4 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2500, Q: 0.85 },
|
||||
lfo: [
|
||||
{ frequency: 0.3, type: 'sine', min: 1000, max: 4000, target: 'frequency' },
|
||||
{ frequency: 0.15, type: 'sine', min: 0.3, max: 0.9, target: 'amplitude' },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 5, wet: 0.7 },
|
||||
],
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Osciladores graves',
|
||||
desc: 'Dos oscs sine < 120 Hz mezclados',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sine');
|
||||
if (oscs.length < 2) return false;
|
||||
return oscs.filter(o => (o.params.frequency ?? 440) < 120).length >= 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Evolución lenta',
|
||||
desc: 'LFO lento (<1 Hz) modulando el filtro',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!lfo || !flt) return false;
|
||||
const isLowFreq = (lfo.params.frequency ?? 2) < 1;
|
||||
const toFilter = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
return isLowFreq && toFilter;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Drone hipnótico',
|
||||
desc: '2 oscs sine detuned bajos, filtro LP, 2+ LFOs muy lentos, reverb largo',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sine');
|
||||
const lfos = mods.filter(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (oscs.length < 2 || lfos.length < 2 || !flt || !rev) return false;
|
||||
const graveBoth = oscs.filter(o => (o.params.frequency ?? 440) < 120).length >= 2;
|
||||
const hasDetune = oscs.some(o => Math.abs(o.params.detune ?? 0) > 2);
|
||||
const slowLfos = lfos.filter(l => (l.params.frequency ?? 2) < 1).length >= 2;
|
||||
const reverbLong = (rev.params.decay ?? 2) > 3;
|
||||
return graveBoth && hasDetune && slowLfos && reverbLong;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 11.4 ───────────────
|
||||
{
|
||||
id: 'w11-4',
|
||||
title: 'Polifonía',
|
||||
subtitle: 'Múltiples voces simultáneamente',
|
||||
description: 'La polifonía significa tocar múltiples notas simultáneamente. En sintética, usas el keyboard con múltiples canales (oscs + envelopes) para que cada nota presionada active una "voz". Cada voz tiene su propio envelope y filtro.',
|
||||
concept: 'Cuatro "voces": cada una es Osc → Filter → VCA. Todas conectan a un Mixer → Output. Keyboard conectado a la freq de todos los oscs Y al gate de todos los envelopes. Así toca 4 notas a la vez.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'mixer', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220, detune: 3 } },
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: -2 } },
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: 5 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3500, Q: 1.2 },
|
||||
envelope: { attack: 0.07, decay: 0.4, sustain: 0.35, release: 0.25 },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Múltiples voces',
|
||||
desc: 'Al menos 3 oscs conectados al keyboard',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (oscs.length < 3 || !kb) return false;
|
||||
const connectedToKb = oscs.filter(o =>
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === o.id && c.to.port === 'freq')
|
||||
).length;
|
||||
return connectedToKb >= 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Voces completas',
|
||||
desc: '3+ oscs, cada uno pasa por filter + VCA, todos al mixer',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flts = mods.filter(m => m.type === 'filter');
|
||||
const vcas = mods.filter(m => m.type === 'vca');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
if (oscs.length < 3 || flts.length < 3 || vcas.length < 3 || !mixer) return false;
|
||||
// Each osc should go through a filter and VCA
|
||||
let voiceCount = 0;
|
||||
oscs.forEach(o => {
|
||||
const hasFilter = conns.some(c => c.from.moduleId === o.id && c.to.moduleId === flts.find(f => true)?.id);
|
||||
if (hasFilter) voiceCount++;
|
||||
});
|
||||
return voiceCount >= 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Polisintetizador',
|
||||
desc: '4+ voces (osc+filter+vca), keyboard a freq Y gates, todos mezclados, envelopes',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
if (oscs.length < 4 || envs.length < 3 || !kb || !mixer) return false;
|
||||
// Keyboard controls freq of oscs
|
||||
const kbFreq = oscs.filter(o =>
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === o.id && c.to.port === 'freq')
|
||||
).length;
|
||||
// Keyboard controls gates
|
||||
const kbGates = envs.filter(e =>
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === e.id && c.to.port === 'gate')
|
||||
).length;
|
||||
return kbFreq >= 3 && kbGates >= 3 && conns.length >= 12;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 11.5 ───────────────
|
||||
{
|
||||
id: 'w11-5',
|
||||
title: 'Sidechain Simulation',
|
||||
subtitle: 'Bajar el volumen al ritmo',
|
||||
description: 'El sidechain es un efecto donde el volumen (amplitud) baja en ritmo con algo — típicamente un beat. Se simula aquí con un envelope o LFO de ritmo rápido que controla un VCA, creando "ducks" de volumen.',
|
||||
concept: 'Osc → Filter → VCA. Un segundo envelope (o sequencer) con ataque/decay rápidos controla la amplitud del VCA. Cada tiempo que el sidechain se "abre", suena; cuando "cierra", se silencia. Efecto de "bomba".',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'sequencer', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 150, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2800, Q: 1.3 },
|
||||
lfo: { frequency: 2.5, type: 'square', min: 0.1, max: 0.95, target: 'amplitude' },
|
||||
envelope: { attack: 0.03, decay: 0.25, sustain: 0.1, release: 0.15 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'VCA modulado',
|
||||
desc: 'Envelope o Sequencer al cv del VCA',
|
||||
test: (mods, conns) => {
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
if (!vca || (!env && !seq)) return false;
|
||||
return (env && conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv')) ||
|
||||
(seq && conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === vca.id && c.to.port === 'cv'));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Ritmo percibible',
|
||||
desc: 'Envelope decay rápido (< 0.3s) para efecto "pump"',
|
||||
test: (mods) => {
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
return envs.some(e => (e.params.decay ?? 0.2) < 0.3);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Sidechain completo',
|
||||
desc: 'Osc → Filter → VCA. Envelope rápido (< 0.3s) al cv, efecto pump clara',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!osc || !flt || !vca || !env) return false;
|
||||
const pump = (env.params.decay ?? 0.2) < 0.3 && (env.params.attack ?? 0.01) < 0.05;
|
||||
const toVca = conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
return pump && toVca && conns.length >= 4;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 11.6 ───────────────
|
||||
{
|
||||
id: 'w11-6',
|
||||
title: 'Bucles de Retroalimentación',
|
||||
subtitle: 'Caos controlado con feedback',
|
||||
description: 'Al conectar la salida de un efecto (delay, reverb) de vuelta a su entrada, creas retroalimentación. Con los parámetros justos, genera texturas evolucionando lentamente. Con los parámetros equivocados, ¡explosión sónica!',
|
||||
concept: 'Osc → Filter → Delay. Salida del delay vuelve a su propia entrada (feedback alto 0.7-0.9). Reverb después del delay. Envelope muy largo para dejar que el feedback crezca. Los sonidos se multiplican y transforman constantemente.',
|
||||
availableModules: ['oscillator', 'filter', 'delay', 'reverb', 'vca', 'envelope'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 120, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2000, Q: 1.5 },
|
||||
effects: [
|
||||
{ type: 'delay', delayTime: 0.5, feedback: 0.8, wet: 0.9 },
|
||||
{ type: 'reverb', decay: 3.5, wet: 0.6 },
|
||||
],
|
||||
envelope: { attack: 0.1, decay: 1, sustain: 0.4, release: 0.5 },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Delay en la cadena',
|
||||
desc: 'Osc → Filter → Delay → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !del || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === del.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Feedback observable',
|
||||
desc: 'Delay con feedback > 0.5 para retroalimentación clara',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
return del && (del.params.feedback ?? 0.4) > 0.5;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Texturas evolucionando',
|
||||
desc: 'Osc → Filtro → Delay (fb > 0.7) → Reverb, envelope largo, sonido crece y cambia',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!osc || !flt || !del || !rev || !env) return false;
|
||||
const highFb = (del.params.feedback ?? 0.4) > 0.7;
|
||||
const longEnv = (env.params.decay ?? 0.2) > 0.5;
|
||||
const chainOk = conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === del.id) &&
|
||||
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === rev.id);
|
||||
return highFb && longEnv && chainOk;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 11.7 ───────────────
|
||||
{
|
||||
id: 'w11-7',
|
||||
title: 'Cross-Modulación',
|
||||
subtitle: 'LFOs modulándose entre sí',
|
||||
description: 'Cuando un LFO modula la frecuencia de otro LFO, creas patrones dinámicos impredecibles. Cuando un LFO modula la amplitud de otro, creas "breathing" de amplitud. Combines esto con osciladores para sonar experimental y alienígena.',
|
||||
concept: 'LFO1 lento (0.5 Hz) → modula freq del LFO2. LFO2 más rápido (4 Hz) → modula cutoff del filtro. Osc grave → Filter → Output. El patrón del filtro cambia constantemente porque LFO2 está siendo modulado.',
|
||||
availableModules: ['oscillator', 'filter', 'lfo', 'vca'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 100, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2500, Q: 1.2 },
|
||||
lfo: [
|
||||
{ frequency: 0.4, type: 'sine', min: 1.5, max: 6.5, target: 'frequency' },
|
||||
{ frequency: 4.5, type: 'sine', min: 1000, max: 4500, target: 'frequency' },
|
||||
],
|
||||
envelope: { attack: 0.08, decay: 0.4, sustain: 0.3, release: 0.25 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'LFO al LFO',
|
||||
desc: 'Un LFO modulando la frecuencia de otro',
|
||||
test: (mods, conns) => {
|
||||
const lfos = mods.filter(m => m.type === 'lfo');
|
||||
if (lfos.length < 2) return false;
|
||||
return lfos.some(l => conns.some(c =>
|
||||
c.from.moduleId === l.id && c.to.moduleId === lfos.find(x => x.id !== l.id)?.id && c.to.port === 'frequency'
|
||||
));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Modulación en cascada',
|
||||
desc: 'LFO modulado a otro LFO, ese LFO modula filter cutoff',
|
||||
test: (mods, conns) => {
|
||||
const lfos = mods.filter(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (lfos.length < 2 || !flt) return false;
|
||||
const hasLfoToLfo = conns.some(c =>
|
||||
c.from.moduleId === lfos[0].id && c.to.moduleId === lfos[1].id
|
||||
);
|
||||
const hasLfoToFilter = conns.some(c =>
|
||||
c.from.moduleId === lfos[1].id && c.to.moduleId === flt.id && c.to.port === 'cutoff'
|
||||
);
|
||||
return hasLfoToLfo && hasLfoToFilter;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Cross-Mod experimental',
|
||||
desc: 'LFO lento (< 1 Hz) modula freq de LFO rápido (> 3 Hz), cutoff oscila dinámicamente',
|
||||
test: (mods, conns) => {
|
||||
const lfos = mods.filter(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (lfos.length < 2 || !flt) return false;
|
||||
const slowLfo = lfos.find(l => (l.params.frequency ?? 2) < 1);
|
||||
const fastLfo = lfos.find(l => (l.params.frequency ?? 2) > 3);
|
||||
if (!slowLfo || !fastLfo) return false;
|
||||
const crossMod = conns.some(c =>
|
||||
c.from.moduleId === slowLfo.id && c.to.moduleId === fastLfo.id && c.to.port === 'frequency'
|
||||
);
|
||||
const toFilter = conns.some(c =>
|
||||
c.from.moduleId === fastLfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff'
|
||||
);
|
||||
return crossMod && toFilter;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 11.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w11-8',
|
||||
title: 'Patch Experimental',
|
||||
subtitle: 'BOSS FINAL: Sin límites de creatividad',
|
||||
description: 'Has dominado las técnicas avanzadas. Ahora construye el patch más experimental, raro y creativo que puedas. Combina oscilaciones de filtro, modulación en anillo, feedback caótico, modulación cruzada... ¡Sin restricciones!',
|
||||
concept: 'Toma todo lo aprendido: self-oscillation, ring mod, drones, polifonía, sidechain, feedback, cross-mod. Combina al menos 3 técnicas avanzadas diferentes en un solo patch. 10+ módulos, 15+ conexiones. ¡Sorpréndete a ti mismo!',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'noise', 'keyboard', 'delay', 'reverb', 'distortion', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 950, y: 160, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 80, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 80, detune: -3 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 7, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2200, Q: 8 },
|
||||
lfo: [
|
||||
{ frequency: 0.25, type: 'sine', min: 1000, max: 3500, target: 'frequency' },
|
||||
{ frequency: 2, type: 'sine', min: 0.2, max: 0.9, target: 'amplitude' },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'distortion', distortion: 0.4, wet: 0.35 },
|
||||
{ type: 'delay', delayTime: 0.4, feedback: 0.65, wet: 0.7 },
|
||||
{ type: 'reverb', decay: 3.2, wet: 0.5 },
|
||||
],
|
||||
envelope: { attack: 0.12, decay: 0.6, sustain: 0.25, release: 0.4 },
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Patch complejo',
|
||||
desc: 'Al menos 8 módulos, 10+ conexiones, sonido sólido',
|
||||
test: (mods, conns) => {
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!out) return false;
|
||||
const nonOutput = mods.filter(m => m.type !== 'output');
|
||||
const hasOutput = conns.some(c => c.to.moduleId === out.id);
|
||||
return nonOutput.length >= 8 && conns.length >= 10 && hasOutput;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Técnicas avanzadas',
|
||||
desc: 'Al menos 2 técnicas avanzadas reconocibles (self-osc, ring mod, feedback, cross-mod, etc)',
|
||||
test: (mods, conns) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const lfos = mods.filter(m => m.type === 'lfo');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
|
||||
let techCount = 0;
|
||||
// Self-oscillation check
|
||||
if (flt && (flt.params.Q ?? 1) > 7) techCount++;
|
||||
// Feedback loop
|
||||
if (del && (del.params.feedback ?? 0.4) > 0.6) techCount++;
|
||||
// Cross-mod (LFO to LFO)
|
||||
if (lfos.length >= 2 && conns.some(c =>
|
||||
lfos.some(l1 => lfos.some(l2 => l1.id !== l2.id && c.from.moduleId === l1.id && c.to.moduleId === l2.id))
|
||||
)) techCount++;
|
||||
// Ring mod (VCA as ring mod)
|
||||
if (vca && conns.some(c => c.to.moduleId === vca.id && c.to.port === 'cv') &&
|
||||
conns.some(c => c.to.moduleId === vca.id && c.to.port === 'in')) techCount++;
|
||||
|
||||
return techCount >= 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Maestro Avanzado',
|
||||
desc: '10+ módulos, 15+ conexiones, 3+ técnicas avanzadas, mixer, efectos, sonido único',
|
||||
test: (mods, conns) => {
|
||||
const nonOutput = mods.filter(m => m.type !== 'output');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const lfos = mods.filter(m => m.type === 'lfo');
|
||||
|
||||
if (nonOutput.length < 10 || !mixer || effects.length === 0 || conns.length < 15) return false;
|
||||
|
||||
let techCount = 0;
|
||||
if (flt && (flt.params.Q ?? 1) > 7) techCount++;
|
||||
if (del && (del.params.feedback ?? 0.4) > 0.6) techCount++;
|
||||
if (lfos.length >= 2) techCount++;
|
||||
|
||||
return techCount >= 3;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
605
packages/client/src/game/levels/world12.js
Normal file
605
packages/client/src/game/levels/world12.js
Normal file
@@ -0,0 +1,605 @@
|
||||
/**
|
||||
* World 12 — "Gran Final" (Grand Finale)
|
||||
*
|
||||
* Teaches: building a complete track from start to finish
|
||||
* 8 levels creating a full production: intro, drop, lead, breakdown, build-up, mix, outro
|
||||
* boss challenge: create a complete musical piece with scope visualization
|
||||
*/
|
||||
|
||||
export const WORLD_12 = {
|
||||
id: 'w12',
|
||||
name: 'Gran Final',
|
||||
subtitle: 'Tu obra maestra',
|
||||
icon: '♛',
|
||||
color: '#ffd700',
|
||||
unlockStars: 132,
|
||||
levels: [
|
||||
// ─────────────── LEVEL 12.1 ───────────────
|
||||
{
|
||||
id: 'w12-1',
|
||||
title: 'Intro Ambiental',
|
||||
subtitle: 'Comenzando suavemente',
|
||||
description: 'Toda gran pista comienza con una introducción ambiental. Crea una atmósfera con pads, sonidos largos y efectos de reverb/delay. Sin ritmo fuerte, solo texturas flotantes.',
|
||||
concept: 'Dos oscs sine graves detuned + Mixer → Filter LP → VCA con envelope muy largo → Reverb → Output. LFO lento al cutoff. Sin percusión, puro ambiente. Cero attack, máximo sustain.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'reverb'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55, detune: -5 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1800, Q: 0.9 },
|
||||
lfo: { frequency: 0.2, type: 'sine', min: 800, max: 3200, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 5.5, wet: 0.65 },
|
||||
],
|
||||
envelope: { attack: 0.01, decay: 2, sustain: 0.8, release: 0.6 },
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Pad ambiental',
|
||||
desc: '2 oscs sine grave + reverb largo',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sine');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (oscs.length < 2 || !rev) return false;
|
||||
return oscs.filter(o => (o.params.frequency ?? 440) < 150).length >= 2 &&
|
||||
(rev.params.decay ?? 2) > 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Evolución lenta',
|
||||
desc: 'LFO < 1 Hz modulando cutoff, envelope muy largo (decay > 1s)',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!lfo || !env) return false;
|
||||
return (lfo.params.frequency ?? 2) < 1 &&
|
||||
(env.params.decay ?? 0.2) > 1 &&
|
||||
(env.params.sustain ?? 0.5) > 0.4;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Intro hipnótica',
|
||||
desc: '2+ oscs detuned, filter LP, LFO lento al cutoff, reverb > 4s, envelope attack 0',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sine');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (oscs.length < 2 || !flt || !lfo || !rev || !env) return false;
|
||||
const graveLong = oscs.filter(o => (o.params.frequency ?? 440) < 150).length >= 2;
|
||||
const hasDetune = oscs.some(o => Math.abs(o.params.detune ?? 0) > 2);
|
||||
const slowLfo = (lfo.params.frequency ?? 2) < 1;
|
||||
const longRev = (rev.params.decay ?? 2) > 4;
|
||||
const niceEnv = (env.params.attack ?? 0.01) < 0.05 && (env.params.decay ?? 0.2) > 1;
|
||||
const lfoToFilter = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
return graveLong && hasDetune && slowLfo && longRev && niceEnv && lfoToFilter;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 12.2 ───────────────
|
||||
{
|
||||
id: 'w12-2',
|
||||
title: 'El Drop',
|
||||
subtitle: 'Entra el beat con fuerza',
|
||||
description: 'Después de la intro, llega el drop: un cambio dramático donde entra el kick, snare y bass graves. Es el momento de tensión y energía. Combina un bass grave con un beat de síntesis.',
|
||||
concept: 'Dos elementos: 1) Drum: Osc sine grave (~55 Hz) con envelope rápido (attack 0, decay 0.2). 2) Bass: Oscs sawtooth detuned, filtro LP abierto, sonido gordo y agresivo. Sequencer para el ritmo.',
|
||||
availableModules: ['oscillator', 'vca', 'envelope', 'mixer', 'filter', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 82, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 82, detune: 4 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 4500, Q: 1.1 },
|
||||
envelope: { attack: 0.01, decay: 0.18, sustain: 0.05, release: 0.1 },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Kick + Bass',
|
||||
desc: 'Osc grave con envelope corto (kick) + osc grave para bass',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (oscs.length < 2 || !vca || !env) return false;
|
||||
const graveOscs = oscs.filter(o => (o.params.frequency ?? 440) < 100);
|
||||
return graveOscs.length >= 2 && (env.params.decay ?? 0.2) < 0.3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Ritmo percibible',
|
||||
desc: 'Sequencer conectado, beat claro con kick percusivo',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!seq || !osc || !env) return false;
|
||||
return conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === osc.id) &&
|
||||
(env.params.decay ?? 0.2) < 0.25;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Drop potente',
|
||||
desc: 'Kick < 80 Hz decay < 0.2s, bass sawtooth detuned, sequencer, sonido gordo y fuerte',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (oscs.length < 2 || !seq || !env) return false;
|
||||
const kickOsc = oscs.find(o => (o.params.frequency ?? 440) < 80);
|
||||
const sawOscs = oscs.filter(o => o.params.waveform === 'sawtooth');
|
||||
const hasDetune = sawOscs.some(o => Math.abs(o.params.detune ?? 0) > 3);
|
||||
const fastKick = (env.params.decay ?? 0.2) < 0.2;
|
||||
const seqConnected = conns.some(c => c.from.moduleId === seq.id);
|
||||
return kickOsc && sawOscs.length > 0 && hasDetune && fastKick && seqConnected;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 12.3 ───────────────
|
||||
{
|
||||
id: 'w12-3',
|
||||
title: 'Lead Melódico',
|
||||
subtitle: 'Melodía protagonista',
|
||||
description: 'Usa el piano roll para crear una melodía líder que brille sobre el bass. El lead es típicamente un solo sintetizado con oscilador brillante, filtro modulado y reverb para espaciosidad.',
|
||||
concept: 'Piano roll → Osc square/bright → Filter LP con resonancia → VCA → Reverb → Mixer. Envelope para notas definidas (attack corto, decay/sustain para "peso"). LFO lento al cutoff para movimiento.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'lfo', 'pianoroll', 'reverb', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 850, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 440, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3800, Q: 3 },
|
||||
lfo: { frequency: 0.5, type: 'sine', min: 2000, max: 5500, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 2.8, wet: 0.45 },
|
||||
],
|
||||
envelope: { attack: 0.04, decay: 0.5, sustain: 0.5, release: 0.3 },
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Melodía activa',
|
||||
desc: 'Piano roll conectado a osc, notas reproducidas',
|
||||
test: (mods, conns) => {
|
||||
const pr = mods.find(m => m.type === 'pianoroll');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
if (!pr || !osc) return false;
|
||||
return conns.some(c => c.from.moduleId === pr.id && c.to.moduleId === osc.id && c.to.port === 'freq');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Lead con carácter',
|
||||
desc: 'Osc square/bright, filter resonante, envelope con ataque corto',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!osc || !flt || !env) return false;
|
||||
const isBright = osc.params.waveform === 'square' || osc.params.waveform === 'sawtooth';
|
||||
const hasResonance = (flt.params.Q ?? 1) > 2;
|
||||
const quickAttack = (env.params.attack ?? 0.01) < 0.05;
|
||||
return isBright && hasResonance && quickAttack;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Lead melódico',
|
||||
desc: 'Piano roll + osc square con filter resonante + LFO al cutoff + reverb, notas claramente escuchables',
|
||||
test: (mods, conns) => {
|
||||
const pr = mods.find(m => m.type === 'pianoroll');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!pr || !osc || !flt || !lfo || !rev || !env) return false;
|
||||
const prConnected = conns.some(c => c.from.moduleId === pr.id && c.to.moduleId === osc.id && c.to.port === 'freq');
|
||||
const gateConnected = conns.some(c => c.from.moduleId === pr.id && c.to.moduleId === env.id && c.to.port === 'gate');
|
||||
const lfoToFilter = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
return prConnected && gateConnected && lfoToFilter && (rev.params.decay ?? 2) > 2;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 12.4 ───────────────
|
||||
{
|
||||
id: 'w12-4',
|
||||
title: 'Breakdown',
|
||||
subtitle: 'Menos es más',
|
||||
description: 'El breakdown es una sección donde quitas elementos clave para crear contraste. Quitas el kick, quitas el bass pesado, dejas solo los pads suaves o un synth secundario. Construye anticipación para el regreso.',
|
||||
concept: 'Calla el kick y bass de secciones previas. Deja solo pads suaves, lead melódico suave, y efectos. Opcional: introduce un elemento nuevo y suave (strings sintéticos, pad etéreo). Todo con reverb abundante.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'lfo', 'mixer', 'reverb', 'pianoroll'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 850, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 110, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 110, detune: -6 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2500, Q: 0.95 },
|
||||
lfo: { frequency: 0.15, type: 'sine', min: 1200, max: 3800, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 4.5, wet: 0.7 },
|
||||
],
|
||||
envelope: { attack: 0.15, decay: 1.5, sustain: 0.6, release: 0.5 },
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Sonido suave',
|
||||
desc: 'Oscs sine/pads, sin percusión aguda, reverb presente',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (!rev || oscs.length < 1) return false;
|
||||
const sines = oscs.filter(o => o.params.waveform === 'sine');
|
||||
return sines.length >= 1 && (rev.params.decay ?? 2) > 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Atmósfera ambiental',
|
||||
desc: 'Múltiples layers suaves, LFO modulando filtro, no hay kicks agudos',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (oscs.length < 2 || !flt || !lfo) return false;
|
||||
const softOscs = oscs.filter(o => (o.params.frequency ?? 440) < 200);
|
||||
return softOscs.length >= 1 &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Breakdown perfecto',
|
||||
desc: '2+ oscs suaves, filtro con LFO, envelope largo, reverb > 3s, sonido flotante y aéreo',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (oscs.length < 2 || !flt || !lfo || !rev || !env) return false;
|
||||
const softOscs = oscs.filter(o => (o.params.frequency ?? 440) < 200);
|
||||
const longEnv = (env.params.decay ?? 0.2) > 1 && (env.params.sustain ?? 0.5) > 0.3;
|
||||
const lfoToFilter = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
return softOscs.length >= 2 && longEnv && lfoToFilter && (rev.params.decay ?? 2) > 3;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 12.5 ───────────────
|
||||
{
|
||||
id: 'w12-5',
|
||||
title: 'Build-Up',
|
||||
subtitle: 'La tensión sube',
|
||||
description: 'El build-up es donde añades elementos gradualmente para construir tensión. Comienzas minimal, y lentamente añades más capas: pads, bass, efectos, filtros abriendo. La audiencia siente que algo grande viene.',
|
||||
concept: 'Empieza con un LFO lento abriendo un filtro sobre un oscilador suave. Gradualmente: añade un segundo osc, un tercer osc, baja el cutoff, suena más agresivo. El sequencer acelera. La reverb se vuelve más agresiva (menos decay).',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'reverb', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 850, y: 140, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 130, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 165, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 165, detune: 3 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3000, Q: 1.4 },
|
||||
lfo: { frequency: 0.4, type: 'sine', min: 1500, max: 5000, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 2, wet: 0.35 },
|
||||
],
|
||||
envelope: { attack: 0.08, decay: 0.6, sustain: 0.4, release: 0.3 },
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Tensión creciente',
|
||||
desc: 'LFO modulando filter cutoff, sonido evoluciona',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!lfo || !flt) return false;
|
||||
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Múltiples layers',
|
||||
desc: '3+ oscs, filtro con LFO, sonido más agresivo que intro',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
if (oscs.length < 3 || !flt) return false;
|
||||
const hasSeq = seq && conns.some(c => c.from.moduleId === seq.id);
|
||||
return hasSeq;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Build-Up intenso',
|
||||
desc: '3+ oscs, LFO lento al cutoff, sequencer activo, reverb < 2s (más seco), sonido cresce',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (oscs.length < 3 || !flt || !lfo || !seq) return false;
|
||||
const slowLfo = (lfo.params.frequency ?? 2) < 1;
|
||||
const dryReverb = rev && (rev.params.decay ?? 2) < 2.5;
|
||||
const seqConnected = conns.some(c => c.from.moduleId === seq.id);
|
||||
const lfoToFilter = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
return slowLfo && seqConnected && lfoToFilter && conns.length >= 10;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 12.6 ───────────────
|
||||
{
|
||||
id: 'w12-6',
|
||||
title: 'Mix Completo',
|
||||
subtitle: 'Todos los elementos unidos',
|
||||
description: 'Ahora mezcla todo: intro, drop, lead, breakdown, build-up. Todos los elementos están presentes. El desafío es balancear los volúmenes para que nada se ahogue. Usa un mixer y output con gain correcto.',
|
||||
concept: 'Enruta todos los elementos de secciones anteriores a un único mixer. Todos los canales del mixer contribuyen al sonido final. Ajusta los gains del mixer y output para balance: nada clipeado, nada muy suave. Sonido cohesivo.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'reverb', 'sequencer', 'pianoroll'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 950, y: 160, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55, detune: -5 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 4 } },
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3500, Q: 1.2 },
|
||||
lfo: { frequency: 0.35, type: 'sine', min: 1500, max: 4500, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 2.5, wet: 0.5 },
|
||||
],
|
||||
envelope: { attack: 0.1, decay: 0.6, sustain: 0.35, release: 0.4 },
|
||||
duration: 6,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Mixer activo',
|
||||
desc: 'Mixer con múltiples entradas, output rellenado',
|
||||
test: (mods, conns) => {
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!mixer || !out) return false;
|
||||
const inputsToMixer = conns.filter(c => c.to.moduleId === mixer.id).length;
|
||||
const mixerToOut = conns.some(c => c.from.moduleId === mixer.id && c.to.moduleId === out.id);
|
||||
return inputsToMixer >= 2 && mixerToOut;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Balance de sonido',
|
||||
desc: 'Múltiples elementos (oscs, reverb, seq, pianoroll) todos en mixer',
|
||||
test: (mods, conns) => {
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const pr = mods.find(m => m.type === 'pianoroll');
|
||||
if (!mixer) return false;
|
||||
const inputCount = conns.filter(c => c.to.moduleId === mixer.id).length;
|
||||
return oscs.length >= 3 && inputCount >= 4;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Mix profesional',
|
||||
desc: '8+ elementos en mixer, sonido balanceado, output -10 a -6dB, 15+ conexiones totales',
|
||||
test: (mods, conns) => {
|
||||
const nonOut = mods.filter(m => m.type !== 'output');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (nonOut.length < 8 || !mixer || !out) return false;
|
||||
const inputsToMixer = conns.filter(c => c.to.moduleId === mixer.id).length;
|
||||
const outVolume = out.params.volume ?? -6;
|
||||
return inputsToMixer >= 5 && outVolume >= -12 && outVolume <= -4 && conns.length >= 15;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 12.7 ───────────────
|
||||
{
|
||||
id: 'w12-7',
|
||||
title: 'Outro Etéreo',
|
||||
subtitle: 'Despedida musical',
|
||||
description: 'El outro es donde se desvanece todo. Quitas elementos poco a poco, quizás repites la intro ambiental, y añades mucha reverb para crear una sensación de distancia y cierre. El sonido debe desvanecer suavemente.',
|
||||
concept: 'Repite elementos de la intro: oscs sine graves detuned, filtro suave, LFO muy lento al cutoff, reverb LARGO (5+ segundos). Envelope con sustain muy bajo para fade suave. Opcional: distorsión suave o delay con feedback para movimiento final.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'reverb', 'delay'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 850, y: 140, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 50, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 50, detune: -7 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1500, Q: 0.85 },
|
||||
lfo: { frequency: 0.12, type: 'sine', min: 600, max: 2500, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'delay', delayTime: 0.6, feedback: 0.5, wet: 0.6 },
|
||||
{ type: 'reverb', decay: 6, wet: 0.75 },
|
||||
],
|
||||
envelope: { attack: 0.05, decay: 1.5, sustain: 0.2, release: 1 },
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Reverb largo',
|
||||
desc: 'Reverb con decay > 4s para fade etéreo',
|
||||
test: (mods) => {
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
return rev && (rev.params.decay ?? 2) > 4;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sonido desvanecido',
|
||||
desc: 'Oscs graves, LFO lento, reverb largo, envelope largo sin gates',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (oscs.length < 1 || !lfo || !rev || !env) return false;
|
||||
const softOscs = oscs.filter(o => (o.params.frequency ?? 440) < 150);
|
||||
const slowLfo = (lfo.params.frequency ?? 2) < 0.5;
|
||||
const veryLongRev = (rev.params.decay ?? 2) > 4;
|
||||
return softOscs.length >= 1 && slowLfo && veryLongRev;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Outro perfecto',
|
||||
subtitle: '2+ oscs graves detuned, LFO < 0.5 Hz, reverb > 5s, delay con feedback, sonido flota al silencio',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (oscs.length < 2 || !flt || !lfo || !rev || !env) return false;
|
||||
const graveDetuned = oscs.filter(o => (o.params.frequency ?? 440) < 150).length >= 2 &&
|
||||
oscs.some(o => Math.abs(o.params.detune ?? 0) > 2);
|
||||
const verySlowLfo = (lfo.params.frequency ?? 2) < 0.5;
|
||||
const veryLongRev = (rev.params.decay ?? 2) > 5;
|
||||
const lfoToFilter = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
return graveDetuned && verySlowLfo && veryLongRev && lfoToFilter;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 12.8: BOSS FINAL ───────────────
|
||||
{
|
||||
id: 'w12-8',
|
||||
title: 'Tu Obra Maestra',
|
||||
subtitle: 'BOSS FINAL: Tu track completa',
|
||||
description: 'Eres un sintetista maestro. Construye una obra musical completa: una pista de principio a fin. Intro, drop, lead, breakdown, build-up, mezcla y outro. Usa el módulo scope para visualizar tu sonido. Sin límites. Solo tu visión.',
|
||||
concept: 'Crea un track de 10+ módulos y 12+ conexiones. Debe tener: keyboard O sequencer, pianoroll para lead, múltiples osciladores, filtros modulados, reverb/delay, y OBLIGATORIO: scope module para visualización. Mixer para balance. Sonido profesional, único y musical.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'reverb', 'delay', 'sequencer', 'pianoroll', 'keyboard', 'scope'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 1000, y: 160, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 50, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 50, detune: -5 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 4 } },
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: -3 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3800, Q: 1.5 },
|
||||
lfo: [
|
||||
{ frequency: 0.3, type: 'sine', min: 1500, max: 4500, target: 'frequency' },
|
||||
{ frequency: 0.8, type: 'sine', min: 0.3, max: 0.9, target: 'amplitude' },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'delay', delayTime: 0.35, feedback: 0.45, wet: 0.5 },
|
||||
{ type: 'reverb', decay: 3, wet: 0.55 },
|
||||
],
|
||||
envelope: { attack: 0.1, decay: 0.7, sustain: 0.4, release: 0.5 },
|
||||
duration: 8,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Track básica',
|
||||
desc: '10+ módulos, 12+ conexiones, scope presente, sonido a través de output',
|
||||
test: (mods, conns) => {
|
||||
const nonOut = mods.filter(m => m.type !== 'output');
|
||||
const scope = mods.find(m => m.type === 'scope');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (nonOut.length < 10 || !scope || !out) return false;
|
||||
const hasOutput = conns.some(c => c.to.moduleId === out.id);
|
||||
return conns.length >= 12 && hasOutput;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Estructura musical',
|
||||
desc: '4+ secciones reconocibles: lead, bass, pads, efectos. Scope visualiza.',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const pr = mods.find(m => m.type === 'pianoroll');
|
||||
const flt = mods.filter(m => m.type === 'filter');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const scope = mods.find(m => m.type === 'scope');
|
||||
if (oscs.length < 4 || !scope) return false;
|
||||
const hasSequencing = seq || pr;
|
||||
const hasMelody = (pr && conns.some(c => c.from.moduleId === pr.id)) ||
|
||||
(seq && conns.some(c => c.from.moduleId === seq.id));
|
||||
return hasSequencing && flt.length >= 2 && rev && hasMelody;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Masterpiece',
|
||||
desc: '10+ módulos, keyboard/sequencer/pianoroll, 4+ oscs, mixer, 3+ efectos, scope, 15+ conexiones, música profesional',
|
||||
test: (mods, conns) => {
|
||||
const nonOut = mods.filter(m => m.type !== 'output');
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const pr = mods.find(m => m.type === 'pianoroll');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
|
||||
const scope = mods.find(m => m.type === 'scope');
|
||||
if (nonOut.length < 10 || oscs.length < 4 || !mixer || !scope || conns.length < 15) return false;
|
||||
const hasControl = (seq && conns.some(c => c.from.moduleId === seq.id)) ||
|
||||
(pr && conns.some(c => c.from.moduleId === pr.id)) ||
|
||||
(kb && conns.some(c => c.from.moduleId === kb.id));
|
||||
return hasControl && effects.length >= 3 && conns.length >= 15;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
490
packages/client/src/game/levels/world2.js
Normal file
490
packages/client/src/game/levels/world2.js
Normal file
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* World 2 — "Filtros" (Filters)
|
||||
*
|
||||
* Teaches: lowpass, highpass, bandpass, resonance, cutoff modulation
|
||||
* 8 levels, progressive difficulty
|
||||
*/
|
||||
|
||||
export const WORLD_2 = {
|
||||
id: 'w2',
|
||||
name: 'Filtros',
|
||||
subtitle: 'Esculpe el timbre con filtros',
|
||||
icon: '▽',
|
||||
color: '#ff6644',
|
||||
unlockStars: 12, // Need 12 stars from World 1 to unlock
|
||||
levels: [
|
||||
// ─────────────── LEVEL 2.1 ───────────────
|
||||
{
|
||||
id: 'w2-1',
|
||||
title: 'El Paso Bajo',
|
||||
subtitle: 'Quita los agudos',
|
||||
description: 'Un filtro paso bajo (lowpass) deja pasar las frecuencias graves y elimina las agudas. Es el filtro más usado en síntesis — piensa en cómo suena la música debajo del agua. Conecta el oscilador a través del filtro.',
|
||||
concept: 'Conecta: Oscillator → Filter → Output. El filtro ya está en modo lowpass. El knob "Cutoff" controla hasta qué frecuencia deja pasar. Bájalo para un sonido más oscuro.',
|
||||
availableModules: [],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'filter', x: 340, y: 80, params: { type: 'lowpass', frequency: 2000, Q: 1 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 800 },
|
||||
duration: 2.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Señal filtrada',
|
||||
desc: 'Conecta oscilador → filtro → salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !out) return false;
|
||||
const oscToFlt = conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id);
|
||||
const fltToOut = conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id);
|
||||
return oscToFlt && fltToOut;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Cutoff bajo',
|
||||
desc: 'Baja el cutoff por debajo de 1200 Hz',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
return flt && (flt.params.frequency ?? 2000) < 1200;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Sonido oscuro',
|
||||
desc: 'Cutoff cercano a 800 Hz (±200 Hz)',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!flt) return false;
|
||||
return Math.abs((flt.params.frequency ?? 2000) - 800) <= 200;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 2.2 ───────────────
|
||||
{
|
||||
id: 'w2-2',
|
||||
title: 'El Paso Alto',
|
||||
subtitle: 'Solo los agudos',
|
||||
description: 'El filtro paso alto (highpass) es lo opuesto: elimina los graves y deja pasar los agudos. Es perfecto para quitar el "barro" de un sonido o crear texturas etéreas y delgadas.',
|
||||
concept: 'Cambia el tipo de filtro a "highpass". Sube el cutoff para que solo pasen las frecuencias altas. Un cutoff de ~2000 Hz eliminará todo lo grave.',
|
||||
availableModules: ['filter'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
|
||||
],
|
||||
filter: { type: 'highpass', frequency: 2000 },
|
||||
duration: 2.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Filtro conectado',
|
||||
desc: 'Coloca un filtro entre oscilador y salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !out) return false;
|
||||
const oscToFlt = conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id);
|
||||
const fltToOut = conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id);
|
||||
return oscToFlt && fltToOut;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Modo highpass',
|
||||
desc: 'Cambia el filtro a highpass',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
return flt && flt.params.type === 'highpass';
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Cutoff preciso',
|
||||
desc: 'Cutoff cercano a 2000 Hz (±300 Hz)',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!flt) return false;
|
||||
return flt.params.type === 'highpass' && Math.abs((flt.params.frequency ?? 1000) - 2000) <= 300;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 2.3 ───────────────
|
||||
{
|
||||
id: 'w2-3',
|
||||
title: 'Resonancia',
|
||||
subtitle: 'El pico que canta',
|
||||
description: 'La resonancia (Q) amplifica las frecuencias justo alrededor del punto de corte. Con poca resonancia el filtro es suave. Con mucha, el filtro "canta" — es el sonido ácido clásico del TB-303.',
|
||||
concept: 'Sube el knob "Reso" (Q) del filtro a un valor alto (~8-12). Mantén el cutoff bajo (~600 Hz) con lowpass. Escucharás cómo el filtro enfatiza esa frecuencia.',
|
||||
availableModules: [],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sawtooth', frequency: 110, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'filter', x: 340, y: 80, params: { type: 'lowpass', frequency: 1000, Q: 1 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 600, y: 100, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 600, Q: 10 },
|
||||
duration: 2.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Señal filtrada',
|
||||
desc: 'Conecta oscilador → filtro → salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Resonancia alta',
|
||||
desc: 'Sube la resonancia (Q) por encima de 5',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
return flt && (flt.params.Q ?? 1) > 5;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Sonido ácido',
|
||||
desc: 'Q alto (~8-12) y cutoff bajo (~600 Hz ±200)',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!flt) return false;
|
||||
const q = flt.params.Q ?? 1;
|
||||
const freq = flt.params.frequency ?? 1000;
|
||||
return q >= 7 && q <= 15 && Math.abs(freq - 600) <= 200;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 2.4 ───────────────
|
||||
{
|
||||
id: 'w2-4',
|
||||
title: 'Banda Pasante',
|
||||
subtitle: 'Solo el medio',
|
||||
description: 'El filtro bandpass deja pasar solo un rango estrecho de frecuencias alrededor del cutoff. Es como poner un lowpass y un highpass a la vez. Crea sonidos nasales, tipo telefono o walkie-talkie.',
|
||||
concept: 'Cambia el tipo a "bandpass". El cutoff define el centro de la banda. Sube la Q para hacerla más estrecha (más nasal). Un cutoff de ~1000 Hz con Q alta suena como una voz por teléfono.',
|
||||
availableModules: ['filter'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'noise', x: 80, y: 80, params: { type: 'white' }, locked: true },
|
||||
{ id: 2, type: 'output', x: 600, y: 100, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'bandpass', frequency: 1000, Q: 8 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Ruido filtrado',
|
||||
desc: 'Coloca filtro entre noise y salida',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!noise || !flt || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === noise.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Modo bandpass',
|
||||
desc: 'Filtro en modo bandpass',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
return flt && flt.params.type === 'bandpass';
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Nasal perfecto',
|
||||
desc: 'Bandpass a ~1000 Hz con Q alta (>5)',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!flt) return false;
|
||||
return flt.params.type === 'bandpass' &&
|
||||
Math.abs((flt.params.frequency ?? 1000) - 1000) <= 300 &&
|
||||
(flt.params.Q ?? 1) > 5;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 2.5 ───────────────
|
||||
{
|
||||
id: 'w2-5',
|
||||
title: 'Filtro en Movimiento',
|
||||
subtitle: 'LFO → Cutoff',
|
||||
description: 'Los filtros estáticos son útiles, pero los filtros en movimiento son mágicos. Conectar un LFO al cutoff de un filtro crea un barrido cíclico — es el sonido "wah-wah" clásico del funk y la música electrónica.',
|
||||
concept: 'Conecta un LFO a la entrada "Cutoff" del filtro. El LFO modulará el punto de corte automáticamente. Ajusta la velocidad del LFO (~2-4 Hz) para un wobble rítmico.',
|
||||
availableModules: ['lfo'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 60, params: { waveform: 'sawtooth', frequency: 110, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'filter', x: 340, y: 60, params: { type: 'lowpass', frequency: 800, Q: 5 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 600, y: 80, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 800, Q: 5 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena de audio',
|
||||
desc: 'Oscilador → filtro → salida conectados',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'LFO conectado',
|
||||
desc: 'Conecta un LFO a la entrada Cutoff del filtro',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!lfo || !flt) return false;
|
||||
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Wobble rítmico',
|
||||
desc: 'LFO entre 1-6 Hz, resonancia > 3',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!lfo || !flt) return false;
|
||||
const rate = lfo.params.frequency ?? 2;
|
||||
return rate >= 1 && rate <= 6 && (flt.params.Q ?? 1) > 3;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 2.6 ───────────────
|
||||
{
|
||||
id: 'w2-6',
|
||||
title: 'Dos Filtros',
|
||||
subtitle: 'Escultura sónica',
|
||||
description: 'Los ingenieros de sonido encadenan filtros para obtener formas más complejas. Un highpass para quitar el subgrave seguido de un lowpass para suavizar los agudos es una técnica estándar de mezcla.',
|
||||
concept: 'Conecta: Oscilador → Filtro 1 (highpass, ~200 Hz) → Filtro 2 (lowpass, ~3000 Hz) → Output. Esto deja solo las frecuencias medias — "limpia" el sonido.',
|
||||
availableModules: ['filter'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 60, y: 80, params: { waveform: 'sawtooth', frequency: 110, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 720, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110 } },
|
||||
],
|
||||
duration: 2.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena doble',
|
||||
desc: 'Oscilador → filtro → filtro → salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flts = mods.filter(m => m.type === 'filter');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || flts.length < 2 || !out) return false;
|
||||
// Check chain exists
|
||||
const oscToFlt = flts.some(f => conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === f.id));
|
||||
const fltToOut = flts.some(f => conns.some(c => c.from.moduleId === f.id && c.to.moduleId === out.id));
|
||||
const fltToFlt = flts.some(f1 => flts.some(f2 =>
|
||||
f1.id !== f2.id && conns.some(c => c.from.moduleId === f1.id && c.to.moduleId === f2.id)
|
||||
));
|
||||
return oscToFlt && fltToOut && fltToFlt;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Highpass + Lowpass',
|
||||
desc: 'Un filtro en highpass y otro en lowpass',
|
||||
test: (mods) => {
|
||||
const flts = mods.filter(m => m.type === 'filter');
|
||||
if (flts.length < 2) return false;
|
||||
const types = flts.map(f => f.params.type);
|
||||
return types.includes('highpass') && types.includes('lowpass');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Banda limpia',
|
||||
desc: 'HP ~200 Hz (±100) + LP ~3000 Hz (±1000)',
|
||||
test: (mods) => {
|
||||
const flts = mods.filter(m => m.type === 'filter');
|
||||
const hp = flts.find(f => f.params.type === 'highpass');
|
||||
const lp = flts.find(f => f.params.type === 'lowpass');
|
||||
if (!hp || !lp) return false;
|
||||
return Math.abs((hp.params.frequency ?? 1000) - 200) <= 100 &&
|
||||
Math.abs((lp.params.frequency ?? 1000) - 3000) <= 1000;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 2.7 ───────────────
|
||||
{
|
||||
id: 'w2-7',
|
||||
title: 'Filtro + Mezcla',
|
||||
subtitle: 'Timbres paralelos',
|
||||
description: 'Filtra dos osciladores de forma diferente y mézclalos. Es la base del diseño de sonido: capas con diferentes caracteres tímbricos que juntas crean algo más rico que la suma de sus partes.',
|
||||
concept: 'Dos osciladores, cada uno con su propio filtro (diferentes cutoffs), ambos al mixer, mixer al output. Uno oscuro y gordo (LP bajo), otro brillante (LP alto o sin filtro).',
|
||||
availableModules: ['oscillator', 'filter', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110 } },
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220 } },
|
||||
],
|
||||
duration: 2.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dos cadenas',
|
||||
desc: 'Dos osciladores, cada uno filtrado, al mixer',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flts = mods.filter(m => m.type === 'filter');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
return oscs.length >= 2 && flts.length >= 2 && mixer && out &&
|
||||
conns.some(c => c.from.moduleId === mixer.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Filtros diferentes',
|
||||
desc: 'Los dos filtros tienen cutoffs distintos (diferencia > 500 Hz)',
|
||||
test: (mods) => {
|
||||
const flts = mods.filter(m => m.type === 'filter');
|
||||
if (flts.length < 2) return false;
|
||||
const freqs = flts.map(f => f.params.frequency ?? 1000);
|
||||
return Math.abs(freqs[0] - freqs[1]) > 500;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Capas contrastadas',
|
||||
desc: 'Un filtro oscuro (<600 Hz) y otro brillante (>3000 Hz)',
|
||||
test: (mods) => {
|
||||
const flts = mods.filter(m => m.type === 'filter');
|
||||
if (flts.length < 2) return false;
|
||||
const freqs = flts.map(f => f.params.frequency ?? 1000).sort((a, b) => a - b);
|
||||
return freqs[0] < 600 && freqs[freqs.length - 1] > 3000;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 2.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w2-8',
|
||||
title: 'Acid Bass',
|
||||
subtitle: 'BOSS: El sonido TB-303',
|
||||
description: 'El Roland TB-303 definió el acid house. Su secreto: un oscilador cuadrado/sierra a frecuencia baja, un filtro lowpass con MUCHA resonancia, y modulación del cutoff. Recrea ese sonido legendario.',
|
||||
concept: 'Oscilador saw a ~55-110 Hz → Filtro lowpass con Q alta (~12-15) y cutoff medio (~400-800 Hz) → Output. Añade un LFO lento (~0.5-2 Hz) modulando el cutoff para el movimiento ácido.',
|
||||
availableModules: ['oscillator', 'filter', 'lfo'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 82 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 500, Q: 14 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena ácida',
|
||||
desc: 'Oscilador → filtro → salida con LFO al cutoff',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !out || !lfo) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id) &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Resonancia ácida',
|
||||
desc: 'Filtro lowpass con Q > 10',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
return flt && flt.params.type === 'lowpass' && (flt.params.Q ?? 1) > 10;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: '303 auténtico',
|
||||
desc: 'Saw/square baja (<130Hz), Q>10, cutoff 300-900Hz, LFO lento (<3Hz)',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!osc || !flt || !lfo) return false;
|
||||
const freq = osc.params.frequency ?? 440;
|
||||
const wave = osc.params.waveform;
|
||||
const cutoff = flt.params.frequency ?? 1000;
|
||||
const q = flt.params.Q ?? 1;
|
||||
const rate = lfo.params.frequency ?? 2;
|
||||
return freq < 130 && (wave === 'sawtooth' || wave === 'square') &&
|
||||
flt.params.type === 'lowpass' && q > 10 &&
|
||||
cutoff >= 300 && cutoff <= 900 && rate < 3;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
511
packages/client/src/game/levels/world3.js
Normal file
511
packages/client/src/game/levels/world3.js
Normal file
@@ -0,0 +1,511 @@
|
||||
/**
|
||||
* World 3 — "Envelopes" (ADSR)
|
||||
*
|
||||
* Teaches: attack, decay, sustain, release, VCA, amplitude shaping, sound design
|
||||
* 8 levels, progressive difficulty
|
||||
*/
|
||||
|
||||
export const WORLD_3 = {
|
||||
id: 'w3',
|
||||
name: 'Envelopes',
|
||||
subtitle: 'Dale forma al sonido en el tiempo',
|
||||
icon: '⏤╲',
|
||||
color: '#aa55ff',
|
||||
unlockStars: 24, // Need 24 stars from World 1+2 to unlock
|
||||
levels: [
|
||||
// ─────────────── LEVEL 3.1 ───────────────
|
||||
{
|
||||
id: 'w3-1',
|
||||
title: 'El VCA',
|
||||
subtitle: 'Control de volumen',
|
||||
description: 'Un VCA (Voltage Controlled Amplifier) es un amplificador cuyo volumen se puede controlar con una señal externa. Pasa el oscilador por un VCA para poder controlar su volumen.',
|
||||
concept: 'Conecta: Oscilador → VCA (input "In") → Output. El knob "Gain" del VCA controla cuánto deja pasar. Es como un grifo para el sonido.',
|
||||
availableModules: [],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'vca', x: 340, y: 80, params: { gain: 0.5 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 580, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
|
||||
],
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'VCA conectado',
|
||||
desc: 'Conecta oscilador → VCA → salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !vca || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === vca.id && c.to.port === 'in') &&
|
||||
conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Volumen moderado',
|
||||
desc: 'Gain del VCA por debajo de 0.7',
|
||||
test: (mods) => {
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
return vca && (vca.params.gain ?? 0.8) < 0.7;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Medio volumen',
|
||||
desc: 'Gain cercano a 0.5 (±0.1)',
|
||||
test: (mods) => {
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
return vca && Math.abs((vca.params.gain ?? 0.8) - 0.5) <= 0.1;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 3.2 ───────────────
|
||||
{
|
||||
id: 'w3-2',
|
||||
title: 'ADSR',
|
||||
subtitle: 'Las 4 fases del sonido',
|
||||
description: 'Todo sonido tiene una forma en el tiempo: el Attack (subida), Decay (bajada), Sustain (mantenimiento) y Release (apagado). Un Envelope genera esa curva ADSR que puedes usar para controlar el VCA.',
|
||||
concept: 'Conecta el Envelope al VCA: la salida del Envelope → entrada CV del VCA. Conecta el Keyboard al Gate del Envelope para que se dispare al tocar. Toca notas y escucha cómo el Envelope da forma al volumen.',
|
||||
availableModules: ['envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 60, params: { waveform: 'square', frequency: 440, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'vca', x: 400, y: 60, params: { gain: 0 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 620, y: 80, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 440 } },
|
||||
],
|
||||
envelope: { attack: 0.2, decay: 0.15, sustain: 0.6, release: 0.5 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena con VCA',
|
||||
desc: 'Oscilador → VCA → Salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !vca || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === vca.id) &&
|
||||
conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Envelope al VCA',
|
||||
desc: 'Conecta Envelope → VCA (CV) y Keyboard → Envelope (Gate)',
|
||||
test: (mods, conns) => {
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!env || !vca || !kb) return false;
|
||||
return conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv') &&
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id && c.to.port === 'gate');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Keyboard controla frecuencia',
|
||||
desc: 'Keyboard → Osc (Freq) para tocar melodías',
|
||||
test: (mods, conns) => {
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
if (!kb || !osc) return false;
|
||||
return conns.some(c => c.from.moduleId === kb.id && c.from.port === 'freq' && c.to.moduleId === osc.id && c.to.port === 'freq');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 3.3 ───────────────
|
||||
{
|
||||
id: 'w3-3',
|
||||
title: 'Percusión',
|
||||
subtitle: 'Attack rápido, decay corto',
|
||||
description: 'Los sonidos percusivos tienen un attack instantáneo y un decay corto sin sustain. Piensa en un tambor, un clic, un bleep — el sonido aparece de golpe y muere rápido. Configura un envelope percusivo.',
|
||||
concept: 'Attack muy bajo (~0.001s), Decay corto (~0.1-0.2s), Sustain a 0, Release corto. Esto crea un "blip" percusivo. Perfecto para hi-hats, kicks sintéticos, y bleeps 8-bit.',
|
||||
availableModules: ['envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 60, params: { waveform: 'sine', frequency: 440, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'vca', x: 400, y: 60, params: { gain: 0 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 620, y: 80, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
|
||||
],
|
||||
envelope: { attack: 0.005, decay: 0.15, sustain: 0, release: 0.1 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Señal con envelope',
|
||||
desc: 'Osc → VCA → Out, con Envelope al CV y Keyboard al Gate',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !vca || !env || !kb || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === vca.id) &&
|
||||
conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id) &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv') &&
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sin sustain',
|
||||
desc: 'Sustain a 0 (o casi)',
|
||||
test: (mods) => {
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
return env && (env.params.sustain ?? 0.5) < 0.05;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Blip perfecto',
|
||||
desc: 'Attack <0.01s, Decay 0.05-0.3s, Sustain ~0',
|
||||
test: (mods) => {
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!env) return false;
|
||||
return (env.params.attack ?? 0.01) < 0.015 &&
|
||||
(env.params.decay ?? 0.2) >= 0.05 && (env.params.decay ?? 0.2) <= 0.3 &&
|
||||
(env.params.sustain ?? 0.5) < 0.05;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 3.4 ───────────────
|
||||
{
|
||||
id: 'w3-4',
|
||||
title: 'Pad Atmosférico',
|
||||
subtitle: 'Suave y envolvente',
|
||||
description: 'Los pads son sonidos largos y suaves que rellenan el fondo de una mezcla. Se consiguen con un attack lento (el sonido entra gradualmente), sustain alto, y release largo (se desvanece lentamente).',
|
||||
concept: 'Attack lento (~1-2s), Decay corto (~0.3s), Sustain alto (~0.7-0.9), Release largo (~2-4s). El sonido "respira" — entra suave y se queda flotando.',
|
||||
availableModules: ['envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 60, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'vca', x: 400, y: 60, params: { gain: 0 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 620, y: 80, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
|
||||
],
|
||||
envelope: { attack: 1.2, decay: 0.3, sustain: 0.75, release: 2.5 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Señal con envelope',
|
||||
desc: 'Osc → VCA → Out, Envelope al CV, Keyboard al Gate',
|
||||
test: (mods, conns) => {
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!env || !vca || !kb) return false;
|
||||
return conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv') &&
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Attack lento',
|
||||
desc: 'Attack mayor de 0.5 segundos',
|
||||
test: (mods) => {
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
return env && (env.params.attack ?? 0.01) > 0.5;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Pad perfecto',
|
||||
desc: 'Attack >0.8s, Sustain >0.6, Release >1.5s',
|
||||
test: (mods) => {
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!env) return false;
|
||||
return (env.params.attack ?? 0.01) > 0.8 &&
|
||||
(env.params.sustain ?? 0.5) > 0.6 &&
|
||||
(env.params.release ?? 0.5) > 1.5;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 3.5 ───────────────
|
||||
{
|
||||
id: 'w3-5',
|
||||
title: 'Pluck',
|
||||
subtitle: 'Cuerdas pulsadas',
|
||||
description: 'El sonido de una cuerda pulsada (guitarra, arpa) tiene un attack rápido y un decay medio. No tiene sustain real — el sonido decrece naturalmente. El filtro ayuda a que suene más natural.',
|
||||
concept: 'Envelope con Attack rápido (~0.001s), Decay medio (~0.4-0.8s), Sustain bajo (~0.1), Release ~0.3s. Usa una onda triangle o saw con un filtro lowpass para suavizar.',
|
||||
availableModules: ['envelope', 'keyboard', 'filter'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 60, params: { waveform: 'triangle', frequency: 440, detune: 0 }, locked: false },
|
||||
{ id: 2, type: 'vca', x: 500, y: 60, params: { gain: 0 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 760, y: 80, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'triangle', frequency: 440 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3000, Q: 2 },
|
||||
envelope: { attack: 0.008, decay: 0.5, sustain: 0.05, release: 0.2 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena completa',
|
||||
desc: 'Osc → (Filter →) VCA → Out con Envelope y Keyboard',
|
||||
test: (mods, conns) => {
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!env || !vca || !kb || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id) &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.port === 'cv') &&
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.port === 'gate');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Forma pluck',
|
||||
desc: 'Attack rápido (<0.02s), Sustain bajo (<0.2)',
|
||||
test: (mods) => {
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!env) return false;
|
||||
return (env.params.attack ?? 0.01) < 0.02 && (env.params.sustain ?? 0.5) < 0.2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Pluck natural',
|
||||
desc: 'Pluck shape + filtro lowpass en la cadena',
|
||||
test: (mods, conns) => {
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!env || !flt) return false;
|
||||
return (env.params.attack ?? 0.01) < 0.02 &&
|
||||
(env.params.sustain ?? 0.5) < 0.2 &&
|
||||
(env.params.decay ?? 0.2) >= 0.3 &&
|
||||
flt.params.type === 'lowpass';
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 3.6 ───────────────
|
||||
{
|
||||
id: 'w3-6',
|
||||
title: 'Filtro Dinámico',
|
||||
subtitle: 'Envelope → Cutoff',
|
||||
description: 'Los envelopes no solo controlan volumen — ¡también pueden controlar el filtro! Conectar un envelope al cutoff crea sonidos que se "abren" y "cierran" con cada nota. Es la técnica más importante de síntesis sustractiva.',
|
||||
concept: 'Conecta un segundo Envelope a la entrada Cutoff del filtro. Keyboard → Gate de ambos envelopes. Un envelope controla volumen (VCA), otro controla brillo (filtro cutoff).',
|
||||
availableModules: ['envelope', 'keyboard', 'filter'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 60, y: 40, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'vca', x: 520, y: 40, params: { gain: 0 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 760, y: 80, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 800, Q: 4 },
|
||||
envelope: { attack: 0.01, decay: 0.3, sustain: 0.4, release: 0.2 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Doble envelope',
|
||||
desc: 'Dos envelopes: uno al VCA, otro al filtro cutoff',
|
||||
test: (mods, conns) => {
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (envs.length < 2 || !vca || !flt) return false;
|
||||
const envToVca = envs.some(e => conns.some(c => c.from.moduleId === e.id && c.to.moduleId === vca.id && c.to.port === 'cv'));
|
||||
const envToFlt = envs.some(e => conns.some(c => c.from.moduleId === e.id && c.to.moduleId === flt.id && c.to.port === 'cutoff'));
|
||||
return envToVca && envToFlt;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Gates conectados',
|
||||
desc: 'Keyboard → Gate de ambos envelopes',
|
||||
test: (mods, conns) => {
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
if (!kb || envs.length < 2) return false;
|
||||
const gatedEnvs = envs.filter(e =>
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === e.id && c.to.port === 'gate')
|
||||
);
|
||||
return gatedEnvs.length >= 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Envelopes distintos',
|
||||
desc: 'Los dos envelopes tienen decays diferentes (>0.1s diferencia)',
|
||||
test: (mods) => {
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
if (envs.length < 2) return false;
|
||||
const decays = envs.map(e => e.params.decay ?? 0.2);
|
||||
return Math.abs(decays[0] - decays[1]) > 0.1;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 3.7 ───────────────
|
||||
{
|
||||
id: 'w3-7',
|
||||
title: 'Tremolo',
|
||||
subtitle: 'LFO → Volumen',
|
||||
description: 'El tremolo es una variación rítmica del volumen. Se consigue conectando un LFO a la ganancia del VCA. Es un efecto clásico de guitarras, órganos y sintetizadores vintage.',
|
||||
concept: 'Conecta un LFO a la entrada CV del VCA (no del filtro). Un LFO a ~4-8 Hz con amplitud moderada crea un tremolo clásico. Más lento (~1-2 Hz) suena como un "pulso".',
|
||||
availableModules: ['lfo'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 60, params: { waveform: 'sine', frequency: 440, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'vca', x: 340, y: 60, params: { gain: 0.7 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 580, y: 80, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
|
||||
],
|
||||
lfo: { frequency: 6, type: 'sine', min: 0.2, max: 1.0, target: 'amplitude' },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena básica',
|
||||
desc: 'Oscilador → VCA → Salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !vca || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === vca.id) &&
|
||||
conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'LFO al VCA',
|
||||
desc: 'Conecta LFO → VCA (CV)',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (!lfo || !vca) return false;
|
||||
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Tremolo rítmico',
|
||||
desc: 'LFO entre 3-10 Hz (tremolo audible)',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!lfo) return false;
|
||||
const rate = lfo.params.frequency ?? 2;
|
||||
return rate >= 3 && rate <= 10;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 3.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w3-8',
|
||||
title: 'Synth Lead Completo',
|
||||
subtitle: 'BOSS: Ponlo todo junto',
|
||||
description: 'Es hora de construir un sonido de lead completo desde cero. Combina todo lo que has aprendido: oscilador, filtro con envelope, VCA con envelope, y keyboard para tocar. Es el patch clásico de síntesis sustractiva.',
|
||||
concept: 'Keyboard → Osc (freq) + Env1 (gate) + Env2 (gate). Osc → Filter → VCA → Output. Env1 → Filter cutoff (decay medio para "apertura"). Env2 → VCA cv (sustain para mantener). Ajusta para un lead expresivo.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2000, Q: 6 },
|
||||
envelope: { attack: 0.05, decay: 0.3, sustain: 0.5, release: 0.6 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena sustractiva',
|
||||
desc: 'Osc → Filter → VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !vca || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === vca.id) &&
|
||||
conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Doble modulación',
|
||||
desc: 'Envelope al filtro cutoff Y envelope al VCA cv',
|
||||
test: (mods, conns) => {
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (envs.length < 2 || !flt || !vca) return false;
|
||||
const envToFlt = envs.some(e => conns.some(c => c.from.moduleId === e.id && c.to.moduleId === flt.id && c.to.port === 'cutoff'));
|
||||
const envToVca = envs.some(e => conns.some(c => c.from.moduleId === e.id && c.to.moduleId === vca.id && c.to.port === 'cv'));
|
||||
return envToFlt && envToVca;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Lead expresivo',
|
||||
desc: 'Keyboard controla freq + gates, envelopes distintos',
|
||||
test: (mods, conns) => {
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
if (!kb || !osc || envs.length < 2) return false;
|
||||
// KB → osc freq
|
||||
const kbFreq = conns.some(c => c.from.moduleId === kb.id && c.from.port === 'freq' && c.to.moduleId === osc.id);
|
||||
// KB → both env gates
|
||||
const gated = envs.filter(e =>
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === e.id && c.to.port === 'gate')
|
||||
);
|
||||
// Envelopes have different settings
|
||||
const decays = envs.map(e => e.params.decay ?? 0.2);
|
||||
const diffDecay = Math.abs(decays[0] - decays[1]) > 0.05;
|
||||
return kbFreq && gated.length >= 2 && diffDecay;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
529
packages/client/src/game/levels/world4.js
Normal file
529
packages/client/src/game/levels/world4.js
Normal file
@@ -0,0 +1,529 @@
|
||||
/**
|
||||
* World 4 — "Modulación" (Modulation)
|
||||
*
|
||||
* Teaches: LFO routing, vibrato, PWM, FM synthesis, ring modulation, complex patches
|
||||
* 8 levels, progressive difficulty
|
||||
*/
|
||||
|
||||
export const WORLD_4 = {
|
||||
id: 'w4',
|
||||
name: 'Modulación',
|
||||
subtitle: 'Haz que el sonido viva y respire',
|
||||
icon: '∿',
|
||||
color: '#ffcc00',
|
||||
unlockStars: 36,
|
||||
levels: [
|
||||
// ─────────────── LEVEL 4.1 ───────────────
|
||||
{
|
||||
id: 'w4-1',
|
||||
title: 'Vibrato',
|
||||
subtitle: 'LFO → Frecuencia',
|
||||
description: 'El vibrato es una oscilación sutil de la frecuencia. Cantantes, violinistas y sintetizadores lo usan para dar expresividad. Se consigue conectando un LFO lento a la frecuencia del oscilador.',
|
||||
concept: 'Conecta un LFO a la entrada "Freq" del oscilador. Un LFO a ~5-7 Hz con amplitud pequeña crea un vibrato natural. Demasiado rápido o amplio suena a sirena.',
|
||||
availableModules: ['lfo'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sine', frequency: 440, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 500, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
|
||||
],
|
||||
lfo: { frequency: 6, type: 'sine', min: 420, max: 460, target: 'frequency' },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Señal básica',
|
||||
desc: 'Oscilador conectado a la salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
return osc && out && conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'LFO a frecuencia',
|
||||
desc: 'Conecta LFO → Osc (Freq)',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
if (!lfo || !osc) return false;
|
||||
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === osc.id && c.to.port === 'freq');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Vibrato musical',
|
||||
desc: 'LFO entre 4-8 Hz (vibrato natural)',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!lfo) return false;
|
||||
const rate = lfo.params.frequency ?? 2;
|
||||
return rate >= 4 && rate <= 8;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 4.2 ───────────────
|
||||
{
|
||||
id: 'w4-2',
|
||||
title: 'Sirena',
|
||||
subtitle: 'LFO lento = pitch sweep',
|
||||
description: 'Cuando el LFO es muy lento y con mucha amplitud, el vibrato se convierte en un barrido de frecuencia — como una sirena. Los DJs y productores usan este efecto para crear tensión y transiciones.',
|
||||
concept: 'Usa un LFO muy lento (~0.2-0.5 Hz) con forma de onda sine o triangle conectado a la frecuencia del oscilador. La velocidad lenta crea un sweep dramático arriba y abajo.',
|
||||
availableModules: ['lfo'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'square', frequency: 440, detune: 0 }, locked: false },
|
||||
{ id: 2, type: 'output', x: 500, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 440 } },
|
||||
],
|
||||
lfo: { frequency: 0.3, type: 'sine', min: 200, max: 800, target: 'frequency' },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'LFO conectado',
|
||||
desc: 'LFO → Osc (Freq) → Output',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
if (!lfo || !osc) return false;
|
||||
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === osc.id && c.to.port === 'freq');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sweep lento',
|
||||
desc: 'LFO por debajo de 1 Hz',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
return lfo && (lfo.params.frequency ?? 2) < 1;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Sirena perfecta',
|
||||
desc: 'LFO 0.1-0.5 Hz, onda sine o triangle',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!lfo) return false;
|
||||
const rate = lfo.params.frequency ?? 2;
|
||||
const wave = lfo.params.waveform ?? 'sine';
|
||||
return rate >= 0.1 && rate <= 0.5 && (wave === 'sine' || wave === 'triangle');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 4.3 ───────────────
|
||||
{
|
||||
id: 'w4-3',
|
||||
title: 'Wah-Wah Rítmico',
|
||||
subtitle: 'LFO square → Cutoff',
|
||||
description: 'Un LFO con onda cuadrada crea cambios bruscos en el cutoff del filtro — el filtro salta entre abierto y cerrado. Es un efecto rítmico perfecto para música electrónica y funk.',
|
||||
concept: 'LFO square a ~2-4 Hz conectado al cutoff del filtro. La onda cuadrada crea un on/off rítmico. Ajusta el cutoff base del filtro y la resonancia para darle más carácter.',
|
||||
availableModules: ['lfo'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 60, y: 60, params: { waveform: 'sawtooth', frequency: 110, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'filter', x: 300, y: 60, params: { type: 'lowpass', frequency: 600, Q: 4 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 560, y: 80, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 800, Q: 5 },
|
||||
lfo: { frequency: 3, type: 'square', min: 400, max: 4000, target: 'frequency' },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena con LFO',
|
||||
desc: 'Osc → Filter → Out, LFO al Cutoff',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !lfo || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id) &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'LFO cuadrado',
|
||||
desc: 'LFO con onda square',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
return lfo && (lfo.params.waveform ?? 'sine') === 'square';
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Wah rítmico',
|
||||
desc: 'LFO square a 2-4 Hz, filtro con Q > 3',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!lfo || !flt) return false;
|
||||
const rate = lfo.params.frequency ?? 2;
|
||||
return (lfo.params.waveform ?? 'sine') === 'square' &&
|
||||
rate >= 2 && rate <= 4 && (flt.params.Q ?? 1) > 3;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 4.4 ───────────────
|
||||
{
|
||||
id: 'w4-4',
|
||||
title: 'Auto-Pan',
|
||||
subtitle: 'Sonido en movimiento',
|
||||
description: 'Conectar LFOs a los niveles de un mixer permite mover el sonido entre canales. Si envías el mismo oscilador al Left y Right con LFOs invertidos, el sonido viaja de un altavoz al otro.',
|
||||
concept: 'Conecta el oscilador al output con dos cables (Left y Right). Añade un LFO que module algo para crear movimiento estéreo. El efecto auto-pan crea una sensación de espacio.',
|
||||
availableModules: ['lfo', 'vca', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sine', frequency: 440, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
|
||||
],
|
||||
lfo: { frequency: 2, type: 'sine', min: 0.3, max: 1.0, target: 'amplitude' },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Estéreo',
|
||||
desc: 'Oscilador conectado a ambos canales (Left + Right)',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !out) return false;
|
||||
// Direct or through other modules to both channels
|
||||
const toLeft = conns.some(c => c.to.moduleId === out.id && c.to.port === 'left');
|
||||
const toRight = conns.some(c => c.to.moduleId === out.id && c.to.port === 'right');
|
||||
return toLeft && toRight;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'LFO presente',
|
||||
desc: 'Hay al menos un LFO conectado',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!lfo) return false;
|
||||
return conns.some(c => c.from.moduleId === lfo.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Modulación estéreo',
|
||||
desc: 'LFO modula VCA(s) en la cadena estéreo',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const vcas = mods.filter(m => m.type === 'vca');
|
||||
if (!lfo || vcas.length < 1) return false;
|
||||
return vcas.some(v => conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === v.id));
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 4.5 ───────────────
|
||||
{
|
||||
id: 'w4-5',
|
||||
title: 'Doble Modulación',
|
||||
subtitle: 'LFO al filter + LFO al VCA',
|
||||
description: 'Un solo LFO puede modular múltiples destinos a la vez. Conectar el mismo LFO al cutoff del filtro y al gain del VCA crea un sonido que se abre y se hace más fuerte simultáneamente — un efecto muy dinámico.',
|
||||
concept: 'Usa un LFO y conéctalo tanto al Cutoff del filtro como al CV del VCA. El mismo movimiento cíclico afecta brillo y volumen a la vez. Ajusta ~2-3 Hz.',
|
||||
availableModules: ['lfo'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 60, y: 40, params: { waveform: 'sawtooth', frequency: 110, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'filter', x: 280, y: 40, params: { type: 'lowpass', frequency: 800, Q: 5 }, locked: false },
|
||||
{ id: 3, type: 'vca', x: 480, y: 40, params: { gain: 0.6 }, locked: false },
|
||||
{ id: 4, type: 'output', x: 680, y: 60, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1200, Q: 6 },
|
||||
lfo: { frequency: 2.5, type: 'sine', min: 400, max: 3500, target: 'frequency' },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena completa',
|
||||
desc: 'Osc → Filter → VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !vca || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === vca.id) &&
|
||||
conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'LFO a dos destinos',
|
||||
desc: 'Un LFO conectado al Cutoff Y al CV',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (!lfo || !flt || !vca) return false;
|
||||
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff') &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Pulso rítmico',
|
||||
desc: 'LFO a 1-4 Hz, filtro resonante (Q > 4)',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!lfo || !flt) return false;
|
||||
const rate = lfo.params.frequency ?? 2;
|
||||
return rate >= 1 && rate <= 4 && (flt.params.Q ?? 1) > 4;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 4.6 ───────────────
|
||||
{
|
||||
id: 'w4-6',
|
||||
title: 'Cross-Modulation',
|
||||
subtitle: 'Oscilador modula oscilador',
|
||||
description: 'Cuando un oscilador modula la frecuencia de otro oscilador a velocidades audibles (>20 Hz), se crea FM synthesis — timbres metálicos, campanas, y texturas inarmónicas que no puedes conseguir de otra forma.',
|
||||
concept: 'Conecta la salida de un oscilador a la entrada "Freq" de otro. Si el modulador está a frecuencia audible (>50 Hz), se crea FM. Frequencies bajas = vibrato, altas = nuevos timbres.',
|
||||
availableModules: ['oscillator', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 600, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 220 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 880 } },
|
||||
],
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dos osciladores',
|
||||
desc: 'Al menos 2 osciladores con uno modulando al otro',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
if (oscs.length < 2) return false;
|
||||
// One osc connected to another osc's freq
|
||||
return oscs.some(o1 => oscs.some(o2 =>
|
||||
o1.id !== o2.id && conns.some(c =>
|
||||
c.from.moduleId === o1.id && c.to.moduleId === o2.id && c.to.port === 'freq'
|
||||
)
|
||||
));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sonido audible',
|
||||
desc: 'El oscilador portador está conectado a la salida',
|
||||
test: (mods, conns) => {
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!out) return false;
|
||||
// Something reaches the output
|
||||
return conns.some(c => c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'FM metálico',
|
||||
desc: 'Modulador > 50 Hz (crea timbres FM reales)',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
if (oscs.length < 2) return false;
|
||||
// Find modulator: osc that connects to another osc's freq
|
||||
for (const o1 of oscs) {
|
||||
for (const o2 of oscs) {
|
||||
if (o1.id !== o2.id) {
|
||||
const isModulating = conns.some(c =>
|
||||
c.from.moduleId === o1.id && c.to.moduleId === o2.id && c.to.port === 'freq'
|
||||
);
|
||||
if (isModulating && (o1.params.frequency ?? 440) > 50) return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 4.7 ───────────────
|
||||
{
|
||||
id: 'w4-7',
|
||||
title: 'Modulación Compleja',
|
||||
subtitle: 'Multi-destino',
|
||||
description: 'Los sintetizadores modulares brillan cuando conectas múltiples fuentes de modulación a múltiples destinos. Un LFO al filtro, un envelope al VCA, el keyboard a la frecuencia — cada conexión añade expresividad.',
|
||||
concept: 'Construye un patch con: Keyboard → Osc freq + Env gate. LFO → Filter cutoff. Envelope → VCA cv. Cada fuente de modulación controla un aspecto diferente del sonido.',
|
||||
availableModules: ['lfo', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 60, y: 40, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: false },
|
||||
{ id: 2, type: 'filter', x: 280, y: 40, params: { type: 'lowpass', frequency: 1000, Q: 4 }, locked: false },
|
||||
{ id: 3, type: 'vca', x: 480, y: 40, params: { gain: 0 }, locked: false },
|
||||
{ id: 4, type: 'output', x: 680, y: 60, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1500, Q: 5 },
|
||||
envelope: { attack: 0.1, decay: 0.3, sustain: 0.5, release: 0.4 },
|
||||
lfo: { frequency: 3, type: 'sine', min: 600, max: 3000, target: 'frequency' },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena sustractiva',
|
||||
desc: 'Osc → Filter → VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !vca || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === vca.id) &&
|
||||
conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Tres moduladores',
|
||||
desc: 'LFO, Envelope y Keyboard todos conectados',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!lfo || !env || !kb) return false;
|
||||
const lfoConn = conns.some(c => c.from.moduleId === lfo.id);
|
||||
const envConn = conns.some(c => c.from.moduleId === env.id);
|
||||
const kbConn = conns.some(c => c.from.moduleId === kb.id);
|
||||
return lfoConn && envConn && kbConn;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Routing correcto',
|
||||
desc: 'KB→freq, LFO→cutoff, Env→VCA cv, KB→gate',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (!lfo || !env || !kb || !osc || !flt || !vca) return false;
|
||||
return conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === osc.id && c.to.port === 'freq') &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff') &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv') &&
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id && c.to.port === 'gate');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 4.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w4-8',
|
||||
title: 'Dubstep Wobble',
|
||||
subtitle: 'BOSS: El bajo que wobbles',
|
||||
description: 'El wobble bass de dubstep es modulación pura: un oscilador grave con un filtro lowpass resonante modulado por un LFO. Añade un envelope para el ataque y tienes el sonido que definió un género.',
|
||||
concept: 'Osc saw grave (~55 Hz) → Filter LP resonante → VCA → Output. LFO (~1-3 Hz) → Filter cutoff. Envelope → VCA cv. Keyboard → gate + freq. Q alta (~10+) para ese sonido agresivo.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 400, Q: 10 },
|
||||
envelope: { attack: 0.02, decay: 0.2, sustain: 0.7, release: 0.3 },
|
||||
lfo: { frequency: 1.5, type: 'sine', min: 200, max: 2000, target: 'frequency' },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena con modulación',
|
||||
desc: 'Osc → Filter → VCA → Output con LFO al cutoff',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !vca || !lfo || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === vca.id) &&
|
||||
conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id) &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Wobble bass',
|
||||
desc: 'Osc grave (<130 Hz), LFO lento (1-3 Hz), Q > 8',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!osc || !flt || !lfo) return false;
|
||||
return (osc.params.frequency ?? 440) < 130 &&
|
||||
(lfo.params.frequency ?? 2) >= 1 && (lfo.params.frequency ?? 2) <= 3 &&
|
||||
(flt.params.Q ?? 1) > 8;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Wobble completo',
|
||||
desc: 'Todo lo anterior + Envelope al VCA + Keyboard al gate',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!osc || !flt || !lfo || !env || !vca || !kb) return false;
|
||||
return (osc.params.frequency ?? 440) < 130 &&
|
||||
(flt.params.Q ?? 1) > 8 &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv') &&
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id && c.to.port === 'gate');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
519
packages/client/src/game/levels/world5.js
Normal file
519
packages/client/src/game/levels/world5.js
Normal file
@@ -0,0 +1,519 @@
|
||||
/**
|
||||
* World 5 — "Efectos" (Effects)
|
||||
*
|
||||
* Teaches: delay, reverb, distortion, effect chains, wet/dry mixing
|
||||
* 8 levels, progressive difficulty
|
||||
*/
|
||||
|
||||
export const WORLD_5 = {
|
||||
id: 'w5',
|
||||
name: 'Efectos',
|
||||
subtitle: 'Transforma el sonido con efectos',
|
||||
icon: '◈',
|
||||
color: '#44ff88',
|
||||
unlockStars: 48,
|
||||
levels: [
|
||||
// ─────────────── LEVEL 5.1 ───────────────
|
||||
{
|
||||
id: 'w5-1',
|
||||
title: 'El Eco',
|
||||
subtitle: 'Delay básico',
|
||||
description: 'El delay repite el sonido después de un tiempo. Es como gritar en un cañón y escuchar tu voz rebotando. El delay más simple tiene un tiempo de repetición y un feedback que controla cuántas veces se repite.',
|
||||
concept: 'Conecta: Oscilador → Delay → Output. El knob "Time" controla el tiempo entre repeticiones. El "Feedback" controla cuántas repeticiones. Empieza con un feedback bajo (~0.3).',
|
||||
availableModules: [],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sine', frequency: 440, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'delay', x: 340, y: 80, params: { time: 0.3, feedback: 0.3, mix: 0.5 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'delay', time: 0.35, feedback: 0.4, wet: 0.6 },
|
||||
],
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Delay conectado',
|
||||
desc: 'Oscilador → Delay → Salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !del || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === del.id) &&
|
||||
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Repeticiones',
|
||||
desc: 'Feedback por encima de 0.2',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
return del && (del.params.feedback ?? 0) > 0.2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Eco rítmico',
|
||||
desc: 'Delay time 0.2-0.5s, feedback 0.3-0.6',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
if (!del) return false;
|
||||
const t = del.params.time ?? 0.3;
|
||||
const fb = del.params.feedback ?? 0;
|
||||
return t >= 0.2 && t <= 0.5 && fb >= 0.3 && fb <= 0.6;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 5.2 ───────────────
|
||||
{
|
||||
id: 'w5-2',
|
||||
title: 'Slapback',
|
||||
subtitle: 'El delay rockabilly',
|
||||
description: 'El slapback es un delay muy corto (50-120ms) con una sola repetición. Es el efecto clásico de las voces de Elvis y el rockabilly — da presencia sin crear un eco largo.',
|
||||
concept: 'Delay con tiempo corto (~0.05-0.12s) y feedback muy bajo (~0.1 o menos). Una sola repetición rápida. El mix controla cuánto delay se mezcla con la señal original.',
|
||||
availableModules: ['delay'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'square', frequency: 330, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 330 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'delay', time: 0.08, feedback: 0.05, wet: 0.5 },
|
||||
],
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Delay en la cadena',
|
||||
desc: 'Osc → Delay → Salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !del || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === del.id) &&
|
||||
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Tiempo corto',
|
||||
desc: 'Delay time menor de 0.15s',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
return del && (del.params.time ?? 0.3) < 0.15;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Slapback perfecto',
|
||||
desc: 'Time 0.05-0.12s, feedback < 0.15',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
if (!del) return false;
|
||||
const t = del.params.time ?? 0.3;
|
||||
const fb = del.params.feedback ?? 0.3;
|
||||
return t >= 0.05 && t <= 0.12 && fb < 0.15;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 5.3 ───────────────
|
||||
{
|
||||
id: 'w5-3',
|
||||
title: 'Reverb Espacial',
|
||||
subtitle: 'El sonido del espacio',
|
||||
description: 'La reverb simula el sonido de un espacio acústico — desde una habitación pequeña hasta una catedral enorme. Es quizás el efecto más usado en toda la producción musical.',
|
||||
concept: 'Conecta: Oscilador → Reverb → Output. El knob de "decay" (o room size) controla el tamaño del espacio. Más largo = catedral. Más corto = habitación pequeña.',
|
||||
availableModules: [],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'triangle', frequency: 440, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'reverb', x: 340, y: 80, params: { decay: 2, mix: 0.4 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'triangle', frequency: 440 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 5.5, wet: 0.55 },
|
||||
],
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Reverb conectada',
|
||||
desc: 'Oscilador → Reverb → Salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !rev || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === rev.id) &&
|
||||
conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Espacio grande',
|
||||
desc: 'Decay mayor de 3 segundos',
|
||||
test: (mods) => {
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
return rev && (rev.params.decay ?? 2) > 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Catedral',
|
||||
desc: 'Decay > 5s, mix 0.3-0.6 (no demasiado)',
|
||||
test: (mods) => {
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (!rev) return false;
|
||||
return (rev.params.decay ?? 2) > 5 &&
|
||||
(rev.params.mix ?? 0.5) >= 0.3 && (rev.params.mix ?? 0.5) <= 0.6;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 5.4 ───────────────
|
||||
{
|
||||
id: 'w5-4',
|
||||
title: 'Distorsión',
|
||||
subtitle: 'Rompe la señal',
|
||||
description: 'La distorsión amplifica la señal hasta que se "rompe", creando armónicos nuevos. Desde el overdrive suave de un amplificador de guitarra hasta el fuzz salvaje — la distorsión añade agresividad y presencia.',
|
||||
concept: 'Conecta: Oscilador → Distortion → Output. Sube el "Drive" para más distorsión. Con una onda sine pura, escucharás cómo aparecen armónicos que no estaban antes.',
|
||||
availableModules: ['distortion'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sine', frequency: 220, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 600, y: 100, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 220 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'distortion', amount: 6 },
|
||||
],
|
||||
duration: 2.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Distorsión conectada',
|
||||
desc: 'Osc → Distortion → Salida',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !dist || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === dist.id) &&
|
||||
conns.some(c => c.from.moduleId === dist.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Drive alto',
|
||||
desc: 'Distorsión con drive moderado-alto',
|
||||
test: (mods) => {
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
return dist && (dist.params.drive ?? 1) > 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Fuzz sine',
|
||||
desc: 'Onda sine con drive > 5 (máxima transformación)',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
if (!osc || !dist) return false;
|
||||
return osc.params.waveform === 'sine' && (dist.params.drive ?? 1) > 5;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 5.5 ───────────────
|
||||
{
|
||||
id: 'w5-5',
|
||||
title: 'Cadena de Efectos',
|
||||
subtitle: 'Orden importa',
|
||||
description: 'El orden de los efectos cambia radicalmente el resultado. Distorsión antes de delay suena diferente a delay antes de distorsión. Experimenta encadenando efectos en diferente orden.',
|
||||
concept: 'Prueba: Osc → Distortion → Delay → Output (la distorsión se repite limpia). El orden crea caracteres distintos. Encadena al menos 2 efectos diferentes.',
|
||||
availableModules: ['delay', 'distortion'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 60, y: 80, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 740, y: 100, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'distortion', amount: 3 },
|
||||
{ type: 'delay', time: 0.35, feedback: 0.35, wet: 0.5 },
|
||||
],
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dos efectos',
|
||||
desc: 'Al menos 2 módulos de efecto en la cadena',
|
||||
test: (mods, conns) => {
|
||||
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
return effects.length >= 2 && out && conns.some(c => c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Efectos encadenados',
|
||||
desc: 'Los efectos están conectados en serie (uno al otro)',
|
||||
test: (mods, conns) => {
|
||||
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
|
||||
if (effects.length < 2) return false;
|
||||
// Check if any effect connects to another effect
|
||||
return effects.some(e1 => effects.some(e2 =>
|
||||
e1.id !== e2.id && conns.some(c => c.from.moduleId === e1.id && c.to.moduleId === e2.id)
|
||||
));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Cadena completa',
|
||||
desc: 'Osc → efecto1 → efecto2 → Output (cadena lineal)',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || effects.length < 2 || !out) return false;
|
||||
// Osc → some effect
|
||||
const oscToFx = effects.find(e => conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === e.id));
|
||||
if (!oscToFx) return false;
|
||||
// That effect → another effect
|
||||
const fxToFx = effects.find(e => e.id !== oscToFx.id &&
|
||||
conns.some(c => c.from.moduleId === oscToFx.id && c.to.moduleId === e.id));
|
||||
if (!fxToFx) return false;
|
||||
// Second effect → output
|
||||
return conns.some(c => c.from.moduleId === fxToFx.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 5.6 ───────────────
|
||||
{
|
||||
id: 'w5-6',
|
||||
title: 'Delay + Filtro',
|
||||
subtitle: 'Dub echo',
|
||||
description: 'El sonido dub es delay con feedback alto pasado por un filtro que va quitando agudos. Cada repetición suena más oscura y lejana — es el efecto que definió el reggae dub en los 70.',
|
||||
concept: 'Osc → Delay (feedback alto ~0.5-0.7) → Filter (lowpass, cutoff bajo ~800 Hz) → Output. El filtro después del delay oscurece las repeticiones, creando profundidad.',
|
||||
availableModules: ['delay', 'filter'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 60, y: 80, params: { waveform: 'square', frequency: 330, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 720, y: 100, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 330 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 850, Q: 2 },
|
||||
effects: [
|
||||
{ type: 'delay', time: 0.4, feedback: 0.6, wet: 0.6 },
|
||||
],
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Delay + Filter',
|
||||
desc: 'Osc → Delay → Filter → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !del || !flt || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === del.id) &&
|
||||
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Echo largo',
|
||||
desc: 'Delay feedback > 0.4, time > 0.2s',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
return del && (del.params.feedback ?? 0) > 0.4 && (del.params.time ?? 0.3) > 0.2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Dub echo',
|
||||
desc: 'Feedback 0.5-0.7, filtro lowpass cutoff < 1000 Hz',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!del || !flt) return false;
|
||||
const fb = del.params.feedback ?? 0;
|
||||
return fb >= 0.5 && fb <= 0.7 &&
|
||||
flt.params.type === 'lowpass' && (flt.params.frequency ?? 2000) < 1000;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 5.7 ───────────────
|
||||
{
|
||||
id: 'w5-7',
|
||||
title: 'Shoegaze Wall',
|
||||
subtitle: 'Reverb + Distorsión',
|
||||
description: 'El sonido shoegaze (My Bloody Valentine, Slowdive) es una pared de sonido creada con distorsión y reverb masiva. La distorsión aplasta la señal y la reverb la convierte en una nube etérea.',
|
||||
concept: 'Osc → Distortion (drive medio) → Reverb (decay largo, mix alto) → Output. La combinación de distorsión y reverb crea una textura densa y atmosférica.',
|
||||
availableModules: ['distortion', 'reverb'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'oscillator', x: 60, y: 80, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: false },
|
||||
{ id: 2, type: 'output', x: 720, y: 100, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'distortion', amount: 5 },
|
||||
{ type: 'reverb', decay: 6.5, wet: 0.65 },
|
||||
],
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dist + Reverb',
|
||||
desc: 'Osc → Distortion → Reverb → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !dist || !rev || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === dist.id) &&
|
||||
conns.some(c => c.from.moduleId === dist.id && c.to.moduleId === rev.id) &&
|
||||
conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Pared de sonido',
|
||||
desc: 'Drive > 3, reverb decay > 4s',
|
||||
test: (mods) => {
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (!dist || !rev) return false;
|
||||
return (dist.params.drive ?? 1) > 3 && (rev.params.decay ?? 2) > 4;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Shoegaze perfecto',
|
||||
desc: 'Drive 4-8, decay > 6s, reverb mix > 0.5',
|
||||
test: (mods) => {
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (!dist || !rev) return false;
|
||||
const drive = dist.params.drive ?? 1;
|
||||
return drive >= 4 && drive <= 8 &&
|
||||
(rev.params.decay ?? 2) > 6 && (rev.params.mix ?? 0.5) > 0.5;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 5.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w5-8',
|
||||
title: 'Ambient Scape',
|
||||
subtitle: 'BOSS: Paisaje sonoro',
|
||||
description: 'Crea un paisaje sonoro ambient completo: un sonido que evoluciona lentamente, envuelto en efectos. Combina osciladores, filtros, modulación y efectos para crear una textura atmosférica.',
|
||||
concept: 'Osc → Filter (LFO al cutoff) → Delay → Reverb → Output. Envelope al VCA para control. Experimenta con tiempos largos, feedback alto, y modulación lenta para un sonido que "flota".',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'delay', 'reverb', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 110 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1200, Q: 3 },
|
||||
envelope: { attack: 0.5, decay: 0.3, sustain: 0.6, release: 1.5 },
|
||||
lfo: { frequency: 0.5, type: 'sine', min: 400, max: 4000, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'delay', time: 0.5, feedback: 0.5, wet: 0.5 },
|
||||
{ type: 'reverb', decay: 5, wet: 0.6 },
|
||||
],
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Cadena con efectos',
|
||||
desc: 'Al menos un efecto (delay/reverb) conectado a la salida',
|
||||
test: (mods, conns) => {
|
||||
const effects = mods.filter(m => ['delay', 'reverb'].includes(m.type));
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (effects.length === 0 || !out) return false;
|
||||
return effects.some(e => conns.some(c => c.from.moduleId === e.id && c.to.moduleId === out.id)) ||
|
||||
conns.some(c => c.to.moduleId === out.id && effects.some(e => c.from.moduleId === e.id));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Modulación + Efectos',
|
||||
desc: 'Tiene oscilador, filtro, y al menos 2 efectos conectados',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
|
||||
if (!osc || !flt || effects.length < 2) return false;
|
||||
// Check osc is connected to something
|
||||
return conns.some(c => c.from.moduleId === osc.id) && effects.length >= 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Paisaje completo',
|
||||
desc: 'Osc+Filter+LFO(cutoff)+Delay+Reverb, todo conectado',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !lfo || !del || !rev || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff') &&
|
||||
conns.some(c => c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
554
packages/client/src/game/levels/world6.js
Normal file
554
packages/client/src/game/levels/world6.js
Normal file
@@ -0,0 +1,554 @@
|
||||
/**
|
||||
* World 6 — "Diseño Sonoro" (Sound Design Mastery)
|
||||
*
|
||||
* Teaches: putting it ALL together, real-world sound recreation
|
||||
* 8 levels, boss challenges combining everything learned
|
||||
*/
|
||||
|
||||
export const WORLD_6 = {
|
||||
id: 'w6',
|
||||
name: 'Diseño Sonoro',
|
||||
subtitle: 'Combina todo para crear sonidos reales',
|
||||
icon: '◉',
|
||||
color: '#ff44aa',
|
||||
unlockStars: 60,
|
||||
levels: [
|
||||
// ─────────────── LEVEL 6.1 ───────────────
|
||||
{
|
||||
id: 'w6-1',
|
||||
title: 'Kick Drum',
|
||||
subtitle: 'El latido del beat',
|
||||
description: 'Un kick sintético se crea con un oscilador sine a frecuencia baja + un envelope muy rápido en el VCA para el golpe. Algunos añaden un pitch envelope para el "click" del ataque.',
|
||||
concept: 'Osc sine a ~55 Hz → VCA → Output. Envelope con attack 0, decay ~0.2s, sustain 0. El envelope al VCA crea el golpe. Para el click: un segundo osc más agudo con decay ultra-corto.',
|
||||
availableModules: ['oscillator', 'vca', 'envelope', 'keyboard', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55 } },
|
||||
],
|
||||
envelope: { attack: 0, decay: 0.25, sustain: 0, release: 0.1 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Kick básico',
|
||||
desc: 'Osc sine grave + VCA + Envelope → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !vca || !env || !out) return false;
|
||||
return (osc.params.frequency ?? 440) < 100 &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Punch',
|
||||
desc: 'Sine < 80 Hz, envelope rápido (attack < 0.01, decay < 0.3)',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!osc || !env) return false;
|
||||
return (osc.params.frequency ?? 440) < 80 &&
|
||||
osc.params.waveform === 'sine' &&
|
||||
(env.params.attack ?? 0.01) < 0.01 &&
|
||||
(env.params.decay ?? 0.2) < 0.3 &&
|
||||
(env.params.sustain ?? 0.5) < 0.1;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: '808 Kick',
|
||||
desc: 'Frecuencia 40-60 Hz, decay 0.15-0.4s, keyboard conectado',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!osc || !env || !kb) return false;
|
||||
const freq = osc.params.frequency ?? 440;
|
||||
const decay = env.params.decay ?? 0.2;
|
||||
return freq >= 40 && freq <= 60 && decay >= 0.15 && decay <= 0.4 &&
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id && c.to.port === 'gate');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 6.2 ───────────────
|
||||
{
|
||||
id: 'w6-2',
|
||||
title: 'Hi-Hat',
|
||||
subtitle: 'Noise + Filtro + Envelope',
|
||||
description: 'Los hi-hats son ruido blanco filtrado con un envelope corto. El ruido proporciona la textura metálica, el filtro highpass quita los graves, y el envelope corto le da el "tss".',
|
||||
concept: 'Noise → Filter (highpass, cutoff alto ~6000+ Hz) → VCA → Output. Envelope corto (attack 0, decay ~0.05-0.15s, sustain 0) al VCA. Keyboard al gate del envelope.',
|
||||
availableModules: ['noise', 'filter', 'vca', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'highpass', frequency: 7000, Q: 2 },
|
||||
envelope: { attack: 0, decay: 0.08, sustain: 0, release: 0 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Ruido filtrado',
|
||||
desc: 'Noise → Filter → VCA → Output con envelope',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!noise || !flt || !vca || !env) return false;
|
||||
return conns.some(c => c.from.moduleId === noise.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sonido metálico',
|
||||
desc: 'Filtro highpass, cutoff > 4000 Hz',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
return flt && flt.params.type === 'highpass' && (flt.params.frequency ?? 1000) > 4000;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Hi-hat cerrado',
|
||||
desc: 'HP > 6000 Hz, envelope ultra-corto (decay < 0.1s)',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!flt || !env) return false;
|
||||
return flt.params.type === 'highpass' && (flt.params.frequency ?? 1000) > 6000 &&
|
||||
(env.params.decay ?? 0.2) < 0.1 && (env.params.sustain ?? 0.5) < 0.05;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 6.3 ───────────────
|
||||
{
|
||||
id: 'w6-3',
|
||||
title: 'Snare',
|
||||
subtitle: 'Tono + Ruido',
|
||||
description: 'Un snare es la combinación de un cuerpo tonal (oscilador) y una cola de ruido (noise). Se mezclan juntos con envelopes diferentes — el tono muere rápido y el ruido un poco después.',
|
||||
concept: 'Dos cadenas: 1) Osc sine (~200 Hz) → VCA1 → Mixer. 2) Noise → Filter HP → VCA2 → Mixer. Mixer → Output. Envelopes diferentes: el tono más corto que el ruido.',
|
||||
availableModules: ['oscillator', 'noise', 'filter', 'vca', 'envelope', 'keyboard', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 200 } },
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'highpass', frequency: 3000, Q: 1.5 },
|
||||
envelope: { attack: 0, decay: 0.12, sustain: 0, release: 0.05 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dos fuentes',
|
||||
desc: 'Oscilador Y Noise, ambos al mixer → output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
return osc && noise && mixer && out &&
|
||||
conns.some(c => c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Envelopes',
|
||||
desc: 'Al menos 2 envelopes controlando VCAs',
|
||||
test: (mods, conns) => {
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const vcas = mods.filter(m => m.type === 'vca');
|
||||
if (envs.length < 2 || vcas.length < 2) return false;
|
||||
const envToVca = envs.filter(e =>
|
||||
vcas.some(v => conns.some(c => c.from.moduleId === e.id && c.to.moduleId === v.id && c.to.port === 'cv'))
|
||||
);
|
||||
return envToVca.length >= 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Snare realista',
|
||||
desc: 'Osc ~150-250 Hz, noise filtrado HP, decays diferentes',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
if (!osc || !flt || envs.length < 2) return false;
|
||||
const freq = osc.params.frequency ?? 440;
|
||||
const decays = envs.map(e => e.params.decay ?? 0.2);
|
||||
return freq >= 150 && freq <= 250 &&
|
||||
flt.params.type === 'highpass' &&
|
||||
Math.abs(decays[0] - decays[1]) > 0.03;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 6.4 ───────────────
|
||||
{
|
||||
id: 'w6-4',
|
||||
title: 'Pad Espacial',
|
||||
subtitle: 'Capas + Efectos',
|
||||
description: 'Un pad espacial combina múltiples osciladores detuned, un filtro suave, un envelope lento, y efectos de reverb/delay para crear una textura inmersiva que rellena todo el espectro.',
|
||||
concept: 'Dos oscs saw detuned → Mixer → Filter LP → VCA → Reverb → Output. Envelope lento al VCA. LFO lento al cutoff. Reverb con decay largo. El resultado: un colchón de sonido etéreo.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'reverb', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: -8 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 8 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1500, Q: 3 },
|
||||
envelope: { attack: 1, decay: 0.4, sustain: 0.7, release: 2 },
|
||||
lfo: { frequency: 0.6, type: 'sine', min: 600, max: 3500, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 5.5, wet: 0.6 },
|
||||
],
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Múltiples osciladores',
|
||||
desc: 'Al menos 2 osciladores mezclados',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
return oscs.length >= 2 && mixer;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Con efectos',
|
||||
desc: 'Reverb en la cadena con decay > 3s',
|
||||
test: (mods) => {
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
return rev && (rev.params.decay ?? 2) > 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Pad completo',
|
||||
desc: '2+ oscs detuned, filtro, LFO al cutoff, envelope al VCA, reverb',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (oscs.length < 2 || !flt || !lfo || !env || !vca || !rev) return false;
|
||||
// Check detune
|
||||
const hasDetune = oscs.some(o => Math.abs(o.params.detune ?? 0) > 2);
|
||||
// Check LFO to cutoff
|
||||
const lfoToCutoff = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
// Check env to VCA
|
||||
const envToVca = conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
return hasDetune && lfoToCutoff && envToVca;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 6.5 ───────────────
|
||||
{
|
||||
id: 'w6-5',
|
||||
title: 'Bajo Reese',
|
||||
subtitle: 'El bajo de Drum & Bass',
|
||||
description: 'El Reese bass es un bajo icónico del Drum & Bass: dos osciladores sawtooth detuned a frecuencia grave, pasados por un filtro lowpass que se abre y cierra. Es gordo, agresivo y hipnótico.',
|
||||
concept: 'Dos oscs sawtooth a ~55 Hz, uno con detune +7-12. Mixer → Filter LP resonante → VCA → Output. LFO lento (~0.3-1 Hz) al cutoff del filtro. El "movimiento" del filtro es lo que le da vida.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'mixer', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: -9 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: 9 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 400, Q: 8 },
|
||||
lfo: { frequency: 0.7, type: 'sine', min: 200, max: 2000, target: 'frequency' },
|
||||
envelope: { attack: 0.05, decay: 0.2, sustain: 0.6, release: 0.3 },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dos sierras graves',
|
||||
desc: '2 osciladores saw < 100 Hz mezclados',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
if (oscs.length < 2) return false;
|
||||
return oscs.filter(o => o.params.waveform === 'sawtooth' && (o.params.frequency ?? 440) < 100).length >= 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Detune + Filtro',
|
||||
desc: 'Osciladores detuned, filtro LP en la cadena',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sawtooth');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (oscs.length < 2 || !flt) return false;
|
||||
const hasDetune = oscs.some(o => Math.abs(o.params.detune ?? 0) > 3);
|
||||
return hasDetune && flt.params.type === 'lowpass';
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Reese Bass',
|
||||
desc: 'Detuned saws + LP resonante + LFO al cutoff',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sawtooth');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (oscs.length < 2 || !flt || !lfo) return false;
|
||||
const hasDetune = oscs.some(o => Math.abs(o.params.detune ?? 0) > 3);
|
||||
const isLPres = flt.params.type === 'lowpass' && (flt.params.Q ?? 1) > 3;
|
||||
const lfoToCut = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
return hasDetune && isLPres && lfoToCut;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 6.6 ───────────────
|
||||
{
|
||||
id: 'w6-6',
|
||||
title: 'Efecto Laser',
|
||||
subtitle: 'Pew pew!',
|
||||
description: 'El sonido laser clásico de los juegos retro es un oscilador cuya frecuencia baja rápidamente — un pitch sweep descendente. Se consigue con un envelope que modula la frecuencia del oscilador.',
|
||||
concept: 'Osc square/saw → VCA → Output. Envelope al VCA (ataque rápido, decay corto). Un SEGUNDO envelope a la frecuencia del osc (empieza agudo y baja rápido). Keyboard dispara ambos.',
|
||||
availableModules: ['oscillator', 'vca', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 440 } },
|
||||
],
|
||||
envelope: { attack: 0.01, decay: 0.15, sustain: 0.05, release: 0.1 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Sonido con envelope',
|
||||
desc: 'Osc → VCA → Output con envelope y keyboard',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
return osc && vca && env && kb && out &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Pitch envelope',
|
||||
desc: 'Un envelope conectado a la frecuencia del oscilador',
|
||||
test: (mods, conns) => {
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
if (!osc || envs.length < 2) return false;
|
||||
return envs.some(e => conns.some(c =>
|
||||
c.from.moduleId === e.id && c.to.moduleId === osc.id && c.to.port === 'freq'
|
||||
));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Pew pew!',
|
||||
desc: 'Osc square/saw, pitch env corto (decay < 0.2s), keyboard a ambos gates',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!osc || envs.length < 2 || !kb) return false;
|
||||
const wave = osc.params.waveform;
|
||||
const pitchEnv = envs.find(e => conns.some(c =>
|
||||
c.from.moduleId === e.id && c.to.moduleId === osc.id && c.to.port === 'freq'
|
||||
));
|
||||
if (!pitchEnv) return false;
|
||||
const gated = envs.filter(e =>
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === e.id && c.to.port === 'gate')
|
||||
);
|
||||
return (wave === 'square' || wave === 'sawtooth') &&
|
||||
(pitchEnv.params.decay ?? 0.2) < 0.2 &&
|
||||
gated.length >= 2;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 6.7 ───────────────
|
||||
{
|
||||
id: 'w6-7',
|
||||
title: 'Arpegio Trance',
|
||||
subtitle: 'Secuenciador + Synth',
|
||||
description: 'Los arpegios de trance son notas rápidas que crean patrones hipnóticos. Usa el secuenciador para disparar notas en el sintetizador con un envelope corto y un filtro que sube y baja.',
|
||||
concept: 'Sequencer → Osc freq + Envelope gate. Osc → Filter → VCA → Delay → Output. Envelope corto al VCA (pluck). LFO lento al cutoff del filtro. El delay repite el patrón.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'delay', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 330 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2000, Q: 4 },
|
||||
envelope: { attack: 0.005, decay: 0.15, sustain: 0.1, release: 0.08 },
|
||||
lfo: { frequency: 1.5, type: 'sine', min: 1000, max: 4000, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'delay', time: 0.25, feedback: 0.35, wet: 0.45 },
|
||||
],
|
||||
triggerPattern: { interval: 0.25, count: 16 },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Secuenciador activo',
|
||||
desc: 'Sequencer conectado al oscilador',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
if (!seq || !osc) return false;
|
||||
return conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === osc.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Synth con envolvente',
|
||||
desc: 'Osc → Filter → VCA → Output con envelope al VCA',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !vca || !env || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Trance completo',
|
||||
desc: 'Sequencer + synth sustractivo completo + delay',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!seq || !osc || !flt || !vca || !del || !env) return false;
|
||||
return conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === osc.id) &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 6.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w6-8',
|
||||
title: 'Tu Sintetizador',
|
||||
subtitle: 'BOSS FINAL: Diseña tu propio sonido',
|
||||
description: 'Has aprendido osciladores, filtros, envelopes, modulación y efectos. Ahora construye el sintetizador más completo que puedas. Sin restricciones. Sin guía. Solo tu creatividad y todo lo que has aprendido.',
|
||||
concept: 'Construye un patch completo con al menos: 2 osciladores, 1 filtro, 1 VCA, 2 envelopes, 1 LFO, 1 efecto, y un keyboard. ¡Hazlo sonar increíble!',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'noise', 'keyboard', 'delay', 'reverb', 'distortion', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 950, y: 160, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 165, detune: -6 } },
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 165, detune: 6 } },
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1800, Q: 6 },
|
||||
envelope: { attack: 0.2, decay: 0.4, sustain: 0.5, release: 0.8 },
|
||||
lfo: { frequency: 2, type: 'sine', min: 0.3, max: 1.2, target: 'amplitude' },
|
||||
effects: [
|
||||
{ type: 'distortion', amount: 2 },
|
||||
{ type: 'delay', time: 0.3, feedback: 0.4, wet: 0.4 },
|
||||
{ type: 'reverb', decay: 3.5, wet: 0.45 },
|
||||
],
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Patch funcional',
|
||||
desc: 'Al menos 5 módulos conectados con sonido a la salida',
|
||||
test: (mods, conns) => {
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!out) return false;
|
||||
// Count non-output modules
|
||||
const modCount = mods.filter(m => m.type !== 'output').length;
|
||||
// Something reaches output
|
||||
const hasOutput = conns.some(c => c.to.moduleId === out.id);
|
||||
return modCount >= 5 && hasOutput && conns.length >= 5;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Síntesis completa',
|
||||
desc: 'Tiene osc + filtro + VCA + envelope + efecto, todos conectados',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
|
||||
if (!osc || !flt || !vca || !env || effects.length === 0) return false;
|
||||
// All main pieces should have connections
|
||||
const oscConn = conns.some(c => c.from.moduleId === osc.id);
|
||||
const envConn = conns.some(c => c.from.moduleId === env.id);
|
||||
return oscConn && envConn && conns.length >= 7;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Maestro del Sonido',
|
||||
desc: '8+ módulos, 2+ oscs, 2+ envelopes, LFO, efecto, keyboard — ¡todo!',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
|
||||
const nonOutput = mods.filter(m => m.type !== 'output');
|
||||
return nonOutput.length >= 8 && oscs.length >= 2 && envs.length >= 2 &&
|
||||
lfo && kb && effects.length >= 1 && conns.length >= 10;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
547
packages/client/src/game/levels/world7.js
Normal file
547
packages/client/src/game/levels/world7.js
Normal file
@@ -0,0 +1,547 @@
|
||||
/**
|
||||
* World 7 — "Secuencias y Ritmos" (Sequences and Rhythms)
|
||||
*
|
||||
* Teaches: sequencer basics, bass sequences, pluck sequences, filtered sequences,
|
||||
* basic drum machines, swing, effects on sequences
|
||||
* 8 levels + boss challenge: "Beat Completo" (Full beat with bass + drums + effects)
|
||||
*/
|
||||
|
||||
export const WORLD_7 = {
|
||||
id: 'w7',
|
||||
name: 'Secuencias y Ritmos',
|
||||
subtitle: 'Programando patrones',
|
||||
icon: '▦',
|
||||
color: '#ff8800',
|
||||
unlockStars: 72,
|
||||
levels: [
|
||||
// ─────────────── LEVEL 7.1 ───────────────
|
||||
{
|
||||
id: 'w7-1',
|
||||
title: 'Primer Secuenciador',
|
||||
subtitle: 'Notas en secuencia',
|
||||
description: 'El secuenciador es como un metrónomo que dispara notas en un patrón. Cada paso tiene una nota. Conéctalo a un oscilador y tendrás una melodía que se repite.',
|
||||
concept: 'Sequencer → Osc freq. Osc → VCA → Output. Envelope dispara el VCA. El resultado: una melodía secuenciada.',
|
||||
availableModules: ['oscillator', 'vca', 'envelope', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 330 } },
|
||||
],
|
||||
envelope: { attack: 0.01, decay: 0.12, sustain: 0, release: 0.05 },
|
||||
triggerPattern: { interval: 0.5, count: 4 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Secuenciador conectado',
|
||||
desc: 'Sequencer → Osc → VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!seq || !osc || !vca || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === osc.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sonido rítmico',
|
||||
desc: 'Envelope dispara el VCA en cadencia',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (!seq || !env || !vca) return false;
|
||||
return conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === env.id && c.to.port === 'gate') &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Secuencia limpia',
|
||||
desc: 'Sequencer con BPM 140, oscilador sine, envelope corto (decay < 0.2s)',
|
||||
test: (mods) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!seq || !osc || !env) return false;
|
||||
return (seq.params.bpm ?? 140) === 140 &&
|
||||
osc.params.waveform === 'sine' &&
|
||||
(env.params.decay ?? 0.2) < 0.2;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 7.2 ───────────────
|
||||
{
|
||||
id: 'w7-2',
|
||||
title: 'Bajo Secuenciado',
|
||||
subtitle: 'Riffs graves y hipnóticos',
|
||||
description: 'Un riff de bajo es una frase corta repetida. Usa el secuenciador con un oscilador grave para crear un riff clásico — sawtooth detuned, filtro animado, sonido gordo.',
|
||||
concept: 'Secuenciador → Dos oscs saw (~55 Hz) detuned → Filter LP → VCA → Output. Envelope al VCA. LFO lento al cutoff. Hipnótico y gordo.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'sequencer', 'mixer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: -8 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: 8 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 600, Q: 5 },
|
||||
envelope: { attack: 0.02, decay: 0.15, sustain: 0.3, release: 0.1 },
|
||||
lfo: { frequency: 1, type: 'sine', min: 300, max: 1500, target: 'frequency' },
|
||||
triggerPattern: { interval: 1, count: 3 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Bajo grave',
|
||||
desc: 'Sequencer a oscilador < 100 Hz',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
if (!seq || oscs.length === 0) return false;
|
||||
return oscs.some(o => (o.params.frequency ?? 440) < 100) &&
|
||||
conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === oscs[0].id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Detuned y filtrado',
|
||||
desc: '2 oscs sawtooth detuned, filtro lowpass',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sawtooth');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (oscs.length < 2 || !flt) return false;
|
||||
const hasDetune = oscs.some(o => Math.abs(o.params.detune ?? 0) > 2);
|
||||
return hasDetune && flt.params.type === 'lowpass';
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Riff completo',
|
||||
desc: 'Detuned saws < 70 Hz + LP + LFO al cutoff + envelope corto (decay < 0.3s)',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sawtooth');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (oscs.length < 2 || !flt || !lfo || !env) return false;
|
||||
const allGrave = oscs.every(o => (o.params.frequency ?? 440) < 70);
|
||||
const hasDetune = oscs.some(o => Math.abs(o.params.detune ?? 0) > 2);
|
||||
const lfoToFlt = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
return allGrave && hasDetune && lfoToFlt && (env.params.decay ?? 0.2) < 0.3;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 7.3 ───────────────
|
||||
{
|
||||
id: 'w7-3',
|
||||
title: 'Sonido Pluck',
|
||||
subtitle: 'Notas percusivas secuenciadas',
|
||||
description: 'Un pluck es una nota corta y percusiva que decae rápido — como una gota de agua. Muy usado en lo que se llama "pluck bass" o "pluck lead". El secuenciador lo lanza en cadencia.',
|
||||
concept: 'Sequencer → Osc freq + Envelope gate. Osc square → Filter LP → VCA → Output. Envelope corto (attack 0, decay ~0.15s). LFO moderado al cutoff. El resultado: un sonido de gota de agua que repica.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 750, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2000, Q: 2 },
|
||||
envelope: { attack: 0.005, decay: 0.15, sustain: 0.05, release: 0.08 },
|
||||
lfo: { frequency: 2.5, type: 'sine', min: 1000, max: 4000, target: 'frequency' },
|
||||
triggerPattern: { interval: 0.375, count: 7 },
|
||||
duration: 2.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Pluck básico',
|
||||
desc: 'Sequencer → Osc → Filter → VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (!seq || !osc || !flt || !vca) return false;
|
||||
return conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === osc.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === vca.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Percusivo',
|
||||
desc: 'Envelope muy corto (decay < 0.2s, sustain < 0.1)',
|
||||
test: (mods) => {
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!env) return false;
|
||||
return (env.params.decay ?? 0.2) < 0.2 && (env.params.sustain ?? 0.5) < 0.1;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Pluck líquido',
|
||||
desc: 'Square osc, filtro LP + LFO al cutoff, envelope (attack 0, decay 0.1-0.2s)',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!seq || !osc || !flt || !lfo || !env) return false;
|
||||
const decay = env.params.decay ?? 0.2;
|
||||
const lfoToFlt = conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
return osc.params.waveform === 'square' && flt.params.type === 'lowpass' &&
|
||||
(env.params.attack ?? 0.01) <= 0.01 && decay >= 0.1 && decay <= 0.2 && lfoToFlt;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 7.4 ───────────────
|
||||
{
|
||||
id: 'w7-4',
|
||||
title: 'Secuencia Filtrada',
|
||||
subtitle: 'Caja de ritmo sintética',
|
||||
description: 'Una variación del secuenciador: envía frecuencias a un filtro en lugar de (o además de) un oscilador. Esto crea sonidos únicos — casi como un sintetizador de ritmos donde el sonido source es fijo pero el filtro lo transforma.',
|
||||
concept: 'Noise → Filter LP → VCA → Output. Sequencer al cutoff del filtro (modula en tiempo real). Envelope al VCA. El resultado: un instrumento de ritmo completamente nuevo.',
|
||||
availableModules: ['noise', 'filter', 'vca', 'envelope', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1000, Q: 3.5 },
|
||||
envelope: { attack: 0, decay: 0.1, sustain: 0, release: 0 },
|
||||
triggerPattern: { interval: 0.5, count: 4 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Noise secuenciado',
|
||||
desc: 'Sequencer modula el cutoff del filtro',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!seq || !noise || !flt) return false;
|
||||
return conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Con envelope',
|
||||
desc: 'Noise → Filter → VCA con envelope al VCA',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!noise || !flt || !vca || !env) return false;
|
||||
return conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === vca.id) &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Caja de ritmo',
|
||||
desc: 'Sequencer 16 steps, filtro con resonancia (Q > 2), envelope corto',
|
||||
test: (mods) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!seq || !flt || !env) return false;
|
||||
return (seq.params.steps ?? '16') === '16' &&
|
||||
(flt.params.Q ?? 1) > 2 &&
|
||||
(env.params.decay ?? 0.2) < 0.15;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 7.5 ───────────────
|
||||
{
|
||||
id: 'w7-5',
|
||||
title: 'Kick Secuenciado',
|
||||
subtitle: 'El corazón del beat',
|
||||
description: 'Ahora combina lo aprendido: usa el secuenciador para disparar un kick drum completo. El kick es simple: oscilador sine grave + envelope rápido. El secuenciador lo mantiene en ritmo.',
|
||||
concept: 'Sequencer gate → Envelope → Osc sine (40-60 Hz) + VCA → Output. El envelope dispara en cada paso. Parecido al kick de la sección anterior, pero secuenciado.',
|
||||
availableModules: ['oscillator', 'vca', 'envelope', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 50 } },
|
||||
],
|
||||
envelope: { attack: 0, decay: 0.3, sustain: 0, release: 0.1 },
|
||||
triggerPattern: { interval: 1, count: 2 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Kick básico',
|
||||
desc: 'Sequencer → Envelope → Osc + VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!seq || !osc || !vca || !env) return false;
|
||||
return conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === env.id && c.to.port === 'gate');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sine grave',
|
||||
desc: 'Oscilador sine < 100 Hz, envelope rápido (decay < 0.4s)',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!osc || !env) return false;
|
||||
return osc.params.waveform === 'sine' &&
|
||||
(osc.params.frequency ?? 440) < 100 &&
|
||||
(env.params.decay ?? 0.2) < 0.4;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: '808 rítmico',
|
||||
desc: 'Sine 40-60 Hz, decay 0.2-0.4s, sequencer 140 BPM',
|
||||
test: (mods) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!seq || !osc || !env) return false;
|
||||
const freq = osc.params.frequency ?? 440;
|
||||
const decay = env.params.decay ?? 0.2;
|
||||
return (seq.params.bpm ?? 140) === 140 &&
|
||||
freq >= 40 && freq <= 60 &&
|
||||
decay >= 0.2 && decay <= 0.4;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 7.6 ───────────────
|
||||
{
|
||||
id: 'w7-6',
|
||||
title: 'Swing y Shuffle',
|
||||
subtitle: 'Humaniza tu beat',
|
||||
description: 'El swing es el parámetro que hace que un beat metrónomico suene más humano — desplaza ligeramente ciertos pasos. El shuffle crea ese groove de jazz o swing hip-hop. El secuenciador tiene ambos.',
|
||||
concept: 'Sequencer con swing > 0 crea una sensación de shuffle. Úsalo en un patrón simple: kick, hi-hat, snare. El resultado: una música que fluye, no una máquina rígida.',
|
||||
availableModules: ['noise', 'filter', 'oscillator', 'vca', 'envelope', 'sequencer'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 750, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55 } },
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'highpass', frequency: 6500, Q: 1.5 },
|
||||
envelope: { attack: 0, decay: 0.08, sustain: 0, release: 0 },
|
||||
triggerPattern: { interval: 0.5, count: 5 },
|
||||
duration: 2.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Sequenciador con swing',
|
||||
desc: 'Sequencer con parámetro swing > 0',
|
||||
test: (mods) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
if (!seq) return false;
|
||||
return (seq.params.swing ?? 0) > 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Dos capas de ritmo',
|
||||
desc: 'Al menos 2 fuentes de sonido (kick + hi-hat, por ejemplo)',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const vcas = mods.filter(m => m.type === 'vca');
|
||||
if (!seq) return false;
|
||||
const sources = (oscs.length > 0 ? 1 : 0) + (noise ? 1 : 0);
|
||||
return sources >= 2 && vcas.length >= 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Groove profesional',
|
||||
desc: 'Swing 15+, 2+ fuentes, envelope distintos (uno corto, uno más largo)',
|
||||
test: (mods) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
if (!seq || envs.length < 2) return false;
|
||||
const swing = seq.params.swing ?? 0;
|
||||
const decays = envs.map(e => e.params.decay ?? 0.2);
|
||||
const decayDiff = Math.max(...decays) - Math.min(...decays);
|
||||
return swing >= 15 && decayDiff > 0.05;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 7.7 ───────────────
|
||||
{
|
||||
id: 'w7-7',
|
||||
title: 'Delay en Secuencia',
|
||||
subtitle: 'Ecos secuenciados',
|
||||
description: 'Añade un delay a una secuencia. El delay repite el sonido secuenciado, creando una cola de ecos que se desvanecen. Muy usado en trance, techno y música electrónica para darle profundidad.',
|
||||
concept: 'Secuencia normal → Delay → Output. El delay time se puede sincronizar al BPM del secuenciador para ecos en tiempo. Feedback controla cuántas repeticiones. Wet controla qué tan presente están los ecos.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'sequencer', 'delay'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1500, Q: 2 },
|
||||
envelope: { attack: 0.01, decay: 0.12, sustain: 0, release: 0.05 },
|
||||
effects: [
|
||||
{ type: 'delay', time: 0.35, feedback: 0.5, wet: 0.55 },
|
||||
],
|
||||
triggerPattern: { interval: 0.5, count: 6 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Delay en cadena',
|
||||
desc: 'Sequencer → Osc → VCA → Delay → Output',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!seq || !del || !out) return false;
|
||||
return conns.some(c => c.to.moduleId === del.id) &&
|
||||
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Con retroalimentación',
|
||||
desc: 'Delay con feedback > 0.3 para ecos múltiples',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
if (!del) return false;
|
||||
return (del.params.feedback ?? 0.4) > 0.3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Eco profundo',
|
||||
desc: 'Delay time 0.2-0.5s, feedback 0.4-0.8, wet > 0.4, filtro en la cadena',
|
||||
test: (mods) => {
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!del || !flt) return false;
|
||||
const delayTime = del.params.delayTime ?? 0.3;
|
||||
const feedback = del.params.feedback ?? 0.4;
|
||||
const wet = del.params.wet ?? 0.5;
|
||||
return delayTime >= 0.2 && delayTime <= 0.5 &&
|
||||
feedback >= 0.4 && feedback <= 0.8 &&
|
||||
wet > 0.4;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 7.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w7-8',
|
||||
title: 'Beat Completo',
|
||||
subtitle: 'BOSS FINAL: Tu canción',
|
||||
description: 'Ahora junta todo: un secuenciador principal, un kick, un hi-hat, un bajo secuenciado y al menos un efecto. Crea un beat completo que suene profesional — ritmo, groove, profundidad.',
|
||||
concept: 'Secuenciador 140 BPM con swing. Kick drum (sine < 60 Hz + envelope rápido). Hi-hat (noise + filter HP + envelope corto). Bajo secuenciado (2 oscs detuned + filter). Delay o reverb. Mixer si es necesario.',
|
||||
availableModules: ['oscillator', 'noise', 'filter', 'vca', 'envelope', 'sequencer', 'mixer', 'delay', 'reverb', 'distortion', 'lfo'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 50 } },
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: -8 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: 8 } },
|
||||
],
|
||||
filter: { type: 'highpass', frequency: 7000, Q: 1.5 },
|
||||
envelope: { attack: 0, decay: 0.15, sustain: 0, release: 0.05 },
|
||||
effects: [
|
||||
{ type: 'delay', time: 0.3, feedback: 0.4, wet: 0.35 },
|
||||
{ type: 'reverb', decay: 2.5, wet: 0.25 },
|
||||
],
|
||||
triggerPattern: { interval: 1, count: 5 },
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Beat funcional',
|
||||
desc: 'Sequencer + 3 capas de sonido (kick, hi-hat, bass) → Output',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
const vcas = mods.filter(m => m.type === 'vca');
|
||||
if (!seq || !out || vcas.length < 3) return false;
|
||||
const modCount = mods.filter(m => m.type !== 'output').length;
|
||||
return modCount >= 10 && conns.some(c => c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Kick + Hi-hat + Bajo',
|
||||
desc: 'Oscillator sine + noise + 2 oscs detuned, todos con envelopes',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
if (oscs.length < 3 || !noise || envs.length < 3) return false;
|
||||
const sines = oscs.filter(o => o.params.waveform === 'sine');
|
||||
const detuned = oscs.filter(o => Math.abs(o.params.detune ?? 0) > 2);
|
||||
return sines.length > 0 && detuned.length >= 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Maestro del Ritmo',
|
||||
desc: '140 BPM, swing 15+, kick sine < 60 Hz, hi-hat noise HP > 5000 Hz, bass detuned, delay o reverb',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const sineOscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sine');
|
||||
const detunedOscs = mods.filter(m => m.type === 'oscillator' && Math.abs(m.params.detune ?? 0) > 2);
|
||||
const hpFilter = mods.find(m => m.type === 'filter' && m.params.type === 'highpass');
|
||||
const effects = mods.filter(m => ['delay', 'reverb'].includes(m.type));
|
||||
if (!seq || sineOscs.length === 0 || detunedOscs.length < 2 || !hpFilter || effects.length === 0) return false;
|
||||
const kick = sineOscs.find(o => (o.params.frequency ?? 440) < 60);
|
||||
const hpCutoff = hpFilter.params.frequency ?? 1000;
|
||||
return (seq.params.bpm ?? 140) === 140 &&
|
||||
(seq.params.swing ?? 0) >= 15 &&
|
||||
kick !== undefined &&
|
||||
hpCutoff > 5000;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
537
packages/client/src/game/levels/world8.js
Normal file
537
packages/client/src/game/levels/world8.js
Normal file
@@ -0,0 +1,537 @@
|
||||
/**
|
||||
* World 8 — "Texturas de Ruido" (Noise Textures)
|
||||
*
|
||||
* Teaches: noise types, wind sounds (bandpass), ocean waves (LFO on cutoff),
|
||||
* rain (noise + short envelope), radio static (noise + distortion),
|
||||
* industrial rhythm (noise + LFO on VCA), ambient texture (noise + reverb + delay)
|
||||
* 8 levels + boss challenge: "Paisaje Sonoro" (Soundscape)
|
||||
*/
|
||||
|
||||
export const WORLD_8 = {
|
||||
id: 'w8',
|
||||
name: 'Texturas de Ruido',
|
||||
subtitle: 'Más allá de las notas',
|
||||
icon: '⣿',
|
||||
color: '#88aaff',
|
||||
unlockStars: 84,
|
||||
levels: [
|
||||
// ─────────────── LEVEL 8.1 ───────────────
|
||||
{
|
||||
id: 'w8-1',
|
||||
title: 'Ruido Blanco',
|
||||
subtitle: 'El sonido puro',
|
||||
description: 'El ruido blanco es aleatoriedad pura — todas las frecuencias con igual intensidad. Suena como estática de TV o lluvia lejana. Es el punto de partida para texturas ruidosas.',
|
||||
concept: 'Noise (tipo "white") → VCA → Output. Envelope al VCA. Sonido: "sssshhhhh" — simple pero bonito. Es la base de muchas texturas.',
|
||||
availableModules: ['noise', 'vca', 'envelope'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
envelope: { attack: 0.1, decay: 0.3, sustain: 0.1, release: 0.2 },
|
||||
duration: 1.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Ruido básico',
|
||||
desc: 'Noise → VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!noise || !vca || !out) return false;
|
||||
return conns.some(c => c.from.moduleId === noise.id && c.to.moduleId === vca.id) &&
|
||||
conns.some(c => c.from.moduleId === vca.id && c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Con envelope',
|
||||
desc: 'Envelope dispara el VCA',
|
||||
test: (mods, conns) => {
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (!env || !vca) return false;
|
||||
return conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Ruido controlado',
|
||||
desc: 'Noise white + envelope con attack suave (< 0.1s), decay moderado (0.2-0.5s)',
|
||||
test: (mods) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!noise || !env) return false;
|
||||
const attack = env.params.attack ?? 0.01;
|
||||
const decay = env.params.decay ?? 0.2;
|
||||
return noise.params.type === 'white' && attack < 0.1 && decay >= 0.2 && decay <= 0.5;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 8.2 ───────────────
|
||||
{
|
||||
id: 'w8-2',
|
||||
title: 'Sonido de Viento',
|
||||
subtitle: 'Brisa y vendavales',
|
||||
description: 'El viento es ruido filtrado con un filtro bandpass — solo un rango de frecuencias pasa. Varías el cutoff y Q para cambiar el "tipo" de viento (brisa suave vs. huracán).',
|
||||
concept: 'Noise → Filter bandpass (cutoff ~3000 Hz, Q moderado ~3-5) → VCA → Output. Envelope suave al VCA. Resultado: "whoooosh", viento realista.',
|
||||
availableModules: ['noise', 'filter', 'vca', 'envelope'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'bandpass', frequency: 3000, Q: 4 },
|
||||
envelope: { attack: 0.15, decay: 0.6, sustain: 0.05, release: 0.3 },
|
||||
duration: 1.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Ruido filtrado',
|
||||
desc: 'Noise → Filter bandpass → VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (!noise || !flt || !vca) return false;
|
||||
return flt.params.type === 'bandpass' &&
|
||||
conns.some(c => c.from.moduleId === noise.id && c.to.moduleId === flt.id) &&
|
||||
conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === vca.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Con resonancia',
|
||||
desc: 'Filtro bandpass con Q > 2',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!flt) return false;
|
||||
return flt.params.type === 'bandpass' && (flt.params.Q ?? 1) > 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Viento realista',
|
||||
desc: 'Bandpass 2000-4000 Hz, Q 3-5, envelope suave (attack 0.1-0.2s, decay 0.5+)',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!flt || !env) return false;
|
||||
const cutoff = flt.params.frequency ?? 1000;
|
||||
const Q = flt.params.Q ?? 1;
|
||||
const attack = env.params.attack ?? 0.01;
|
||||
const decay = env.params.decay ?? 0.2;
|
||||
return cutoff >= 2000 && cutoff <= 4000 && Q >= 3 && Q <= 5 &&
|
||||
attack >= 0.1 && attack <= 0.2 && decay >= 0.5;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 8.3 ───────────────
|
||||
{
|
||||
id: 'w8-3',
|
||||
title: 'Olas del Océano',
|
||||
subtitle: 'LFO al cutoff',
|
||||
description: 'El océano "respira" — la amplitud cambia lentamente. Se logra modulando el cutoff del filtro con un LFO muy lento (~0.2-0.5 Hz). El resultado: un sonido que crece y disminuye como olas.',
|
||||
concept: 'Noise → Filter LP → VCA → Output. LFO lento (0.2-0.5 Hz) al cutoff del filtro. Envelope suave al VCA. Resultado: un sonido hipnótico que respira.',
|
||||
availableModules: ['noise', 'filter', 'vca', 'lfo', 'envelope'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 750, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1200, Q: 1 },
|
||||
lfo: { frequency: 0.3, type: 'sine', min: 500, max: 2500, target: 'frequency' },
|
||||
envelope: { attack: 0.2, decay: 0.5, sustain: 0.3, release: 0.4 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'LFO al filtro',
|
||||
desc: 'Noise → Filter → VCA. LFO al cutoff del filtro',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!noise || !flt || !lfo) return false;
|
||||
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'LFO lento',
|
||||
desc: 'LFO con frequency < 1 Hz para movimiento lento',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!lfo) return false;
|
||||
return (lfo.params.frequency ?? 2) < 1;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Olas hipnóticas',
|
||||
desc: 'LFO 0.2-0.5 Hz, filtro LP cutoff 500-3000 Hz, envelope suave (decay 1+)',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!lfo || !flt || !env) return false;
|
||||
const lfoFreq = lfo.params.frequency ?? 2;
|
||||
const cutoff = flt.params.frequency ?? 1000;
|
||||
const decay = env.params.decay ?? 0.2;
|
||||
return lfoFreq >= 0.2 && lfoFreq <= 0.5 &&
|
||||
cutoff >= 500 && cutoff <= 3000 &&
|
||||
flt.params.type === 'lowpass' &&
|
||||
decay >= 1;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 8.4 ───────────────
|
||||
{
|
||||
id: 'w8-4',
|
||||
title: 'Sonido de Lluvia',
|
||||
subtitle: 'Gotas percusivas',
|
||||
description: 'La lluvia es ruido + un envelope muy corto que dispara múltiples veces. Cada "gota" es un ataque y decaimiento rápidos. Varias gotas creadas con los mismos parámetros generan una ilusión de lluvia.',
|
||||
concept: 'Noise → VCA → Output. Envelope CORTO (attack 0, decay ~0.05-0.1s, sustain 0) al VCA. Un keyboard para disparar "gotas". Varias pulsaciones = lluvia.',
|
||||
availableModules: ['noise', 'vca', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
envelope: { attack: 0.01, decay: 0.06, sustain: 0, release: 0.02 },
|
||||
triggerPattern: { interval: 0.15, count: 1 },
|
||||
duration: 1.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Gota de lluvia',
|
||||
desc: 'Noise → VCA con envelope corto (decay < 0.15s)',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!noise || !vca || !env) return false;
|
||||
return conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv') &&
|
||||
(env.params.decay ?? 0.2) < 0.15;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Percusivo',
|
||||
desc: 'Envelope con attack 0, decay 0.05-0.1s, sustain 0',
|
||||
test: (mods) => {
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!env) return false;
|
||||
const decay = env.params.decay ?? 0.2;
|
||||
const sustain = env.params.sustain ?? 0.5;
|
||||
return (env.params.attack ?? 0.01) <= 0.01 && decay >= 0.05 && decay <= 0.1 && sustain < 0.05;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Lluvia realista',
|
||||
desc: 'Noise white, envelope ultra-corto (decay 0.03-0.08s), keyboard conectado',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!noise || !env || !kb) return false;
|
||||
const decay = env.params.decay ?? 0.2;
|
||||
const connected = conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id && c.to.port === 'gate');
|
||||
return noise.params.type === 'white' && decay >= 0.03 && decay <= 0.08 && connected;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 8.5 ───────────────
|
||||
{
|
||||
id: 'w8-5',
|
||||
title: 'Estática de Radio',
|
||||
subtitle: 'Ruido + Distorsión',
|
||||
description: 'La estática de radio es ruido MÁS distorsión — un efecto que "rompe" el sonido de forma agresiva. Crea ese sonido crispante, lo-fi, de radio rota o síntesis glitch.',
|
||||
concept: 'Noise → Distortion (distortion 0.6+) → VCA → Output. Envelope al VCA. La distorsión enfatiza ciertas partes del ruido, creando un sonido más agresivo y texturado.',
|
||||
availableModules: ['noise', 'vca', 'envelope', 'distortion'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -12 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'distortion', distortion: 0.75, wet: 0.7 },
|
||||
],
|
||||
envelope: { attack: 0.08, decay: 0.6, sustain: 0.1, release: 0.25 },
|
||||
duration: 1.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Ruido distorsionado',
|
||||
desc: 'Noise → Distortion → VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (!noise || !dist || !vca) return false;
|
||||
return conns.some(c => c.from.moduleId === noise.id && c.to.moduleId === dist.id) &&
|
||||
conns.some(c => c.from.moduleId === dist.id && c.to.moduleId === vca.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Agresivo',
|
||||
desc: 'Distorsión > 0.4 para un sonido roto',
|
||||
test: (mods) => {
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
if (!dist) return false;
|
||||
return (dist.params.distortion ?? 0.4) > 0.4;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Estática completa',
|
||||
desc: 'Distorsión 0.6-0.9, wet 0.6+, envelope suave (decay 0.5+)',
|
||||
test: (mods) => {
|
||||
const dist = mods.find(m => m.type === 'distortion');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!dist || !env) return false;
|
||||
const distortion = dist.params.distortion ?? 0.4;
|
||||
const wet = dist.params.wet ?? 0.5;
|
||||
const decay = env.params.decay ?? 0.2;
|
||||
return distortion >= 0.6 && distortion <= 0.9 && wet >= 0.6 && decay >= 0.5;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 8.6 ───────────────
|
||||
{
|
||||
id: 'w8-6',
|
||||
title: 'Ritmo Industrial',
|
||||
subtitle: 'LFO modulando VCA',
|
||||
description: 'Ahora modulamos el VCA con un LFO en lugar del envelope — crea un efecto de "pulsación" o "tremolo". Combined con noise, crea un sonido industrial, maquínico, hipnótico.',
|
||||
concept: 'Noise → VCA. LFO (frequency ~1-2 Hz) al CV del VCA. Resultado: el ruido sube y baja rítmicamente, como una máquina industrial.',
|
||||
availableModules: ['noise', 'vca', 'lfo'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'pink' } },
|
||||
],
|
||||
lfo: { frequency: 1.5, type: 'square', min: 0.2, max: 1, target: 'amplitude' },
|
||||
duration: 1.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'LFO al VCA',
|
||||
desc: 'Noise → VCA. LFO al CV del VCA',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!noise || !vca || !lfo) return false;
|
||||
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === vca.id && c.to.port === 'cv') &&
|
||||
conns.some(c => c.from.moduleId === noise.id && c.to.moduleId === vca.id && c.to.port === 'in');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Pulsación',
|
||||
desc: 'LFO frequency 0.5-3 Hz para tremolo audible',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!lfo) return false;
|
||||
const freq = lfo.params.frequency ?? 2;
|
||||
return freq >= 0.5 && freq <= 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Industrial puro',
|
||||
desc: 'LFO 1-2 Hz, square waveform (si hay opción), amplitude > 0.5',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!lfo) return false;
|
||||
const freq = lfo.params.frequency ?? 2;
|
||||
const amplitude = lfo.params.amplitude ?? 0.5;
|
||||
return freq >= 1 && freq <= 2 && amplitude > 0.5;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 8.7 ───────────────
|
||||
{
|
||||
id: 'w8-7',
|
||||
title: 'Textura Ambiental',
|
||||
subtitle: 'Ruido + Reverb + Delay',
|
||||
description: 'Una textura ambiental es ruido filtrado + MUCHO reverb y delay. El reverb añade espacio (como un reverb de catedral), el delay crea repeticiones. El resultado: un sonido envolvente, envolvente, romántico.',
|
||||
concept: 'Noise → Filter LP (cutoff bajo ~1000 Hz) → Reverb (decay 4+) → Delay → Output. No necesitas envelope — deja que el sonido respire solo. Es puro ambiente.',
|
||||
availableModules: ['noise', 'filter', 'reverb', 'delay'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'brown' } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 900, Q: 0.8 },
|
||||
effects: [
|
||||
{ type: 'delay', delayTime: 0.4, feedback: 0.45, wet: 0.6 },
|
||||
{ type: 'reverb', decay: 4.5, wet: 0.7 },
|
||||
],
|
||||
duration: 2.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Reverb en cadena',
|
||||
desc: 'Noise → Filter → Reverb → Output',
|
||||
test: (mods, conns) => {
|
||||
const noise = mods.find(m => m.type === 'noise');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
if (!noise || !flt || !rev) return false;
|
||||
return conns.some(c => c.from.moduleId === flt.id && c.to.moduleId === rev.id) &&
|
||||
conns.some(c => c.from.moduleId === rev.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Espacioso',
|
||||
desc: 'Reverb decay > 3, delay en cadena también',
|
||||
test: (mods, conns) => {
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
if (!rev || !del) return false;
|
||||
return (rev.params.decay ?? 2) > 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Ambiente etéreo',
|
||||
desc: 'LP < 1500 Hz, reverb decay 4+, delay feedback 0.4+, combinación crea sonido flotante',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
if (!flt || !rev || !del) return false;
|
||||
const cutoff = flt.params.frequency ?? 1000;
|
||||
const revDecay = rev.params.decay ?? 2;
|
||||
const delFeedback = del.params.feedback ?? 0.4;
|
||||
return cutoff <= 1500 && revDecay >= 4 && delFeedback >= 0.4;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 8.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w8-8',
|
||||
title: 'Paisaje Sonoro',
|
||||
subtitle: 'BOSS FINAL: Un mundo de sonido',
|
||||
description: 'Combina TODAS las texturas aprendidas en un único paisaje sonoro. Crea una composición con capas: viento, lluvia, olas, estática, ritmo industrial, ambiente. Una sinfonía de ruido y texturas.',
|
||||
concept: 'Mínimo 4 capas de ruido con diferentes características: 1) filtro bandpass (viento), 2) ruido + envelope corto (lluvia), 3) ruido + LFO al filtro (olas), 4) ruido + LFO al VCA (ritmo). Todo mezclado, con reverb y delay, fluyendo en armonía.',
|
||||
availableModules: ['noise', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'delay', 'reverb', 'distortion'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
{ type: 'noise', params: { type: 'pink' } },
|
||||
{ type: 'noise', params: { type: 'brown' } },
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'bandpass', frequency: 2800, Q: 3.5 },
|
||||
lfo: [
|
||||
{ frequency: 0.35, type: 'sine', min: 600, max: 2200, target: 'frequency' },
|
||||
{ frequency: 1.2, type: 'square', min: 0.1, max: 0.9, target: 'amplitude' },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'delay', delayTime: 0.35, feedback: 0.5, wet: 0.5 },
|
||||
{ type: 'reverb', decay: 3.5, wet: 0.55 },
|
||||
],
|
||||
envelope: { attack: 0.12, decay: 0.4, sustain: 0.2, release: 0.3 },
|
||||
duration: 6,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Múltiples texturas',
|
||||
desc: 'Al menos 3 canales de ruido con características diferentes, todos a output',
|
||||
test: (mods, conns) => {
|
||||
const noises = mods.filter(m => m.type === 'noise');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (noises.length < 3 || !out) return false;
|
||||
// Count different filter types or modulators
|
||||
const filters = mods.filter(m => m.type === 'filter');
|
||||
const lfos = mods.filter(m => m.type === 'lfo');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const total = filters.length + lfos.length + envs.length;
|
||||
return total >= 3 && conns.some(c => c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Sonido espacioso',
|
||||
desc: 'Reverb y delay en cadena, crean profundidad y eco',
|
||||
test: (mods, conns) => {
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
if (!rev || !del) return false;
|
||||
// At least one should connect to output or to each other
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
return (conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === del.id) ||
|
||||
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === rev.id) ||
|
||||
(conns.some(c => c.from.moduleId === rev.id && c.to.moduleId === out?.id) &&
|
||||
conns.some(c => c.from.moduleId === del.id && c.to.moduleId === out?.id)));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Maestro de Texturas',
|
||||
desc: '4+ noises, 2+ filters, 2+ LFOs, mixer, reverb decay 3+, delay feedback 0.4+, distorsión opcional',
|
||||
test: (mods, conns) => {
|
||||
const noises = mods.filter(m => m.type === 'noise');
|
||||
const filters = mods.filter(m => m.type === 'filter');
|
||||
const lfos = mods.filter(m => m.type === 'lfo');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const rev = mods.find(m => m.type === 'reverb');
|
||||
const del = mods.find(m => m.type === 'delay');
|
||||
const nonOutput = mods.filter(m => m.type !== 'output');
|
||||
if (noises.length < 4 || filters.length < 2 || lfos.length < 2 || !mixer || !rev || !del) return false;
|
||||
const revDecay = rev.params.decay ?? 2;
|
||||
const delFeedback = del.params.feedback ?? 0.4;
|
||||
return nonOutput.length >= 12 &&
|
||||
revDecay >= 3 && delFeedback >= 0.4 &&
|
||||
conns.length >= 15;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
553
packages/client/src/game/levels/world9.js
Normal file
553
packages/client/src/game/levels/world9.js
Normal file
@@ -0,0 +1,553 @@
|
||||
/**
|
||||
* World 9 — "Síntesis Sustractiva Clásica" (Classic Subtractive Synthesis)
|
||||
*
|
||||
* Teaches: Moog-style synthesis, resonant filters, acid bass, PWM simulation
|
||||
* 8 levels, boss challenges with complete subtractive synth
|
||||
*/
|
||||
|
||||
export const WORLD_9 = {
|
||||
id: 'w9',
|
||||
name: 'Síntesis Sustractiva',
|
||||
subtitle: 'Los sonidos clásicos del sintetizador',
|
||||
icon: '▽~',
|
||||
color: '#ff4466',
|
||||
unlockStars: 96,
|
||||
levels: [
|
||||
// ─────────────── LEVEL 9.1 ───────────────
|
||||
{
|
||||
id: 'w9-1',
|
||||
title: 'Lead Sawtooth',
|
||||
subtitle: 'La onda más rica en armónicos',
|
||||
description: 'El sawtooth es la onda fundamental de la síntesis sustractiva — contiene todos los armónicos. Conecta un oscilador sawtooth a un filtro lowpass para quitar brillo, y un VCA para controlar el volumen.',
|
||||
concept: 'Osc sawtooth → Filter LP → VCA → Output. El filtro controla el brillo, el VCA controla la amplitud. Ajusta la frecuencia y el cutoff del filtro para explorar sonidos.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 4000, Q: 1.2 },
|
||||
envelope: { attack: 0.05, decay: 0.3, sustain: 0.4, release: 0.2 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Sawtooth básico',
|
||||
desc: 'Osc sawtooth → Filter → VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (!osc || !flt || !vca || !out) return false;
|
||||
return osc.params.waveform === 'sawtooth' &&
|
||||
conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Filtro activo',
|
||||
desc: 'Filtro lowpass con cutoff controlable',
|
||||
test: (mods, conns) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!flt) return false;
|
||||
return flt.params.type === 'lowpass' &&
|
||||
(flt.params.frequency ?? 1000) > 500 &&
|
||||
(flt.params.Q ?? 1) >= 1;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Lead completo',
|
||||
desc: 'Sawtooth + LP + VCA + envelope + keyboard',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!osc || !flt || !vca || !env || !kb) return false;
|
||||
return osc.params.waveform === 'sawtooth' &&
|
||||
flt.params.type === 'lowpass' &&
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id && c.to.port === 'gate') &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 9.2 ───────────────
|
||||
{
|
||||
id: 'w9-2',
|
||||
title: 'Filtro Resonante',
|
||||
subtitle: 'El corazón de Moog',
|
||||
description: 'La resonancia (Q alto) en el filtro crea un pico característico en el cutoff frequency. Este es el sonido Moog: cuando bajas el cutoff con resonancia, el filtro empieza a auto-oscilar y cantar.',
|
||||
concept: 'Osc sawtooth → Filter LP (Q > 4) → VCA → Output. Cuanto más alto el Q, más dramático el efecto. Baja el cutoff lentamente para escuchar la resonancia.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 165, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2500, Q: 6 },
|
||||
lfo: { frequency: 0.8, type: 'sine', min: 1000, max: 4500, target: 'frequency' },
|
||||
envelope: { attack: 0.08, decay: 0.4, sustain: 0.3, release: 0.25 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Resonancia perceptible',
|
||||
desc: 'Filtro LP con Q > 3',
|
||||
test: (mods) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!flt) return false;
|
||||
return flt.params.type === 'lowpass' &&
|
||||
(flt.params.Q ?? 1) > 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Moog Resonante',
|
||||
desc: 'Sawtooth + LP (Q > 5) + VCA + envelope',
|
||||
test: (mods) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!osc || !flt || !vca || !env) return false;
|
||||
return osc.params.waveform === 'sawtooth' &&
|
||||
flt.params.type === 'lowpass' &&
|
||||
(flt.params.Q ?? 1) > 5 &&
|
||||
(env.params.attack ?? 0.01) < 0.1;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Barrido de Filtro',
|
||||
desc: 'LFO modulando el cutoff del filtro con resonancia alta',
|
||||
test: (mods, conns) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!flt || !lfo) return false;
|
||||
return flt.params.type === 'lowpass' &&
|
||||
(flt.params.Q ?? 1) > 4 &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 9.3 ───────────────
|
||||
{
|
||||
id: 'w9-3',
|
||||
title: 'Brass Stab',
|
||||
subtitle: 'El ataque metálico',
|
||||
description: 'Un "brass stab" es un sonido de trompeta: square wave, filtro que se abre rápido en el ataque y luego se cierra. El envelope en el filtro crea el efecto de "toque" de la trompeta.',
|
||||
concept: 'Osc square → Filter LP → VCA → Output. El truco: el envelope NO va al VCA sino al CUTOFF del filtro. Attack del env muy corto. El filtro sube y baja, no el volumen.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 330, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1800, Q: 2 },
|
||||
envelope: { attack: 0.01, decay: 0.35, sustain: 0.1, release: 0.15 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Square + Filtro',
|
||||
desc: 'Osc square → Filter → VCA → Output',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
if (!osc || !flt || !vca) return false;
|
||||
return osc.params.waveform === 'square' &&
|
||||
flt.params.type === 'lowpass' &&
|
||||
conns.some(c => c.from.moduleId === osc.id && c.to.moduleId === flt.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Envelope al Filtro',
|
||||
desc: 'Envelope conectado al cutoff del filtro',
|
||||
test: (mods, conns) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!flt || !env) return false;
|
||||
return conns.some(c => c.from.moduleId === env.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Brass Stab Perfecta',
|
||||
desc: 'Square + LP, envelope (attack < 0.02s) al cutoff, keyboard gatea el env',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
if (!osc || !flt || !env || !kb) return false;
|
||||
return osc.params.waveform === 'square' &&
|
||||
flt.params.type === 'lowpass' &&
|
||||
(env.params.attack ?? 0.01) < 0.02 &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === flt.id && c.to.port === 'cutoff') &&
|
||||
conns.some(c => c.from.moduleId === kb.id && c.to.moduleId === env.id && c.to.port === 'gate');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 9.4 ───────────────
|
||||
{
|
||||
id: 'w9-4',
|
||||
title: 'Acid Bass 303',
|
||||
subtitle: 'El sonido de la danza',
|
||||
description: 'El acid bass es el legendario sonido del sintetizador TB-303: oscilador a frecuencia grave, filtro lowpass muy resonante, y un envelope que modula el cutoff para crear el "slide" característico.',
|
||||
concept: 'Osc sawtooth/square ~55 Hz → Sequencer freq. Filter LP (Q muy alto, ~8+) → VCA → Output. Envelope rápido al cutoff. El sequencer proporciona las notas; el filtro hace el sonido "acid".',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'envelope', 'sequencer', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 800, Q: 9 },
|
||||
envelope: { attack: 0.02, decay: 0.25, sustain: 0.05, release: 0.15 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Bajo + Secuenciador',
|
||||
desc: 'Sequencer → Osc grave + Filter → Output',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!seq || !osc || !flt) return false;
|
||||
return (osc.params.frequency ?? 440) < 100 &&
|
||||
conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === osc.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Resonancia acid',
|
||||
desc: 'Filtro LP con Q > 6, envelope al cutoff',
|
||||
test: (mods, conns) => {
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!flt || !env) return false;
|
||||
return flt.params.type === 'lowpass' &&
|
||||
(flt.params.Q ?? 1) > 6 &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: '303 Clásico',
|
||||
desc: 'Sequencer + osc < 60 Hz + LP (Q > 8) + envelope rápido al cutoff',
|
||||
test: (mods, conns) => {
|
||||
const seq = mods.find(m => m.type === 'sequencer');
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!seq || !osc || !flt || !env) return false;
|
||||
return (osc.params.frequency ?? 440) < 60 &&
|
||||
flt.params.type === 'lowpass' &&
|
||||
(flt.params.Q ?? 1) > 8 &&
|
||||
(env.params.decay ?? 0.2) < 0.3 &&
|
||||
conns.some(c => c.from.moduleId === seq.id && c.to.moduleId === osc.id);
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 9.5 ───────────────
|
||||
{
|
||||
id: 'w9-5',
|
||||
title: 'String Pad Detuned',
|
||||
subtitle: 'Capas de sierras',
|
||||
description: 'Los string pads de las sinfonías electrónicas usan múltiples osciladores ligeramente detuned, un filtro suave, y un envelope lento. El detune crea una "chorusing" natural que emula el sonido de múltiples instrumentos.',
|
||||
concept: '3 oscs sawtooth, cada uno con detune diferente (~0, +5, -7) → Mixer → Filter LP suave → VCA → Output. Envelope lento al VCA. Juntos crean una textura cálida y movible.',
|
||||
availableModules: ['oscillator', 'mixer', 'filter', 'vca', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 5 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: -7 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3500, Q: 0.9 },
|
||||
envelope: { attack: 0.08, decay: 0.8, sustain: 0.5, release: 0.4 },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Múltiples sierras',
|
||||
desc: '3 osciladores sawtooth → Mixer → Output',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
if (oscs.length < 3 || !mixer) return false;
|
||||
return oscs.every(o => o.params.waveform === 'sawtooth') &&
|
||||
oscs.some(o => conns.some(c => c.from.moduleId === o.id && c.to.moduleId === mixer.id));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Detune activo',
|
||||
desc: 'Al menos 2 osciladores con detune diferente (|diff| > 3)',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sawtooth');
|
||||
if (oscs.length < 3) return false;
|
||||
const detunes = oscs.map(o => o.params.detune ?? 0);
|
||||
const maxDiff = Math.max(...detunes) - Math.min(...detunes);
|
||||
return maxDiff > 3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'String Pad Completa',
|
||||
desc: '3 saws detuned + mixer + LP + envelope lento al VCA',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'sawtooth');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (oscs.length < 3 || !mixer || !flt || !vca || !env) return false;
|
||||
const detunes = oscs.map(o => o.params.detune ?? 0);
|
||||
const maxDiff = Math.max(...detunes) - Math.min(...detunes);
|
||||
return maxDiff > 3 &&
|
||||
flt.params.type === 'lowpass' &&
|
||||
(env.params.attack ?? 0.01) < 0.1 &&
|
||||
(env.params.decay ?? 0.2) > 0.5 &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === vca.id && c.to.port === 'cv');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 9.6 ───────────────
|
||||
{
|
||||
id: 'w9-6',
|
||||
title: 'PWM Simulator',
|
||||
subtitle: 'Pseudo Pulse Width Modulation',
|
||||
description: 'El PWM (Pulse Width Modulation) es cuando varías el ancho del pulso de una onda square. Podemos simularla mezclando dos osciladores square ligeramente detuned — crean una "beating" que suena como PWM.',
|
||||
concept: '2 oscs square, uno a frecuencia base, otro detuned ~3-5 cents → Mixer → Filter → VCA → Output. El beating de frecuencias crea la ilusión de PWM. Un LFO puede modular más aún.',
|
||||
availableModules: ['oscillator', 'mixer', 'filter', 'vca', 'lfo', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: 4 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3000, Q: 1.5 },
|
||||
lfo: { frequency: 0.6, type: 'sine', min: 2500, max: 4500, target: 'frequency' },
|
||||
envelope: { attack: 0.06, decay: 0.35, sustain: 0.35, release: 0.2 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Dos squares',
|
||||
desc: '2 osciladores square → Mixer → Output',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'square');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
if (oscs.length < 2 || !mixer) return false;
|
||||
return oscs.some(o => conns.some(c => c.from.moduleId === o.id && c.to.moduleId === mixer.id));
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Beating audible',
|
||||
desc: 'Detune entre squares > 2 cents para audible beating',
|
||||
test: (mods) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'square');
|
||||
if (oscs.length < 2) return false;
|
||||
const detunes = oscs.map(o => o.params.detune ?? 0);
|
||||
return Math.abs(detunes[0] - detunes[1]) > 2;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'PWM Dinámico',
|
||||
desc: '2 squares detuned + mixer + filter + LFO al detune de un osc',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator' && m.params.waveform === 'square');
|
||||
const mixer = mods.find(m => m.type === 'mixer');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (oscs.length < 2 || !mixer || !lfo || !flt) return false;
|
||||
const detunes = oscs.map(o => o.params.detune ?? 0);
|
||||
const hasDetune = Math.abs(detunes[0] - detunes[1]) > 2;
|
||||
const lfoToOsc = oscs.some(o =>
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === o.id && c.to.port === 'detune')
|
||||
);
|
||||
return hasDetune && lfoToOsc && flt.params.type === 'lowpass';
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 9.7 ───────────────
|
||||
{
|
||||
id: 'w9-7',
|
||||
title: 'Filter Sweep Técnica',
|
||||
subtitle: 'Control dinámico del timbre',
|
||||
description: 'El filter sweep es el corazón de la síntesis sustractiva: modular la frecuencia de cutoff con un LFO o envelope. Esto cambia el timbre del sonido en tiempo real. Es la vida de la síntesis.',
|
||||
concept: 'Osc sawtooth → Filter LP → VCA → Output. LFO (frecuencia baja ~0.2-2 Hz) → Cutoff del filter. También conecta envelope al cutoff para un sweep más rápido. Keyboard dispara ambos.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'keyboard'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 130, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2000, Q: 2 },
|
||||
lfo: { frequency: 1, type: 'sine', min: 500, max: 5000, target: 'frequency' },
|
||||
envelope: { attack: 0.07, decay: 0.5, sustain: 0.2, release: 0.25 },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'LFO al Cutoff',
|
||||
desc: 'LFO conectado a cutoff del filtro',
|
||||
test: (mods, conns) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
if (!lfo || !flt) return false;
|
||||
return conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'LFO lento',
|
||||
desc: 'LFO con frecuencia < 2 Hz para sweep audible',
|
||||
test: (mods) => {
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
if (!lfo) return false;
|
||||
return (lfo.params.frequency ?? 2) < 2 &&
|
||||
(lfo.params.amplitude ?? 0.5) > 0.3;
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Sweep Completo',
|
||||
desc: 'Sawtooth + LP + LFO lento + envelope al cutoff',
|
||||
test: (mods, conns) => {
|
||||
const osc = mods.find(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (!osc || !flt || !lfo || !env) return false;
|
||||
return osc.params.waveform === 'sawtooth' &&
|
||||
flt.params.type === 'lowpass' &&
|
||||
(lfo.params.frequency ?? 2) < 2 &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff') &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// ─────────────── LEVEL 9.8: BOSS ───────────────
|
||||
{
|
||||
id: 'w9-8',
|
||||
title: 'Sintetizador Clásico',
|
||||
subtitle: 'BOSS FINAL: Moog Completo',
|
||||
description: 'Construye el sintetizador sustractivo completo: múltiples osciladores, filtro resonante, envelopes, LFO, y todo conectado para crear sonidos ricos y expressivos. Este es el verdadero sintetizador analógico.',
|
||||
concept: 'Construye un synth con: 2+ osciladores (mezcla de saw/square), filtro LP resonante (Q > 4), 2+ envelopes, 1+ LFO, VCA, keyboard, y al menos un efecto. Todo debe sonar cohesivo y expressivo.',
|
||||
availableModules: ['oscillator', 'filter', 'vca', 'lfo', 'envelope', 'mixer', 'keyboard', 'delay', 'distortion', 'reverb'],
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 110, detune: 3 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3000, Q: 6 },
|
||||
lfo: { frequency: 0.5, type: 'sine', min: 1000, max: 4000, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 2.5, wet: 0.4 },
|
||||
],
|
||||
envelope: { attack: 0.08, decay: 0.5, sustain: 0.3, release: 0.3 },
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
name: 'Síntesis funcional',
|
||||
desc: 'Múltiples oscs + filtro LP + VCA + envelope + keyboard',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const vca = mods.find(m => m.type === 'vca');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const out = mods.find(m => m.type === 'output');
|
||||
if (oscs.length < 2 || !flt || !vca || !env || !kb || !out) return false;
|
||||
return flt.params.type === 'lowpass' &&
|
||||
conns.some(c => c.to.moduleId === out.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 2,
|
||||
name: 'Moog característico',
|
||||
desc: '2+ oscs + filtro LP resonante (Q > 4) + envelope modulando cutoff',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const env = mods.find(m => m.type === 'envelope');
|
||||
if (oscs.length < 2 || !flt || !env) return false;
|
||||
return flt.params.type === 'lowpass' &&
|
||||
(flt.params.Q ?? 1) > 4 &&
|
||||
conns.some(c => c.from.moduleId === env.id && c.to.moduleId === flt.id && c.to.port === 'cutoff');
|
||||
},
|
||||
},
|
||||
{
|
||||
star: 3,
|
||||
name: 'Maestro Sustractivo',
|
||||
desc: '2+ oscs detuned + LP (Q > 5) + 2 envs + LFO + efecto + keyboard',
|
||||
test: (mods, conns) => {
|
||||
const oscs = mods.filter(m => m.type === 'oscillator');
|
||||
const flt = mods.find(m => m.type === 'filter');
|
||||
const envs = mods.filter(m => m.type === 'envelope');
|
||||
const lfo = mods.find(m => m.type === 'lfo');
|
||||
const kb = mods.find(m => m.type === 'keyboard');
|
||||
const effects = mods.filter(m => ['delay', 'reverb', 'distortion'].includes(m.type));
|
||||
if (oscs.length < 2 || !flt || envs.length < 2 || !lfo || !kb || effects.length < 1) return false;
|
||||
const detunes = oscs.map(o => o.params.detune ?? 0);
|
||||
const hasDetune = Math.max(...detunes) - Math.min(...detunes) > 2;
|
||||
return flt.params.type === 'lowpass' &&
|
||||
(flt.params.Q ?? 1) > 5 &&
|
||||
hasDetune &&
|
||||
conns.some(c => c.from.moduleId === lfo.id && c.to.moduleId === flt.id && c.to.port === 'cutoff') &&
|
||||
conns.length >= 12;
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
};
|
||||
193
packages/client/src/game/targetAudio.js
Normal file
193
packages/client/src/game/targetAudio.js
Normal 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;
|
||||
}
|
||||
14
packages/client/src/hooks/useIsMobile.js
Normal file
14
packages/client/src/hooks/useIsMobile.js
Normal file
@@ -0,0 +1,14 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
export function useIsMobile(breakpoint = 768) {
|
||||
const [isMobile, setIsMobile] = useState(() => window.innerWidth <= breakpoint);
|
||||
|
||||
useEffect(() => {
|
||||
const mql = window.matchMedia(`(max-width: ${breakpoint}px)`);
|
||||
const handler = (e) => setIsMobile(e.matches);
|
||||
mql.addEventListener('change', handler);
|
||||
return () => mql.removeEventListener('change', handler);
|
||||
}, [breakpoint]);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
48
packages/client/src/hooks/usePinchZoom.js
Normal file
48
packages/client/src/hooks/usePinchZoom.js
Normal 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]);
|
||||
}
|
||||
1505
packages/client/src/index.css
Normal file
1505
packages/client/src/index.css
Normal file
File diff suppressed because it is too large
Load Diff
52
packages/client/src/main.jsx
Normal file
52
packages/client/src/main.jsx
Normal 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(() => {});
|
||||
});
|
||||
}
|
||||
79
packages/client/src/services/AuthContext.jsx
Normal file
79
packages/client/src/services/AuthContext.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
packages/client/src/services/api.js
Normal file
103
packages/client/src/services/api.js
Normal 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),
|
||||
};
|
||||
134
packages/client/src/services/syncService.js
Normal file
134
packages/client/src/services/syncService.js
Normal 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;
|
||||
}
|
||||
}
|
||||
13
packages/client/vite.config.js
Normal file
13
packages/client/vite.config.js
Normal 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 }
|
||||
});
|
||||
5
packages/server/.env.example
Normal file
5
packages/server/.env.example
Normal 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
|
||||
8
packages/server/drizzle.config.js
Normal file
8
packages/server/drizzle.config.js
Normal 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',
|
||||
},
|
||||
};
|
||||
29
packages/server/package.json
Normal file
29
packages/server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
9
packages/server/src/db/index.js
Normal file
9
packages/server/src/db/index.js
Normal 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 };
|
||||
80
packages/server/src/db/schema.js
Normal file
80
packages/server/src/db/schema.js
Normal 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(),
|
||||
});
|
||||
80
packages/server/src/index.js
Normal file
80
packages/server/src/index.js
Normal 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);
|
||||
}
|
||||
18
packages/server/src/middleware/auth.js
Normal file
18
packages/server/src/middleware/auth.js
Normal 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' });
|
||||
}
|
||||
}
|
||||
103
packages/server/src/routes/admin.js
Normal file
103
packages/server/src/routes/admin.js
Normal 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;
|
||||
});
|
||||
}
|
||||
206
packages/server/src/routes/auth.js
Normal file
206
packages/server/src/routes/auth.js
Normal 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 };
|
||||
});
|
||||
}
|
||||
95
packages/server/src/routes/levels.js
Normal file
95
packages/server/src/routes/levels.js
Normal 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;
|
||||
});
|
||||
}
|
||||
104
packages/server/src/routes/sync.js
Normal file
104
packages/server/src/routes/sync.js
Normal 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 };
|
||||
});
|
||||
}
|
||||
56
packages/server/src/routes/users.js
Normal file
56
packages/server/src/routes/users.js
Normal 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;
|
||||
});
|
||||
}
|
||||
175
packages/server/src/routes/workshop.js
Normal file
175
packages/server/src/routes/workshop.js
Normal 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
180
producto.md
Normal 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.*
|
||||
293
src/App.jsx
293
src/App.jsx
@@ -1,293 +0,0 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { state, subscribe, addModule, emit, addConnection, updateModulePosition, deserialize } from './engine/state.js';
|
||||
import { startAudio, stopAudio, connectWire, rebuildGraph } from './engine/audioEngine.js';
|
||||
import { getModuleDef } from './engine/moduleRegistry.js';
|
||||
import { autoSave, autoLoad, exportPatch, importPatch } from './engine/presets.js';
|
||||
import ModuleNode from './components/ModuleNode.jsx';
|
||||
import WireLayer from './components/WireLayer.jsx';
|
||||
import ModulePalette from './components/ModulePalette.jsx';
|
||||
import PresetModal from './components/PresetModal.jsx';
|
||||
import { CHIPTUNE_PRESET } from './presets/chiptune.js';
|
||||
|
||||
export default function App({ onSwitchToGame }) {
|
||||
const [, forceUpdate] = useState(0);
|
||||
const containerRef = useRef(null);
|
||||
const portPositions = useRef({});
|
||||
const [tempWire, setTempWire] = useState(null);
|
||||
const connectingRef = useRef(null);
|
||||
const [presetModal, setPresetModal] = useState(null);
|
||||
const importRef = useRef(null);
|
||||
|
||||
// Subscribe to state changes
|
||||
useEffect(() => {
|
||||
const unsub = subscribe(() => forceUpdate(n => n + 1));
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
// Auto-load on mount, or load chiptune demo if empty
|
||||
useEffect(() => {
|
||||
const loaded = autoLoad();
|
||||
if (!loaded || state.modules.length === 0) {
|
||||
// Load chiptune demo preset
|
||||
deserialize(CHIPTUNE_PRESET);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Auto-save interval
|
||||
useEffect(() => {
|
||||
const interval = setInterval(autoSave, 3000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
// Port position reporting
|
||||
const handlePortPosition = useCallback((moduleId, portName, direction, el) => {
|
||||
const key = `${moduleId}-${portName}-${direction}`;
|
||||
portPositions.current[key] = el;
|
||||
}, []);
|
||||
|
||||
// Start connecting a wire
|
||||
const handleStartConnect = useCallback((info) => {
|
||||
connectingRef.current = info;
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
setTempWire({
|
||||
portType: info.portType,
|
||||
startX: info.startX - containerRect.left,
|
||||
startY: info.startY - containerRect.top,
|
||||
endX: info.startX - containerRect.left,
|
||||
endY: info.startY - containerRect.top,
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Find port-dot element at pointer position (including nearby)
|
||||
const findPortAtPoint = (x, y) => {
|
||||
// First try exact hit
|
||||
const el = document.elementFromPoint(x, y);
|
||||
if (el && el.classList.contains('port-dot') && el.dataset.moduleId) {
|
||||
return el;
|
||||
}
|
||||
// Try a small radius around the point
|
||||
for (const [dx, dy] of [[0,0],[-8,0],[8,0],[0,-8],[0,8],[-6,-6],[6,-6],[-6,6],[6,6]]) {
|
||||
const hit = document.elementFromPoint(x + dx, y + dy);
|
||||
if (hit && hit.classList.contains('port-dot') && hit.dataset.moduleId) {
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Canvas pointer events
|
||||
const handlePointerDown = useCallback((e) => {
|
||||
if (e.button === 1 || (e.button === 0 && e.altKey) || e.button === 2) {
|
||||
state.panning = true;
|
||||
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
|
||||
e.preventDefault();
|
||||
} else if (e.button === 0 && !connectingRef.current) {
|
||||
state.selectedModuleId = null;
|
||||
emit();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePointerMove = useCallback((e) => {
|
||||
if (state.panning && state.panStart) {
|
||||
state.camX = e.clientX - state.panStart.x;
|
||||
state.camY = e.clientY - state.panStart.y;
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
if (state.dragging) {
|
||||
const newX = e.clientX / state.zoom - state.dragging.offsetX;
|
||||
const newY = e.clientY / state.zoom - state.dragging.offsetY;
|
||||
updateModulePosition(state.dragging.moduleId, newX, newY);
|
||||
return;
|
||||
}
|
||||
if (connectingRef.current && containerRef.current) {
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
setTempWire(prev => prev ? {
|
||||
...prev,
|
||||
endX: e.clientX - containerRect.left,
|
||||
endY: e.clientY - containerRect.top,
|
||||
} : null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePointerUp = useCallback((e) => {
|
||||
if (state.panning) {
|
||||
state.panning = false;
|
||||
state.panStart = null;
|
||||
}
|
||||
if (state.dragging) {
|
||||
state.dragging = null;
|
||||
emit();
|
||||
}
|
||||
|
||||
// End connecting
|
||||
if (connectingRef.current) {
|
||||
const portEl = findPortAtPoint(e.clientX, e.clientY);
|
||||
if (portEl) {
|
||||
finishConnection(portEl);
|
||||
}
|
||||
connectingRef.current = null;
|
||||
setTempWire(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const finishConnection = (portEl) => {
|
||||
const from = connectingRef.current;
|
||||
if (!from) return;
|
||||
|
||||
// Read data attributes directly — clean and reliable
|
||||
const targetModuleId = parseInt(portEl.dataset.moduleId);
|
||||
const targetPort = portEl.dataset.portName;
|
||||
const targetDirection = portEl.dataset.portDirection;
|
||||
|
||||
if (!targetModuleId || !targetPort || !targetDirection) return;
|
||||
if (targetModuleId === from.moduleId && targetPort === from.port) return;
|
||||
|
||||
// Determine from/to
|
||||
let fromMod, fromPort, toMod, toPort;
|
||||
if (from.direction === 'output' && targetDirection === 'input') {
|
||||
fromMod = from.moduleId; fromPort = from.port;
|
||||
toMod = targetModuleId; toPort = targetPort;
|
||||
} else if (from.direction === 'input' && targetDirection === 'output') {
|
||||
fromMod = targetModuleId; fromPort = targetPort;
|
||||
toMod = from.moduleId; toPort = from.port;
|
||||
} else {
|
||||
return; // same direction — invalid
|
||||
}
|
||||
|
||||
const connId = addConnection(fromMod, fromPort, toMod, toPort);
|
||||
if (connId && state.isRunning) {
|
||||
const conn = state.connections.find(c => c.id === connId);
|
||||
if (conn) connectWire(conn);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWheel = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
const delta = -e.deltaY * 0.001;
|
||||
state.zoom = Math.max(0.3, Math.min(3, state.zoom + delta));
|
||||
emit();
|
||||
}, []);
|
||||
|
||||
const handleContextMenu = useCallback((e) => e.preventDefault(), []);
|
||||
|
||||
const handleToggleAudio = async () => {
|
||||
if (state.isRunning) {
|
||||
stopAudio();
|
||||
} else {
|
||||
await startAudio();
|
||||
}
|
||||
emit();
|
||||
};
|
||||
|
||||
const handleAddModule = (type) => {
|
||||
const x = (-state.camX + 300) / state.zoom + Math.random() * 50;
|
||||
const y = (-state.camY + 200) / state.zoom + Math.random() * 50;
|
||||
addModule(type, x, y);
|
||||
if (state.isRunning) rebuildGraph();
|
||||
};
|
||||
|
||||
const handleImport = async (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
await importPatch(file);
|
||||
emit();
|
||||
e.target.value = '';
|
||||
};
|
||||
|
||||
const handleLoadDemo = () => {
|
||||
deserialize(CHIPTUNE_PRESET);
|
||||
if (state.isRunning) rebuildGraph();
|
||||
emit();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{/* Toolbar */}
|
||||
<div className="toolbar">
|
||||
{onSwitchToGame && (
|
||||
<button className="toolbar-btn" onClick={onSwitchToGame} style={{ color: 'var(--yellow)' }}>
|
||||
🎮 Game
|
||||
</button>
|
||||
)}
|
||||
<span className="toolbar-title">Reaktor</span>
|
||||
<div className="toolbar-sep" />
|
||||
<button className={`toolbar-btn ${state.isRunning ? 'active' : ''}`} onClick={handleToggleAudio}>
|
||||
{state.isRunning ? '⏹ Stop' : '▶ Start'}
|
||||
</button>
|
||||
<div className="toolbar-sep" />
|
||||
<div className="toolbar-group">
|
||||
<button className="toolbar-btn" onClick={() => setPresetModal('save')}>💾 Save</button>
|
||||
<button className="toolbar-btn" onClick={() => setPresetModal('load')}>📂 Load</button>
|
||||
<button className="toolbar-btn" onClick={exportPatch}>📤 Export</button>
|
||||
<button className="toolbar-btn" onClick={() => importRef.current?.click()}>📥 Import</button>
|
||||
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
|
||||
</div>
|
||||
<div className="toolbar-sep" />
|
||||
<button className="toolbar-btn" onClick={handleLoadDemo} style={{ color: 'var(--yellow)' }}>
|
||||
🎮 Chiptune Demo
|
||||
</button>
|
||||
<div className="toolbar-sep" />
|
||||
<span className="toolbar-label" style={{ color: state.isRunning ? 'var(--green)' : 'var(--text2)' }}>
|
||||
{state.isRunning ? '● LIVE' : '○ OFF'}
|
||||
</span>
|
||||
<span className="toolbar-label" style={{ marginLeft: 'auto' }}>
|
||||
{state.modules.length} modules · {state.connections.length} wires
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Main canvas area */}
|
||||
<div className="main-area">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`node-canvas ${state.panning ? 'grabbing' : ''} ${connectingRef.current ? 'connecting' : ''}`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onWheel={handleWheel}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{/* Grid background */}
|
||||
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', zIndex: 0 }}>
|
||||
<defs>
|
||||
<pattern id="grid" width={20 * state.zoom} height={20 * state.zoom}
|
||||
patternUnits="userSpaceOnUse"
|
||||
x={state.camX % (20 * state.zoom)} y={state.camY % (20 * state.zoom)}>
|
||||
<circle cx={1} cy={1} r={0.8} fill="#1a1a30" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
</svg>
|
||||
|
||||
{/* Wire layer (behind modules, uses getBoundingClientRect) */}
|
||||
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} zoom={state.zoom} camX={state.camX} camY={state.camY} />
|
||||
|
||||
{/* Modules container (offset by camera) */}
|
||||
<div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
|
||||
{state.modules.map(mod => (
|
||||
<ModuleNode
|
||||
key={mod.id}
|
||||
mod={mod}
|
||||
zoom={state.zoom}
|
||||
onStartConnect={handleStartConnect}
|
||||
onPortPosition={handlePortPosition}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Module palette */}
|
||||
<ModulePalette onAddModule={handleAddModule} />
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
<div className="status-bar">
|
||||
<span className="status-accent">Reaktor — MontLab Modular Synth</span>
|
||||
<span>Zoom: {(state.zoom * 100).toFixed(0)}%</span>
|
||||
<span>RClick: pan · Wheel: zoom · Drag port: wire · Click wire: delete</span>
|
||||
</div>
|
||||
|
||||
{presetModal && <PresetModal mode={presetModal} onClose={() => setPresetModal(null)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
import React, { useRef, useCallback } from 'react';
|
||||
|
||||
const SIZE = 32;
|
||||
const RADIUS = 12;
|
||||
const STROKE = 3;
|
||||
const START_ANGLE = 225;
|
||||
const END_ANGLE = -45;
|
||||
const RANGE = 270; // degrees
|
||||
|
||||
function polarToCart(cx, cy, r, deg) {
|
||||
const rad = (deg - 90) * Math.PI / 180;
|
||||
return { x: cx + r * Math.cos(rad), y: cy + r * Math.sin(rad) };
|
||||
}
|
||||
|
||||
function describeArc(cx, cy, r, startDeg, endDeg) {
|
||||
const start = polarToCart(cx, cy, r, endDeg);
|
||||
const end = polarToCart(cx, cy, r, startDeg);
|
||||
const large = endDeg - startDeg <= 180 ? '0' : '1';
|
||||
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${large} 0 ${end.x} ${end.y}`;
|
||||
}
|
||||
|
||||
export default function Knob({ value, min, max, onChange, color = 'var(--accent)', label, unit, formatValue }) {
|
||||
const ref = useRef(null);
|
||||
const dragRef = useRef(null);
|
||||
|
||||
const norm = Math.max(0, Math.min(1, (value - min) / (max - min)));
|
||||
const angleDeg = START_ANGLE - norm * RANGE;
|
||||
|
||||
const cx = SIZE / 2, cy = SIZE / 2;
|
||||
const trackPath = describeArc(cx, cy, RADIUS, END_ANGLE, START_ANGLE);
|
||||
const fillAngle = START_ANGLE - norm * RANGE;
|
||||
const fillPath = norm > 0.001 ? describeArc(cx, cy, RADIUS, fillAngle, START_ANGLE) : '';
|
||||
|
||||
const dotPos = polarToCart(cx, cy, RADIUS - 4, angleDeg);
|
||||
|
||||
const displayVal = formatValue ? formatValue(value) :
|
||||
value >= 1000 ? `${(value / 1000).toFixed(1)}k` :
|
||||
value >= 100 ? Math.round(value) :
|
||||
value >= 1 ? value.toFixed(1) :
|
||||
value.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
|
||||
|
||||
const handlePointerDown = useCallback((e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
dragRef.current = { startY: e.clientY, startValue: value };
|
||||
const handleMove = (me) => {
|
||||
const dy = dragRef.current.startY - me.clientY;
|
||||
const sensitivity = (max - min) / 200;
|
||||
let newVal = dragRef.current.startValue + dy * sensitivity;
|
||||
newVal = Math.max(min, Math.min(max, newVal));
|
||||
// Snap to nice values for integer ranges
|
||||
if (max - min > 10 && Number.isInteger(min) && Number.isInteger(max)) {
|
||||
newVal = Math.round(newVal);
|
||||
}
|
||||
onChange(newVal);
|
||||
};
|
||||
const handleUp = () => {
|
||||
window.removeEventListener('pointermove', handleMove);
|
||||
window.removeEventListener('pointerup', handleUp);
|
||||
dragRef.current = null;
|
||||
};
|
||||
window.addEventListener('pointermove', handleMove);
|
||||
window.addEventListener('pointerup', handleUp);
|
||||
}, [value, min, max, onChange]);
|
||||
|
||||
const handleWheel = useCallback((e) => {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
const step = (max - min) / 100;
|
||||
const newVal = Math.max(min, Math.min(max, value - Math.sign(e.deltaY) * step));
|
||||
onChange(newVal);
|
||||
}, [value, min, max, onChange]);
|
||||
|
||||
return (
|
||||
<div className="knob-container" onWheel={handleWheel}>
|
||||
<svg className="knob-svg" viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||
onPointerDown={handlePointerDown} ref={ref}>
|
||||
<path className="knob-track" d={trackPath} />
|
||||
{fillPath && <path className="knob-fill" d={fillPath} style={{ stroke: color }} />}
|
||||
<circle className="knob-dot" cx={dotPos.x} cy={dotPos.y} r={2} />
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,175 +0,0 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import { getModuleDef } from '../engine/moduleRegistry.js';
|
||||
import { state, removeModule, updateModuleParam, isPortConnected, emit } from '../engine/state.js';
|
||||
import { updateParam } from '../engine/audioEngine.js';
|
||||
import Knob from './Knob.jsx';
|
||||
import ScopeDisplay from './ScopeDisplay.jsx';
|
||||
import KeyboardWidget from './KeyboardWidget.jsx';
|
||||
import SequencerWidget from './SequencerWidget.jsx';
|
||||
import PianoRollWidget from './PianoRollWidget.jsx';
|
||||
|
||||
export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }) {
|
||||
const def = getModuleDef(mod.type);
|
||||
if (!def) return null;
|
||||
|
||||
const isSelected = state.selectedModuleId === mod.id;
|
||||
|
||||
// Merge default params
|
||||
const params = { ...Object.fromEntries(Object.entries(def.params).map(([k, v]) => [k, v.default])), ...mod.params };
|
||||
|
||||
const handleParamChange = useCallback((name, value) => {
|
||||
updateModuleParam(mod.id, name, value);
|
||||
updateParam(mod.id, name, value);
|
||||
}, [mod.id]);
|
||||
|
||||
const handleHeaderDown = useCallback((e) => {
|
||||
if (e.button !== 0) return;
|
||||
e.stopPropagation();
|
||||
state.selectedModuleId = mod.id;
|
||||
state.dragging = {
|
||||
moduleId: mod.id,
|
||||
offsetX: e.clientX / zoom - mod.x,
|
||||
offsetY: e.clientY / zoom - mod.y,
|
||||
};
|
||||
emit();
|
||||
}, [mod, zoom]);
|
||||
|
||||
const handleDelete = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
removeModule(mod.id);
|
||||
}, [mod.id]);
|
||||
|
||||
const handlePortMouseDown = useCallback((e, portName, direction) => {
|
||||
e.stopPropagation(); e.preventDefault();
|
||||
const portDef = direction === 'output'
|
||||
? def.outputs.find(p => p.name === portName)
|
||||
: def.inputs.find(p => p.name === portName);
|
||||
if (!portDef) return;
|
||||
|
||||
const rect = e.currentTarget.getBoundingClientRect();
|
||||
onStartConnect({
|
||||
moduleId: mod.id,
|
||||
port: portName,
|
||||
portType: portDef.type,
|
||||
direction,
|
||||
startX: rect.left + rect.width / 2,
|
||||
startY: rect.top + rect.height / 2,
|
||||
});
|
||||
}, [mod.id, def, onStartConnect]);
|
||||
|
||||
// Report port positions for wire rendering
|
||||
const portRef = useCallback((el, portName, direction) => {
|
||||
if (el) {
|
||||
onPortPosition(mod.id, portName, direction, el);
|
||||
}
|
||||
}, [mod.id, onPortPosition]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`module ${isSelected ? 'selected' : ''}`}
|
||||
style={{
|
||||
left: mod.x * zoom, top: mod.y * zoom,
|
||||
transform: `scale(${zoom})`, transformOrigin: 'top left',
|
||||
...(mod.type === 'pianoroll' ? { width: 520 } : mod.type === 'sequencer' ? { width: 310 } : {}),
|
||||
}}
|
||||
data-module-id={mod.id}
|
||||
onPointerDown={(e) => {
|
||||
// Don't deselect when clicking inside a module
|
||||
e.stopPropagation();
|
||||
state.selectedModuleId = mod.id; emit();
|
||||
}}
|
||||
>
|
||||
<div className="module-header" onPointerDown={handleHeaderDown}>
|
||||
<span className="type-icon">{def.icon}</span>
|
||||
<span className="type-name">{def.name}</span>
|
||||
<button className="close-btn" onClick={handleDelete}>✕</button>
|
||||
</div>
|
||||
|
||||
<div className="module-body">
|
||||
{/* Input ports */}
|
||||
{def.inputs.map(port => (
|
||||
<div key={port.name} className="port-row input">
|
||||
<div
|
||||
className={`port-dot ${port.type} ${isPortConnected(mod.id, port.name, 'input') ? 'connected' : ''}`}
|
||||
ref={el => portRef(el, port.name, 'input')}
|
||||
data-module-id={mod.id}
|
||||
data-port-name={port.name}
|
||||
data-port-direction="input"
|
||||
data-port-type={port.type}
|
||||
onPointerDown={e => handlePortMouseDown(e, port.name, 'input')}
|
||||
/>
|
||||
<span className="port-label">{port.label}</span>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Parameters */}
|
||||
{Object.entries(def.params).map(([name, paramDef]) => {
|
||||
if (paramDef.type === 'knob') {
|
||||
const color = paramDef.unit === 'Hz' ? 'var(--accent)' :
|
||||
paramDef.unit === 'dB' ? 'var(--green)' :
|
||||
paramDef.unit === 's' ? 'var(--purple)' : 'var(--accent)';
|
||||
return (
|
||||
<div key={name} className="param-row">
|
||||
<span className="param-label">{paramDef.label}</span>
|
||||
<Knob
|
||||
value={params[name]}
|
||||
min={paramDef.min}
|
||||
max={paramDef.max}
|
||||
onChange={v => handleParamChange(name, v)}
|
||||
color={color}
|
||||
/>
|
||||
<span className="param-value">
|
||||
{params[name] >= 1000 ? `${(params[name] / 1000).toFixed(1)}k` :
|
||||
params[name] >= 100 ? Math.round(params[name]) :
|
||||
params[name] >= 1 ? Number(params[name]).toFixed(1) :
|
||||
Number(params[name]).toFixed(3).replace(/0+$/, '').replace(/\.$/, '')}
|
||||
{paramDef.unit ? ` ${paramDef.unit}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (paramDef.type === 'select') {
|
||||
return (
|
||||
<div key={name} className="param-row">
|
||||
<span className="param-label">{paramDef.label}</span>
|
||||
<select className="param-select" value={params[name]}
|
||||
onChange={e => handleParamChange(name, e.target.value)}>
|
||||
{paramDef.options.map(opt => <option key={opt} value={opt}>{opt}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return null;
|
||||
})}
|
||||
|
||||
{/* Scope display */}
|
||||
{mod.type === 'scope' && <ScopeDisplay moduleId={mod.id} />}
|
||||
|
||||
{/* Keyboard widget */}
|
||||
{mod.type === 'keyboard' && <KeyboardWidget moduleId={mod.id} />}
|
||||
|
||||
{/* Sequencer widget */}
|
||||
{mod.type === 'sequencer' && <SequencerWidget moduleId={mod.id} />}
|
||||
|
||||
{/* Piano Roll widget */}
|
||||
{mod.type === 'pianoroll' && <PianoRollWidget moduleId={mod.id} />}
|
||||
|
||||
{/* Output ports */}
|
||||
{def.outputs.map(port => (
|
||||
<div key={port.name} className="port-row output">
|
||||
<div
|
||||
className={`port-dot ${port.type} ${isPortConnected(mod.id, port.name, 'output') ? 'connected' : ''}`}
|
||||
ref={el => portRef(el, port.name, 'output')}
|
||||
data-module-id={mod.id}
|
||||
data-port-name={port.name}
|
||||
data-port-direction="output"
|
||||
data-port-type={port.type}
|
||||
onPointerDown={e => handlePortMouseDown(e, port.name, 'output')}
|
||||
/>
|
||||
<span className="port-label">{port.label}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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" />;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import WorldMap from './WorldMap.jsx';
|
||||
import PuzzleView from './PuzzleView.jsx';
|
||||
import { WORLD_1 } from './levels/world1.js';
|
||||
|
||||
export default function GameApp({ onSwitchToSandbox }) {
|
||||
const [view, setView] = useState('map'); // 'map' | 'puzzle'
|
||||
const [currentLevel, setCurrentLevel] = useState(null);
|
||||
const [currentLevelIndex, setCurrentLevelIndex] = useState(0);
|
||||
|
||||
const worldLevels = WORLD_1.levels;
|
||||
|
||||
const handleSelectLevel = useCallback((level) => {
|
||||
const idx = worldLevels.findIndex(l => l.id === level.id);
|
||||
setCurrentLevel(level);
|
||||
setCurrentLevelIndex(idx);
|
||||
setView('puzzle');
|
||||
}, [worldLevels]);
|
||||
|
||||
const handleBack = useCallback(() => {
|
||||
setView('map');
|
||||
setCurrentLevel(null);
|
||||
}, []);
|
||||
|
||||
const handleNextLevel = useCallback(() => {
|
||||
const nextIdx = currentLevelIndex + 1;
|
||||
if (nextIdx < worldLevels.length) {
|
||||
setCurrentLevel(worldLevels[nextIdx]);
|
||||
setCurrentLevelIndex(nextIdx);
|
||||
} else {
|
||||
setView('map');
|
||||
}
|
||||
}, [currentLevelIndex, worldLevels]);
|
||||
|
||||
if (view === 'puzzle' && currentLevel) {
|
||||
return (
|
||||
<PuzzleView
|
||||
key={currentLevel.id}
|
||||
level={currentLevel}
|
||||
levelIndex={currentLevelIndex}
|
||||
worldLevels={worldLevels}
|
||||
onBack={handleBack}
|
||||
onNextLevel={handleNextLevel}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<WorldMap
|
||||
onSelectLevel={handleSelectLevel}
|
||||
onSandbox={onSwitchToSandbox}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,379 +0,0 @@
|
||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { state, subscribe, addModule, emit, addConnection, removeModule, updateModulePosition, deserialize } from '../engine/state.js';
|
||||
import { startAudio, stopAudio, connectWire, rebuildGraph } from '../engine/audioEngine.js';
|
||||
import { getModuleDef } from '../engine/moduleRegistry.js';
|
||||
import ModuleNode from '../components/ModuleNode.jsx';
|
||||
import WireLayer from '../components/WireLayer.jsx';
|
||||
import { playTarget, stopTarget, isTargetPlaying } from './targetAudio.js';
|
||||
import LevelComplete from './LevelComplete.jsx';
|
||||
import { completeLevel } from './gameState.js';
|
||||
|
||||
export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onNextLevel }) {
|
||||
const [, forceUpdate] = useState(0);
|
||||
const containerRef = useRef(null);
|
||||
const portPositions = useRef({});
|
||||
const [tempWire, setTempWire] = useState(null);
|
||||
const connectingRef = useRef(null);
|
||||
const [showConcept, setShowConcept] = useState(true);
|
||||
const [result, setResult] = useState(null); // { stars, checks }
|
||||
const [targetPlaying, setTargetPlaying] = useState(false);
|
||||
|
||||
// Subscribe to state changes
|
||||
useEffect(() => {
|
||||
const unsub = subscribe(() => forceUpdate(n => n + 1));
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
// Load level on mount
|
||||
useEffect(() => {
|
||||
loadLevel();
|
||||
return () => {
|
||||
stopAudio();
|
||||
stopTarget();
|
||||
};
|
||||
}, [level.id]);
|
||||
|
||||
const loadLevel = useCallback(() => {
|
||||
// Clear state and load preplaced modules
|
||||
const data = {
|
||||
modules: (level.preplacedModules || []).map(m => ({
|
||||
id: m.id,
|
||||
type: m.type,
|
||||
x: m.x,
|
||||
y: m.y,
|
||||
params: { ...m.params },
|
||||
})),
|
||||
connections: [],
|
||||
camera: { camX: 0, camY: 0, zoom: 1 },
|
||||
};
|
||||
deserialize(data);
|
||||
setResult(null);
|
||||
setShowConcept(true);
|
||||
if (state.isRunning) stopAudio();
|
||||
}, [level]);
|
||||
|
||||
// Port position reporting
|
||||
const handlePortPosition = useCallback((moduleId, portName, direction, el) => {
|
||||
const key = `${moduleId}-${portName}-${direction}`;
|
||||
portPositions.current[key] = el;
|
||||
}, []);
|
||||
|
||||
// Start connecting wire
|
||||
const handleStartConnect = useCallback((info) => {
|
||||
connectingRef.current = info;
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
setTempWire({
|
||||
portType: info.portType,
|
||||
startX: info.startX - containerRect.left,
|
||||
startY: info.startY - containerRect.top,
|
||||
endX: info.startX - containerRect.left,
|
||||
endY: info.startY - containerRect.top,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const findPortAtPoint = (x, y) => {
|
||||
for (const [dx, dy] of [[0,0],[-8,0],[8,0],[0,-8],[0,8],[-6,-6],[6,-6],[-6,6],[6,6]]) {
|
||||
const hit = document.elementFromPoint(x + dx, y + dy);
|
||||
if (hit && hit.classList.contains('port-dot') && hit.dataset.moduleId) {
|
||||
return hit;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const handlePointerDown = useCallback((e) => {
|
||||
if (e.button === 1 || (e.button === 0 && e.altKey) || e.button === 2) {
|
||||
state.panning = true;
|
||||
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
|
||||
e.preventDefault();
|
||||
} else if (e.button === 0 && !connectingRef.current) {
|
||||
state.selectedModuleId = null;
|
||||
emit();
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePointerMove = useCallback((e) => {
|
||||
if (state.panning && state.panStart) {
|
||||
state.camX = e.clientX - state.panStart.x;
|
||||
state.camY = e.clientY - state.panStart.y;
|
||||
emit();
|
||||
return;
|
||||
}
|
||||
if (state.dragging) {
|
||||
const newX = e.clientX / state.zoom - state.dragging.offsetX;
|
||||
const newY = e.clientY / state.zoom - state.dragging.offsetY;
|
||||
updateModulePosition(state.dragging.moduleId, newX, newY);
|
||||
return;
|
||||
}
|
||||
if (connectingRef.current && containerRef.current) {
|
||||
const containerRect = containerRef.current.getBoundingClientRect();
|
||||
setTempWire(prev => prev ? {
|
||||
...prev,
|
||||
endX: e.clientX - containerRect.left,
|
||||
endY: e.clientY - containerRect.top,
|
||||
} : null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handlePointerUp = useCallback((e) => {
|
||||
if (state.panning) {
|
||||
state.panning = false;
|
||||
state.panStart = null;
|
||||
}
|
||||
if (state.dragging) {
|
||||
state.dragging = null;
|
||||
emit();
|
||||
}
|
||||
if (connectingRef.current) {
|
||||
const portEl = findPortAtPoint(e.clientX, e.clientY);
|
||||
if (portEl) finishConnection(portEl);
|
||||
connectingRef.current = null;
|
||||
setTempWire(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const finishConnection = (portEl) => {
|
||||
const from = connectingRef.current;
|
||||
if (!from) return;
|
||||
const targetModuleId = parseInt(portEl.dataset.moduleId);
|
||||
const targetPort = portEl.dataset.portName;
|
||||
const targetDirection = portEl.dataset.portDirection;
|
||||
if (!targetModuleId || !targetPort || !targetDirection) return;
|
||||
if (targetModuleId === from.moduleId && targetPort === from.port) return;
|
||||
|
||||
let fromMod, fromPort, toMod, toPort;
|
||||
if (from.direction === 'output' && targetDirection === 'input') {
|
||||
fromMod = from.moduleId; fromPort = from.port;
|
||||
toMod = targetModuleId; toPort = targetPort;
|
||||
} else if (from.direction === 'input' && targetDirection === 'output') {
|
||||
fromMod = targetModuleId; fromPort = targetPort;
|
||||
toMod = from.moduleId; toPort = from.port;
|
||||
} else return;
|
||||
|
||||
const connId = addConnection(fromMod, fromPort, toMod, toPort);
|
||||
if (connId && state.isRunning) {
|
||||
const conn = state.connections.find(c => c.id === connId);
|
||||
if (conn) connectWire(conn);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWheel = useCallback((e) => {
|
||||
e.preventDefault();
|
||||
const delta = -e.deltaY * 0.001;
|
||||
state.zoom = Math.max(0.3, Math.min(3, state.zoom + delta));
|
||||
emit();
|
||||
}, []);
|
||||
|
||||
const handleContextMenu = useCallback((e) => e.preventDefault(), []);
|
||||
|
||||
// Add module from palette
|
||||
const handleAddModule = (type) => {
|
||||
const x = (-state.camX + 250) / state.zoom + Math.random() * 30;
|
||||
const y = (-state.camY + 150) / state.zoom + Math.random() * 30;
|
||||
addModule(type, x, y);
|
||||
if (state.isRunning) rebuildGraph();
|
||||
};
|
||||
|
||||
// Toggle player audio
|
||||
const handleToggleAudio = async () => {
|
||||
if (state.isRunning) {
|
||||
stopAudio();
|
||||
} else {
|
||||
await startAudio();
|
||||
}
|
||||
emit();
|
||||
};
|
||||
|
||||
// Play target sound
|
||||
const handlePlayTarget = async () => {
|
||||
if (isTargetPlaying()) {
|
||||
stopTarget();
|
||||
setTargetPlaying(false);
|
||||
} else {
|
||||
setTargetPlaying(true);
|
||||
await playTarget(level.target);
|
||||
// Auto-update when target stops
|
||||
setTimeout(() => setTargetPlaying(false), (level.target.duration || 2) * 1000 + 100);
|
||||
}
|
||||
};
|
||||
|
||||
// Validate solution
|
||||
const handleCheck = () => {
|
||||
const mods = state.modules;
|
||||
const conns = state.connections;
|
||||
const checks = level.checks.map(check => ({
|
||||
...check,
|
||||
passed: check.test(mods, conns),
|
||||
}));
|
||||
|
||||
// Stars: sequential — need all previous stars to earn next
|
||||
let stars = 0;
|
||||
for (const check of checks) {
|
||||
if (check.passed) stars = check.star;
|
||||
else break;
|
||||
}
|
||||
|
||||
setResult({ stars, checks });
|
||||
|
||||
if (stars >= 1) {
|
||||
completeLevel(level.id, stars);
|
||||
}
|
||||
};
|
||||
|
||||
const isLastLevel = levelIndex >= worldLevels.length - 1;
|
||||
|
||||
return (
|
||||
<div className="gm-puzzle">
|
||||
{/* Top bar */}
|
||||
<div className="gm-puzzle-bar">
|
||||
<button className="gm-btn icon" onClick={onBack}>← Mapa</button>
|
||||
<div className="gm-puzzle-title">
|
||||
<span className="gm-puzzle-num">{levelIndex + 1}/{worldLevels.length}</span>
|
||||
<span className="gm-puzzle-name">{level.title}</span>
|
||||
</div>
|
||||
<div className="gm-puzzle-actions">
|
||||
<button
|
||||
className={`gm-btn ${targetPlaying ? 'active' : 'target'}`}
|
||||
onClick={handlePlayTarget}
|
||||
>
|
||||
{targetPlaying ? '⏹ Parar' : '🎯 Objetivo'}
|
||||
</button>
|
||||
<button
|
||||
className={`gm-btn ${state.isRunning ? 'active' : ''}`}
|
||||
onClick={handleToggleAudio}
|
||||
>
|
||||
{state.isRunning ? '⏹ Parar' : '▶ Mi Sonido'}
|
||||
</button>
|
||||
<button className="gm-btn check" onClick={handleCheck}>
|
||||
✓ Comprobar
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="gm-puzzle-content">
|
||||
{/* Left sidebar: concept + module palette */}
|
||||
<div className="gm-puzzle-sidebar">
|
||||
{/* Concept panel */}
|
||||
<div className="gm-concept-panel">
|
||||
<div className="gm-concept-header" onClick={() => setShowConcept(!showConcept)}>
|
||||
<span>💡 Concepto</span>
|
||||
<span>{showConcept ? '▼' : '▶'}</span>
|
||||
</div>
|
||||
{showConcept && (
|
||||
<div className="gm-concept-body">
|
||||
<p className="gm-concept-desc">{level.description}</p>
|
||||
<div className="gm-concept-tip">
|
||||
<strong>Pista:</strong> {level.concept}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Objectives */}
|
||||
<div className="gm-objectives">
|
||||
<div className="gm-obj-title">Objetivos</div>
|
||||
{level.checks.map((check, i) => {
|
||||
const passed = result?.checks?.[i]?.passed;
|
||||
return (
|
||||
<div key={i} className={`gm-obj ${passed === true ? 'passed' : passed === false ? 'failed' : ''}`}>
|
||||
<span className="gm-obj-star">{'★'.repeat(check.star)}</span>
|
||||
<span className="gm-obj-name">{check.desc}</span>
|
||||
{passed === true && <span className="gm-obj-check">✓</span>}
|
||||
{passed === false && <span className="gm-obj-x">✗</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Module palette for this level */}
|
||||
{level.availableModules.length > 0 && (
|
||||
<div className="gm-module-palette">
|
||||
<div className="gm-palette-title">Modulos Disponibles</div>
|
||||
{level.availableModules.map(type => {
|
||||
const def = getModuleDef(type);
|
||||
if (!def) return null;
|
||||
return (
|
||||
<div
|
||||
key={type}
|
||||
className="gm-palette-item"
|
||||
onClick={() => handleAddModule(type)}
|
||||
>
|
||||
<span className="gm-palette-icon">{def.icon}</span>
|
||||
<span className="gm-palette-name">{def.name}</span>
|
||||
<span className="gm-palette-add">+</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Reset button */}
|
||||
<button className="gm-btn danger" onClick={loadLevel} style={{ marginTop: 'auto' }}>
|
||||
↺ Reiniciar Nivel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Main canvas */}
|
||||
<div className="gm-puzzle-canvas-wrap">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`node-canvas ${state.panning ? 'grabbing' : ''} ${connectingRef.current ? 'connecting' : ''}`}
|
||||
onPointerDown={handlePointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onWheel={handleWheel}
|
||||
onContextMenu={handleContextMenu}
|
||||
>
|
||||
{/* Grid */}
|
||||
<svg style={{ position: 'absolute', inset: 0, width: '100%', height: '100%', zIndex: 0 }}>
|
||||
<defs>
|
||||
<pattern id="puzzle-grid" width={20 * state.zoom} height={20 * state.zoom}
|
||||
patternUnits="userSpaceOnUse"
|
||||
x={state.camX % (20 * state.zoom)} y={state.camY % (20 * state.zoom)}>
|
||||
<circle cx={1} cy={1} r={0.8} fill="#1a1a30" />
|
||||
</pattern>
|
||||
</defs>
|
||||
<rect width="100%" height="100%" fill="url(#puzzle-grid)" />
|
||||
</svg>
|
||||
|
||||
{/* Wires */}
|
||||
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} zoom={state.zoom} camX={state.camX} camY={state.camY} />
|
||||
|
||||
{/* Modules */}
|
||||
<div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
|
||||
{state.modules.map(mod => (
|
||||
<ModuleNode
|
||||
key={mod.id}
|
||||
mod={mod}
|
||||
zoom={state.zoom}
|
||||
onStartConnect={handleStartConnect}
|
||||
onPortPosition={handlePortPosition}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas hints */}
|
||||
{state.modules.length > 0 && state.connections.length === 0 && (
|
||||
<div className="gm-canvas-hint">
|
||||
Arrastra de un puerto (circulo) a otro para conectar modulos
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Level complete overlay */}
|
||||
{result && result.stars >= 1 && (
|
||||
<LevelComplete
|
||||
stars={result.stars}
|
||||
checks={result.checks}
|
||||
levelTitle={level.title}
|
||||
isLastLevel={isLastLevel}
|
||||
onRetry={loadLevel}
|
||||
onMap={onBack}
|
||||
onNext={onNextLevel}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,111 +0,0 @@
|
||||
import React from 'react';
|
||||
import { WORLD_1 } from './levels/world1.js';
|
||||
import { getLevelProgress, isLevelUnlocked } from './gameState.js';
|
||||
|
||||
const worlds = [WORLD_1];
|
||||
|
||||
function Stars({ count, max = 3 }) {
|
||||
return (
|
||||
<span className="gm-stars">
|
||||
{Array.from({ length: max }, (_, i) => (
|
||||
<span key={i} className={i < count ? 'star filled' : 'star empty'}>★</span>
|
||||
))}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WorldMap({ onSelectLevel, onSandbox }) {
|
||||
const world = WORLD_1;
|
||||
const totalStars = world.levels.reduce((s, l) => {
|
||||
const p = getLevelProgress(l.id);
|
||||
return s + (p?.stars || 0);
|
||||
}, 0);
|
||||
const maxStars = world.levels.length * 3;
|
||||
|
||||
return (
|
||||
<div className="gm-worldmap">
|
||||
{/* Header */}
|
||||
<div className="gm-header">
|
||||
<div className="gm-logo">
|
||||
<span className="gm-logo-icon">~</span>
|
||||
<div>
|
||||
<h1 className="gm-title">SynthQuest</h1>
|
||||
<p className="gm-tagline">Aprende sintesis modular resolviendo puzzles</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="gm-header-right">
|
||||
<div className="gm-total-stars">
|
||||
<span className="star filled">★</span> {totalStars}/{maxStars}
|
||||
</div>
|
||||
<button className="gm-sandbox-btn" onClick={onSandbox}>
|
||||
🎛 Sandbox
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* World section */}
|
||||
<div className="gm-world-section">
|
||||
<div className="gm-world-header">
|
||||
<span className="gm-world-icon" style={{ color: world.color }}>{world.icon}</span>
|
||||
<div>
|
||||
<h2 className="gm-world-name">Mundo 1: {world.name}</h2>
|
||||
<p className="gm-world-sub">{world.subtitle}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Level grid */}
|
||||
<div className="gm-level-grid">
|
||||
{world.levels.map((level, idx) => {
|
||||
const progress = getLevelProgress(level.id);
|
||||
const unlocked = isLevelUnlocked(level.id, world.levels);
|
||||
const stars = progress?.stars || 0;
|
||||
const isBoss = idx === world.levels.length - 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={level.id}
|
||||
className={`gm-level-card ${unlocked ? 'unlocked' : 'locked'} ${isBoss ? 'boss' : ''} ${stars === 3 ? 'perfect' : ''}`}
|
||||
onClick={() => unlocked && onSelectLevel(level)}
|
||||
>
|
||||
<div className="gm-level-number">{idx + 1}</div>
|
||||
<div className="gm-level-info">
|
||||
<h3 className="gm-level-title">{level.title}</h3>
|
||||
<p className="gm-level-subtitle">{level.subtitle}</p>
|
||||
</div>
|
||||
{unlocked ? (
|
||||
<Stars count={stars} />
|
||||
) : (
|
||||
<span className="gm-lock">🔒</span>
|
||||
)}
|
||||
{!unlocked && <div className="gm-lock-overlay" />}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Future worlds teaser */}
|
||||
<div className="gm-world-section gm-locked-world">
|
||||
<div className="gm-world-header">
|
||||
<span className="gm-world-icon" style={{ color: '#666' }}>▽</span>
|
||||
<div>
|
||||
<h2 className="gm-world-name" style={{ color: '#666' }}>Mundo 2: Filtros</h2>
|
||||
<p className="gm-world-sub">Proximamente... Consigue {Math.ceil(maxStars * 0.6)} estrellas para desbloquear</p>
|
||||
</div>
|
||||
<span className="gm-lock" style={{ fontSize: 24 }}>🔒</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="gm-world-section gm-locked-world">
|
||||
<div className="gm-world-header">
|
||||
<span className="gm-world-icon" style={{ color: '#666' }}>⏤╲</span>
|
||||
<div>
|
||||
<h2 className="gm-world-name" style={{ color: '#666' }}>Mundo 3: Envelopes</h2>
|
||||
<p className="gm-world-sub">Proximamente...</p>
|
||||
</div>
|
||||
<span className="gm-lock" style={{ fontSize: 24 }}>🔒</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* gameState.js — Game progress persistence
|
||||
* Tracks completed levels, stars earned, and unlocks
|
||||
*/
|
||||
|
||||
const STORAGE_KEY = 'synthquest-progress';
|
||||
|
||||
const defaultProgress = {
|
||||
currentWorld: 'w1',
|
||||
completedLevels: {}, // { levelId: { stars: 3, bestTime: 12.5 } }
|
||||
unlockedWorlds: ['w1'],
|
||||
totalStars: 0,
|
||||
};
|
||||
|
||||
let _progress = null;
|
||||
|
||||
export function loadProgress() {
|
||||
if (_progress) return _progress;
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
_progress = raw ? { ...defaultProgress, ...JSON.parse(raw) } : { ...defaultProgress };
|
||||
} catch {
|
||||
_progress = { ...defaultProgress };
|
||||
}
|
||||
return _progress;
|
||||
}
|
||||
|
||||
export function saveProgress() {
|
||||
if (!_progress) return;
|
||||
_progress.totalStars = Object.values(_progress.completedLevels)
|
||||
.reduce((sum, l) => sum + (l.stars || 0), 0);
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(_progress));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
export function completeLevel(levelId, stars) {
|
||||
const p = loadProgress();
|
||||
const existing = p.completedLevels[levelId];
|
||||
if (!existing || stars > existing.stars) {
|
||||
p.completedLevels[levelId] = { stars, completedAt: Date.now() };
|
||||
}
|
||||
saveProgress();
|
||||
}
|
||||
|
||||
export function getLevelProgress(levelId) {
|
||||
const p = loadProgress();
|
||||
return p.completedLevels[levelId] || null;
|
||||
}
|
||||
|
||||
export function isLevelUnlocked(levelId, worldLevels) {
|
||||
const p = loadProgress();
|
||||
// First level is always unlocked
|
||||
const idx = worldLevels.findIndex(l => l.id === levelId);
|
||||
if (idx === 0) return true;
|
||||
// Previous level must have at least 1 star
|
||||
const prevId = worldLevels[idx - 1]?.id;
|
||||
return prevId && p.completedLevels[prevId]?.stars >= 1;
|
||||
}
|
||||
|
||||
export function resetProgress() {
|
||||
_progress = { ...defaultProgress };
|
||||
saveProgress();
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* targetAudio.js — Plays the "target" sound for a puzzle level
|
||||
* Builds a temporary Tone.js graph from the level's target config
|
||||
*/
|
||||
import * as Tone from 'tone';
|
||||
|
||||
let _activeNodes = [];
|
||||
let _isPlaying = false;
|
||||
let _stopTimeout = null;
|
||||
|
||||
export function isTargetPlaying() {
|
||||
return _isPlaying;
|
||||
}
|
||||
|
||||
export async function playTarget(target) {
|
||||
if (_isPlaying) {
|
||||
stopTarget();
|
||||
return;
|
||||
}
|
||||
|
||||
await Tone.start();
|
||||
_isPlaying = true;
|
||||
|
||||
const nodes = [];
|
||||
const output = new Tone.Gain(0.5).toDestination();
|
||||
nodes.push(output);
|
||||
|
||||
// Build oscillators from target.build
|
||||
for (const spec of target.build) {
|
||||
if (spec.type === 'oscillator') {
|
||||
const osc = new Tone.Oscillator({
|
||||
type: spec.params.waveform || 'sine',
|
||||
frequency: spec.params.frequency || 440,
|
||||
detune: spec.params.detune || 0,
|
||||
});
|
||||
osc.connect(output);
|
||||
osc.start();
|
||||
nodes.push(osc);
|
||||
}
|
||||
}
|
||||
|
||||
_activeNodes = nodes;
|
||||
|
||||
// Auto-stop after duration
|
||||
const dur = (target.duration || 2) * 1000;
|
||||
_stopTimeout = setTimeout(() => stopTarget(), dur);
|
||||
}
|
||||
|
||||
export function stopTarget() {
|
||||
if (_stopTimeout) {
|
||||
clearTimeout(_stopTimeout);
|
||||
_stopTimeout = null;
|
||||
}
|
||||
for (const node of _activeNodes) {
|
||||
try {
|
||||
if (node.stop) node.stop();
|
||||
if (node.disconnect) node.disconnect();
|
||||
if (node.dispose) node.dispose();
|
||||
} catch {}
|
||||
}
|
||||
_activeNodes = [];
|
||||
_isPlaying = false;
|
||||
}
|
||||
511
src/index.css
511
src/index.css
@@ -1,511 +0,0 @@
|
||||
/* ===== Reset & Base ===== */
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
--bg: #08080f;
|
||||
--panel: #0e0e1a;
|
||||
--surface: #14142a;
|
||||
--surface2: #1a1a35;
|
||||
--border: #252545;
|
||||
--text: #c8cce0;
|
||||
--text2: #6668a0;
|
||||
--accent: #00e5ff;
|
||||
--accent2: #ff6644;
|
||||
--green: #44ff88;
|
||||
--yellow: #ffcc00;
|
||||
--purple: #aa55ff;
|
||||
--red: #ff4466;
|
||||
--wire-audio: #00e5ff;
|
||||
--wire-control: #ff6644;
|
||||
--wire-trigger: #ffcc00;
|
||||
--knob-track: #333;
|
||||
--knob-fill: #00e5ff;
|
||||
--module-w: 180;
|
||||
--port-r: 6;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
width: 100%; height: 100%; overflow: hidden;
|
||||
background: var(--bg); color: var(--text);
|
||||
font-family: 'Inter', 'SF Pro', -apple-system, system-ui, sans-serif;
|
||||
font-size: 12px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ===== Layout ===== */
|
||||
.app { display: flex; flex-direction: column; height: 100vh; }
|
||||
|
||||
.toolbar {
|
||||
height: 40px; background: var(--panel); border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; padding: 0 12px; gap: 8px; flex-shrink: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
font-weight: 700; font-size: 14px; color: var(--accent);
|
||||
letter-spacing: 1px; text-transform: uppercase; margin-right: 16px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
padding: 4px 10px; border: 1px solid var(--border); border-radius: 4px;
|
||||
background: var(--surface); color: var(--text2); cursor: pointer;
|
||||
font-size: 11px; font-weight: 500; transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.toolbar-btn:hover { border-color: var(--accent); color: var(--text); }
|
||||
.toolbar-btn.active { background: var(--accent); color: #000; border-color: var(--accent); }
|
||||
.toolbar-btn.danger { border-color: var(--red); color: var(--red); }
|
||||
.toolbar-btn.danger:hover { background: var(--red); color: #000; }
|
||||
|
||||
.toolbar-sep { width: 1px; height: 20px; background: var(--border); margin: 0 4px; }
|
||||
|
||||
.toolbar-group { display: flex; gap: 4px; align-items: center; }
|
||||
|
||||
.toolbar-label { color: var(--text2); font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
|
||||
.main-area { flex: 1; position: relative; overflow: hidden; }
|
||||
|
||||
/* ===== Node Canvas ===== */
|
||||
.node-canvas {
|
||||
position: absolute; inset: 0; cursor: grab;
|
||||
}
|
||||
.node-canvas.grabbing { cursor: grabbing; }
|
||||
.node-canvas.connecting { cursor: crosshair; }
|
||||
|
||||
.wires-svg {
|
||||
position: absolute; inset: 0; pointer-events: none; z-index: 3;
|
||||
overflow: visible;
|
||||
}
|
||||
.wires-svg path {
|
||||
fill: none; stroke-width: 2.5; stroke-linecap: round;
|
||||
pointer-events: stroke; cursor: pointer;
|
||||
filter: drop-shadow(0 0 3px rgba(0,229,255,0.3));
|
||||
}
|
||||
.wires-svg path.audio { stroke: var(--wire-audio); opacity: 0.8; }
|
||||
.wires-svg path.control { stroke: var(--wire-control); opacity: 0.8; }
|
||||
.wires-svg path.trigger { stroke: var(--wire-trigger); opacity: 0.8; }
|
||||
.wires-svg path.temp { stroke-dasharray: 6 4; opacity: 0.5; filter: none; }
|
||||
.wires-svg path:hover { stroke-width: 4; opacity: 1; filter: drop-shadow(0 0 6px rgba(0,229,255,0.6)); }
|
||||
|
||||
/* ===== Modules ===== */
|
||||
.module {
|
||||
position: absolute; width: 180px; min-width: 180px;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 8px; user-select: none; z-index: 2;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
.module.selected { border-color: var(--accent); box-shadow: 0 0 20px rgba(0,229,255,0.15); }
|
||||
.module:hover { box-shadow: 0 6px 24px rgba(0,0,0,0.5); }
|
||||
|
||||
.module-header {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 10px; border-bottom: 1px solid var(--border);
|
||||
cursor: grab; border-radius: 8px 8px 0 0;
|
||||
background: var(--surface2);
|
||||
}
|
||||
.module-header .type-icon { font-size: 14px; }
|
||||
.module-header .type-name {
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.5px; color: var(--text);
|
||||
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.module-header .close-btn {
|
||||
width: 18px; height: 18px; border: none; background: transparent;
|
||||
color: var(--text2); cursor: pointer; font-size: 12px; border-radius: 3px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.module-header .close-btn:hover { background: var(--red); color: #fff; }
|
||||
|
||||
.module-body { padding: 8px 10px; display: flex; flex-direction: column; gap: 6px; }
|
||||
|
||||
/* Ports */
|
||||
.port-row {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
position: relative; height: 20px;
|
||||
}
|
||||
.port-row.input { flex-direction: row; }
|
||||
.port-row.output { flex-direction: row-reverse; }
|
||||
|
||||
.port-dot {
|
||||
width: 12px; height: 12px; border-radius: 50%;
|
||||
border: 2px solid var(--border); background: var(--surface);
|
||||
cursor: pointer; flex-shrink: 0; transition: all 0.15s;
|
||||
position: relative; z-index: 5;
|
||||
}
|
||||
.port-dot.audio { border-color: var(--wire-audio); }
|
||||
.port-dot.control { border-color: var(--wire-control); }
|
||||
.port-dot.trigger { border-color: var(--wire-trigger); }
|
||||
.port-dot:hover { transform: scale(1.3); }
|
||||
.port-dot.connected { background: currentColor; }
|
||||
.port-dot.audio.connected { background: var(--wire-audio); }
|
||||
.port-dot.control.connected { background: var(--wire-control); }
|
||||
.port-dot.trigger.connected { background: var(--wire-trigger); }
|
||||
.port-dot.compatible { animation: pulse-port 0.6s infinite alternate; }
|
||||
|
||||
@keyframes pulse-port {
|
||||
from { box-shadow: 0 0 2px currentColor; }
|
||||
to { box-shadow: 0 0 8px currentColor; }
|
||||
}
|
||||
|
||||
.port-label {
|
||||
font-size: 10px; color: var(--text2); text-transform: uppercase;
|
||||
letter-spacing: 0.3px; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Knobs */
|
||||
.param-row {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.param-label {
|
||||
font-size: 10px; color: var(--text2); width: 48px;
|
||||
text-transform: uppercase; letter-spacing: 0.3px; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.knob-container { position: relative; width: 32px; height: 32px; flex-shrink: 0; }
|
||||
.knob-svg { width: 32px; height: 32px; cursor: pointer; }
|
||||
.knob-track { fill: none; stroke: var(--knob-track); stroke-width: 3; stroke-linecap: round; }
|
||||
.knob-fill { fill: none; stroke-width: 3; stroke-linecap: round; }
|
||||
.knob-dot { fill: var(--text); }
|
||||
|
||||
.param-value {
|
||||
font-size: 10px; color: var(--accent); font-family: 'JetBrains Mono', monospace;
|
||||
min-width: 40px; text-align: right;
|
||||
}
|
||||
|
||||
/* Select param */
|
||||
.param-select {
|
||||
flex: 1; background: var(--bg); border: 1px solid var(--border);
|
||||
border-radius: 3px; padding: 2px 4px; color: var(--text);
|
||||
font-size: 10px; font-family: inherit; cursor: pointer;
|
||||
}
|
||||
.param-select:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
/* Scope canvas */
|
||||
.scope-canvas {
|
||||
width: 100%; height: 60px; border-radius: 4px;
|
||||
background: #050510; border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ===== Module Palette (sidebar) ===== */
|
||||
.palette {
|
||||
position: absolute; left: 8px; top: 8px; z-index: 20;
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 8px; display: flex; flex-direction: column; gap: 4px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
max-height: calc(100% - 16px); overflow-y: auto;
|
||||
}
|
||||
.palette-title {
|
||||
font-size: 9px; font-weight: 700; color: var(--text2);
|
||||
text-transform: uppercase; letter-spacing: 1px; padding: 2px 4px;
|
||||
}
|
||||
.palette-item {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 5px 8px; border-radius: 4px; cursor: pointer;
|
||||
font-size: 11px; color: var(--text); transition: all 0.1s;
|
||||
}
|
||||
.palette-item:hover { background: var(--surface2); }
|
||||
.palette-item .p-icon { font-size: 14px; width: 20px; text-align: center; }
|
||||
.palette-item .p-name { font-weight: 500; }
|
||||
.palette-item .p-cat { font-size: 9px; color: var(--text2); margin-left: auto; }
|
||||
|
||||
/* ===== Status Bar ===== */
|
||||
.status-bar {
|
||||
height: 24px; background: var(--panel); border-top: 1px solid var(--border);
|
||||
display: flex; align-items: center; padding: 0 12px; gap: 16px;
|
||||
font-size: 10px; color: var(--text2); flex-shrink: 0; z-index: 10;
|
||||
}
|
||||
.status-bar .status-accent { color: var(--accent); }
|
||||
|
||||
/* ===== Preset Modal ===== */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.7);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||
}
|
||||
.modal {
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 20px; min-width: 360px; max-width: 500px;
|
||||
box-shadow: 0 24px 64px rgba(0,0,0,0.6);
|
||||
}
|
||||
.modal h2 { font-size: 15px; color: var(--accent); margin-bottom: 12px; }
|
||||
.modal input {
|
||||
width: 100%; padding: 8px 10px; background: var(--bg);
|
||||
border: 1px solid var(--border); border-radius: 4px;
|
||||
color: var(--text); font-size: 13px; font-family: inherit;
|
||||
}
|
||||
.modal input:focus { outline: none; border-color: var(--accent); }
|
||||
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; }
|
||||
.modal-actions button { padding: 6px 14px; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 600; border: 1px solid var(--border); background: var(--surface); color: var(--text); font-family: inherit; }
|
||||
.modal-actions .primary { background: var(--accent); color: #000; border-color: var(--accent); }
|
||||
|
||||
.preset-list { max-height: 200px; overflow-y: auto; margin: 8px 0; }
|
||||
.preset-item {
|
||||
padding: 6px 10px; cursor: pointer; border-radius: 4px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
font-size: 12px;
|
||||
}
|
||||
.preset-item:hover { background: var(--surface2); }
|
||||
.preset-item .preset-date { color: var(--text2); font-size: 10px; }
|
||||
|
||||
/* ======================================================
|
||||
GAME MODE — SynthQuest
|
||||
====================================================== */
|
||||
|
||||
/* ===== World Map ===== */
|
||||
.gm-worldmap {
|
||||
height: 100vh; overflow-y: auto;
|
||||
background: linear-gradient(180deg, #08080f 0%, #0a0a1a 50%, #08080f 100%);
|
||||
padding: 0 24px 40px;
|
||||
}
|
||||
|
||||
.gm-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 20px 0; border-bottom: 1px solid var(--border); margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.gm-logo { display: flex; align-items: center; gap: 12px; }
|
||||
.gm-logo-icon {
|
||||
font-size: 36px; color: var(--accent);
|
||||
width: 56px; height: 56px; display: flex; align-items: center; justify-content: center;
|
||||
border: 2px solid var(--accent); border-radius: 12px; background: rgba(0,229,255,0.05);
|
||||
}
|
||||
.gm-title { font-size: 22px; font-weight: 800; color: var(--text); letter-spacing: 1px; }
|
||||
.gm-tagline { font-size: 12px; color: var(--text2); margin-top: 2px; }
|
||||
|
||||
.gm-header-right { display: flex; align-items: center; gap: 16px; }
|
||||
.gm-total-stars { font-size: 16px; color: var(--yellow); font-weight: 700; }
|
||||
.gm-sandbox-btn {
|
||||
padding: 8px 16px; border: 1px solid var(--border); border-radius: 6px;
|
||||
background: var(--surface); color: var(--text2); cursor: pointer;
|
||||
font-size: 12px; font-weight: 600; font-family: inherit; transition: all 0.15s;
|
||||
}
|
||||
.gm-sandbox-btn:hover { border-color: var(--accent); color: var(--text); }
|
||||
|
||||
/* World sections */
|
||||
.gm-world-section { margin-bottom: 32px; }
|
||||
.gm-locked-world { opacity: 0.4; }
|
||||
.gm-world-header {
|
||||
display: flex; align-items: center; gap: 12px; margin-bottom: 16px;
|
||||
}
|
||||
.gm-world-icon { font-size: 28px; }
|
||||
.gm-world-name { font-size: 16px; font-weight: 700; color: var(--text); }
|
||||
.gm-world-sub { font-size: 11px; color: var(--text2); margin-top: 2px; }
|
||||
|
||||
/* Level grid */
|
||||
.gm-level-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.gm-level-card {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 14px 16px; border-radius: 10px; cursor: pointer;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
transition: all 0.2s; position: relative; overflow: hidden;
|
||||
}
|
||||
.gm-level-card.unlocked:hover {
|
||||
border-color: var(--accent); transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0,229,255,0.1);
|
||||
}
|
||||
.gm-level-card.locked { cursor: default; opacity: 0.5; }
|
||||
.gm-level-card.boss { border-color: var(--yellow); }
|
||||
.gm-level-card.boss .gm-level-number { background: var(--yellow); color: #000; }
|
||||
.gm-level-card.perfect { border-color: var(--green); }
|
||||
|
||||
.gm-level-number {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: var(--surface2); display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 800; font-size: 14px; color: var(--accent); flex-shrink: 0;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.gm-level-info { flex: 1; min-width: 0; }
|
||||
.gm-level-title { font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
.gm-level-subtitle { font-size: 10px; color: var(--text2); margin-top: 2px; }
|
||||
|
||||
.gm-stars { display: flex; gap: 2px; }
|
||||
.gm-stars .star { font-size: 16px; }
|
||||
.gm-stars .star.filled { color: var(--yellow); }
|
||||
.gm-stars .star.empty { color: var(--border); }
|
||||
|
||||
.gm-lock { font-size: 18px; }
|
||||
.gm-lock-overlay {
|
||||
position: absolute; inset: 0; background: rgba(8,8,15,0.3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ===== Puzzle View ===== */
|
||||
.gm-puzzle { display: flex; flex-direction: column; height: 100vh; }
|
||||
|
||||
.gm-puzzle-bar {
|
||||
height: 48px; background: var(--panel); border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; padding: 0 16px; gap: 12px;
|
||||
flex-shrink: 0; z-index: 10;
|
||||
}
|
||||
|
||||
.gm-puzzle-title { display: flex; align-items: center; gap: 8px; }
|
||||
.gm-puzzle-num {
|
||||
font-size: 10px; color: var(--text2); background: var(--surface);
|
||||
padding: 2px 8px; border-radius: 4px; font-weight: 600;
|
||||
}
|
||||
.gm-puzzle-name { font-size: 14px; font-weight: 700; color: var(--text); }
|
||||
.gm-puzzle-actions { margin-left: auto; display: flex; gap: 8px; }
|
||||
|
||||
/* Buttons */
|
||||
.gm-btn {
|
||||
padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px;
|
||||
background: var(--surface); color: var(--text); cursor: pointer;
|
||||
font-size: 12px; font-weight: 600; font-family: inherit; transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gm-btn:hover { border-color: var(--accent); }
|
||||
.gm-btn.icon { padding: 6px 10px; }
|
||||
.gm-btn.primary { background: var(--accent); color: #000; border-color: var(--accent); }
|
||||
.gm-btn.primary:hover { background: #33ecff; }
|
||||
.gm-btn.secondary { background: var(--surface2); }
|
||||
.gm-btn.target { border-color: var(--yellow); color: var(--yellow); }
|
||||
.gm-btn.target:hover { background: rgba(255,204,0,0.1); }
|
||||
.gm-btn.check { border-color: var(--green); color: var(--green); }
|
||||
.gm-btn.check:hover { background: rgba(68,255,136,0.1); }
|
||||
.gm-btn.active { background: var(--accent); color: #000; border-color: var(--accent); }
|
||||
.gm-btn.danger { border-color: var(--red); color: var(--red); }
|
||||
.gm-btn.danger:hover { background: rgba(255,68,102,0.1); }
|
||||
|
||||
/* Puzzle layout */
|
||||
.gm-puzzle-content { flex: 1; display: flex; overflow: hidden; }
|
||||
|
||||
.gm-puzzle-sidebar {
|
||||
width: 280px; flex-shrink: 0; background: var(--panel);
|
||||
border-right: 1px solid var(--border); overflow-y: auto;
|
||||
padding: 12px; display: flex; flex-direction: column; gap: 12px;
|
||||
}
|
||||
|
||||
.gm-puzzle-canvas-wrap {
|
||||
flex: 1; position: relative; overflow: hidden;
|
||||
}
|
||||
|
||||
/* Concept panel */
|
||||
.gm-concept-panel {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gm-concept-header {
|
||||
padding: 10px 12px; cursor: pointer; display: flex; justify-content: space-between;
|
||||
align-items: center; font-size: 12px; font-weight: 600; color: var(--yellow);
|
||||
}
|
||||
.gm-concept-body { padding: 0 12px 12px; }
|
||||
.gm-concept-desc { font-size: 11px; color: var(--text); line-height: 1.5; margin-bottom: 8px; }
|
||||
.gm-concept-tip {
|
||||
font-size: 10px; color: var(--text2); line-height: 1.5;
|
||||
padding: 8px; background: var(--bg); border-radius: 4px;
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
/* Objectives */
|
||||
.gm-objectives {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.gm-obj-title {
|
||||
font-size: 10px; font-weight: 700; color: var(--text2);
|
||||
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px;
|
||||
}
|
||||
.gm-obj {
|
||||
display: flex; align-items: center; gap: 8px; padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border); font-size: 11px;
|
||||
}
|
||||
.gm-obj:last-child { border-bottom: none; }
|
||||
.gm-obj-star { color: var(--yellow); font-size: 12px; flex-shrink: 0; width: 30px; }
|
||||
.gm-obj-name { flex: 1; color: var(--text2); }
|
||||
.gm-obj.passed .gm-obj-name { color: var(--green); }
|
||||
.gm-obj.failed .gm-obj-name { color: var(--text2); }
|
||||
.gm-obj-check { color: var(--green); font-weight: 700; }
|
||||
.gm-obj-x { color: var(--red); font-weight: 700; }
|
||||
|
||||
/* Module palette (game) */
|
||||
.gm-module-palette {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.gm-palette-title {
|
||||
font-size: 10px; font-weight: 700; color: var(--text2);
|
||||
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px;
|
||||
}
|
||||
.gm-palette-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px; border-radius: 6px; cursor: pointer;
|
||||
transition: all 0.15s; font-size: 12px; color: var(--text);
|
||||
}
|
||||
.gm-palette-item:hover { background: var(--surface2); }
|
||||
.gm-palette-icon { font-size: 16px; width: 24px; text-align: center; }
|
||||
.gm-palette-name { flex: 1; font-weight: 500; }
|
||||
.gm-palette-add {
|
||||
width: 22px; height: 22px; border-radius: 50%;
|
||||
background: var(--surface2); display: flex; align-items: center; justify-content: center;
|
||||
font-size: 14px; color: var(--accent); font-weight: 700;
|
||||
}
|
||||
|
||||
/* Canvas hint */
|
||||
.gm-canvas-hint {
|
||||
position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
|
||||
padding: 8px 16px; background: rgba(0,0,0,0.7); border-radius: 8px;
|
||||
font-size: 11px; color: var(--text2); pointer-events: none;
|
||||
border: 1px solid var(--border); z-index: 10;
|
||||
}
|
||||
|
||||
/* ===== Level Complete Overlay ===== */
|
||||
.gm-complete-overlay {
|
||||
position: fixed; inset: 0; background: rgba(8,8,15,0.85);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 200; animation: fadeIn 0.3s;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
.gm-complete-card {
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: 16px;
|
||||
padding: 32px 40px; text-align: center; min-width: 400px;
|
||||
box-shadow: 0 32px 64px rgba(0,0,0,0.5);
|
||||
animation: slideUp 0.4s ease-out;
|
||||
}
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(40px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.gm-complete-title { font-size: 20px; font-weight: 800; color: var(--green); margin-bottom: 4px; }
|
||||
.gm-complete-level { font-size: 13px; color: var(--text2); margin-bottom: 20px; }
|
||||
|
||||
.gm-complete-stars { display: flex; justify-content: center; gap: 12px; margin-bottom: 12px; }
|
||||
.gm-big-star {
|
||||
font-size: 48px; transition: all 0.4s ease-out;
|
||||
}
|
||||
.gm-big-star.empty { color: var(--border); transform: scale(0.8); }
|
||||
.gm-big-star.earned {
|
||||
color: var(--yellow); transform: scale(1);
|
||||
filter: drop-shadow(0 0 12px rgba(255,204,0,0.4));
|
||||
animation: starPop 0.4s ease-out;
|
||||
}
|
||||
@keyframes starPop {
|
||||
0% { transform: scale(0.3); opacity: 0; }
|
||||
60% { transform: scale(1.3); }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.gm-complete-msg { font-size: 13px; color: var(--text2); margin-bottom: 20px; font-style: italic; }
|
||||
|
||||
.gm-checks {
|
||||
margin-bottom: 24px; text-align: left;
|
||||
background: var(--surface); border-radius: 8px; padding: 12px;
|
||||
}
|
||||
.gm-check {
|
||||
display: flex; align-items: center; gap: 8px; padding: 6px 0;
|
||||
font-size: 12px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.gm-check:last-child { border-bottom: none; }
|
||||
.gm-check-icon { font-size: 14px; width: 20px; text-align: center; }
|
||||
.gm-check.passed .gm-check-icon { color: var(--green); }
|
||||
.gm-check.failed .gm-check-icon { color: var(--red); }
|
||||
.gm-check-name { flex: 1; color: var(--text); }
|
||||
.gm-check-star { color: var(--yellow); }
|
||||
|
||||
.gm-complete-actions { display: flex; gap: 8px; justify-content: center; }
|
||||
17
src/main.jsx
17
src/main.jsx
@@ -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 />);
|
||||
@@ -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' }
|
||||
});
|
||||
Reference in New Issue
Block a user