Compare commits
50 Commits
be66d9a7cf
...
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 |
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;
|
||||
})
|
||||
);
|
||||
});
|
||||
@@ -8,16 +8,29 @@ import ModuleNode from './components/ModuleNode.jsx';
|
||||
import WireLayer from './components/WireLayer.jsx';
|
||||
import ModulePalette from './components/ModulePalette.jsx';
|
||||
import PresetModal from './components/PresetModal.jsx';
|
||||
import BottomSheet from './components/BottomSheet.jsx';
|
||||
import { CHIPTUNE_PRESET } from './presets/chiptune.js';
|
||||
import { useIsMobile } from './hooks/useIsMobile.js';
|
||||
import { usePinchZoom } from './hooks/usePinchZoom.js';
|
||||
import { getModulesByCategory } from './engine/moduleRegistry.js';
|
||||
import { useAuth } from './services/AuthContext.jsx';
|
||||
|
||||
export default function App({ onSwitchToGame }) {
|
||||
export default function App({ onSwitchToGame, onSwitchToWorkshop, onSwitchToAdmin }) {
|
||||
const [, forceUpdate] = useState(0);
|
||||
const containerRef = useRef(null);
|
||||
const portPositions = useRef({});
|
||||
const [tempWire, setTempWire] = useState(null);
|
||||
const connectingRef = useRef(null);
|
||||
const [presetModal, setPresetModal] = useState(null);
|
||||
const { user, isLoggedIn, isAdmin, openAuth, logout } = useAuth();
|
||||
const importRef = useRef(null);
|
||||
const isMobile = useIsMobile();
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
// Pinch-to-zoom on mobile
|
||||
const getZoom = useCallback(() => state.zoom, []);
|
||||
const setZoom = useCallback((z) => { state.zoom = z; emit(); }, []);
|
||||
usePinchZoom(containerRef, getZoom, setZoom);
|
||||
|
||||
// Subscribe to state changes
|
||||
useEffect(() => {
|
||||
@@ -25,11 +38,11 @@ export default function App({ onSwitchToGame }) {
|
||||
return unsub;
|
||||
}, []);
|
||||
|
||||
// Auto-load on mount, or load chiptune demo if empty
|
||||
// Auto-load on mount, but skip if modules already loaded (e.g. from Workshop)
|
||||
useEffect(() => {
|
||||
if (state.modules.length > 0) return; // Already loaded (Workshop, etc.)
|
||||
const loaded = autoLoad();
|
||||
if (!loaded || state.modules.length === 0) {
|
||||
// Load chiptune demo preset
|
||||
deserialize(CHIPTUNE_PRESET);
|
||||
}
|
||||
}, []);
|
||||
@@ -86,10 +99,17 @@ export default function App({ onSwitchToGame }) {
|
||||
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
|
||||
e.preventDefault();
|
||||
} else if (e.button === 0 && !connectingRef.current) {
|
||||
// On mobile (touch), single finger on empty canvas = pan
|
||||
if (isMobile && e.pointerType === 'touch') {
|
||||
state.panning = true;
|
||||
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
state.selectedModuleId = null;
|
||||
emit();
|
||||
}
|
||||
}, []);
|
||||
}, [isMobile]);
|
||||
|
||||
const handlePointerMove = useCallback((e) => {
|
||||
if (state.panning && state.panStart) {
|
||||
@@ -191,6 +211,26 @@ export default function App({ onSwitchToGame }) {
|
||||
emit();
|
||||
}, []);
|
||||
|
||||
// Center view on all modules
|
||||
const handleCenterView = useCallback(() => {
|
||||
if (state.modules.length === 0) { state.camX = 0; state.camY = 0; emit(); return; }
|
||||
const container = containerRef.current;
|
||||
const cw = container?.clientWidth || 800;
|
||||
const ch = container?.clientHeight || 600;
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const m of state.modules) {
|
||||
minX = Math.min(minX, m.x);
|
||||
minY = Math.min(minY, m.y);
|
||||
maxX = Math.max(maxX, m.x + 200);
|
||||
maxY = Math.max(maxY, m.y + 150);
|
||||
}
|
||||
const cx = (minX + maxX) / 2 * state.zoom;
|
||||
const cy = (minY + maxY) / 2 * state.zoom;
|
||||
state.camX = cw / 2 - cx;
|
||||
state.camY = ch / 2 - cy;
|
||||
emit();
|
||||
}, []);
|
||||
|
||||
const handleToggleAudio = async () => {
|
||||
if (state.isRunning) {
|
||||
stopAudio();
|
||||
@@ -223,40 +263,111 @@ export default function App({ onSwitchToGame }) {
|
||||
emit();
|
||||
};
|
||||
|
||||
const handleClearCanvas = () => {
|
||||
if (state.isRunning) stopAudio();
|
||||
deserialize({ modules: [], connections: [] });
|
||||
emit();
|
||||
};
|
||||
|
||||
// Flatten all modules for mobile grid
|
||||
const allModuleDefs = Object.values(getModulesByCategory()).flat();
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
{/* Toolbar */}
|
||||
<div className="toolbar">
|
||||
{onSwitchToGame && (
|
||||
{onSwitchToGame && !isMobile && (
|
||||
<button className="toolbar-btn" onClick={onSwitchToGame} style={{ color: 'var(--yellow)' }}>
|
||||
🎮 Game
|
||||
</button>
|
||||
)}
|
||||
{onSwitchToWorkshop && !isMobile && (
|
||||
<button className="toolbar-btn" onClick={onSwitchToWorkshop}>
|
||||
🎵 Workshop
|
||||
</button>
|
||||
)}
|
||||
<span className="toolbar-title">Reaktor</span>
|
||||
<div className="toolbar-sep" />
|
||||
<button className={`toolbar-btn ${state.isRunning ? 'active' : ''}`} onClick={handleToggleAudio}>
|
||||
{!isMobile && <div className="toolbar-sep" />}
|
||||
{!isMobile && (
|
||||
<button className={`toolbar-btn start-btn ${state.isRunning ? 'active' : ''}`} onClick={handleToggleAudio}>
|
||||
{state.isRunning ? '⏹ Stop' : '▶ Start'}
|
||||
</button>
|
||||
<div className="toolbar-sep" />
|
||||
)}
|
||||
{!isMobile && <div className="toolbar-sep" />}
|
||||
{!isMobile && (
|
||||
<div className="toolbar-group">
|
||||
<button className="toolbar-btn" onClick={() => setPresetModal('save')}>💾 Save</button>
|
||||
<button className="toolbar-btn" onClick={() => setPresetModal('load')}>📂 Load</button>
|
||||
<button className="toolbar-btn" onClick={exportPatch}>📤 Export</button>
|
||||
<button className="toolbar-btn" onClick={() => importRef.current?.click()}>📥 Import</button>
|
||||
<button className="toolbar-btn save-btn" onClick={() => setPresetModal('save')}>💾 Save</button>
|
||||
<button className="toolbar-btn load-btn" onClick={() => setPresetModal('load')}>📂 Load</button>
|
||||
<button className="toolbar-btn export-btn" onClick={exportPatch}>📤 Export</button>
|
||||
<button className="toolbar-btn import-btn" onClick={() => importRef.current?.click()}>📥 Import</button>
|
||||
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
|
||||
</div>
|
||||
<div className="toolbar-sep" />
|
||||
<button className="toolbar-btn" onClick={handleLoadDemo} style={{ color: 'var(--yellow)' }}>
|
||||
)}
|
||||
{!isMobile && <div className="toolbar-sep" />}
|
||||
{!isMobile && (
|
||||
<>
|
||||
<button className="toolbar-btn demo-btn" onClick={handleLoadDemo} style={{ color: 'var(--yellow)' }}>
|
||||
🎮 Chiptune Demo
|
||||
</button>
|
||||
<button className="toolbar-btn clear-btn" onClick={handleClearCanvas}>
|
||||
🗑 Limpiar
|
||||
</button>
|
||||
<div className="toolbar-sep" />
|
||||
<span className="toolbar-label" style={{ color: state.isRunning ? 'var(--green)' : 'var(--text2)' }}>
|
||||
</>
|
||||
)}
|
||||
<span className="toolbar-label" style={{ color: state.isRunning ? 'var(--green)' : 'var(--text2)', marginLeft: isMobile ? 'auto' : undefined }}>
|
||||
{state.isRunning ? '● LIVE' : '○ OFF'}
|
||||
</span>
|
||||
{!isMobile && (
|
||||
<span className="toolbar-label" style={{ marginLeft: 'auto' }}>
|
||||
{state.modules.length} modules · {state.connections.length} wires
|
||||
</span>
|
||||
)}
|
||||
{isLoggedIn ? (
|
||||
<div className="user-dropdown">
|
||||
<div className="user-badge">
|
||||
<div className="user-avatar">{user.username?.[0]?.toUpperCase()}</div>
|
||||
<span className="user-name">{user.username}</span>
|
||||
</div>
|
||||
<div className="user-dropdown-menu">
|
||||
{isAdmin && onSwitchToAdmin && <button onClick={onSwitchToAdmin}>🛠 Admin</button>}
|
||||
<button onClick={logout}>Cerrar sesion</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<button className="login-btn" onClick={openAuth}>Entrar</button>
|
||||
)}
|
||||
{isMobile && (
|
||||
<button className="mobile-menu-btn" onClick={() => setMenuOpen(true)}>≡</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Mobile menu overlay */}
|
||||
{isMobile && menuOpen && (
|
||||
<div className="mobile-menu-overlay" onClick={() => setMenuOpen(false)}>
|
||||
<div className="mobile-menu-panel" onClick={e => e.stopPropagation()}>
|
||||
{onSwitchToGame && (
|
||||
<button className="toolbar-btn" onClick={() => { setMenuOpen(false); onSwitchToGame(); }} style={{ color: 'var(--yellow)' }}>
|
||||
🎮 Game
|
||||
</button>
|
||||
)}
|
||||
<button className="toolbar-btn" onClick={() => { setPresetModal('save'); setMenuOpen(false); }}>💾 Save</button>
|
||||
<button className="toolbar-btn" onClick={() => { setPresetModal('load'); setMenuOpen(false); }}>📂 Load</button>
|
||||
<button className="toolbar-btn" onClick={() => { exportPatch(); setMenuOpen(false); }}>📤 Export</button>
|
||||
<button className="toolbar-btn" onClick={() => { importRef.current?.click(); setMenuOpen(false); }}>📥 Import</button>
|
||||
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
|
||||
<button className="toolbar-btn" onClick={() => { handleLoadDemo(); setMenuOpen(false); }} style={{ color: 'var(--yellow)' }}>
|
||||
🎮 Chiptune Demo
|
||||
</button>
|
||||
<button className="toolbar-btn" onClick={() => { handleClearCanvas(); setMenuOpen(false); }}>🗑 Limpiar</button>
|
||||
{onSwitchToWorkshop && (
|
||||
<button className="toolbar-btn" onClick={() => { setMenuOpen(false); onSwitchToWorkshop(); }} style={{ color: 'var(--accent)' }}>
|
||||
🎵 Workshop
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Main canvas area */}
|
||||
<div className="main-area">
|
||||
@@ -281,10 +392,10 @@ export default function App({ onSwitchToGame }) {
|
||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||
</svg>
|
||||
|
||||
{/* Wire layer (behind modules, uses getBoundingClientRect) */}
|
||||
{/* Wire layer */}
|
||||
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} zoom={state.zoom} camX={state.camX} camY={state.camY} />
|
||||
|
||||
{/* Modules container (offset by camera) */}
|
||||
{/* Modules container */}
|
||||
<div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
|
||||
{state.modules.map(mod => (
|
||||
<ModuleNode
|
||||
@@ -298,20 +409,50 @@ export default function App({ onSwitchToGame }) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Zoom controls — top right of canvas */}
|
||||
{/* Zoom controls */}
|
||||
<div className="zoom-controls">
|
||||
<button className="zoom-btn" onClick={handleZoomIn} title="Zoom in">+</button>
|
||||
<button className="zoom-btn zoom-label" onClick={handleZoomReset} title="Reset zoom">
|
||||
{(state.zoom * 100).toFixed(0)}%
|
||||
</button>
|
||||
<button className="zoom-btn" onClick={handleZoomOut} title="Zoom out">−</button>
|
||||
<button className="zoom-btn" onClick={handleCenterView} title="Centrar vista">⌂</button>
|
||||
</div>
|
||||
|
||||
{/* Module palette */}
|
||||
<ModulePalette onAddModule={handleAddModule} />
|
||||
{/* Desktop palette */}
|
||||
{!isMobile && <ModulePalette onAddModule={handleAddModule} />}
|
||||
</div>
|
||||
|
||||
{/* Status bar */}
|
||||
{/* Mobile action bar */}
|
||||
{isMobile && (
|
||||
<div className="mobile-action-bar">
|
||||
<button
|
||||
className={`start-btn-mobile ${state.isRunning ? 'active' : ''}`}
|
||||
onClick={handleToggleAudio}
|
||||
>
|
||||
{state.isRunning ? '⏹ STOP' : '▶ START'}
|
||||
</button>
|
||||
<button className="action-icon-btn" onClick={() => setPresetModal('save')}>💾</button>
|
||||
<button className="action-icon-btn" onClick={exportPatch}>📤</button>
|
||||
<button className="action-icon-btn" onClick={handleClearCanvas}>🗑</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Mobile bottom sheet with modules */}
|
||||
{isMobile && (
|
||||
<BottomSheet>
|
||||
<div className="mobile-module-grid">
|
||||
{allModuleDefs.map(def => (
|
||||
<div key={def.type} className="mobile-module-tile" onClick={() => handleAddModule(def.type)}>
|
||||
<span className="tile-icon">{def.icon}</span>
|
||||
<span className="tile-name">{def.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</BottomSheet>
|
||||
)}
|
||||
|
||||
{/* Status bar (hidden on mobile via CSS) */}
|
||||
<div className="status-bar">
|
||||
<span className="status-accent">Reaktor — MontLab Modular Synth</span>
|
||||
<span>Zoom: {(state.zoom * 100).toFixed(0)}%</span>
|
||||
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
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,13 +1,28 @@
|
||||
import React, { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { getModuleDef } from '../engine/moduleRegistry.js';
|
||||
import { state, removeModule, updateModuleParam, isPortConnected, emit } from '../engine/state.js';
|
||||
import { updateParam } from '../engine/audioEngine.js';
|
||||
import { updateParam, getAudioNode } from '../engine/audioEngine.js';
|
||||
import Knob from './Knob.jsx';
|
||||
import ScopeDisplay from './ScopeDisplay.jsx';
|
||||
import KeyboardWidget from './KeyboardWidget.jsx';
|
||||
import DrumPadWidget from './DrumPadWidget.jsx';
|
||||
import SequencerWidget from './SequencerWidget.jsx';
|
||||
import PianoRollWidget from './PianoRollWidget.jsx';
|
||||
|
||||
// Dynamic module widths for sequencer/pianoroll based on step/bar count
|
||||
function getModuleWidth(mod, type) {
|
||||
if (type === 'sequencer') {
|
||||
const numSteps = parseInt(mod?.params?.steps || '16');
|
||||
return Math.max(200, numSteps * 18 + 20); // CELL_W=18 + padding
|
||||
}
|
||||
if (type === 'pianoroll') {
|
||||
const bars = parseInt(mod?.params?.bars || '4');
|
||||
const totalBeats = bars * 4;
|
||||
return 24 + totalBeats * 30 + 20; // KEY_W + beats*BEAT_PX + padding
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Map input port names → the param name they modulate (for visual feedback)
|
||||
const PORT_TO_PARAM = {
|
||||
filter: { cutoff: 'frequency' },
|
||||
@@ -31,6 +46,7 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
|
||||
if (!def) return null;
|
||||
|
||||
const isSelected = state.selectedModuleId === mod.id;
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
|
||||
// Merge default params
|
||||
const params = { ...Object.fromEntries(Object.entries(def.params).map(([k, v]) => [k, v.default])), ...mod.params };
|
||||
@@ -44,7 +60,7 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Live LFO modulation visualization ====================
|
||||
// ==================== Live modulation visualization (any source → any param) ====================
|
||||
const [liveValues, setLiveValues] = useState({});
|
||||
const rafRef = useRef(null);
|
||||
const startTimeRef = useRef(performance.now() / 1000);
|
||||
@@ -55,40 +71,83 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
|
||||
return;
|
||||
}
|
||||
|
||||
let frameCount = 0;
|
||||
const tick = () => {
|
||||
frameCount++;
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
if (frameCount % 4 !== 0) return;
|
||||
|
||||
const t = performance.now() / 1000 - startTimeRef.current;
|
||||
const newValues = {};
|
||||
|
||||
// Read current params fresh from state each tick (avoid stale closure)
|
||||
const curMod = state.modules.find(m => m.id === mod.id);
|
||||
if (!curMod) return;
|
||||
const curDef = getModuleDef(curMod.type);
|
||||
if (!curDef) return;
|
||||
const curParams = { ...Object.fromEntries(Object.entries(curDef.params).map(([k, v]) => [k, v.default])), ...curMod.params };
|
||||
|
||||
for (const conn of state.connections) {
|
||||
if (conn.to.moduleId !== mod.id) continue;
|
||||
const paramName = portMap[conn.to.port];
|
||||
if (!paramName) continue;
|
||||
|
||||
const srcMod = state.modules.find(m => m.id === conn.from.moduleId);
|
||||
if (!srcMod || srcMod.type !== 'lfo') continue;
|
||||
if (!srcMod) continue;
|
||||
|
||||
// Read LFO params from state
|
||||
const baseValue = curParams[paramName];
|
||||
|
||||
// Modulation scale based on target parameter
|
||||
const getScale = () => {
|
||||
if (curMod.type === 'oscillator' && paramName === 'frequency') return baseValue * 0.5;
|
||||
if (curMod.type === 'filter' && paramName === 'frequency') return baseValue;
|
||||
if (curMod.type === 'vca' && paramName === 'gain') return 1;
|
||||
return baseValue || 1;
|
||||
};
|
||||
|
||||
if (srcMod.type === 'lfo') {
|
||||
// LFO: simulate waveform for smooth visual
|
||||
const lfoDef = getModuleDef('lfo');
|
||||
const lfoP = { ...Object.fromEntries(Object.entries(lfoDef.params).map(([k, v]) => [k, v.default])), ...srcMod.params };
|
||||
const freq = lfoP.frequency;
|
||||
const amp = lfoP.amplitude;
|
||||
const waveform = lfoP.waveform;
|
||||
const phase = (t * freq) % 1;
|
||||
const lfoVal = simulateLFO(waveform, phase) * amp;
|
||||
const phase = (t * lfoP.frequency) % 1;
|
||||
const lfoVal = simulateLFO(lfoP.waveform, phase) * lfoP.amplitude;
|
||||
newValues[paramName] = baseValue + lfoVal * getScale();
|
||||
|
||||
// Compute modulated value (same scaling as audioEngine)
|
||||
const baseValue = params[paramName];
|
||||
let scale;
|
||||
if (mod.type === 'oscillator' && paramName === 'frequency') scale = baseValue * 0.5;
|
||||
else if (mod.type === 'filter' && paramName === 'frequency') scale = baseValue;
|
||||
else if (mod.type === 'vca' && paramName === 'gain') scale = 1;
|
||||
else scale = baseValue || 1;
|
||||
} else if (srcMod.type === 'envelope') {
|
||||
// Envelope: read current level (0-1) from the source envelope node
|
||||
const envEntry = getAudioNode(srcMod.id);
|
||||
if (envEntry?.node) {
|
||||
const envValue = typeof envEntry.node.value === 'number' ? envEntry.node.value : 0;
|
||||
if (curMod.type === 'vca' && paramName === 'gain') {
|
||||
newValues[paramName] = envValue; // Envelope directly drives gain (0→1)
|
||||
} else {
|
||||
newValues[paramName] = baseValue + envValue * getScale();
|
||||
}
|
||||
}
|
||||
|
||||
newValues[paramName] = baseValue + lfoVal * scale;
|
||||
} else if (srcMod.type === 'oscillator') {
|
||||
// Oscillator FM: simulate modulating oscillator waveform
|
||||
const srcDef = getModuleDef('oscillator');
|
||||
const srcP = { ...Object.fromEntries(Object.entries(srcDef.params).map(([k, v]) => [k, v.default])), ...srcMod.params };
|
||||
// Clamp visual frequency to avoid aliasing — show a slow representation
|
||||
const visFreq = Math.min(srcP.frequency, 8);
|
||||
const phase = (t * visFreq) % 1;
|
||||
const modVal = simulateLFO(srcP.waveform, phase) * 0.5;
|
||||
newValues[paramName] = baseValue + modVal * getScale();
|
||||
|
||||
} else if (srcMod.type === 'noise') {
|
||||
// Noise: random jitter
|
||||
const noiseVal = (Math.random() * 2 - 1) * 0.3;
|
||||
newValues[paramName] = baseValue + noiseVal * getScale();
|
||||
|
||||
} else {
|
||||
// Generic fallback: subtle visual pulse so user sees modulation is active
|
||||
const pulseVal = Math.sin(2 * Math.PI * t) * 0.2;
|
||||
newValues[paramName] = baseValue + pulseVal * getScale();
|
||||
}
|
||||
}
|
||||
|
||||
setLiveValues(newValues);
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
@@ -150,7 +209,7 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
|
||||
style={{
|
||||
left: mod.x * zoom, top: mod.y * zoom,
|
||||
transform: `scale(${zoom})`, transformOrigin: 'top left',
|
||||
...(mod.type === 'pianoroll' ? { width: 520 } : mod.type === 'sequencer' ? { width: 310 } : {}),
|
||||
...(mod.type === 'pianoroll' ? { width: getModuleWidth(mod, 'pianoroll') } : mod.type === 'sequencer' ? { width: getModuleWidth(mod, 'sequencer') } : {}),
|
||||
}}
|
||||
data-module-id={mod.id}
|
||||
onPointerDown={(e) => {
|
||||
@@ -162,6 +221,13 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
|
||||
<div className="module-header" onPointerDown={handleHeaderDown}>
|
||||
<span className="type-icon">{def.icon}</span>
|
||||
<span className="type-name">{def.name}</span>
|
||||
{(mod.type === 'keyboard' || mod.type === 'drumpad') && (
|
||||
<button
|
||||
className="expand-btn"
|
||||
onClick={(e) => { e.stopPropagation(); setFullscreen(true); }}
|
||||
title="Pantalla completa"
|
||||
>⤢</button>
|
||||
)}
|
||||
<button className="close-btn" onClick={handleDelete}>✕</button>
|
||||
</div>
|
||||
|
||||
@@ -232,7 +298,10 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
|
||||
{mod.type === 'scope' && <ScopeDisplay moduleId={mod.id} />}
|
||||
|
||||
{/* Keyboard widget */}
|
||||
{mod.type === 'keyboard' && <KeyboardWidget moduleId={mod.id} />}
|
||||
{mod.type === 'keyboard' && <KeyboardWidget moduleId={mod.id} fullscreen={fullscreen} onCloseFullscreen={() => setFullscreen(false)} />}
|
||||
|
||||
{/* Drum Pad widget */}
|
||||
{mod.type === 'drumpad' && <DrumPadWidget moduleId={mod.id} fullscreen={fullscreen} onCloseFullscreen={() => setFullscreen(false)} />}
|
||||
|
||||
{/* Sequencer widget */}
|
||||
{mod.type === 'sequencer' && <SequencerWidget moduleId={mod.id} />}
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
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) {
|
||||
@@ -84,13 +126,17 @@ function createNode(mod) {
|
||||
};
|
||||
}
|
||||
case 'vca': {
|
||||
// Use a Multiply node: in × cv
|
||||
const gain = new Tone.Gain(p.gain);
|
||||
// CV scaler: always gain=1 so envelope (0-1) passes through fully.
|
||||
// When CV is connected, base gain is zeroed — envelope controls amplitude entirely.
|
||||
const cvMod = new Tone.Gain(1);
|
||||
cvMod.connect(gain.gain);
|
||||
return {
|
||||
node: gain,
|
||||
inputs: { in: gain, cv: gain.gain },
|
||||
_cvMod: cvMod,
|
||||
inputs: { in: gain, cv: cvMod },
|
||||
outputs: { out: gain },
|
||||
dispose: () => gain.dispose(),
|
||||
dispose: () => { cvMod.disconnect(); cvMod.dispose(); gain.dispose(); },
|
||||
};
|
||||
}
|
||||
case 'delay': {
|
||||
@@ -136,7 +182,7 @@ function createNode(mod) {
|
||||
};
|
||||
}
|
||||
case 'scope': {
|
||||
const analyser = new Tone.Analyser('waveform', 256);
|
||||
const analyser = new Tone.Analyser('waveform', 2048);
|
||||
return {
|
||||
node: analyser,
|
||||
inputs: { in: analyser },
|
||||
@@ -145,6 +191,20 @@ function createNode(mod) {
|
||||
dispose: () => analyser.dispose(),
|
||||
};
|
||||
}
|
||||
case 'cv2gate': {
|
||||
// Converts a continuous CV signal to gate on/off based on threshold.
|
||||
// Uses an analyser to read the CV value and triggers connected envelopes.
|
||||
const analyser = new Tone.Analyser('waveform', 32);
|
||||
const gateSig = new Tone.Signal(0);
|
||||
return {
|
||||
node: analyser,
|
||||
_gateSig: gateSig,
|
||||
_gateState: false,
|
||||
inputs: { in: analyser },
|
||||
outputs: { gate: gateSig },
|
||||
dispose: () => { analyser.dispose(); gateSig.dispose(); },
|
||||
};
|
||||
}
|
||||
case 'output': {
|
||||
// True stereo output: separate left/right channels → merge → master gain → destination
|
||||
const leftGain = new Tone.Gain(1);
|
||||
@@ -170,7 +230,8 @@ function createNode(mod) {
|
||||
},
|
||||
};
|
||||
}
|
||||
case 'keyboard': {
|
||||
case 'keyboard':
|
||||
case 'drumpad': {
|
||||
const freqSig = new Tone.Signal(440);
|
||||
const gateSig = new Tone.Signal(0);
|
||||
return {
|
||||
@@ -245,6 +306,17 @@ export function connectWire(conn) {
|
||||
const toEntry = ensureNode(conn.to.moduleId);
|
||||
if (!fromEntry || !toEntry) return;
|
||||
|
||||
// Skip audio-graph connection for keyboard/sequencer/pianoroll freq → oscillator freq.
|
||||
// These signals carry absolute Hz values that would be mangled by the oscillator's
|
||||
// frequency-modulation Gain scaler. Instead, triggerKeyboard / setSequencerSignals
|
||||
// set the oscillator frequency directly when notes are played.
|
||||
const fromMod = state.modules.find(m => m.id === conn.from.moduleId);
|
||||
const toMod = state.modules.find(m => m.id === conn.to.moduleId);
|
||||
if (fromMod && ['keyboard', 'drumpad', 'sequencer', 'pianoroll'].includes(fromMod.type) &&
|
||||
conn.from.port === 'freq' && toMod?.type === 'oscillator' && conn.to.port === 'freq') {
|
||||
return; // handled imperatively in triggerKeyboard / setSequencerSignals
|
||||
}
|
||||
|
||||
const output = fromEntry.outputs[conn.from.port];
|
||||
const input = toEntry.inputs[conn.to.port];
|
||||
if (!output || input === undefined || input === null) return;
|
||||
@@ -256,6 +328,11 @@ export function connectWire(conn) {
|
||||
} catch (e) {
|
||||
console.warn('connect error', e);
|
||||
}
|
||||
|
||||
// When CV is connected to VCA, zero the base gain so only envelope controls it
|
||||
if (toMod?.type === 'vca' && conn.to.port === 'cv') {
|
||||
toEntry.node.gain.value = 0;
|
||||
}
|
||||
}
|
||||
|
||||
export function disconnectWire(conn) {
|
||||
@@ -274,6 +351,12 @@ export function disconnectWire(conn) {
|
||||
} catch (e) {
|
||||
// Tone.js may throw if not connected
|
||||
}
|
||||
|
||||
// When CV is disconnected from VCA, restore base gain from params
|
||||
const toMod = state.modules.find(m => m.id === conn.to.moduleId);
|
||||
if (toMod?.type === 'vca' && conn.to.port === 'cv') {
|
||||
toEntry.node.gain.value = toMod.params?.gain ?? 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
export function updateParam(moduleId, paramName, value) {
|
||||
@@ -318,7 +401,12 @@ export function updateParam(moduleId, paramName, value) {
|
||||
else if (paramName === 'release') entry.node.release = value;
|
||||
break;
|
||||
case 'vca':
|
||||
if (paramName === 'gain') entry.node.gain.value = value;
|
||||
if (paramName === 'gain') {
|
||||
// Only update base gain if no CV is connected (CV zeroes it)
|
||||
const hasCV = state.connections.some(c => c.to.moduleId === moduleId && c.to.port === 'cv');
|
||||
if (!hasCV) entry.node.gain.value = value;
|
||||
// cvMod stays at 1 always — envelope controls full range
|
||||
}
|
||||
break;
|
||||
case 'delay':
|
||||
if (paramName === 'delayTime') entry.node.delayTime.value = value;
|
||||
@@ -343,6 +431,8 @@ export function updateParam(moduleId, paramName, value) {
|
||||
if (paramName === 'volume') entry.node.gain.value = Tone.dbToGain(value);
|
||||
break;
|
||||
case 'keyboard':
|
||||
case 'drumpad':
|
||||
case 'cv2gate':
|
||||
case 'sequencer':
|
||||
case 'pianoroll':
|
||||
// All params stored in state, managed by widgets
|
||||
@@ -350,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();
|
||||
@@ -366,7 +485,6 @@ export function setSequencerSignals(moduleId, freq, gate) {
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function triggerKeyboard(moduleId, freq, gate) {
|
||||
const entry = audioNodes[moduleId];
|
||||
@@ -374,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();
|
||||
@@ -384,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));
|
||||
@@ -417,6 +551,55 @@ export function rebuildGraph() {
|
||||
for (const conn of state.connections) {
|
||||
connectWire(conn);
|
||||
}
|
||||
|
||||
// Zero base gain on VCAs with active CV connection.
|
||||
// When envelope controls VCA, base gain must be 0 so silence is possible.
|
||||
for (const mod of state.modules) {
|
||||
if (mod.type !== 'vca') continue;
|
||||
const hasCV = state.connections.some(c => c.to.moduleId === mod.id && c.to.port === 'cv');
|
||||
const entry = audioNodes[mod.id];
|
||||
if (entry && hasCV) entry.node.gain.value = 0;
|
||||
}
|
||||
|
||||
// Auto-trigger envelopes that have no gate connection (free-running mode).
|
||||
// This allows noise/ambient patches to work without a keyboard/sequencer.
|
||||
for (const mod of state.modules) {
|
||||
if (mod.type !== 'envelope') continue;
|
||||
const hasGateInput = state.connections.some(
|
||||
c => c.to.moduleId === mod.id && c.to.port === 'gate'
|
||||
);
|
||||
if (!hasGateInput) {
|
||||
const entry = audioNodes[mod.id];
|
||||
if (entry && entry.node && typeof entry.node.triggerAttack === 'function') {
|
||||
entry.node.triggerAttack();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Register CV→Gate modules on master clock for threshold detection
|
||||
for (const mod of state.modules) {
|
||||
if (mod.type !== 'cv2gate') continue;
|
||||
const entry = audioNodes[mod.id];
|
||||
if (!entry) continue;
|
||||
subscribeTick(`cv2gate-${mod.id}`, () => {
|
||||
const data = entry.node.getValue();
|
||||
const sample = typeof data === 'number' ? data : (data?.[0] ?? 0);
|
||||
const threshold = mod.params?.threshold ?? 0.5;
|
||||
const gateOn = sample > threshold;
|
||||
if (gateOn !== entry._gateState) {
|
||||
entry._gateState = gateOn;
|
||||
entry._gateSig.value = gateOn ? 1 : 0;
|
||||
// Trigger/release connected envelopes
|
||||
for (const conn of getConnectionsFrom(mod.id, 'gate')) {
|
||||
const envEntry = audioNodes[conn.to.moduleId];
|
||||
if (envEntry && envEntry.node instanceof Tone.Envelope) {
|
||||
if (gateOn) envEntry.node.triggerAttack();
|
||||
else envEntry.node.triggerRelease();
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export function getAnalyserData(moduleId) {
|
||||
@@ -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', {
|
||||
@@ -4,6 +4,7 @@
|
||||
*/
|
||||
import { playConnect, playDisconnect, playModuleAdd, playModuleDelete } from './uiSounds.js';
|
||||
import { getModuleDef } from './moduleRegistry.js';
|
||||
import { invalidateConnectionCache } from './audioEngine.js';
|
||||
|
||||
let _listeners = new Set();
|
||||
let _nextModuleId = 1;
|
||||
@@ -93,6 +94,7 @@ export function addConnection(fromModuleId, fromPort, toModuleId, toPort) {
|
||||
|
||||
const id = _nextConnectionId++;
|
||||
state.connections.push({ id, from: { moduleId: fromModuleId, port: fromPort }, to: { moduleId: toModuleId, port: toPort } });
|
||||
invalidateConnectionCache();
|
||||
emit();
|
||||
playConnect();
|
||||
return id;
|
||||
@@ -100,6 +102,7 @@ export function addConnection(fromModuleId, fromPort, toModuleId, toPort) {
|
||||
|
||||
export function removeConnection(id, _silent = false) {
|
||||
state.connections = state.connections.filter(c => c.id !== id);
|
||||
invalidateConnectionCache();
|
||||
emit();
|
||||
if (!_silent) playDisconnect();
|
||||
}
|
||||
@@ -17,7 +17,7 @@ import { WORLD_12 } from './levels/world12.js';
|
||||
|
||||
const allWorlds = [WORLD_1, WORLD_2, WORLD_3, WORLD_4, WORLD_5, WORLD_6, WORLD_7, WORLD_8, WORLD_9, WORLD_10, WORLD_11, WORLD_12];
|
||||
|
||||
export default function GameApp({ onSwitchToSandbox }) {
|
||||
export default function GameApp({ onSwitchToSandbox, onSwitchToWorkshop }) {
|
||||
const [view, setView] = useState('map');
|
||||
const [currentLevel, setCurrentLevel] = useState(null);
|
||||
const [currentLevelIndex, setCurrentLevelIndex] = useState(0);
|
||||
@@ -78,6 +78,7 @@ export default function GameApp({ onSwitchToSandbox }) {
|
||||
<WorldMap
|
||||
onSelectLevel={handleSelectLevel}
|
||||
onSandbox={onSwitchToSandbox}
|
||||
onWorkshop={onSwitchToWorkshop}
|
||||
onAdmin={() => setShowAdmin(true)}
|
||||
/>
|
||||
{showAdmin && (
|
||||
@@ -4,10 +4,14 @@ import { startAudio, stopAudio, connectWire, rebuildGraph } from '../engine/audi
|
||||
import { getModuleDef } from '../engine/moduleRegistry.js';
|
||||
import ModuleNode from '../components/ModuleNode.jsx';
|
||||
import WireLayer from '../components/WireLayer.jsx';
|
||||
import BottomSheet from '../components/BottomSheet.jsx';
|
||||
import { useIsMobile } from '../hooks/useIsMobile.js';
|
||||
import { usePinchZoom } from '../hooks/usePinchZoom.js';
|
||||
import { playTarget, stopTarget, isTargetPlaying } from './targetAudio.js';
|
||||
import LevelComplete from './LevelComplete.jsx';
|
||||
import { completeLevel, saveLevelPatch, getLevelPatch, markHintUsed, wasHintUsed } from './gameState.js';
|
||||
import { completeLevel, saveLevelPatch, getLevelPatch, clearLevelPatch, markHintUsed, wasHintUsed } from './gameState.js';
|
||||
import { playLevelComplete, playFail, playHint, playEngineStart, playEngineStop, playNav } from '../engine/uiSounds.js';
|
||||
import { SOLUTIONS } from './autoSolver.js';
|
||||
|
||||
export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onNextLevel, adminMode }) {
|
||||
const [, forceUpdate] = useState(0);
|
||||
@@ -19,6 +23,13 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
||||
const [showHint, setShowHint] = useState(false);
|
||||
const [result, setResult] = useState(null);
|
||||
const [targetPlaying, setTargetPlaying] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const [mobileTab, setMobileTab] = useState('mission');
|
||||
|
||||
// Pinch-to-zoom on mobile
|
||||
const getZoom = useCallback(() => state.zoom, []);
|
||||
const setZoom = useCallback((z) => { state.zoom = z; emit(); }, []);
|
||||
usePinchZoom(containerRef, getZoom, setZoom);
|
||||
|
||||
useEffect(() => {
|
||||
const unsub = subscribe(() => {
|
||||
@@ -48,7 +59,10 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
||||
|
||||
useEffect(() => {
|
||||
loadLevel();
|
||||
// Center view on modules after level loads and DOM settles
|
||||
const timer = setTimeout(() => handleCenterView(), 100);
|
||||
return () => {
|
||||
clearTimeout(timer);
|
||||
stopAudio();
|
||||
stopTarget();
|
||||
};
|
||||
@@ -126,10 +140,17 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
||||
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
|
||||
e.preventDefault();
|
||||
} else if (e.button === 0 && !connectingRef.current) {
|
||||
// On mobile (touch), single finger on empty canvas = pan
|
||||
if (isMobile && e.pointerType === 'touch') {
|
||||
state.panning = true;
|
||||
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
|
||||
e.preventDefault();
|
||||
return;
|
||||
}
|
||||
state.selectedModuleId = null;
|
||||
emit();
|
||||
}
|
||||
}, []);
|
||||
}, [isMobile]);
|
||||
|
||||
const handlePointerMove = useCallback((e) => {
|
||||
if (state.panning && state.panStart) {
|
||||
@@ -221,6 +242,26 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
||||
emit();
|
||||
}, []);
|
||||
|
||||
// Center view on all modules
|
||||
const handleCenterView = useCallback(() => {
|
||||
if (state.modules.length === 0) { state.camX = 0; state.camY = 0; emit(); return; }
|
||||
const container = containerRef.current;
|
||||
const cw = container?.clientWidth || 800;
|
||||
const ch = container?.clientHeight || 600;
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const m of state.modules) {
|
||||
minX = Math.min(minX, m.x);
|
||||
minY = Math.min(minY, m.y);
|
||||
maxX = Math.max(maxX, m.x + 200);
|
||||
maxY = Math.max(maxY, m.y + 150);
|
||||
}
|
||||
const cx = (minX + maxX) / 2 * state.zoom;
|
||||
const cy = (minY + maxY) / 2 * state.zoom;
|
||||
state.camX = cw / 2 - cx;
|
||||
state.camY = ch / 2 - cy;
|
||||
emit();
|
||||
}, []);
|
||||
|
||||
const handleAddModule = (type) => {
|
||||
const x = (-state.camX + 250) / state.zoom + Math.random() * 30;
|
||||
const y = (-state.camY + 150) / state.zoom + Math.random() * 30;
|
||||
@@ -250,6 +291,13 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
||||
}
|
||||
};
|
||||
|
||||
// Clear canvas — remove all user-added modules and reset to preplaced only
|
||||
const handleClearCanvas = () => {
|
||||
if (state.isRunning) stopAudio();
|
||||
clearLevelPatch(level.id);
|
||||
loadLevel(true);
|
||||
};
|
||||
|
||||
// Reveal hint — PERMANENTLY caps this level at 2 stars (persisted, survives reload)
|
||||
const handleRevealHint = () => {
|
||||
setHintUsed(true);
|
||||
@@ -285,15 +333,20 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
||||
}
|
||||
};
|
||||
|
||||
// Admin auto-solve — gives 3 stars instantly
|
||||
// Admin auto-solve — loads the actual solution modules/connections and validates naturally
|
||||
const handleAutoSolve = () => {
|
||||
const checks = level.checks.map(check => ({
|
||||
...check,
|
||||
passed: true,
|
||||
}));
|
||||
completeLevel(level.id, 3);
|
||||
setResult({ stars: 3, checks, hintPenalty: false });
|
||||
playLevelComplete();
|
||||
const solution = SOLUTIONS[level.id];
|
||||
if (!solution) {
|
||||
console.warn(`No auto-solve solution for level ${level.id}`);
|
||||
return;
|
||||
}
|
||||
// Load the solution patch into the engine state
|
||||
deserialize(solution);
|
||||
emit();
|
||||
// Now run the normal check logic against the loaded patch
|
||||
setTimeout(() => {
|
||||
handleCheck();
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const isLastLevel = levelIndex >= worldLevels.length - 1;
|
||||
@@ -302,7 +355,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
||||
<div className="gm-puzzle">
|
||||
{/* Top bar */}
|
||||
<div className="gm-puzzle-bar">
|
||||
<button className="gm-btn icon" onClick={() => { playNav(); onBack(); }}>← Mapa</button>
|
||||
<button className="gm-btn icon" onClick={() => { playNav(); onBack(); }}>{isMobile ? '←' : '← Mapa'}</button>
|
||||
<div className="gm-puzzle-title">
|
||||
<span className="gm-puzzle-num">{levelIndex + 1}/{worldLevels.length}</span>
|
||||
<span className="gm-puzzle-name">{level.title}</span>
|
||||
@@ -312,16 +365,21 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
||||
className={`gm-btn ${targetPlaying ? 'active' : 'target'}`}
|
||||
onClick={handlePlayTarget}
|
||||
>
|
||||
{targetPlaying ? '⏹ Parar' : '🎯 Objetivo'}
|
||||
{targetPlaying ? '⏹' : '🎯'}{!isMobile && <span className="btn-label">{targetPlaying ? ' Parar' : ' Objetivo'}</span>}
|
||||
</button>
|
||||
<button
|
||||
className={`gm-btn ${state.isRunning ? 'active' : ''}`}
|
||||
onClick={handleToggleAudio}
|
||||
>
|
||||
{state.isRunning ? '⏹ Parar' : '▶ Mi Sonido'}
|
||||
{state.isRunning ? '⏹' : '▶'}{!isMobile && <span className="btn-label">{state.isRunning ? ' Parar' : ' Mi Sonido'}</span>}
|
||||
</button>
|
||||
{!isMobile && (
|
||||
<button className="gm-btn clear" onClick={handleClearCanvas} title="Limpiar canvas">
|
||||
🗑 Limpiar
|
||||
</button>
|
||||
)}
|
||||
<button className="gm-btn check" onClick={handleCheck}>
|
||||
✓ Comprobar
|
||||
✓{!isMobile && <span className="btn-label"> Comprobar</span>}
|
||||
</button>
|
||||
{adminMode && (
|
||||
<button className="gm-btn admin-solve" onClick={handleAutoSolve} title="Admin: resolver nivel">
|
||||
@@ -332,7 +390,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
||||
</div>
|
||||
|
||||
<div className="gm-puzzle-content">
|
||||
{/* Left sidebar */}
|
||||
{/* Left sidebar (desktop only — hidden on mobile via CSS) */}
|
||||
<div className="gm-puzzle-sidebar">
|
||||
{/* Description — always visible */}
|
||||
<div className="gm-concept-panel">
|
||||
@@ -455,6 +513,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
||||
{(state.zoom * 100).toFixed(0)}%
|
||||
</button>
|
||||
<button className="zoom-btn" onClick={handleZoomOut} title="Alejar">−</button>
|
||||
<button className="zoom-btn" onClick={handleCenterView} title="Centrar vista">⌂</button>
|
||||
</div>
|
||||
|
||||
{state.modules.length > 0 && state.connections.length === 0 && (
|
||||
@@ -465,6 +524,89 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile bottom sheet with tabs (replaces sidebar) */}
|
||||
{isMobile && (
|
||||
<BottomSheet
|
||||
tabs={[
|
||||
{ id: 'mission', label: 'MISION' },
|
||||
{ id: 'objectives', label: 'OBJETIVOS' },
|
||||
{ id: 'modules', label: 'MODULOS' },
|
||||
]}
|
||||
activeTab={mobileTab}
|
||||
onTabChange={setMobileTab}
|
||||
>
|
||||
{mobileTab === 'mission' && (
|
||||
<div>
|
||||
<p className="puzzle-mission-text">{level.description}</p>
|
||||
{!showHint ? (
|
||||
<button className="puzzle-hint-btn" onClick={handleRevealHint}>
|
||||
<span className="puzzle-hint-icon">💡</span>
|
||||
<span className="puzzle-hint-label">Mostrar Pista</span>
|
||||
<span className="puzzle-hint-penalty">max ★★</span>
|
||||
</button>
|
||||
) : (
|
||||
<div style={{ marginTop: 8, padding: '10px 12px', background: 'var(--surface)', borderRadius: 8, border: '1px solid var(--yellow)' }}>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--yellow)', marginBottom: 6 }}>💡 Pista <span className="puzzle-hint-penalty">max ★★</span></div>
|
||||
<p style={{ fontSize: 12, color: 'var(--text)', lineHeight: 1.5 }}>{level.concept}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mobileTab === 'objectives' && (
|
||||
<div>
|
||||
{level.checks.map((check, i) => {
|
||||
const passed = result?.checks?.[i]?.passed;
|
||||
const cappedByStar = hintUsed && check.star === 3;
|
||||
return (
|
||||
<div key={i} className="puzzle-obj-item">
|
||||
<span className="puzzle-obj-star">{'★'.repeat(check.star)}</span>
|
||||
<span className="puzzle-obj-desc" style={passed === true ? { color: 'var(--green)' } : passed === false ? { color: 'var(--red)' } : {}}>
|
||||
{check.desc}
|
||||
{cappedByStar && ' 🔒'}
|
||||
</span>
|
||||
{passed === true && !cappedByStar && <span style={{ color: 'var(--green)', fontWeight: 700 }}>✓</span>}
|
||||
{passed === false && <span style={{ color: 'var(--red)', fontWeight: 700 }}>✗</span>}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{hintUsed && (
|
||||
<div style={{ marginTop: 8, padding: '6px 8px', background: 'rgba(255,204,0,0.08)', borderRadius: 4, fontSize: 10, color: 'var(--yellow)' }}>
|
||||
Pista usada — maximo 2 estrellas (permanente).
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{mobileTab === 'modules' && (
|
||||
<div>
|
||||
{level.availableModules.length > 0 ? (
|
||||
<div className="mobile-module-grid" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
|
||||
{level.availableModules.map(type => {
|
||||
const def = getModuleDef(type);
|
||||
if (!def) return null;
|
||||
return (
|
||||
<div key={type} className="mobile-module-tile" onClick={() => handleAddModule(type)}>
|
||||
<span className="tile-icon">{def.icon}</span>
|
||||
<span className="tile-name">{def.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p style={{ fontSize: 12, color: 'var(--text2)' }}>No hay modulos extra disponibles para este nivel.</p>
|
||||
)}
|
||||
<button className="gm-btn danger" onClick={() => loadLevel(true)} style={{ width: '100%', marginTop: 12, justifyContent: 'center' }}>
|
||||
↺ Reiniciar Nivel
|
||||
</button>
|
||||
<button className="gm-btn clear" onClick={handleClearCanvas} style={{ width: '100%', marginTop: 6, justifyContent: 'center' }}>
|
||||
🗑 Limpiar
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</BottomSheet>
|
||||
)}
|
||||
|
||||
{/* Level complete overlay */}
|
||||
{result && result.stars >= 1 && (
|
||||
<LevelComplete
|
||||
@@ -1,4 +1,7 @@
|
||||
import React from 'react';
|
||||
import React, { useState, useRef } from 'react';
|
||||
import MobileTabBar from '../components/MobileTabBar.jsx';
|
||||
import { useIsMobile } from '../hooks/useIsMobile.js';
|
||||
import { useAuth } from '../services/AuthContext.jsx';
|
||||
import { WORLD_1 } from './levels/world1.js';
|
||||
import { WORLD_2 } from './levels/world2.js';
|
||||
import { WORLD_3 } from './levels/world3.js';
|
||||
@@ -39,9 +42,33 @@ function isWorldUnlocked(world) {
|
||||
return getTotalStars() >= world.unlockStars;
|
||||
}
|
||||
|
||||
export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
|
||||
const MOBILE_TABS = [
|
||||
{ id: 'game', label: 'JUEGO', icon: '🎮' },
|
||||
{ id: 'sandbox', label: 'SANDBOX', icon: '🎛' },
|
||||
{ id: 'workshop', label: 'WORKSHOP', icon: '🎵' },
|
||||
{ id: 'config', label: 'CONFIG', icon: '⚙' },
|
||||
];
|
||||
|
||||
export default function WorldMap({ onSelectLevel, onSandbox, onAdmin, onWorkshop }) {
|
||||
const totalStars = getTotalStars();
|
||||
const maxStars = getMaxStars();
|
||||
const [search, setSearch] = useState('');
|
||||
const searchRef = useRef(null);
|
||||
const isMobile = useIsMobile();
|
||||
const { user, isLoggedIn, openAuth, logout } = useAuth();
|
||||
|
||||
const query = search.trim().toLowerCase();
|
||||
|
||||
// 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">
|
||||
@@ -66,11 +93,75 @@ export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
|
||||
🛠
|
||||
</button>
|
||||
)}
|
||||
{isLoggedIn ? (
|
||||
<div className="user-badge" onClick={logout} title="Cerrar sesion">
|
||||
<div className="user-avatar">{user.username?.[0]?.toUpperCase()}</div>
|
||||
<span className="user-name">{user.username}</span>
|
||||
</div>
|
||||
) : (
|
||||
<button className="login-btn" onClick={openAuth}>Entrar</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* All worlds */}
|
||||
{worlds.map((world, worldIdx) => {
|
||||
{/* 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);
|
||||
@@ -136,7 +227,21 @@ export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
|
||||
</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
@@ -24,7 +24,14 @@ export const WORLD_10 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 220, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 330, detune: 0 } },
|
||||
],
|
||||
envelope: { attack: 0.05, decay: 0.3, sustain: 0.4, release: 0.2 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -87,7 +94,14 @@ export const WORLD_10 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 220, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 220, detune: 8 } },
|
||||
],
|
||||
envelope: { attack: 0.06, decay: 0.35, sustain: 0.35, release: 0.25 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -148,7 +162,16 @@ export const WORLD_10 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 165, detune: 0 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'delay', delayTime: 0.035, feedback: 0.15, wet: 0.8 },
|
||||
],
|
||||
envelope: { attack: 0.08, decay: 0.4, sustain: 0.3, release: 0.3 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -205,7 +228,16 @@ export const WORLD_10 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 262, detune: 0 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 1.5, wet: 0.4 },
|
||||
],
|
||||
envelope: { attack: 0.07, decay: 0.4, sustain: 0.35, release: 0.25 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -262,7 +294,16 @@ export const WORLD_10 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 4 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 196, detune: 0 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 4.2, wet: 0.65 },
|
||||
],
|
||||
envelope: { attack: 0.06, decay: 0.8, sustain: 0.4, release: 0.5 },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -317,7 +358,16 @@ export const WORLD_10 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: 0 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'delay', delayTime: 0.15, feedback: 0.08, wet: 0.75 },
|
||||
],
|
||||
envelope: { attack: 0.05, decay: 0.35, sustain: 0.4, release: 0.2 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -374,7 +424,19 @@ export const WORLD_10 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 4 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 280, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3500, Q: 1.5 },
|
||||
effects: [
|
||||
{ type: 'distortion', distortion: 0.45, wet: 0.5 },
|
||||
{ type: 'delay', delayTime: 0.3, feedback: 0.35, wet: 0.55 },
|
||||
{ type: 'reverb', decay: 2.2, wet: 0.45 },
|
||||
],
|
||||
envelope: { attack: 0.08, decay: 0.45, sustain: 0.25, release: 0.3 },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -442,7 +504,21 @@ export const WORLD_10 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 950, y: 160, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 5 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 110, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 110, detune: 10 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 4000, Q: 1.3 },
|
||||
lfo: { frequency: 0.6, type: 'sine', min: 2000, max: 5000, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'delay', delayTime: 0.25, feedback: 0.4, wet: 0.6 },
|
||||
{ type: 'reverb', decay: 3, wet: 0.55 },
|
||||
],
|
||||
envelope: { attack: 0.1, decay: 0.5, sustain: 0.3, release: 0.4 },
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -25,7 +25,15 @@ export const WORLD_11 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1500, Q: 9.5 },
|
||||
lfo: { frequency: 1, type: 'sine', min: 600, max: 3500, target: 'frequency' },
|
||||
envelope: { attack: 0.1, decay: 0.4, sustain: 0.3, release: 0.25 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -80,7 +88,14 @@ export const WORLD_11 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 200, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 8, detune: 0 } },
|
||||
],
|
||||
envelope: { attack: 0.08, decay: 0.35, sustain: 0.35, release: 0.2 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -140,7 +155,21 @@ export const WORLD_11 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 5 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 60, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 60, detune: -4 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2500, Q: 0.85 },
|
||||
lfo: [
|
||||
{ frequency: 0.3, type: 'sine', min: 1000, max: 4000, target: 'frequency' },
|
||||
{ frequency: 0.15, type: 'sine', min: 0.3, max: 0.9, target: 'amplitude' },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 5, wet: 0.7 },
|
||||
],
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -196,7 +225,17 @@ export const WORLD_11 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 4 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220, detune: 3 } },
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: -2 } },
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: 5 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3500, Q: 1.2 },
|
||||
envelope: { attack: 0.07, decay: 0.4, sustain: 0.35, release: 0.25 },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -266,7 +305,15 @@ export const WORLD_11 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 150, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2800, Q: 1.3 },
|
||||
lfo: { frequency: 2.5, type: 'square', min: 0.1, max: 0.95, target: 'amplitude' },
|
||||
envelope: { attack: 0.03, decay: 0.25, sustain: 0.1, release: 0.15 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -319,7 +366,18 @@ export const WORLD_11 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 4 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 120, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2000, Q: 1.5 },
|
||||
effects: [
|
||||
{ type: 'delay', delayTime: 0.5, feedback: 0.8, wet: 0.9 },
|
||||
{ type: 'reverb', decay: 3.5, wet: 0.6 },
|
||||
],
|
||||
envelope: { attack: 0.1, decay: 1, sustain: 0.4, release: 0.5 },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -375,7 +433,18 @@ export const WORLD_11 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 100, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2500, Q: 1.2 },
|
||||
lfo: [
|
||||
{ frequency: 0.4, type: 'sine', min: 1.5, max: 6.5, target: 'frequency' },
|
||||
{ frequency: 4.5, type: 'sine', min: 1000, max: 4500, target: 'frequency' },
|
||||
],
|
||||
envelope: { attack: 0.08, decay: 0.4, sustain: 0.3, release: 0.25 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -440,7 +509,26 @@ export const WORLD_11 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 950, y: 160, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 5 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 80, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 80, detune: -3 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 7, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2200, Q: 8 },
|
||||
lfo: [
|
||||
{ frequency: 0.25, type: 'sine', min: 1000, max: 3500, target: 'frequency' },
|
||||
{ frequency: 2, type: 'sine', min: 0.2, max: 0.9, target: 'amplitude' },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'distortion', distortion: 0.4, wet: 0.35 },
|
||||
{ type: 'delay', delayTime: 0.4, feedback: 0.65, wet: 0.7 },
|
||||
{ type: 'reverb', decay: 3.2, wet: 0.5 },
|
||||
],
|
||||
envelope: { attack: 0.12, decay: 0.6, sustain: 0.25, release: 0.4 },
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -25,7 +25,19 @@ export const WORLD_12 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 5 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55, detune: -5 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1800, Q: 0.9 },
|
||||
lfo: { frequency: 0.2, type: 'sine', min: 800, max: 3200, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 5.5, wet: 0.65 },
|
||||
],
|
||||
envelope: { attack: 0.01, decay: 2, sustain: 0.8, release: 0.6 },
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -86,7 +98,16 @@ export const WORLD_12 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 4 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 82, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 82, detune: 4 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 4500, Q: 1.1 },
|
||||
envelope: { attack: 0.01, decay: 0.18, sustain: 0.05, release: 0.1 },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -146,7 +167,18 @@ export const WORLD_12 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 850, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 5 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 440, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3800, Q: 3 },
|
||||
lfo: { frequency: 0.5, type: 'sine', min: 2000, max: 5500, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 2.8, wet: 0.45 },
|
||||
],
|
||||
envelope: { attack: 0.04, decay: 0.5, sustain: 0.5, release: 0.3 },
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -206,7 +238,19 @@ export const WORLD_12 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 850, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 5 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 110, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 110, detune: -6 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2500, Q: 0.95 },
|
||||
lfo: { frequency: 0.15, type: 'sine', min: 1200, max: 3800, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 4.5, wet: 0.7 },
|
||||
],
|
||||
envelope: { attack: 0.15, decay: 1.5, sustain: 0.6, release: 0.5 },
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -265,7 +309,20 @@ export const WORLD_12 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 850, y: 140, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 5 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 130, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 165, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 165, detune: 3 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3000, Q: 1.4 },
|
||||
lfo: { frequency: 0.4, type: 'sine', min: 1500, max: 5000, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 2, wet: 0.35 },
|
||||
],
|
||||
envelope: { attack: 0.08, decay: 0.6, sustain: 0.4, release: 0.3 },
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -323,7 +380,22 @@ export const WORLD_12 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 950, y: 160, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 6 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55, detune: -5 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 4 } },
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3500, Q: 1.2 },
|
||||
lfo: { frequency: 0.35, type: 'sine', min: 1500, max: 4500, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 2.5, wet: 0.5 },
|
||||
],
|
||||
envelope: { attack: 0.1, decay: 0.6, sustain: 0.35, release: 0.4 },
|
||||
duration: 6,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -380,7 +452,20 @@ export const WORLD_12 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 850, y: 140, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 5 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 50, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 50, detune: -7 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1500, Q: 0.85 },
|
||||
lfo: { frequency: 0.12, type: 'sine', min: 600, max: 2500, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'delay', delayTime: 0.6, feedback: 0.5, wet: 0.6 },
|
||||
{ type: 'reverb', decay: 6, wet: 0.75 },
|
||||
],
|
||||
envelope: { attack: 0.05, decay: 1.5, sustain: 0.2, release: 1 },
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -441,7 +526,27 @@ export const WORLD_12 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 1000, y: 160, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 8 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 50, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 50, detune: -5 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 4 } },
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: -3 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3800, Q: 1.5 },
|
||||
lfo: [
|
||||
{ frequency: 0.3, type: 'sine', min: 1500, max: 4500, target: 'frequency' },
|
||||
{ frequency: 0.8, type: 'sine', min: 0.3, max: 0.9, target: 'amplitude' },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'delay', delayTime: 0.35, feedback: 0.45, wet: 0.5 },
|
||||
{ type: 'reverb', decay: 3, wet: 0.55 },
|
||||
],
|
||||
envelope: { attack: 0.1, decay: 0.7, sustain: 0.4, release: 0.5 },
|
||||
duration: 8,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -80,7 +80,13 @@ export const WORLD_3 = {
|
||||
{ id: 2, type: 'vca', x: 400, y: 60, params: { gain: 0 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 620, y: 80, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 440 } },
|
||||
],
|
||||
envelope: { attack: 0.2, decay: 0.15, sustain: 0.6, release: 0.5 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -135,7 +141,13 @@ export const WORLD_3 = {
|
||||
{ id: 2, type: 'vca', x: 400, y: 60, params: { gain: 0 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 620, y: 80, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
|
||||
],
|
||||
envelope: { attack: 0.005, decay: 0.15, sustain: 0, release: 0.1 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -191,7 +203,13 @@ export const WORLD_3 = {
|
||||
{ id: 2, type: 'vca', x: 400, y: 60, params: { gain: 0 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 620, y: 80, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
|
||||
],
|
||||
envelope: { attack: 1.2, decay: 0.3, sustain: 0.75, release: 2.5 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -243,7 +261,14 @@ export const WORLD_3 = {
|
||||
{ id: 2, type: 'vca', x: 500, y: 60, params: { gain: 0 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 760, y: 80, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'triangle', frequency: 440 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3000, Q: 2 },
|
||||
envelope: { attack: 0.008, decay: 0.5, sustain: 0.05, release: 0.2 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -300,7 +325,14 @@ export const WORLD_3 = {
|
||||
{ id: 2, type: 'vca', x: 520, y: 40, params: { gain: 0 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 760, y: 80, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 800, Q: 4 },
|
||||
envelope: { attack: 0.01, decay: 0.3, sustain: 0.4, release: 0.2 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -357,7 +389,13 @@ export const WORLD_3 = {
|
||||
{ id: 2, type: 'vca', x: 340, y: 60, params: { gain: 0.7 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 580, y: 80, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
|
||||
],
|
||||
lfo: { frequency: 6, type: 'sine', min: 0.2, max: 1.0, target: 'amplitude' },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -408,7 +446,14 @@ export const WORLD_3 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2000, Q: 6 },
|
||||
envelope: { attack: 0.05, decay: 0.3, sustain: 0.5, release: 0.6 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -25,7 +25,13 @@ export const WORLD_4 = {
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sine', frequency: 440, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 500, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
|
||||
],
|
||||
lfo: { frequency: 6, type: 'sine', min: 420, max: 460, target: 'frequency' },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -74,7 +80,13 @@ export const WORLD_4 = {
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'square', frequency: 440, detune: 0 }, locked: false },
|
||||
{ id: 2, type: 'output', x: 500, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 4 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 440 } },
|
||||
],
|
||||
lfo: { frequency: 0.3, type: 'sine', min: 200, max: 800, target: 'frequency' },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -124,7 +136,14 @@ export const WORLD_4 = {
|
||||
{ id: 2, type: 'filter', x: 300, y: 60, params: { type: 'lowpass', frequency: 600, Q: 4 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 560, y: 80, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 800, Q: 5 },
|
||||
lfo: { frequency: 3, type: 'square', min: 400, max: 4000, target: 'frequency' },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -178,7 +197,13 @@ export const WORLD_4 = {
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sine', frequency: 440, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
|
||||
],
|
||||
lfo: { frequency: 2, type: 'sine', min: 0.3, max: 1.0, target: 'amplitude' },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -232,7 +257,14 @@ export const WORLD_4 = {
|
||||
{ id: 3, type: 'vca', x: 480, y: 40, params: { gain: 0.6 }, locked: false },
|
||||
{ id: 4, type: 'output', x: 680, y: 60, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1200, Q: 6 },
|
||||
lfo: { frequency: 2.5, type: 'sine', min: 400, max: 3500, target: 'frequency' },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -288,7 +320,13 @@ export const WORLD_4 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 600, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 220 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 880 } },
|
||||
],
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -354,7 +392,15 @@ export const WORLD_4 = {
|
||||
{ id: 3, type: 'vca', x: 480, y: 40, params: { gain: 0 }, locked: false },
|
||||
{ id: 4, type: 'output', x: 680, y: 60, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1500, Q: 5 },
|
||||
envelope: { attack: 0.1, decay: 0.3, sustain: 0.5, release: 0.4 },
|
||||
lfo: { frequency: 3, type: 'sine', min: 600, max: 3000, target: 'frequency' },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -418,7 +464,15 @@ export const WORLD_4 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 4 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 400, Q: 10 },
|
||||
envelope: { attack: 0.02, decay: 0.2, sustain: 0.7, release: 0.3 },
|
||||
lfo: { frequency: 1.5, type: 'sine', min: 200, max: 2000, target: 'frequency' },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -26,7 +26,15 @@ export const WORLD_5 = {
|
||||
{ id: 2, type: 'delay', x: 340, y: 80, params: { time: 0.3, feedback: 0.3, mix: 0.5 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'delay', time: 0.35, feedback: 0.4, wet: 0.6 },
|
||||
],
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -77,7 +85,15 @@ export const WORLD_5 = {
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'square', frequency: 330, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 330 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'delay', time: 0.08, feedback: 0.05, wet: 0.5 },
|
||||
],
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -129,7 +145,15 @@ export const WORLD_5 = {
|
||||
{ id: 2, type: 'reverb', x: 340, y: 80, params: { decay: 2, mix: 0.4 }, locked: false },
|
||||
{ id: 3, type: 'output', x: 600, y: 100, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'triangle', frequency: 440 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 5.5, wet: 0.55 },
|
||||
],
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -179,7 +203,15 @@ export const WORLD_5 = {
|
||||
{ id: 1, type: 'oscillator', x: 80, y: 80, params: { waveform: 'sine', frequency: 220, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 600, y: 100, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2.5 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 220 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'distortion', amount: 6 },
|
||||
],
|
||||
duration: 2.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -229,7 +261,16 @@ export const WORLD_5 = {
|
||||
{ id: 1, type: 'oscillator', x: 60, y: 80, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 740, y: 100, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'distortion', amount: 3 },
|
||||
{ type: 'delay', time: 0.35, feedback: 0.35, wet: 0.5 },
|
||||
],
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -289,7 +330,16 @@ export const WORLD_5 = {
|
||||
{ id: 1, type: 'oscillator', x: 60, y: 80, params: { waveform: 'square', frequency: 330, detune: 0 }, locked: true },
|
||||
{ id: 2, type: 'output', x: 720, y: 100, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 330 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 850, Q: 2 },
|
||||
effects: [
|
||||
{ type: 'delay', time: 0.4, feedback: 0.6, wet: 0.6 },
|
||||
],
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -343,7 +393,16 @@ export const WORLD_5 = {
|
||||
{ id: 1, type: 'oscillator', x: 60, y: 80, params: { waveform: 'sawtooth', frequency: 220, detune: 0 }, locked: false },
|
||||
{ id: 2, type: 'output', x: 720, y: 100, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 4 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220 } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'distortion', amount: 5 },
|
||||
{ type: 'reverb', decay: 6.5, wet: 0.65 },
|
||||
],
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -398,7 +457,19 @@ export const WORLD_5 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 5 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 110 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1200, Q: 3 },
|
||||
envelope: { attack: 0.5, decay: 0.3, sustain: 0.6, release: 1.5 },
|
||||
lfo: { frequency: 0.5, type: 'sine', min: 400, max: 4000, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'delay', time: 0.5, feedback: 0.5, wet: 0.5 },
|
||||
{ type: 'reverb', decay: 5, wet: 0.6 },
|
||||
],
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -24,7 +24,13 @@ export const WORLD_6 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55 } },
|
||||
],
|
||||
envelope: { attack: 0, decay: 0.25, sustain: 0, release: 0.1 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -84,7 +90,14 @@ export const WORLD_6 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'highpass', frequency: 7000, Q: 2 },
|
||||
envelope: { attack: 0, decay: 0.08, sustain: 0, release: 0 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -135,7 +148,15 @@ export const WORLD_6 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 200 } },
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'highpass', frequency: 3000, Q: 1.5 },
|
||||
envelope: { attack: 0, decay: 0.12, sustain: 0, release: 0.05 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -194,7 +215,19 @@ export const WORLD_6 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 5 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: -8 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 8 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1500, Q: 3 },
|
||||
envelope: { attack: 1, decay: 0.4, sustain: 0.7, release: 2 },
|
||||
lfo: { frequency: 0.6, type: 'sine', min: 600, max: 3500, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 5.5, wet: 0.6 },
|
||||
],
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -250,7 +283,16 @@ export const WORLD_6 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 4 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: -9 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: 9 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 400, Q: 8 },
|
||||
lfo: { frequency: 0.7, type: 'sine', min: 200, max: 2000, target: 'frequency' },
|
||||
envelope: { attack: 0.05, decay: 0.2, sustain: 0.6, release: 0.3 },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -303,7 +345,13 @@ export const WORLD_6 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 440 } },
|
||||
],
|
||||
envelope: { attack: 0.01, decay: 0.15, sustain: 0.05, release: 0.1 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -368,7 +416,19 @@ export const WORLD_6 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 4 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 330 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2000, Q: 4 },
|
||||
envelope: { attack: 0.005, decay: 0.15, sustain: 0.1, release: 0.08 },
|
||||
lfo: { frequency: 1.5, type: 'sine', min: 1000, max: 4000, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'delay', time: 0.25, feedback: 0.35, wet: 0.45 },
|
||||
],
|
||||
triggerPattern: { interval: 0.25, count: 16 },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -425,7 +485,22 @@ export const WORLD_6 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 950, y: 160, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 5 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 165, detune: -6 } },
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 165, detune: 6 } },
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1800, Q: 6 },
|
||||
envelope: { attack: 0.2, decay: 0.4, sustain: 0.5, release: 0.8 },
|
||||
lfo: { frequency: 2, type: 'sine', min: 0.3, max: 1.2, target: 'amplitude' },
|
||||
effects: [
|
||||
{ type: 'distortion', amount: 2 },
|
||||
{ type: 'delay', time: 0.3, feedback: 0.4, wet: 0.4 },
|
||||
{ type: 'reverb', decay: 3.5, wet: 0.45 },
|
||||
],
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -25,7 +25,14 @@ export const WORLD_7 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 330 } },
|
||||
],
|
||||
envelope: { attack: 0.01, decay: 0.12, sustain: 0, release: 0.05 },
|
||||
triggerPattern: { interval: 0.5, count: 4 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -81,7 +88,17 @@ export const WORLD_7 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: -8 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: 8 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 600, Q: 5 },
|
||||
envelope: { attack: 0.02, decay: 0.15, sustain: 0.3, release: 0.1 },
|
||||
lfo: { frequency: 1, type: 'sine', min: 300, max: 1500, target: 'frequency' },
|
||||
triggerPattern: { interval: 1, count: 3 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -137,7 +154,16 @@ export const WORLD_7 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 750, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2.5 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2000, Q: 2 },
|
||||
envelope: { attack: 0.005, decay: 0.15, sustain: 0.05, release: 0.08 },
|
||||
lfo: { frequency: 2.5, type: 'sine', min: 1000, max: 4000, target: 'frequency' },
|
||||
triggerPattern: { interval: 0.375, count: 7 },
|
||||
duration: 2.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -194,7 +220,15 @@ export const WORLD_7 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1000, Q: 3.5 },
|
||||
envelope: { attack: 0, decay: 0.1, sustain: 0, release: 0 },
|
||||
triggerPattern: { interval: 0.5, count: 4 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -250,7 +284,14 @@ export const WORLD_7 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 50 } },
|
||||
],
|
||||
envelope: { attack: 0, decay: 0.3, sustain: 0, release: 0.1 },
|
||||
triggerPattern: { interval: 1, count: 2 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -308,7 +349,16 @@ export const WORLD_7 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 750, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2.5 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 55 } },
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'highpass', frequency: 6500, Q: 1.5 },
|
||||
envelope: { attack: 0, decay: 0.08, sustain: 0, release: 0 },
|
||||
triggerPattern: { interval: 0.5, count: 5 },
|
||||
duration: 2.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -362,7 +412,18 @@ export const WORLD_7 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 440 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1500, Q: 2 },
|
||||
envelope: { attack: 0.01, decay: 0.12, sustain: 0, release: 0.05 },
|
||||
effects: [
|
||||
{ type: 'delay', time: 0.35, feedback: 0.5, wet: 0.55 },
|
||||
],
|
||||
triggerPattern: { interval: 0.5, count: 6 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -417,7 +478,22 @@ export const WORLD_7 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 5 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sine', frequency: 50 } },
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: -8 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: 8 } },
|
||||
],
|
||||
filter: { type: 'highpass', frequency: 7000, Q: 1.5 },
|
||||
envelope: { attack: 0, decay: 0.15, sustain: 0, release: 0.05 },
|
||||
effects: [
|
||||
{ type: 'delay', time: 0.3, feedback: 0.4, wet: 0.35 },
|
||||
{ type: 'reverb', decay: 2.5, wet: 0.25 },
|
||||
],
|
||||
triggerPattern: { interval: 1, count: 5 },
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -26,7 +26,13 @@ export const WORLD_8 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 1.5 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
envelope: { attack: 0.1, decay: 0.3, sustain: 0.1, release: 0.2 },
|
||||
duration: 1.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -79,7 +85,14 @@ export const WORLD_8 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 1.5 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'bandpass', frequency: 3000, Q: 4 },
|
||||
envelope: { attack: 0.15, decay: 0.6, sustain: 0.05, release: 0.3 },
|
||||
duration: 1.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -135,7 +148,15 @@ export const WORLD_8 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 750, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1200, Q: 1 },
|
||||
lfo: { frequency: 0.3, type: 'sine', min: 500, max: 2500, target: 'frequency' },
|
||||
envelope: { attack: 0.2, decay: 0.5, sustain: 0.3, release: 0.4 },
|
||||
duration: 2,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -191,7 +212,14 @@ export const WORLD_8 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -10 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 1.5 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
envelope: { attack: 0.01, decay: 0.06, sustain: 0, release: 0.02 },
|
||||
triggerPattern: { interval: 0.15, count: 1 },
|
||||
duration: 1.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -246,7 +274,16 @@ export const WORLD_8 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -12 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 1.5 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'distortion', distortion: 0.75, wet: 0.7 },
|
||||
],
|
||||
envelope: { attack: 0.08, decay: 0.6, sustain: 0.1, release: 0.25 },
|
||||
duration: 1.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -299,7 +336,13 @@ export const WORLD_8 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 1.5 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'pink' } },
|
||||
],
|
||||
lfo: { frequency: 1.5, type: 'square', min: 0.2, max: 1, target: 'amplitude' },
|
||||
duration: 1.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -351,7 +394,17 @@ export const WORLD_8 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 2.5 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'brown' } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 900, Q: 0.8 },
|
||||
effects: [
|
||||
{ type: 'delay', delayTime: 0.4, feedback: 0.45, wet: 0.6 },
|
||||
{ type: 'reverb', decay: 4.5, wet: 0.7 },
|
||||
],
|
||||
duration: 2.5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -406,7 +459,25 @@ export const WORLD_8 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 6 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
{ type: 'noise', params: { type: 'pink' } },
|
||||
{ type: 'noise', params: { type: 'brown' } },
|
||||
{ type: 'noise', params: { type: 'white' } },
|
||||
],
|
||||
filter: { type: 'bandpass', frequency: 2800, Q: 3.5 },
|
||||
lfo: [
|
||||
{ frequency: 0.35, type: 'sine', min: 600, max: 2200, target: 'frequency' },
|
||||
{ frequency: 1.2, type: 'square', min: 0.1, max: 0.9, target: 'amplitude' },
|
||||
],
|
||||
effects: [
|
||||
{ type: 'delay', delayTime: 0.35, feedback: 0.5, wet: 0.5 },
|
||||
{ type: 'reverb', decay: 3.5, wet: 0.55 },
|
||||
],
|
||||
envelope: { attack: 0.12, decay: 0.4, sustain: 0.2, release: 0.3 },
|
||||
duration: 6,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -24,7 +24,14 @@ export const WORLD_9 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 220, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 4000, Q: 1.2 },
|
||||
envelope: { attack: 0.05, decay: 0.3, sustain: 0.4, release: 0.2 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -83,7 +90,15 @@ export const WORLD_9 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 165, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2500, Q: 6 },
|
||||
lfo: { frequency: 0.8, type: 'sine', min: 1000, max: 4500, target: 'frequency' },
|
||||
envelope: { attack: 0.08, decay: 0.4, sustain: 0.3, release: 0.25 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -139,7 +154,14 @@ export const WORLD_9 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 330, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 1800, Q: 2 },
|
||||
envelope: { attack: 0.01, decay: 0.35, sustain: 0.1, release: 0.15 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -197,7 +219,14 @@ export const WORLD_9 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 55, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 800, Q: 9 },
|
||||
envelope: { attack: 0.02, decay: 0.25, sustain: 0.05, release: 0.15 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -256,7 +285,16 @@ export const WORLD_9 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 4 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 5 } },
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: -7 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3500, Q: 0.9 },
|
||||
envelope: { attack: 0.08, decay: 0.8, sustain: 0.5, release: 0.4 },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -316,7 +354,16 @@ export const WORLD_9 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 3 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 220, detune: 4 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3000, Q: 1.5 },
|
||||
lfo: { frequency: 0.6, type: 'sine', min: 2500, max: 4500, target: 'frequency' },
|
||||
envelope: { attack: 0.06, decay: 0.35, sustain: 0.35, release: 0.2 },
|
||||
duration: 3,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -372,7 +419,15 @@ export const WORLD_9 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 4 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 130, detune: 0 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 2000, Q: 2 },
|
||||
lfo: { frequency: 1, type: 'sine', min: 500, max: 5000, target: 'frequency' },
|
||||
envelope: { attack: 0.07, decay: 0.5, sustain: 0.2, release: 0.25 },
|
||||
duration: 4,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
@@ -427,7 +482,19 @@ export const WORLD_9 = {
|
||||
preplacedModules: [
|
||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
||||
],
|
||||
target: { build: [], duration: 5 },
|
||||
target: {
|
||||
build: [
|
||||
{ type: 'oscillator', params: { waveform: 'sawtooth', frequency: 110, detune: 0 } },
|
||||
{ type: 'oscillator', params: { waveform: 'square', frequency: 110, detune: 3 } },
|
||||
],
|
||||
filter: { type: 'lowpass', frequency: 3000, Q: 6 },
|
||||
lfo: { frequency: 0.5, type: 'sine', min: 1000, max: 4000, target: 'frequency' },
|
||||
effects: [
|
||||
{ type: 'reverb', decay: 2.5, wet: 0.4 },
|
||||
],
|
||||
envelope: { attack: 0.08, decay: 0.5, sustain: 0.3, release: 0.3 },
|
||||
duration: 5,
|
||||
},
|
||||
checks: [
|
||||
{
|
||||
star: 1,
|
||||
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.*
|
||||
@@ -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,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,81 +0,0 @@
|
||||
/**
|
||||
* targetAudio.js — Plays the "target" sound for a puzzle level
|
||||
* Builds a temporary Tone.js graph from the level's target config
|
||||
*/
|
||||
import * as Tone from 'tone';
|
||||
|
||||
let _activeNodes = [];
|
||||
let _isPlaying = false;
|
||||
let _stopTimeout = null;
|
||||
|
||||
export function isTargetPlaying() {
|
||||
return _isPlaying;
|
||||
}
|
||||
|
||||
export async function playTarget(target) {
|
||||
if (_isPlaying) {
|
||||
stopTarget();
|
||||
return;
|
||||
}
|
||||
|
||||
await Tone.start();
|
||||
_isPlaying = true;
|
||||
|
||||
const nodes = [];
|
||||
const output = new Tone.Gain(0.5).toDestination();
|
||||
nodes.push(output);
|
||||
|
||||
// Optional filter in the chain
|
||||
let destination = output;
|
||||
if (target.filter) {
|
||||
const filter = new Tone.Filter({
|
||||
type: target.filter.type || 'lowpass',
|
||||
frequency: target.filter.frequency || 1000,
|
||||
Q: target.filter.Q || 1,
|
||||
});
|
||||
filter.connect(output);
|
||||
destination = filter;
|
||||
nodes.push(filter);
|
||||
}
|
||||
|
||||
// Build oscillators / noise from target.build
|
||||
for (const spec of target.build) {
|
||||
if (spec.type === 'oscillator') {
|
||||
const osc = new Tone.Oscillator({
|
||||
type: spec.params.waveform || 'sine',
|
||||
frequency: spec.params.frequency || 440,
|
||||
detune: spec.params.detune || 0,
|
||||
});
|
||||
osc.connect(destination);
|
||||
osc.start();
|
||||
nodes.push(osc);
|
||||
} else if (spec.type === 'noise') {
|
||||
const noise = new Tone.Noise(spec.params.type || 'white');
|
||||
noise.connect(destination);
|
||||
noise.start();
|
||||
nodes.push(noise);
|
||||
}
|
||||
}
|
||||
|
||||
_activeNodes = nodes;
|
||||
|
||||
// Auto-stop after duration
|
||||
const dur = (target.duration || 2) * 1000;
|
||||
_stopTimeout = setTimeout(() => stopTarget(), dur);
|
||||
}
|
||||
|
||||
export function stopTarget() {
|
||||
if (_stopTimeout) {
|
||||
clearTimeout(_stopTimeout);
|
||||
_stopTimeout = null;
|
||||
}
|
||||
for (const node of _activeNodes) {
|
||||
try {
|
||||
if (node.stop) node.stop();
|
||||
if (node.disconnect) node.disconnect();
|
||||
if (node.dispose) node.dispose();
|
||||
} catch {}
|
||||
}
|
||||
_activeNodes = [];
|
||||
_isPlaying = false;
|
||||
}
|
||||
763
src/index.css
763
src/index.css
@@ -1,763 +0,0 @@
|
||||
/* ===== Reset & Base ===== */
|
||||
*, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
|
||||
:root {
|
||||
--bg: #08080f;
|
||||
--panel: #0e0e1a;
|
||||
--surface: #14142a;
|
||||
--surface2: #1a1a35;
|
||||
--border: #252545;
|
||||
--text: #c8cce0;
|
||||
--text2: #6668a0;
|
||||
--accent: #00e5ff;
|
||||
--accent2: #ff6644;
|
||||
--green: #44ff88;
|
||||
--yellow: #ffcc00;
|
||||
--purple: #aa55ff;
|
||||
--red: #ff4466;
|
||||
--wire-audio: #00e5ff;
|
||||
--wire-control: #ff6644;
|
||||
--wire-trigger: #ffcc00;
|
||||
--knob-track: #333;
|
||||
--knob-fill: #00e5ff;
|
||||
--module-w: 180;
|
||||
--port-r: 6;
|
||||
}
|
||||
|
||||
html, body, #root {
|
||||
width: 100%; height: 100%; overflow: hidden;
|
||||
background: var(--bg); color: var(--text);
|
||||
font-family: 'Inter', 'SF Pro', -apple-system, system-ui, sans-serif;
|
||||
font-size: 12px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
/* ===== Layout ===== */
|
||||
.app { display: flex; flex-direction: column; height: 100vh; }
|
||||
|
||||
.toolbar {
|
||||
height: 40px; background: var(--panel); border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; padding: 0 12px; gap: 8px; flex-shrink: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.toolbar-title {
|
||||
font-weight: 700; font-size: 14px; color: var(--accent);
|
||||
letter-spacing: 1px; text-transform: uppercase; margin-right: 16px;
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
padding: 4px 10px; border: 1px solid var(--border); border-radius: 4px;
|
||||
background: var(--surface); color: var(--text2); cursor: pointer;
|
||||
font-size: 11px; font-weight: 500; transition: all 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.toolbar-btn:hover { border-color: var(--accent); color: var(--text); }
|
||||
.toolbar-btn.active { background: var(--accent); color: #000; border-color: var(--accent); }
|
||||
.toolbar-btn.danger { border-color: var(--red); color: var(--red); }
|
||||
.toolbar-btn.danger:hover { background: var(--red); color: #000; }
|
||||
|
||||
.toolbar-sep { width: 1px; height: 20px; background: var(--border); margin: 0 4px; }
|
||||
|
||||
.toolbar-group { display: flex; gap: 4px; align-items: center; }
|
||||
|
||||
.toolbar-label { color: var(--text2); font-size: 10px; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
|
||||
.main-area { flex: 1; position: relative; overflow: hidden; }
|
||||
|
||||
/* ===== Node Canvas ===== */
|
||||
.node-canvas {
|
||||
position: absolute; inset: 0; cursor: grab;
|
||||
}
|
||||
.node-canvas.grabbing { cursor: grabbing; }
|
||||
.node-canvas.connecting { cursor: crosshair; }
|
||||
|
||||
.wires-svg {
|
||||
position: absolute; inset: 0; pointer-events: none; z-index: 3;
|
||||
overflow: visible;
|
||||
}
|
||||
.wires-svg path {
|
||||
fill: none; stroke-width: 2.5; stroke-linecap: round;
|
||||
pointer-events: stroke; cursor: pointer;
|
||||
filter: drop-shadow(0 0 3px rgba(0,229,255,0.3));
|
||||
}
|
||||
.wires-svg path.audio { stroke: var(--wire-audio); opacity: 0.8; }
|
||||
.wires-svg path.control { stroke: var(--wire-control); opacity: 0.8; }
|
||||
.wires-svg path.trigger { stroke: var(--wire-trigger); opacity: 0.8; }
|
||||
.wires-svg path.temp { stroke-dasharray: 6 4; opacity: 0.5; filter: none; }
|
||||
.wires-svg path:hover { stroke-width: 4; opacity: 1; filter: drop-shadow(0 0 6px rgba(0,229,255,0.6)); }
|
||||
|
||||
/* ===== Modules ===== */
|
||||
.module {
|
||||
position: absolute; width: 180px; min-width: 180px;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 8px; user-select: none; z-index: 2;
|
||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||
transition: box-shadow 0.15s;
|
||||
}
|
||||
.module.selected { border-color: var(--accent); box-shadow: 0 0 20px rgba(0,229,255,0.15); }
|
||||
.module:hover { box-shadow: 0 6px 24px rgba(0,0,0,0.5); }
|
||||
|
||||
.module-header {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 6px 10px; border-bottom: 1px solid var(--border);
|
||||
cursor: grab; border-radius: 8px 8px 0 0;
|
||||
background: var(--surface2);
|
||||
}
|
||||
.module-header .type-icon { font-size: 14px; }
|
||||
.module-header .type-name {
|
||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
||||
letter-spacing: 0.5px; color: var(--text);
|
||||
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||
}
|
||||
.module-header .close-btn {
|
||||
width: 18px; height: 18px; border: none; background: transparent;
|
||||
color: var(--text2); cursor: pointer; font-size: 12px; border-radius: 3px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.module-header .close-btn:hover { background: var(--red); color: #fff; }
|
||||
|
||||
.module-body { padding: 8px 10px; display: flex; flex-direction: column; gap: 6px; }
|
||||
|
||||
/* Ports */
|
||||
.port-row {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
position: relative; height: 20px;
|
||||
}
|
||||
.port-row.input { flex-direction: row; }
|
||||
.port-row.output { flex-direction: row-reverse; }
|
||||
|
||||
.port-dot {
|
||||
width: 12px; height: 12px; border-radius: 50%;
|
||||
border: 2px solid var(--border); background: var(--surface);
|
||||
cursor: pointer; flex-shrink: 0; transition: all 0.15s;
|
||||
position: relative; z-index: 5;
|
||||
}
|
||||
.port-dot.audio { border-color: var(--wire-audio); }
|
||||
.port-dot.control { border-color: var(--wire-control); }
|
||||
.port-dot.trigger { border-color: var(--wire-trigger); }
|
||||
.port-dot:hover { transform: scale(1.3); }
|
||||
.port-dot.connected { background: currentColor; }
|
||||
.port-dot.audio.connected { background: var(--wire-audio); }
|
||||
.port-dot.control.connected { background: var(--wire-control); }
|
||||
.port-dot.trigger.connected { background: var(--wire-trigger); }
|
||||
.port-dot.compatible { animation: pulse-port 0.6s infinite alternate; }
|
||||
|
||||
@keyframes pulse-port {
|
||||
from { box-shadow: 0 0 2px currentColor; }
|
||||
to { box-shadow: 0 0 8px currentColor; }
|
||||
}
|
||||
|
||||
.port-label {
|
||||
font-size: 10px; color: var(--text2); text-transform: uppercase;
|
||||
letter-spacing: 0.3px; white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Knobs */
|
||||
.param-row {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
}
|
||||
.param-label {
|
||||
font-size: 10px; color: var(--text2); width: 48px;
|
||||
text-transform: uppercase; letter-spacing: 0.3px; flex-shrink: 0;
|
||||
}
|
||||
|
||||
.knob-container { position: relative; width: 32px; height: 32px; flex-shrink: 0; }
|
||||
.knob-svg { width: 32px; height: 32px; cursor: pointer; }
|
||||
.knob-track { fill: none; stroke: var(--knob-track); stroke-width: 3; stroke-linecap: round; }
|
||||
.knob-fill { fill: none; stroke-width: 3; stroke-linecap: round; }
|
||||
.knob-dot { fill: var(--text); }
|
||||
|
||||
/* Modulation indicator: pulsing ring around modulated knobs */
|
||||
.knob-mod-ring {
|
||||
fill: none;
|
||||
stroke-width: 1.5;
|
||||
stroke-dasharray: 3 2;
|
||||
opacity: 0.7;
|
||||
animation: knob-mod-pulse 1.2s ease-in-out infinite alternate, knob-mod-spin 4s linear infinite;
|
||||
}
|
||||
@keyframes knob-mod-pulse {
|
||||
from { opacity: 0.3; stroke-width: 1; }
|
||||
to { opacity: 0.9; stroke-width: 2; }
|
||||
}
|
||||
@keyframes knob-mod-spin {
|
||||
from { stroke-dashoffset: 0; }
|
||||
to { stroke-dashoffset: 30; }
|
||||
}
|
||||
.knob-modulated .param-label,
|
||||
.knob-container.knob-modulated + .param-value {
|
||||
color: var(--accent2);
|
||||
}
|
||||
/* Ghost dot showing base value position while modulated */
|
||||
.knob-base-dot {
|
||||
fill: var(--text2);
|
||||
opacity: 0.4;
|
||||
}
|
||||
/* Live modulation number — highlight color + subtle glow */
|
||||
.param-value-live {
|
||||
color: var(--accent) !important;
|
||||
text-shadow: 0 0 6px rgba(0, 229, 255, 0.5);
|
||||
font-variant-numeric: tabular-nums;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.knob-editing { display: flex; align-items: center; justify-content: center; }
|
||||
.knob-input {
|
||||
width: 48px; height: 22px; padding: 0 4px;
|
||||
background: var(--bg); border: 1px solid var(--accent); border-radius: 3px;
|
||||
color: var(--accent); font-size: 11px; font-family: 'JetBrains Mono', monospace;
|
||||
text-align: center; outline: none;
|
||||
}
|
||||
.knob-input:focus { box-shadow: 0 0 6px rgba(0,229,255,0.3); }
|
||||
|
||||
.param-value {
|
||||
font-size: 10px; color: var(--accent); font-family: 'JetBrains Mono', monospace;
|
||||
min-width: 40px; text-align: right;
|
||||
}
|
||||
|
||||
/* Select param */
|
||||
.param-select {
|
||||
flex: 1; background: var(--bg); border: 1px solid var(--border);
|
||||
border-radius: 3px; padding: 2px 4px; color: var(--text);
|
||||
font-size: 10px; font-family: inherit; cursor: pointer;
|
||||
}
|
||||
.param-select:focus { outline: none; border-color: var(--accent); }
|
||||
|
||||
/* Scope canvas */
|
||||
.scope-canvas {
|
||||
width: 100%; height: 60px; border-radius: 4px;
|
||||
background: #050510; border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
/* ===== Module Palette (sidebar) ===== */
|
||||
.palette {
|
||||
position: absolute; left: 8px; top: 8px; z-index: 20;
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 8px; display: flex; flex-direction: column; gap: 4px;
|
||||
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
|
||||
max-height: calc(100% - 16px); overflow-y: auto;
|
||||
}
|
||||
.palette-title {
|
||||
font-size: 9px; font-weight: 700; color: var(--text2);
|
||||
text-transform: uppercase; letter-spacing: 1px; padding: 2px 4px;
|
||||
}
|
||||
.palette-item {
|
||||
display: flex; align-items: center; gap: 6px;
|
||||
padding: 5px 8px; border-radius: 4px; cursor: pointer;
|
||||
font-size: 11px; color: var(--text); transition: all 0.1s;
|
||||
}
|
||||
.palette-item:hover { background: var(--surface2); }
|
||||
.palette-item .p-icon { font-size: 14px; width: 20px; text-align: center; }
|
||||
.palette-item .p-name { font-weight: 500; }
|
||||
.palette-item .p-cat { font-size: 9px; color: var(--text2); margin-left: auto; }
|
||||
|
||||
/* ===== Status Bar ===== */
|
||||
.status-bar {
|
||||
height: 24px; background: var(--panel); border-top: 1px solid var(--border);
|
||||
display: flex; align-items: center; padding: 0 12px; gap: 16px;
|
||||
font-size: 10px; color: var(--text2); flex-shrink: 0; z-index: 10;
|
||||
}
|
||||
.status-bar .status-accent { color: var(--accent); }
|
||||
|
||||
/* ===== Preset Modal ===== */
|
||||
.modal-overlay {
|
||||
position: fixed; inset: 0; background: rgba(0,0,0,0.7);
|
||||
display: flex; align-items: center; justify-content: center; z-index: 100;
|
||||
}
|
||||
.modal {
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
|
||||
padding: 20px; min-width: 360px; max-width: 500px;
|
||||
box-shadow: 0 24px 64px rgba(0,0,0,0.6);
|
||||
}
|
||||
.modal h2 { font-size: 15px; color: var(--accent); margin-bottom: 12px; }
|
||||
.modal input {
|
||||
width: 100%; padding: 8px 10px; background: var(--bg);
|
||||
border: 1px solid var(--border); border-radius: 4px;
|
||||
color: var(--text); font-size: 13px; font-family: inherit;
|
||||
}
|
||||
.modal input:focus { outline: none; border-color: var(--accent); }
|
||||
.modal-actions { display: flex; gap: 8px; justify-content: flex-end; margin-top: 12px; }
|
||||
.modal-actions button { padding: 6px 14px; border-radius: 4px; cursor: pointer; font-size: 12px; font-weight: 600; border: 1px solid var(--border); background: var(--surface); color: var(--text); font-family: inherit; }
|
||||
.modal-actions .primary { background: var(--accent); color: #000; border-color: var(--accent); }
|
||||
|
||||
.preset-list { max-height: 200px; overflow-y: auto; margin: 8px 0; }
|
||||
.preset-item {
|
||||
padding: 6px 10px; cursor: pointer; border-radius: 4px;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
font-size: 12px;
|
||||
}
|
||||
.preset-item:hover { background: var(--surface2); }
|
||||
.preset-item .preset-date { color: var(--text2); font-size: 10px; }
|
||||
|
||||
/* ======================================================
|
||||
GAME MODE — SynthQuest
|
||||
====================================================== */
|
||||
|
||||
/* ===== World Map ===== */
|
||||
.gm-worldmap {
|
||||
height: 100vh; overflow-y: auto;
|
||||
background: linear-gradient(180deg, #08080f 0%, #0a0a1a 50%, #08080f 100%);
|
||||
padding: 0 24px 40px;
|
||||
}
|
||||
|
||||
.gm-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 20px 0; border-bottom: 1px solid var(--border); margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.gm-logo { display: flex; align-items: center; gap: 12px; }
|
||||
.gm-logo-icon {
|
||||
font-size: 36px; color: var(--accent);
|
||||
width: 56px; height: 56px; display: flex; align-items: center; justify-content: center;
|
||||
border: 2px solid var(--accent); border-radius: 12px; background: rgba(0,229,255,0.05);
|
||||
}
|
||||
.gm-title { font-size: 22px; font-weight: 800; color: var(--text); letter-spacing: 1px; }
|
||||
.gm-tagline { font-size: 12px; color: var(--text2); margin-top: 2px; }
|
||||
|
||||
.gm-header-right { display: flex; align-items: center; gap: 16px; }
|
||||
.gm-total-stars { font-size: 16px; color: var(--yellow); font-weight: 700; }
|
||||
.gm-sandbox-btn {
|
||||
padding: 8px 16px; border: 1px solid var(--border); border-radius: 6px;
|
||||
background: var(--surface); color: var(--text2); cursor: pointer;
|
||||
font-size: 12px; font-weight: 600; font-family: inherit; transition: all 0.15s;
|
||||
}
|
||||
.gm-sandbox-btn:hover { border-color: var(--accent); color: var(--text); }
|
||||
|
||||
/* World sections */
|
||||
.gm-world-section { margin-bottom: 32px; }
|
||||
.gm-locked-world { opacity: 0.4; }
|
||||
.gm-world-header {
|
||||
display: flex; align-items: center; gap: 12px; margin-bottom: 16px;
|
||||
}
|
||||
.gm-world-icon { font-size: 28px; }
|
||||
.gm-world-name { font-size: 16px; font-weight: 700; color: var(--text); }
|
||||
.gm-world-sub { font-size: 11px; color: var(--text2); margin-top: 2px; }
|
||||
|
||||
/* Level grid */
|
||||
.gm-level-grid {
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.gm-level-card {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
padding: 14px 16px; border-radius: 10px; cursor: pointer;
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
transition: all 0.2s; position: relative; overflow: hidden;
|
||||
}
|
||||
.gm-level-card.unlocked:hover {
|
||||
border-color: var(--accent); transform: translateY(-2px);
|
||||
box-shadow: 0 8px 24px rgba(0,229,255,0.1);
|
||||
}
|
||||
.gm-level-card.locked { cursor: default; opacity: 0.5; }
|
||||
.gm-level-card.boss { border-color: var(--yellow); }
|
||||
.gm-level-card.boss .gm-level-number { background: var(--yellow); color: #000; }
|
||||
.gm-level-card.perfect { border-color: var(--green); }
|
||||
|
||||
.gm-level-number {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: var(--surface2); display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 800; font-size: 14px; color: var(--accent); flex-shrink: 0;
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.gm-level-info { flex: 1; min-width: 0; }
|
||||
.gm-level-title { font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
.gm-level-subtitle { font-size: 10px; color: var(--text2); margin-top: 2px; }
|
||||
|
||||
.gm-stars { display: flex; gap: 2px; }
|
||||
.gm-stars .star { font-size: 16px; }
|
||||
.gm-stars .star.filled { color: var(--yellow); }
|
||||
.gm-stars .star.empty { color: var(--border); }
|
||||
|
||||
.gm-lock { font-size: 18px; }
|
||||
.gm-lock-overlay {
|
||||
position: absolute; inset: 0; background: rgba(8,8,15,0.3);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ===== Puzzle View ===== */
|
||||
.gm-puzzle { display: flex; flex-direction: column; height: 100vh; }
|
||||
|
||||
.gm-puzzle-bar {
|
||||
height: 48px; background: var(--panel); border-bottom: 1px solid var(--border);
|
||||
display: flex; align-items: center; padding: 0 16px; gap: 12px;
|
||||
flex-shrink: 0; z-index: 10;
|
||||
}
|
||||
|
||||
.gm-puzzle-title { display: flex; align-items: center; gap: 8px; }
|
||||
.gm-puzzle-num {
|
||||
font-size: 10px; color: var(--text2); background: var(--surface);
|
||||
padding: 2px 8px; border-radius: 4px; font-weight: 600;
|
||||
}
|
||||
.gm-puzzle-name { font-size: 14px; font-weight: 700; color: var(--text); }
|
||||
.gm-puzzle-actions { margin-left: auto; display: flex; gap: 8px; }
|
||||
|
||||
/* Buttons */
|
||||
.gm-btn {
|
||||
padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px;
|
||||
background: var(--surface); color: var(--text); cursor: pointer;
|
||||
font-size: 12px; font-weight: 600; font-family: inherit; transition: all 0.15s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.gm-btn:hover { border-color: var(--accent); }
|
||||
.gm-btn.icon { padding: 6px 10px; }
|
||||
.gm-btn.primary { background: var(--accent); color: #000; border-color: var(--accent); }
|
||||
.gm-btn.primary:hover { background: #33ecff; }
|
||||
.gm-btn.secondary { background: var(--surface2); }
|
||||
.gm-btn.target { border-color: var(--yellow); color: var(--yellow); }
|
||||
.gm-btn.target:hover { background: rgba(255,204,0,0.1); }
|
||||
.gm-btn.check { border-color: var(--green); color: var(--green); }
|
||||
.gm-btn.check:hover { background: rgba(68,255,136,0.1); }
|
||||
.gm-btn.active { background: var(--accent); color: #000; border-color: var(--accent); }
|
||||
.gm-btn.danger { border-color: var(--red); color: var(--red); }
|
||||
.gm-btn.danger:hover { background: rgba(255,68,102,0.1); }
|
||||
|
||||
/* Puzzle layout */
|
||||
.gm-puzzle-content { flex: 1; display: flex; overflow: hidden; min-height: 0; }
|
||||
|
||||
.gm-puzzle-sidebar {
|
||||
width: 280px; flex-shrink: 0; background: var(--panel);
|
||||
border-right: 1px solid var(--border); overflow-y: auto;
|
||||
padding: 12px; display: flex; flex-direction: column; gap: 12px;
|
||||
min-height: 0; /* Allow flex item to shrink below content — enables scrolling */
|
||||
}
|
||||
|
||||
.gm-puzzle-canvas-wrap {
|
||||
flex: 1; position: relative; overflow: hidden;
|
||||
}
|
||||
|
||||
/* Concept panel */
|
||||
.gm-concept-panel {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gm-concept-header {
|
||||
padding: 10px 12px; cursor: pointer; display: flex; justify-content: space-between;
|
||||
align-items: center; font-size: 12px; font-weight: 600; color: var(--yellow);
|
||||
}
|
||||
.gm-concept-body { padding: 0 12px 12px; }
|
||||
.gm-concept-desc { font-size: 11px; color: var(--text); line-height: 1.5; margin-bottom: 8px; }
|
||||
.gm-concept-tip {
|
||||
font-size: 10px; color: var(--text2); line-height: 1.5;
|
||||
padding: 8px; background: var(--bg); border-radius: 4px;
|
||||
border-left: 3px solid var(--accent);
|
||||
}
|
||||
|
||||
/* Objectives */
|
||||
.gm-objectives {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.gm-obj-title {
|
||||
font-size: 10px; font-weight: 700; color: var(--text2);
|
||||
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px;
|
||||
}
|
||||
.gm-obj {
|
||||
display: flex; align-items: center; gap: 8px; padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border); font-size: 11px;
|
||||
}
|
||||
.gm-obj:last-child { border-bottom: none; }
|
||||
.gm-obj-star { color: var(--yellow); font-size: 12px; flex-shrink: 0; width: 30px; }
|
||||
.gm-obj-name { flex: 1; color: var(--text2); }
|
||||
.gm-obj.passed .gm-obj-name { color: var(--green); }
|
||||
.gm-obj.failed .gm-obj-name { color: var(--text2); }
|
||||
.gm-obj-check { color: var(--green); font-weight: 700; }
|
||||
.gm-obj-x { color: var(--red); font-weight: 700; }
|
||||
.gm-obj.capped .gm-obj-name { color: var(--text2); text-decoration: line-through; }
|
||||
.gm-obj-locked { color: var(--text2); font-size: 10px; }
|
||||
|
||||
.gm-hint-warning {
|
||||
margin-top: 8px; padding: 6px 8px; background: rgba(255,204,0,0.08);
|
||||
border-radius: 4px; font-size: 10px; color: var(--yellow); line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Hint panel */
|
||||
.gm-hint-panel { }
|
||||
.gm-hint-btn {
|
||||
width: 100%; display: flex; align-items: center; gap: 8px;
|
||||
padding: 10px 12px; border: 1px dashed var(--yellow); border-radius: 8px;
|
||||
background: rgba(255,204,0,0.04); cursor: pointer;
|
||||
font-family: inherit; transition: all 0.15s;
|
||||
}
|
||||
.gm-hint-btn:hover { background: rgba(255,204,0,0.1); border-style: solid; }
|
||||
.gm-hint-icon { font-size: 16px; }
|
||||
.gm-hint-label { font-size: 12px; font-weight: 600; color: var(--yellow); flex: 1; text-align: left; }
|
||||
.gm-hint-penalty {
|
||||
font-size: 9px; color: var(--red); background: rgba(255,68,102,0.15);
|
||||
padding: 2px 6px; border-radius: 3px; font-weight: 700;
|
||||
}
|
||||
|
||||
.gm-hint-revealed {
|
||||
background: var(--surface); border: 1px solid var(--yellow); border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gm-hint-header {
|
||||
padding: 8px 12px; display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 12px; font-weight: 600; color: var(--yellow);
|
||||
background: rgba(255,204,0,0.06);
|
||||
}
|
||||
.gm-hint-penalty-tag {
|
||||
font-size: 9px; color: var(--red); background: rgba(255,68,102,0.15);
|
||||
padding: 2px 6px; border-radius: 3px; font-weight: 700;
|
||||
}
|
||||
.gm-hint-text {
|
||||
padding: 8px 12px 12px; font-size: 11px; color: var(--text); line-height: 1.5;
|
||||
}
|
||||
|
||||
.gm-hint-penalty-msg {
|
||||
font-size: 11px; color: var(--yellow); margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.gm-big-star.locked {
|
||||
color: var(--border); font-size: 36px;
|
||||
}
|
||||
|
||||
/* Module palette (game) */
|
||||
.gm-module-palette {
|
||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
.gm-palette-title {
|
||||
font-size: 10px; font-weight: 700; color: var(--text2);
|
||||
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px;
|
||||
}
|
||||
.gm-palette-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px; border-radius: 6px; cursor: pointer;
|
||||
transition: all 0.15s; font-size: 12px; color: var(--text);
|
||||
}
|
||||
.gm-palette-item:hover { background: var(--surface2); }
|
||||
.gm-palette-icon { font-size: 16px; width: 24px; text-align: center; }
|
||||
.gm-palette-name { flex: 1; font-weight: 500; }
|
||||
.gm-palette-add {
|
||||
width: 22px; height: 22px; border-radius: 50%;
|
||||
background: var(--surface2); display: flex; align-items: center; justify-content: center;
|
||||
font-size: 14px; color: var(--accent); font-weight: 700;
|
||||
}
|
||||
|
||||
/* Canvas hint */
|
||||
.gm-canvas-hint {
|
||||
position: absolute; bottom: 16px; left: 50%; transform: translateX(-50%);
|
||||
padding: 8px 16px; background: rgba(0,0,0,0.7); border-radius: 8px;
|
||||
font-size: 11px; color: var(--text2); pointer-events: none;
|
||||
border: 1px solid var(--border); z-index: 10;
|
||||
}
|
||||
|
||||
/* ===== Level Complete Overlay ===== */
|
||||
.gm-complete-overlay {
|
||||
position: fixed; inset: 0; background: rgba(8,8,15,0.85);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
z-index: 200; animation: fadeIn 0.3s;
|
||||
}
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
|
||||
.gm-complete-card {
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: 16px;
|
||||
padding: 32px 40px; text-align: center; min-width: 400px;
|
||||
box-shadow: 0 32px 64px rgba(0,0,0,0.5);
|
||||
animation: slideUp 0.4s ease-out;
|
||||
}
|
||||
@keyframes slideUp {
|
||||
from { transform: translateY(40px); opacity: 0; }
|
||||
to { transform: translateY(0); opacity: 1; }
|
||||
}
|
||||
|
||||
.gm-complete-title { font-size: 20px; font-weight: 800; color: var(--green); margin-bottom: 4px; }
|
||||
.gm-complete-level { font-size: 13px; color: var(--text2); margin-bottom: 20px; }
|
||||
|
||||
.gm-complete-stars { display: flex; justify-content: center; gap: 12px; margin-bottom: 12px; }
|
||||
.gm-big-star {
|
||||
font-size: 48px; transition: all 0.4s ease-out;
|
||||
}
|
||||
.gm-big-star.empty { color: var(--border); transform: scale(0.8); }
|
||||
.gm-big-star.earned {
|
||||
color: var(--yellow); transform: scale(1);
|
||||
filter: drop-shadow(0 0 12px rgba(255,204,0,0.4));
|
||||
animation: starPop 0.4s ease-out;
|
||||
}
|
||||
@keyframes starPop {
|
||||
0% { transform: scale(0.3); opacity: 0; }
|
||||
60% { transform: scale(1.3); }
|
||||
100% { transform: scale(1); opacity: 1; }
|
||||
}
|
||||
|
||||
.gm-complete-msg { font-size: 13px; color: var(--text2); margin-bottom: 20px; font-style: italic; }
|
||||
|
||||
.gm-checks {
|
||||
margin-bottom: 24px; text-align: left;
|
||||
background: var(--surface); border-radius: 8px; padding: 12px;
|
||||
}
|
||||
.gm-check {
|
||||
display: flex; align-items: center; gap: 8px; padding: 6px 0;
|
||||
font-size: 12px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.gm-check:last-child { border-bottom: none; }
|
||||
.gm-check-icon { font-size: 14px; width: 20px; text-align: center; }
|
||||
.gm-check.passed .gm-check-icon { color: var(--green); }
|
||||
.gm-check.failed .gm-check-icon { color: var(--red); }
|
||||
.gm-check-name { flex: 1; color: var(--text); }
|
||||
.gm-check-star { color: var(--yellow); }
|
||||
|
||||
.gm-complete-actions { display: flex; gap: 8px; justify-content: center; }
|
||||
|
||||
/* World stars counter */
|
||||
.gm-world-stars {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
font-size: 14px; color: var(--text2);
|
||||
background: var(--surface); border-radius: 12px; padding: 4px 10px;
|
||||
}
|
||||
.gm-world-stars .star.filled { color: var(--yellow); }
|
||||
|
||||
/* ===== Zoom Controls (Google Maps style) ===== */
|
||||
.zoom-controls {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 50;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
|
||||
}
|
||||
.zoom-btn {
|
||||
width: 36px;
|
||||
height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.zoom-btn:hover {
|
||||
background: var(--surface2);
|
||||
}
|
||||
.zoom-btn:active {
|
||||
background: var(--border);
|
||||
}
|
||||
.zoom-btn.zoom-label {
|
||||
font-size: 10px;
|
||||
font-weight: 500;
|
||||
color: var(--text2);
|
||||
border-top: 1px solid var(--border);
|
||||
border-bottom: 1px solid var(--border);
|
||||
height: 26px;
|
||||
width: 36px;
|
||||
}
|
||||
|
||||
/* Zoom positioning inside puzzle canvas */
|
||||
.gm-puzzle-canvas-wrap .zoom-controls {
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
}
|
||||
|
||||
/* Position zoom inside sandbox main-area (offset for palette sidebar) */
|
||||
.main-area .zoom-controls {
|
||||
top: 12px;
|
||||
right: 220px;
|
||||
}
|
||||
|
||||
/* ===== Admin Panel ===== */
|
||||
.gm-admin-btn {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text2);
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.gm-admin-btn:hover { background: var(--surface2); color: var(--text); }
|
||||
|
||||
.admin-overlay {
|
||||
position: fixed; inset: 0; z-index: 1000;
|
||||
background: rgba(0,0,0,0.7);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
.admin-panel {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
width: 90%; max-width: 700px; max-height: 85vh;
|
||||
overflow-y: auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.admin-header {
|
||||
display: flex; align-items: center; gap: 12px;
|
||||
margin-bottom: 16px; padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.admin-header h2 { font-size: 18px; color: var(--yellow); flex: 1; }
|
||||
.admin-total { color: var(--yellow); font-size: 14px; font-weight: 600; }
|
||||
.admin-close {
|
||||
background: none; border: none; color: var(--text2);
|
||||
cursor: pointer; font-size: 18px; padding: 4px 8px;
|
||||
}
|
||||
.admin-close:hover { color: var(--text); }
|
||||
|
||||
.admin-actions {
|
||||
display: flex; gap: 8px; margin-bottom: 16px;
|
||||
}
|
||||
.admin-action-btn {
|
||||
padding: 6px 14px; border-radius: 6px; border: 1px solid var(--border);
|
||||
background: var(--surface); color: var(--text); cursor: pointer;
|
||||
font-size: 12px; font-weight: 500; transition: all 0.15s;
|
||||
}
|
||||
.admin-action-btn.gold { border-color: var(--yellow); color: var(--yellow); }
|
||||
.admin-action-btn.gold:hover { background: var(--yellow); color: var(--bg); }
|
||||
.admin-action-btn.active { border-color: var(--green); color: var(--green); background: rgba(68, 255, 136, 0.1); }
|
||||
.admin-action-btn.active:hover { background: var(--green); color: var(--bg); }
|
||||
.admin-action-btn.danger { border-color: var(--red); color: var(--red); }
|
||||
.admin-action-btn.danger:hover { background: var(--red); color: #fff; }
|
||||
|
||||
/* Admin auto-solve button in puzzle bar */
|
||||
.gm-btn.admin-solve {
|
||||
background: rgba(170, 85, 255, 0.15); border-color: var(--purple); color: var(--purple);
|
||||
}
|
||||
.gm-btn.admin-solve:hover { background: var(--purple); color: #fff; }
|
||||
|
||||
.admin-world { margin-bottom: 16px; }
|
||||
.admin-world-header {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 8px 0; border-bottom: 1px solid var(--border);
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.admin-world-icon { font-size: 18px; }
|
||||
.admin-world-name { flex: 1; font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
.admin-world-stars { font-size: 12px; color: var(--yellow); }
|
||||
.admin-unlock-btn {
|
||||
padding: 3px 10px; border-radius: 4px; border: 1px solid var(--green);
|
||||
background: transparent; color: var(--green); cursor: pointer;
|
||||
font-size: 11px; transition: all 0.15s;
|
||||
}
|
||||
.admin-unlock-btn:hover { background: var(--green); color: var(--bg); }
|
||||
|
||||
.admin-levels { display: flex; flex-direction: column; gap: 2px; }
|
||||
.admin-level {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 4px 8px; border-radius: 4px;
|
||||
transition: background 0.1s;
|
||||
}
|
||||
.admin-level:hover { background: var(--surface); }
|
||||
.admin-level-num { font-size: 11px; color: var(--text2); width: 24px; }
|
||||
.admin-level-name { flex: 1; font-size: 12px; color: var(--text); }
|
||||
.admin-star-btns { display: flex; gap: 3px; }
|
||||
.admin-star-btn {
|
||||
padding: 2px 6px; border-radius: 3px; border: 1px solid var(--border);
|
||||
background: transparent; cursor: pointer; font-size: 11px;
|
||||
color: var(--text2); transition: all 0.1s;
|
||||
}
|
||||
.admin-star-btn:hover { border-color: var(--yellow); color: var(--yellow); }
|
||||
.admin-star-btn.active { background: var(--yellow); color: var(--bg); border-color: var(--yellow); }
|
||||
.admin-star-btn.zero { color: var(--red); }
|
||||
.admin-star-btn.zero:hover { border-color: var(--red); color: var(--red); }
|
||||
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