Compare commits
32 Commits
be66d9a7cf
...
feat/mobil
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -2,9 +2,14 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||||
<title>Reaktor — MontLab Modular Synth</title>
|
<title>Reaktor — MontLab Modular Synth</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<link rel="manifest" href="/manifest.json" />
|
||||||
|
<meta name="theme-color" content="#00e5ff" />
|
||||||
|
<link rel="apple-touch-icon" href="/icon-192.png" />
|
||||||
|
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||||
|
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
BIN
public/icon-192.png
Normal file
BIN
public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.5 KiB |
BIN
public/icon-512.png
Normal file
BIN
public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 11 KiB |
15
public/manifest.json
Normal file
15
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
public/sw.js
Normal file
33
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;
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
151
src/App.jsx
151
src/App.jsx
@@ -8,7 +8,11 @@ import ModuleNode from './components/ModuleNode.jsx';
|
|||||||
import WireLayer from './components/WireLayer.jsx';
|
import WireLayer from './components/WireLayer.jsx';
|
||||||
import ModulePalette from './components/ModulePalette.jsx';
|
import ModulePalette from './components/ModulePalette.jsx';
|
||||||
import PresetModal from './components/PresetModal.jsx';
|
import PresetModal from './components/PresetModal.jsx';
|
||||||
|
import BottomSheet from './components/BottomSheet.jsx';
|
||||||
import { CHIPTUNE_PRESET } from './presets/chiptune.js';
|
import { CHIPTUNE_PRESET } from './presets/chiptune.js';
|
||||||
|
import { useIsMobile } from './hooks/useIsMobile.js';
|
||||||
|
import { usePinchZoom } from './hooks/usePinchZoom.js';
|
||||||
|
import { getModulesByCategory } from './engine/moduleRegistry.js';
|
||||||
|
|
||||||
export default function App({ onSwitchToGame }) {
|
export default function App({ onSwitchToGame }) {
|
||||||
const [, forceUpdate] = useState(0);
|
const [, forceUpdate] = useState(0);
|
||||||
@@ -18,6 +22,13 @@ export default function App({ onSwitchToGame }) {
|
|||||||
const connectingRef = useRef(null);
|
const connectingRef = useRef(null);
|
||||||
const [presetModal, setPresetModal] = useState(null);
|
const [presetModal, setPresetModal] = useState(null);
|
||||||
const importRef = useRef(null);
|
const importRef = useRef(null);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
// Pinch-to-zoom on mobile
|
||||||
|
const getZoom = useCallback(() => state.zoom, []);
|
||||||
|
const setZoom = useCallback((z) => { state.zoom = z; emit(); }, []);
|
||||||
|
usePinchZoom(containerRef, getZoom, setZoom);
|
||||||
|
|
||||||
// Subscribe to state changes
|
// Subscribe to state changes
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -86,10 +97,17 @@ export default function App({ onSwitchToGame }) {
|
|||||||
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
|
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if (e.button === 0 && !connectingRef.current) {
|
} else if (e.button === 0 && !connectingRef.current) {
|
||||||
|
// On mobile (touch), single finger on empty canvas = pan
|
||||||
|
if (isMobile && e.pointerType === 'touch') {
|
||||||
|
state.panning = true;
|
||||||
|
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
state.selectedModuleId = null;
|
state.selectedModuleId = null;
|
||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
}, []);
|
}, [isMobile]);
|
||||||
|
|
||||||
const handlePointerMove = useCallback((e) => {
|
const handlePointerMove = useCallback((e) => {
|
||||||
if (state.panning && state.panStart) {
|
if (state.panning && state.panStart) {
|
||||||
@@ -191,6 +209,26 @@ export default function App({ onSwitchToGame }) {
|
|||||||
emit();
|
emit();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Center view on all modules
|
||||||
|
const handleCenterView = useCallback(() => {
|
||||||
|
if (state.modules.length === 0) { state.camX = 0; state.camY = 0; emit(); return; }
|
||||||
|
const container = containerRef.current;
|
||||||
|
const cw = container?.clientWidth || 800;
|
||||||
|
const ch = container?.clientHeight || 600;
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
for (const m of state.modules) {
|
||||||
|
minX = Math.min(minX, m.x);
|
||||||
|
minY = Math.min(minY, m.y);
|
||||||
|
maxX = Math.max(maxX, m.x + 200);
|
||||||
|
maxY = Math.max(maxY, m.y + 150);
|
||||||
|
}
|
||||||
|
const cx = (minX + maxX) / 2 * state.zoom;
|
||||||
|
const cy = (minY + maxY) / 2 * state.zoom;
|
||||||
|
state.camX = cw / 2 - cx;
|
||||||
|
state.camY = ch / 2 - cy;
|
||||||
|
emit();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleToggleAudio = async () => {
|
const handleToggleAudio = async () => {
|
||||||
if (state.isRunning) {
|
if (state.isRunning) {
|
||||||
stopAudio();
|
stopAudio();
|
||||||
@@ -223,41 +261,88 @@ export default function App({ onSwitchToGame }) {
|
|||||||
emit();
|
emit();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleClearCanvas = () => {
|
||||||
|
if (state.isRunning) stopAudio();
|
||||||
|
deserialize({ modules: [], connections: [] });
|
||||||
|
emit();
|
||||||
|
};
|
||||||
|
|
||||||
|
// Flatten all modules for mobile grid
|
||||||
|
const allModuleDefs = Object.values(getModulesByCategory()).flat();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="app">
|
<div className="app">
|
||||||
{/* Toolbar */}
|
{/* Toolbar */}
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
{onSwitchToGame && (
|
{onSwitchToGame && !isMobile && (
|
||||||
<button className="toolbar-btn" onClick={onSwitchToGame} style={{ color: 'var(--yellow)' }}>
|
<button className="toolbar-btn" onClick={onSwitchToGame} style={{ color: 'var(--yellow)' }}>
|
||||||
🎮 Game
|
🎮 Game
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<span className="toolbar-title">Reaktor</span>
|
<span className="toolbar-title">Reaktor</span>
|
||||||
<div className="toolbar-sep" />
|
{!isMobile && <div className="toolbar-sep" />}
|
||||||
<button className={`toolbar-btn ${state.isRunning ? 'active' : ''}`} onClick={handleToggleAudio}>
|
{!isMobile && (
|
||||||
|
<button className={`toolbar-btn start-btn ${state.isRunning ? 'active' : ''}`} onClick={handleToggleAudio}>
|
||||||
{state.isRunning ? '⏹ Stop' : '▶ Start'}
|
{state.isRunning ? '⏹ Stop' : '▶ Start'}
|
||||||
</button>
|
</button>
|
||||||
<div className="toolbar-sep" />
|
)}
|
||||||
|
{!isMobile && <div className="toolbar-sep" />}
|
||||||
|
{!isMobile && (
|
||||||
<div className="toolbar-group">
|
<div className="toolbar-group">
|
||||||
<button className="toolbar-btn" onClick={() => setPresetModal('save')}>💾 Save</button>
|
<button className="toolbar-btn save-btn" onClick={() => setPresetModal('save')}>💾 Save</button>
|
||||||
<button className="toolbar-btn" onClick={() => setPresetModal('load')}>📂 Load</button>
|
<button className="toolbar-btn load-btn" onClick={() => setPresetModal('load')}>📂 Load</button>
|
||||||
<button className="toolbar-btn" onClick={exportPatch}>📤 Export</button>
|
<button className="toolbar-btn export-btn" onClick={exportPatch}>📤 Export</button>
|
||||||
<button className="toolbar-btn" onClick={() => importRef.current?.click()}>📥 Import</button>
|
<button className="toolbar-btn import-btn" onClick={() => importRef.current?.click()}>📥 Import</button>
|
||||||
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
|
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
|
||||||
</div>
|
</div>
|
||||||
<div className="toolbar-sep" />
|
)}
|
||||||
<button className="toolbar-btn" onClick={handleLoadDemo} style={{ color: 'var(--yellow)' }}>
|
{!isMobile && <div className="toolbar-sep" />}
|
||||||
|
{!isMobile && (
|
||||||
|
<>
|
||||||
|
<button className="toolbar-btn demo-btn" onClick={handleLoadDemo} style={{ color: 'var(--yellow)' }}>
|
||||||
🎮 Chiptune Demo
|
🎮 Chiptune Demo
|
||||||
</button>
|
</button>
|
||||||
|
<button className="toolbar-btn clear-btn" onClick={handleClearCanvas}>
|
||||||
|
🗑 Limpiar
|
||||||
|
</button>
|
||||||
<div className="toolbar-sep" />
|
<div className="toolbar-sep" />
|
||||||
<span className="toolbar-label" style={{ color: state.isRunning ? 'var(--green)' : 'var(--text2)' }}>
|
</>
|
||||||
|
)}
|
||||||
|
<span className="toolbar-label" style={{ color: state.isRunning ? 'var(--green)' : 'var(--text2)', marginLeft: isMobile ? 'auto' : undefined }}>
|
||||||
{state.isRunning ? '● LIVE' : '○ OFF'}
|
{state.isRunning ? '● LIVE' : '○ OFF'}
|
||||||
</span>
|
</span>
|
||||||
|
{!isMobile && (
|
||||||
<span className="toolbar-label" style={{ marginLeft: 'auto' }}>
|
<span className="toolbar-label" style={{ marginLeft: 'auto' }}>
|
||||||
{state.modules.length} modules · {state.connections.length} wires
|
{state.modules.length} modules · {state.connections.length} wires
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
|
{isMobile && (
|
||||||
|
<button className="mobile-menu-btn" onClick={() => setMenuOpen(true)}>≡</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile menu overlay */}
|
||||||
|
{isMobile && menuOpen && (
|
||||||
|
<div className="mobile-menu-overlay" onClick={() => setMenuOpen(false)}>
|
||||||
|
<div className="mobile-menu-panel" onClick={e => e.stopPropagation()}>
|
||||||
|
{onSwitchToGame && (
|
||||||
|
<button className="toolbar-btn" onClick={() => { setMenuOpen(false); onSwitchToGame(); }} style={{ color: 'var(--yellow)' }}>
|
||||||
|
🎮 Game
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
<button className="toolbar-btn" onClick={() => { setPresetModal('save'); setMenuOpen(false); }}>💾 Save</button>
|
||||||
|
<button className="toolbar-btn" onClick={() => { setPresetModal('load'); setMenuOpen(false); }}>📂 Load</button>
|
||||||
|
<button className="toolbar-btn" onClick={() => { exportPatch(); setMenuOpen(false); }}>📤 Export</button>
|
||||||
|
<button className="toolbar-btn" onClick={() => { importRef.current?.click(); setMenuOpen(false); }}>📥 Import</button>
|
||||||
|
<input ref={importRef} type="file" accept=".json" style={{ display: 'none' }} onChange={handleImport} />
|
||||||
|
<button className="toolbar-btn" onClick={() => { handleLoadDemo(); setMenuOpen(false); }} style={{ color: 'var(--yellow)' }}>
|
||||||
|
🎮 Chiptune Demo
|
||||||
|
</button>
|
||||||
|
<button className="toolbar-btn" onClick={() => { handleClearCanvas(); setMenuOpen(false); }}>🗑 Limpiar</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Main canvas area */}
|
{/* Main canvas area */}
|
||||||
<div className="main-area">
|
<div className="main-area">
|
||||||
<div
|
<div
|
||||||
@@ -281,10 +366,10 @@ export default function App({ onSwitchToGame }) {
|
|||||||
<rect width="100%" height="100%" fill="url(#grid)" />
|
<rect width="100%" height="100%" fill="url(#grid)" />
|
||||||
</svg>
|
</svg>
|
||||||
|
|
||||||
{/* Wire layer (behind modules, uses getBoundingClientRect) */}
|
{/* Wire layer */}
|
||||||
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} zoom={state.zoom} camX={state.camX} camY={state.camY} />
|
<WireLayer portPositions={portPositions} tempWire={tempWire} containerRef={containerRef} zoom={state.zoom} camX={state.camX} camY={state.camY} />
|
||||||
|
|
||||||
{/* Modules container (offset by camera) */}
|
{/* Modules container */}
|
||||||
<div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
|
<div style={{ position: 'absolute', left: state.camX, top: state.camY, zIndex: 2 }}>
|
||||||
{state.modules.map(mod => (
|
{state.modules.map(mod => (
|
||||||
<ModuleNode
|
<ModuleNode
|
||||||
@@ -298,20 +383,50 @@ export default function App({ onSwitchToGame }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Zoom controls — top right of canvas */}
|
{/* Zoom controls */}
|
||||||
<div className="zoom-controls">
|
<div className="zoom-controls">
|
||||||
<button className="zoom-btn" onClick={handleZoomIn} title="Zoom in">+</button>
|
<button className="zoom-btn" onClick={handleZoomIn} title="Zoom in">+</button>
|
||||||
<button className="zoom-btn zoom-label" onClick={handleZoomReset} title="Reset zoom">
|
<button className="zoom-btn zoom-label" onClick={handleZoomReset} title="Reset zoom">
|
||||||
{(state.zoom * 100).toFixed(0)}%
|
{(state.zoom * 100).toFixed(0)}%
|
||||||
</button>
|
</button>
|
||||||
<button className="zoom-btn" onClick={handleZoomOut} title="Zoom out">−</button>
|
<button className="zoom-btn" onClick={handleZoomOut} title="Zoom out">−</button>
|
||||||
|
<button className="zoom-btn" onClick={handleCenterView} title="Centrar vista">⌂</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Module palette */}
|
{/* Desktop palette */}
|
||||||
<ModulePalette onAddModule={handleAddModule} />
|
{!isMobile && <ModulePalette onAddModule={handleAddModule} />}
|
||||||
</div>
|
</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">
|
<div className="status-bar">
|
||||||
<span className="status-accent">Reaktor — MontLab Modular Synth</span>
|
<span className="status-accent">Reaktor — MontLab Modular Synth</span>
|
||||||
<span>Zoom: {(state.zoom * 100).toFixed(0)}%</span>
|
<span>Zoom: {(state.zoom * 100).toFixed(0)}%</span>
|
||||||
|
|||||||
52
src/components/BottomSheet.jsx
Normal file
52
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
src/components/DrumPadWidget.jsx
Normal file
109
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
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import React, { useCallback, useEffect, useRef } from 'react';
|
import React, { useCallback, useEffect, useRef, useState } from 'react';
|
||||||
|
import { createPortal } from 'react-dom';
|
||||||
import { triggerKeyboard } from '../engine/audioEngine.js';
|
import { triggerKeyboard } from '../engine/audioEngine.js';
|
||||||
import { state } from '../engine/state.js';
|
import { state } from '../engine/state.js';
|
||||||
|
|
||||||
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||||
|
|
||||||
// Computer keyboard to semitone offset mapping (2 octaves)
|
|
||||||
const KEY_MAP = {
|
const KEY_MAP = {
|
||||||
'z': 0, 's': 1, 'x': 2, 'd': 3, 'c': 4, 'v': 5, 'g': 6,
|
'z': 0, 's': 1, 'x': 2, 'd': 3, 'c': 4, 'v': 5, 'g': 6,
|
||||||
'b': 7, 'h': 8, 'n': 9, 'j': 10, 'm': 11, ',': 12,
|
'b': 7, 'h': 8, 'n': 9, 'j': 10, 'm': 11, ',': 12,
|
||||||
@@ -16,7 +16,87 @@ function midiToFreq(midi) {
|
|||||||
return 440 * Math.pow(2, (midi - 69) / 12);
|
return 440 * Math.pow(2, (midi - 69) / 12);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function KeyboardWidget({ moduleId }) {
|
// Fullscreen piano — 1 octave, big comfortable keys like a real piano app
|
||||||
|
function FullscreenPiano({ moduleId, initialOctave, onClose }) {
|
||||||
|
const [oct, setOct] = useState(initialOctave);
|
||||||
|
const [activeNotes, setActiveNotes] = useState(new Set());
|
||||||
|
|
||||||
|
const play = useCallback((semitone) => {
|
||||||
|
const midi = (oct + 1) * 12 + semitone;
|
||||||
|
triggerKeyboard(moduleId, midiToFreq(midi), true);
|
||||||
|
setActiveNotes(prev => new Set(prev).add(semitone));
|
||||||
|
}, [moduleId, oct]);
|
||||||
|
|
||||||
|
const stop = useCallback((semitone) => {
|
||||||
|
setActiveNotes(prev => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(semitone);
|
||||||
|
if (next.size === 0) triggerKeyboard(moduleId, 440, false);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}, [moduleId]);
|
||||||
|
|
||||||
|
// 1 octave: 7 white keys, 5 black keys
|
||||||
|
const whiteKeys = [
|
||||||
|
{ note: 0, name: 'C' },
|
||||||
|
{ note: 2, name: 'D' },
|
||||||
|
{ note: 4, name: 'E' },
|
||||||
|
{ note: 5, name: 'F' },
|
||||||
|
{ note: 7, name: 'G' },
|
||||||
|
{ note: 9, name: 'A' },
|
||||||
|
{ note: 11, name: 'B' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Black key positions relative to white key index (0-6)
|
||||||
|
const blackKeys = [
|
||||||
|
{ note: 1, name: 'C#', after: 0 },
|
||||||
|
{ note: 3, name: 'D#', after: 1 },
|
||||||
|
{ note: 6, name: 'F#', after: 3 },
|
||||||
|
{ note: 8, name: 'G#', after: 4 },
|
||||||
|
{ note: 10, name: 'A#', after: 5 },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="keyboard-fullscreen">
|
||||||
|
<div className="keyboard-fs-header">
|
||||||
|
<button className="keyboard-fs-close" onClick={onClose}>✕</button>
|
||||||
|
<button className="keyboard-fs-oct-btn" onClick={() => setOct(o => Math.max(1, o - 1))}>◀</button>
|
||||||
|
<span className="keyboard-fs-title">Octave {oct}</span>
|
||||||
|
<button className="keyboard-fs-oct-btn" onClick={() => setOct(o => Math.min(8, o + 1))}>▶</button>
|
||||||
|
<div style={{ width: 36 }} />
|
||||||
|
</div>
|
||||||
|
<div className="keyboard-fs-keys">
|
||||||
|
{whiteKeys.map((k, i) => (
|
||||||
|
<div
|
||||||
|
key={k.note}
|
||||||
|
className={`keyboard-fs-white ${activeNotes.has(k.note) ? 'pressed' : ''}`}
|
||||||
|
onPointerDown={() => play(k.note)}
|
||||||
|
onPointerUp={() => stop(k.note)}
|
||||||
|
onPointerLeave={() => stop(k.note)}
|
||||||
|
onPointerCancel={() => stop(k.note)}
|
||||||
|
>
|
||||||
|
<span className="keyboard-fs-note-label">{k.name}{oct}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{blackKeys.map((k) => (
|
||||||
|
<div
|
||||||
|
key={k.note}
|
||||||
|
className={`keyboard-fs-black ${activeNotes.has(k.note) ? 'pressed' : ''}`}
|
||||||
|
style={{ left: `${(k.after + 0.65) * (100 / 7)}%`, width: `${(100 / 7) * 0.65}%` }}
|
||||||
|
onPointerDown={() => play(k.note)}
|
||||||
|
onPointerUp={() => stop(k.note)}
|
||||||
|
onPointerLeave={() => stop(k.note)}
|
||||||
|
onPointerCancel={() => stop(k.note)}
|
||||||
|
>
|
||||||
|
<span className="keyboard-fs-black-label">{k.name}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KeyboardWidget({ moduleId, fullscreen, onCloseFullscreen }) {
|
||||||
const mod = state.modules.find(m => m.id === moduleId);
|
const mod = state.modules.find(m => m.id === moduleId);
|
||||||
const octave = mod?.params?.octave ?? 4;
|
const octave = mod?.params?.octave ?? 4;
|
||||||
const activeKeys = useRef(new Set());
|
const activeKeys = useRef(new Set());
|
||||||
@@ -47,7 +127,6 @@ export default function KeyboardWidget({ moduleId }) {
|
|||||||
if (activeKeys.current.size === 0) stopNote();
|
if (activeKeys.current.size === 0) stopNote();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
window.addEventListener('keydown', handleDown);
|
window.addEventListener('keydown', handleDown);
|
||||||
window.addEventListener('keyup', handleUp);
|
window.addEventListener('keyup', handleUp);
|
||||||
return () => {
|
return () => {
|
||||||
@@ -56,11 +135,12 @@ export default function KeyboardWidget({ moduleId }) {
|
|||||||
};
|
};
|
||||||
}, [playNote, stopNote]);
|
}, [playNote, stopNote]);
|
||||||
|
|
||||||
// Draw mini keyboard (1 octave)
|
// Mini keyboard (1 octave)
|
||||||
const whites = [0, 2, 4, 5, 7, 9, 11];
|
const whites = [0, 2, 4, 5, 7, 9, 11];
|
||||||
const blacks = [1, 3, -1, 6, 8, 10];
|
const blacks = [1, 3, -1, 6, 8, 10];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div style={{ padding: '2px 0' }}>
|
<div style={{ padding: '2px 0' }}>
|
||||||
<svg viewBox="0 0 140 30" style={{ width: '100%', height: 30 }}>
|
<svg viewBox="0 0 140 30" style={{ width: '100%', height: 30 }}>
|
||||||
{whites.map((note, i) => (
|
{whites.map((note, i) => (
|
||||||
@@ -87,5 +167,15 @@ export default function KeyboardWidget({ moduleId }) {
|
|||||||
Z-M / Q-I keys · Oct {octave}
|
Z-M / Q-I keys · Oct {octave}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{fullscreen && createPortal(
|
||||||
|
<FullscreenPiano
|
||||||
|
moduleId={moduleId}
|
||||||
|
initialOctave={octave}
|
||||||
|
onClose={onCloseFullscreen}
|
||||||
|
/>,
|
||||||
|
document.body
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
16
src/components/MobileTabBar.jsx
Normal file
16
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 React, { useCallback, useState, useEffect, useRef } from 'react';
|
||||||
import { getModuleDef } from '../engine/moduleRegistry.js';
|
import { getModuleDef } from '../engine/moduleRegistry.js';
|
||||||
import { state, removeModule, updateModuleParam, isPortConnected, emit } from '../engine/state.js';
|
import { state, removeModule, updateModuleParam, isPortConnected, emit } from '../engine/state.js';
|
||||||
import { updateParam } from '../engine/audioEngine.js';
|
import { updateParam, getAudioNode } from '../engine/audioEngine.js';
|
||||||
import Knob from './Knob.jsx';
|
import Knob from './Knob.jsx';
|
||||||
import ScopeDisplay from './ScopeDisplay.jsx';
|
import ScopeDisplay from './ScopeDisplay.jsx';
|
||||||
import KeyboardWidget from './KeyboardWidget.jsx';
|
import KeyboardWidget from './KeyboardWidget.jsx';
|
||||||
|
import DrumPadWidget from './DrumPadWidget.jsx';
|
||||||
import SequencerWidget from './SequencerWidget.jsx';
|
import SequencerWidget from './SequencerWidget.jsx';
|
||||||
import PianoRollWidget from './PianoRollWidget.jsx';
|
import PianoRollWidget from './PianoRollWidget.jsx';
|
||||||
|
|
||||||
|
// Dynamic module widths for sequencer/pianoroll based on step/bar count
|
||||||
|
function getModuleWidth(mod, type) {
|
||||||
|
if (type === 'sequencer') {
|
||||||
|
const numSteps = parseInt(mod?.params?.steps || '16');
|
||||||
|
return Math.max(200, numSteps * 18 + 20); // CELL_W=18 + padding
|
||||||
|
}
|
||||||
|
if (type === 'pianoroll') {
|
||||||
|
const bars = parseInt(mod?.params?.bars || '4');
|
||||||
|
const totalBeats = bars * 4;
|
||||||
|
return 24 + totalBeats * 30 + 20; // KEY_W + beats*BEAT_PX + padding
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Map input port names → the param name they modulate (for visual feedback)
|
// Map input port names → the param name they modulate (for visual feedback)
|
||||||
const PORT_TO_PARAM = {
|
const PORT_TO_PARAM = {
|
||||||
filter: { cutoff: 'frequency' },
|
filter: { cutoff: 'frequency' },
|
||||||
@@ -31,6 +46,7 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
|
|||||||
if (!def) return null;
|
if (!def) return null;
|
||||||
|
|
||||||
const isSelected = state.selectedModuleId === mod.id;
|
const isSelected = state.selectedModuleId === mod.id;
|
||||||
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
|
||||||
// Merge default params
|
// Merge default params
|
||||||
const params = { ...Object.fromEntries(Object.entries(def.params).map(([k, v]) => [k, v.default])), ...mod.params };
|
const params = { ...Object.fromEntries(Object.entries(def.params).map(([k, v]) => [k, v.default])), ...mod.params };
|
||||||
@@ -44,7 +60,7 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Live LFO modulation visualization ====================
|
// ==================== Live modulation visualization (LFO + Envelope + any CV) ====================
|
||||||
const [liveValues, setLiveValues] = useState({});
|
const [liveValues, setLiveValues] = useState({});
|
||||||
const rafRef = useRef(null);
|
const rafRef = useRef(null);
|
||||||
const startTimeRef = useRef(performance.now() / 1000);
|
const startTimeRef = useRef(performance.now() / 1000);
|
||||||
@@ -55,7 +71,12 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let frameCount = 0;
|
||||||
const tick = () => {
|
const tick = () => {
|
||||||
|
frameCount++;
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
if (frameCount % 4 !== 0) return;
|
||||||
|
|
||||||
const t = performance.now() / 1000 - startTimeRef.current;
|
const t = performance.now() / 1000 - startTimeRef.current;
|
||||||
const newValues = {};
|
const newValues = {};
|
||||||
|
|
||||||
@@ -65,9 +86,10 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
|
|||||||
if (!paramName) continue;
|
if (!paramName) continue;
|
||||||
|
|
||||||
const srcMod = state.modules.find(m => m.id === conn.from.moduleId);
|
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
|
if (srcMod.type === 'lfo') {
|
||||||
|
// LFO: simulate waveform for smooth visual
|
||||||
const lfoDef = getModuleDef('lfo');
|
const lfoDef = getModuleDef('lfo');
|
||||||
const lfoP = { ...Object.fromEntries(Object.entries(lfoDef.params).map(([k, v]) => [k, v.default])), ...srcMod.params };
|
const lfoP = { ...Object.fromEntries(Object.entries(lfoDef.params).map(([k, v]) => [k, v.default])), ...srcMod.params };
|
||||||
const freq = lfoP.frequency;
|
const freq = lfoP.frequency;
|
||||||
@@ -76,7 +98,6 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
|
|||||||
const phase = (t * freq) % 1;
|
const phase = (t * freq) % 1;
|
||||||
const lfoVal = simulateLFO(waveform, phase) * amp;
|
const lfoVal = simulateLFO(waveform, phase) * amp;
|
||||||
|
|
||||||
// Compute modulated value (same scaling as audioEngine)
|
|
||||||
const baseValue = params[paramName];
|
const baseValue = params[paramName];
|
||||||
let scale;
|
let scale;
|
||||||
if (mod.type === 'oscillator' && paramName === 'frequency') scale = baseValue * 0.5;
|
if (mod.type === 'oscillator' && paramName === 'frequency') scale = baseValue * 0.5;
|
||||||
@@ -85,10 +106,17 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
|
|||||||
else scale = baseValue || 1;
|
else scale = baseValue || 1;
|
||||||
|
|
||||||
newValues[paramName] = baseValue + lfoVal * scale;
|
newValues[paramName] = baseValue + lfoVal * scale;
|
||||||
|
} else if (srcMod.type === 'envelope') {
|
||||||
|
// Envelope: read the actual audio node gain value for real-time display
|
||||||
|
const audioEntry = getAudioNode(mod.id);
|
||||||
|
if (audioEntry?.node?.gain) {
|
||||||
|
const currentGain = audioEntry.node.gain.value;
|
||||||
|
newValues[paramName] = currentGain;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setLiveValues(newValues);
|
setLiveValues(newValues);
|
||||||
rafRef.current = requestAnimationFrame(tick);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
rafRef.current = requestAnimationFrame(tick);
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
@@ -150,7 +178,7 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
|
|||||||
style={{
|
style={{
|
||||||
left: mod.x * zoom, top: mod.y * zoom,
|
left: mod.x * zoom, top: mod.y * zoom,
|
||||||
transform: `scale(${zoom})`, transformOrigin: 'top left',
|
transform: `scale(${zoom})`, transformOrigin: 'top left',
|
||||||
...(mod.type === 'pianoroll' ? { width: 520 } : mod.type === 'sequencer' ? { width: 310 } : {}),
|
...(mod.type === 'pianoroll' ? { width: getModuleWidth(mod, 'pianoroll') } : mod.type === 'sequencer' ? { width: getModuleWidth(mod, 'sequencer') } : {}),
|
||||||
}}
|
}}
|
||||||
data-module-id={mod.id}
|
data-module-id={mod.id}
|
||||||
onPointerDown={(e) => {
|
onPointerDown={(e) => {
|
||||||
@@ -162,6 +190,13 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
|
|||||||
<div className="module-header" onPointerDown={handleHeaderDown}>
|
<div className="module-header" onPointerDown={handleHeaderDown}>
|
||||||
<span className="type-icon">{def.icon}</span>
|
<span className="type-icon">{def.icon}</span>
|
||||||
<span className="type-name">{def.name}</span>
|
<span className="type-name">{def.name}</span>
|
||||||
|
{(mod.type === 'keyboard' || mod.type === 'drumpad') && (
|
||||||
|
<button
|
||||||
|
className="expand-btn"
|
||||||
|
onClick={(e) => { e.stopPropagation(); setFullscreen(true); }}
|
||||||
|
title="Pantalla completa"
|
||||||
|
>⤢</button>
|
||||||
|
)}
|
||||||
<button className="close-btn" onClick={handleDelete}>✕</button>
|
<button className="close-btn" onClick={handleDelete}>✕</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -232,7 +267,10 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
|
|||||||
{mod.type === 'scope' && <ScopeDisplay moduleId={mod.id} />}
|
{mod.type === 'scope' && <ScopeDisplay moduleId={mod.id} />}
|
||||||
|
|
||||||
{/* Keyboard widget */}
|
{/* Keyboard widget */}
|
||||||
{mod.type === 'keyboard' && <KeyboardWidget moduleId={mod.id} />}
|
{mod.type === 'keyboard' && <KeyboardWidget moduleId={mod.id} fullscreen={fullscreen} onCloseFullscreen={() => setFullscreen(false)} />}
|
||||||
|
|
||||||
|
{/* Drum Pad widget */}
|
||||||
|
{mod.type === 'drumpad' && <DrumPadWidget moduleId={mod.id} fullscreen={fullscreen} onCloseFullscreen={() => setFullscreen(false)} />}
|
||||||
|
|
||||||
{/* Sequencer widget */}
|
{/* Sequencer widget */}
|
||||||
{mod.type === 'sequencer' && <SequencerWidget moduleId={mod.id} />}
|
{mod.type === 'sequencer' && <SequencerWidget moduleId={mod.id} />}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import * as Tone from 'tone';
|
import * as Tone from 'tone';
|
||||||
import { state, updateModuleParam, emit } from '../engine/state.js';
|
import { state, updateModuleParam, emit } from '../engine/state.js';
|
||||||
import { setSequencerSignals } from '../engine/audioEngine.js';
|
import { setSequencerSignals, subscribeTick, unsubscribeTick, MASTER_TICK_RATE } from '../engine/audioEngine.js';
|
||||||
import { parseMidi } from '../utils/midiParser.js';
|
import { parseMidi } from '../utils/midiParser.js';
|
||||||
|
|
||||||
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||||
@@ -79,7 +79,7 @@ const MARIO_MELODY = [
|
|||||||
{ note: 71, start: 70*s, duration: 2*s }, // B4
|
{ note: 71, start: 70*s, duration: 2*s }, // B4
|
||||||
];
|
];
|
||||||
|
|
||||||
const ROLL_W = 500;
|
const BEAT_PX = 30; // pixels per beat — constant density regardless of bar count
|
||||||
const ROLL_H = 200;
|
const ROLL_H = 200;
|
||||||
const KEY_W = 24;
|
const KEY_W = 24;
|
||||||
const MIN_NOTE = 48; // C3
|
const MIN_NOTE = 48; // C3
|
||||||
@@ -90,11 +90,10 @@ const ROW_H = ROLL_H / NOTE_RANGE;
|
|||||||
export default function PianoRollWidget({ moduleId }) {
|
export default function PianoRollWidget({ moduleId }) {
|
||||||
const mod = state.modules.find(m => m.id === moduleId);
|
const mod = state.modules.find(m => m.id === moduleId);
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
const partRef = useRef(null);
|
|
||||||
const [playPos, setPlayPos] = useState(-1);
|
|
||||||
const [tool, setTool] = useState('draw'); // 'draw' | 'erase'
|
const [tool, setTool] = useState('draw'); // 'draw' | 'erase'
|
||||||
const drawingRef = useRef(null);
|
const drawingRef = useRef(null);
|
||||||
const rafRef = useRef(null);
|
const rafRef = useRef(null);
|
||||||
|
const playPosRef = useRef(-1);
|
||||||
const midiInputRef = useRef(null);
|
const midiInputRef = useRef(null);
|
||||||
|
|
||||||
const bpm = mod?.params?.bpm ?? 140;
|
const bpm = mod?.params?.bpm ?? 140;
|
||||||
@@ -110,7 +109,8 @@ export default function PianoRollWidget({ moduleId }) {
|
|||||||
const notesRef = useRef(notes);
|
const notesRef = useRef(notes);
|
||||||
notesRef.current = notes;
|
notesRef.current = notes;
|
||||||
|
|
||||||
const beatW = (ROLL_W - KEY_W) / totalBeats;
|
const rollW = KEY_W + totalBeats * BEAT_PX;
|
||||||
|
const beatW = BEAT_PX;
|
||||||
|
|
||||||
// Draw the piano roll
|
// Draw the piano roll
|
||||||
const draw = useCallback(() => {
|
const draw = useCallback(() => {
|
||||||
@@ -195,8 +195,9 @@ export default function PianoRollWidget({ moduleId }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Playhead
|
// Playhead
|
||||||
if (playPos >= 0 && playPos < totalBeats) {
|
const currentPlayPos = playPosRef.current;
|
||||||
const px = KEY_W + playPos * beatW;
|
if (currentPlayPos >= 0 && currentPlayPos < totalBeats) {
|
||||||
|
const px = KEY_W + currentPlayPos * beatW;
|
||||||
ctx.strokeStyle = '#ff6644';
|
ctx.strokeStyle = '#ff6644';
|
||||||
ctx.lineWidth = 2;
|
ctx.lineWidth = 2;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
@@ -220,7 +221,7 @@ export default function PianoRollWidget({ moduleId }) {
|
|||||||
ctx.fillStyle = 'rgba(0,229,255,0.3)';
|
ctx.fillStyle = 'rgba(0,229,255,0.3)';
|
||||||
ctx.fillRect(x, row * ROW_H, Math.max(nw, 2), ROW_H);
|
ctx.fillRect(x, row * ROW_H, Math.max(nw, 2), ROW_H);
|
||||||
}
|
}
|
||||||
}, [totalBeats, beatW, playPos]);
|
}, [totalBeats, beatW, rollW]);
|
||||||
|
|
||||||
// Animation loop
|
// Animation loop
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -232,66 +233,76 @@ export default function PianoRollWidget({ moduleId }) {
|
|||||||
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
|
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
|
||||||
}, [draw]);
|
}, [draw]);
|
||||||
|
|
||||||
// Playback
|
// Subscribe to global master clock for playback
|
||||||
|
const bpmRef = useRef(bpm);
|
||||||
|
const loopRef = useRef(loop);
|
||||||
|
const totalBeatsRef = useRef(totalBeats);
|
||||||
|
bpmRef.current = bpm;
|
||||||
|
loopRef.current = loop;
|
||||||
|
totalBeatsRef.current = totalBeats;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state.isRunning) {
|
if (!state.isRunning) {
|
||||||
if (partRef.current) { partRef.current.stop(); partRef.current.dispose(); partRef.current = null; }
|
unsubscribeTick(`pr-${moduleId}`);
|
||||||
setPlayPos(-1);
|
playPosRef.current = -1;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Tone.getTransport().bpm.value = bpm;
|
let currentNote = null;
|
||||||
|
let lastQuantPos = -1;
|
||||||
|
|
||||||
// Build Tone.Part from notes using musical time (bars:quarters:sixteenths)
|
subscribeTick(`pr-${moduleId}`, (time, ticks) => {
|
||||||
// This lets the Transport BPM control actual playback speed
|
const currentBpm = bpmRef.current;
|
||||||
const events = notesRef.current.map(n => {
|
const currentLoop = loopRef.current;
|
||||||
// Convert beats to bars:quarters:sixteenths notation
|
const currentTotalBeats = totalBeatsRef.current;
|
||||||
const totalSixteenths = Math.round(n.start * 4);
|
|
||||||
const barNum = Math.floor(totalSixteenths / 16);
|
// Convert ticks to beats: each beat = MASTER_TICK_RATE * 60 / bpm ticks
|
||||||
const remainder = totalSixteenths % 16;
|
// Position in sixteenths: ticks / (ticksPerSixteenth)
|
||||||
const quarterNum = Math.floor(remainder / 4);
|
const ticksPerBeat = MASTER_TICK_RATE * 60 / currentBpm;
|
||||||
const sixteenthNum = remainder % 4;
|
const rawPos = ticks / ticksPerBeat; // in beats
|
||||||
return {
|
const pos = currentLoop ? rawPos % currentTotalBeats : rawPos;
|
||||||
time: `${barNum}:${quarterNum}:${sixteenthNum}`,
|
const quantPos = Math.floor(pos * 4) / 4;
|
||||||
note: n.note,
|
|
||||||
dur: n.duration,
|
if (quantPos === lastQuantPos) return;
|
||||||
};
|
const looped = lastQuantPos >= 0 && quantPos < lastQuantPos;
|
||||||
|
lastQuantPos = quantPos;
|
||||||
|
|
||||||
|
if (!currentLoop && rawPos >= currentTotalBeats) {
|
||||||
|
if (currentNote) {
|
||||||
|
setSequencerSignals(moduleId, 0, false);
|
||||||
|
currentNote = null;
|
||||||
|
}
|
||||||
|
playPosRef.current = -1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
playPosRef.current = pos;
|
||||||
|
|
||||||
|
if (looped && currentNote) {
|
||||||
|
setSequencerSignals(moduleId, 0, false);
|
||||||
|
currentNote = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allNotes = notesRef.current;
|
||||||
|
const activeNote = allNotes.find(n => quantPos >= n.start && quantPos < n.start + n.duration);
|
||||||
|
|
||||||
|
if (activeNote) {
|
||||||
|
if (!currentNote || currentNote.note !== activeNote.note || currentNote.start !== activeNote.start) {
|
||||||
|
setSequencerSignals(moduleId, midiToFreq(activeNote.note), true);
|
||||||
|
currentNote = activeNote;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (currentNote) {
|
||||||
|
setSequencerSignals(moduleId, 0, false);
|
||||||
|
currentNote = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const part = new Tone.Part((time, ev) => {
|
|
||||||
setSequencerSignals(moduleId, midiToFreq(ev.note), true);
|
|
||||||
// Note-off: convert duration beats to musical time for proper BPM-relative timing
|
|
||||||
const durSixteenths = Math.round(ev.dur * 4);
|
|
||||||
const noteOffTime = time + (durSixteenths * (60 / (bpm * 4))) * 0.9;
|
|
||||||
Tone.getTransport().scheduleOnce(() => {
|
|
||||||
setSequencerSignals(moduleId, midiToFreq(ev.note), false);
|
|
||||||
}, noteOffTime);
|
|
||||||
}, events.map(ev => [ev.time, { note: ev.note, dur: ev.dur }]));
|
|
||||||
|
|
||||||
part.loop = loop;
|
|
||||||
part.loopEnd = `${bars}m`;
|
|
||||||
part.start(0);
|
|
||||||
|
|
||||||
if (Tone.getTransport().state !== 'started') {
|
|
||||||
Tone.getTransport().start();
|
|
||||||
}
|
|
||||||
partRef.current = part;
|
|
||||||
|
|
||||||
// Track playhead position
|
|
||||||
const posInterval = setInterval(() => {
|
|
||||||
if (Tone.getTransport().state === 'started') {
|
|
||||||
const pos = Tone.getTransport().seconds;
|
|
||||||
const beatDuration = 60 / bpm;
|
|
||||||
const currentBeat = (pos / beatDuration) % totalBeats;
|
|
||||||
setPlayPos(currentBeat);
|
|
||||||
}
|
|
||||||
}, 30);
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(posInterval);
|
unsubscribeTick(`pr-${moduleId}`);
|
||||||
if (partRef.current) { partRef.current.stop(); partRef.current.dispose(); partRef.current = null; }
|
|
||||||
};
|
};
|
||||||
}, [state.isRunning, moduleId, bpm, bars, loop]);
|
}, [state.isRunning, moduleId]);
|
||||||
|
|
||||||
// Mouse interaction for drawing/erasing notes
|
// Mouse interaction for drawing/erasing notes
|
||||||
const handleMouseDown = useCallback((e) => {
|
const handleMouseDown = useCallback((e) => {
|
||||||
@@ -398,7 +409,7 @@ export default function PianoRollWidget({ moduleId }) {
|
|||||||
}, [mod]);
|
}, [mod]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div style={{ width: ROLL_W }}>
|
<div style={{ width: rollW }}>
|
||||||
{/* Mini toolbar */}
|
{/* Mini toolbar */}
|
||||||
<div style={{ display: 'flex', gap: 4, marginBottom: 3 }}>
|
<div style={{ display: 'flex', gap: 4, marginBottom: 3 }}>
|
||||||
<button
|
<button
|
||||||
@@ -436,9 +447,9 @@ export default function PianoRollWidget({ moduleId }) {
|
|||||||
</div>
|
</div>
|
||||||
<canvas
|
<canvas
|
||||||
ref={canvasRef}
|
ref={canvasRef}
|
||||||
width={ROLL_W}
|
width={rollW}
|
||||||
height={ROLL_H}
|
height={ROLL_H}
|
||||||
style={{ width: ROLL_W, height: ROLL_H, borderRadius: 4, cursor: tool === 'draw' ? 'crosshair' : 'pointer' }}
|
style={{ width: rollW, height: ROLL_H, borderRadius: 4, cursor: tool === 'draw' ? 'crosshair' : 'pointer' }}
|
||||||
onPointerDown={handleMouseDown}
|
onPointerDown={handleMouseDown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,19 @@
|
|||||||
import React, { useRef, useEffect } from 'react';
|
import React, { useRef, useEffect, useState } from 'react';
|
||||||
import { getAnalyserData } from '../engine/audioEngine.js';
|
import { getAnalyserData } from '../engine/audioEngine.js';
|
||||||
|
|
||||||
|
// Zoom levels: how many samples to display (from a 2048-sample buffer)
|
||||||
|
// Fewer samples = zoomed in (more detail), more samples = zoomed out (more time visible)
|
||||||
|
const ZOOM_LEVELS = [64, 128, 256, 512, 1024, 2048];
|
||||||
|
const DEFAULT_ZOOM = 2; // index → 256 samples
|
||||||
|
|
||||||
export default function ScopeDisplay({ moduleId }) {
|
export default function ScopeDisplay({ moduleId }) {
|
||||||
const canvasRef = useRef(null);
|
const canvasRef = useRef(null);
|
||||||
const rafRef = useRef(null);
|
const rafRef = useRef(null);
|
||||||
|
const [zoomIdx, setZoomIdx] = useState(DEFAULT_ZOOM);
|
||||||
|
const zoomRef = useRef(ZOOM_LEVELS[DEFAULT_ZOOM]);
|
||||||
|
|
||||||
|
// Keep ref in sync so the draw loop picks it up without re-creating the effect
|
||||||
|
useEffect(() => { zoomRef.current = ZOOM_LEVELS[zoomIdx]; }, [zoomIdx]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@@ -12,7 +22,13 @@ export default function ScopeDisplay({ moduleId }) {
|
|||||||
const w = canvas.width = 160;
|
const w = canvas.width = 160;
|
||||||
const h = canvas.height = 60;
|
const h = canvas.height = 60;
|
||||||
|
|
||||||
|
let frameCount = 0;
|
||||||
const draw = () => {
|
const draw = () => {
|
||||||
|
frameCount++;
|
||||||
|
rafRef.current = requestAnimationFrame(draw);
|
||||||
|
// Throttle to ~30fps to reduce main thread pressure during playback
|
||||||
|
if (frameCount % 2 !== 0) return;
|
||||||
|
|
||||||
ctx.fillStyle = '#050510';
|
ctx.fillStyle = '#050510';
|
||||||
ctx.fillRect(0, 0, w, h);
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
@@ -23,28 +39,74 @@ export default function ScopeDisplay({ moduleId }) {
|
|||||||
ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2);
|
ctx.moveTo(0, h / 2); ctx.lineTo(w, h / 2);
|
||||||
ctx.moveTo(0, h / 4); ctx.lineTo(w, h / 4);
|
ctx.moveTo(0, h / 4); ctx.lineTo(w, h / 4);
|
||||||
ctx.moveTo(0, h * 3 / 4); ctx.lineTo(w, h * 3 / 4);
|
ctx.moveTo(0, h * 3 / 4); ctx.lineTo(w, h * 3 / 4);
|
||||||
|
for (let x = w / 4; x < w; x += w / 4) {
|
||||||
|
ctx.moveTo(x, 0); ctx.lineTo(x, h);
|
||||||
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
|
|
||||||
const data = getAnalyserData(moduleId);
|
const data = getAnalyserData(moduleId);
|
||||||
if (data && data.length > 0) {
|
if (data && data.length > 0) {
|
||||||
|
const samplesToShow = zoomRef.current;
|
||||||
|
// Center the window in the buffer
|
||||||
|
const offset = Math.max(0, Math.floor((data.length - samplesToShow) / 2));
|
||||||
|
const end = Math.min(data.length, offset + samplesToShow);
|
||||||
|
|
||||||
ctx.strokeStyle = '#00e5ff';
|
ctx.strokeStyle = '#00e5ff';
|
||||||
ctx.lineWidth = 1.5;
|
ctx.lineWidth = 1.5;
|
||||||
ctx.beginPath();
|
ctx.beginPath();
|
||||||
const step = w / data.length;
|
const count = end - offset;
|
||||||
for (let i = 0; i < data.length; i++) {
|
const step = w / count;
|
||||||
const y = h / 2 + data[i] * h / 2 * -1;
|
for (let i = 0; i < count; i++) {
|
||||||
|
const y = h / 2 + data[offset + i] * h / 2 * -1;
|
||||||
if (i === 0) ctx.moveTo(0, y);
|
if (i === 0) ctx.moveTo(0, y);
|
||||||
else ctx.lineTo(i * step, y);
|
else ctx.lineTo(i * step, y);
|
||||||
}
|
}
|
||||||
ctx.stroke();
|
ctx.stroke();
|
||||||
}
|
}
|
||||||
|
|
||||||
rafRef.current = requestAnimationFrame(draw);
|
|
||||||
};
|
};
|
||||||
draw();
|
draw();
|
||||||
|
|
||||||
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
|
return () => { if (rafRef.current) cancelAnimationFrame(rafRef.current); };
|
||||||
}, [moduleId]);
|
}, [moduleId]);
|
||||||
|
|
||||||
return <canvas ref={canvasRef} className="scope-canvas" />;
|
const canZoomIn = zoomIdx > 0;
|
||||||
|
const canZoomOut = zoomIdx < ZOOM_LEVELS.length - 1;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ position: 'relative' }}>
|
||||||
|
<canvas ref={canvasRef} className="scope-canvas" />
|
||||||
|
<div style={{
|
||||||
|
position: 'absolute', bottom: 2, right: 2,
|
||||||
|
display: 'flex', gap: 2,
|
||||||
|
}}>
|
||||||
|
<button
|
||||||
|
onClick={() => canZoomOut && setZoomIdx(i => i + 1)}
|
||||||
|
disabled={!canZoomOut}
|
||||||
|
title="Zoom out (más tiempo)"
|
||||||
|
style={{
|
||||||
|
width: 18, height: 18, padding: 0,
|
||||||
|
background: canZoomOut ? '#1a1a3a' : '#0a0a15',
|
||||||
|
border: '1px solid #333', borderRadius: 3,
|
||||||
|
color: canZoomOut ? '#00e5ff' : '#333',
|
||||||
|
cursor: canZoomOut ? 'pointer' : 'default',
|
||||||
|
fontSize: 12, lineHeight: '16px', fontWeight: 'bold',
|
||||||
|
}}
|
||||||
|
>−</button>
|
||||||
|
<button
|
||||||
|
onClick={() => canZoomIn && setZoomIdx(i => i - 1)}
|
||||||
|
disabled={!canZoomIn}
|
||||||
|
title="Zoom in (más detalle)"
|
||||||
|
style={{
|
||||||
|
width: 18, height: 18, padding: 0,
|
||||||
|
background: canZoomIn ? '#1a1a3a' : '#0a0a15',
|
||||||
|
border: '1px solid #333', borderRadius: 3,
|
||||||
|
color: canZoomIn ? '#00e5ff' : '#333',
|
||||||
|
cursor: canZoomIn ? 'pointer' : 'default',
|
||||||
|
fontSize: 12, lineHeight: '16px', fontWeight: 'bold',
|
||||||
|
}}
|
||||||
|
>+</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
import React, { useState, useEffect, useRef, useCallback } from 'react';
|
||||||
import * as Tone from 'tone';
|
import * as Tone from 'tone';
|
||||||
import { state, updateModuleParam, emit } from '../engine/state.js';
|
import { state, updateModuleParam, emit } from '../engine/state.js';
|
||||||
import { setSequencerSignals, getAudioNode } from '../engine/audioEngine.js';
|
import { setSequencerSignals, getAudioNode, subscribeTick, unsubscribeTick, MASTER_TICK_RATE } from '../engine/audioEngine.js';
|
||||||
|
|
||||||
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
const NOTE_NAMES = ['C', 'C#', 'D', 'D#', 'E', 'F', 'F#', 'G', 'G#', 'A', 'A#', 'B'];
|
||||||
|
|
||||||
function midiToFreq(midi) { return 440 * Math.pow(2, (midi - 69) / 12); }
|
function midiToFreq(midi) { return 440 * Math.pow(2, (midi - 69) / 12); }
|
||||||
function noteLabel(midi) { return NOTE_NAMES[midi % 12] + Math.floor(midi / 12 - 1); }
|
function noteLabel(midi) { return NOTE_NAMES[midi % 12] + Math.floor(midi / 12 - 1); }
|
||||||
|
|
||||||
// Default notes: C minor pentatonic pattern
|
|
||||||
const DEFAULT_STEPS = [
|
const DEFAULT_STEPS = [
|
||||||
{ midi: 60, gate: true }, { midi: 63, gate: true }, { midi: 65, gate: false }, { midi: 67, gate: true },
|
{ midi: 60, gate: true }, { midi: 63, gate: true }, { midi: 65, gate: false }, { midi: 67, gate: true },
|
||||||
{ midi: 70, gate: true }, { midi: 67, gate: true }, { midi: 63, gate: false }, { midi: 60, gate: true },
|
{ midi: 70, gate: true }, { midi: 67, gate: true }, { midi: 63, gate: false }, { midi: 60, gate: true },
|
||||||
@@ -18,17 +17,24 @@ const DEFAULT_STEPS = [
|
|||||||
|
|
||||||
export default function SequencerWidget({ moduleId }) {
|
export default function SequencerWidget({ moduleId }) {
|
||||||
const mod = state.modules.find(m => m.id === moduleId);
|
const mod = state.modules.find(m => m.id === moduleId);
|
||||||
const [currentStep, setCurrentStep] = useState(-1);
|
const currentStepRef = useRef(-1);
|
||||||
const seqRef = useRef(null);
|
const [visualStep, setVisualStep] = useState(-1);
|
||||||
const stepsRef = useRef(null);
|
const stepsRef = useRef(null);
|
||||||
|
const rafRef = useRef(null);
|
||||||
|
|
||||||
// Init steps data
|
// Init steps data
|
||||||
const numSteps = parseInt(mod?.params?.steps || '16');
|
const numSteps = parseInt(mod?.params?.steps || '16');
|
||||||
if (!mod?.params?._steps) {
|
if (mod) {
|
||||||
|
if (!mod.params._steps) {
|
||||||
const initial = DEFAULT_STEPS.slice(0, numSteps);
|
const initial = DEFAULT_STEPS.slice(0, numSteps);
|
||||||
while (initial.length < numSteps) initial.push({ midi: 60, gate: false });
|
while (initial.length < numSteps) initial.push({ midi: 60, gate: false });
|
||||||
if (mod) {
|
|
||||||
mod.params._steps = initial;
|
mod.params._steps = initial;
|
||||||
|
} else if (mod.params._steps.length < numSteps) {
|
||||||
|
while (mod.params._steps.length < numSteps) {
|
||||||
|
mod.params._steps.push({ midi: 60, gate: false });
|
||||||
|
}
|
||||||
|
} else if (mod.params._steps.length > numSteps) {
|
||||||
|
mod.params._steps = mod.params._steps.slice(0, numSteps);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const steps = mod?.params?._steps || DEFAULT_STEPS;
|
const steps = mod?.params?._steps || DEFAULT_STEPS;
|
||||||
@@ -36,46 +42,69 @@ export default function SequencerWidget({ moduleId }) {
|
|||||||
|
|
||||||
const bpm = mod?.params?.bpm ?? 140;
|
const bpm = mod?.params?.bpm ?? 140;
|
||||||
|
|
||||||
// Start/stop sequencer when audio engine runs
|
// Visual update loop — decoupled from audio, uses RAF
|
||||||
|
useEffect(() => {
|
||||||
|
const tick = () => {
|
||||||
|
setVisualStep(currentStepRef.current);
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
};
|
||||||
|
if (state.isRunning) {
|
||||||
|
rafRef.current = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
if (rafRef.current) cancelAnimationFrame(rafRef.current);
|
||||||
|
};
|
||||||
|
}, [state.isRunning]);
|
||||||
|
|
||||||
|
// Subscribe to global master clock — derive step from elapsed time
|
||||||
|
const bpmRef = useRef(bpm);
|
||||||
|
const numStepsRef = useRef(numSteps);
|
||||||
|
bpmRef.current = bpm;
|
||||||
|
numStepsRef.current = numSteps;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state.isRunning) {
|
if (!state.isRunning) {
|
||||||
if (seqRef.current) { seqRef.current.stop(); seqRef.current.dispose(); seqRef.current = null; }
|
unsubscribeTick(`seq-${moduleId}`);
|
||||||
setCurrentStep(-1);
|
currentStepRef.current = -1;
|
||||||
|
setVisualStep(-1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Tone.getTransport().bpm.value = bpm;
|
let lastStepIdx = -1;
|
||||||
|
let lastGateOn = false;
|
||||||
|
|
||||||
|
subscribeTick(`seq-${moduleId}`, (time, ticks) => {
|
||||||
|
const currentBpm = bpmRef.current;
|
||||||
|
const currentNumSteps = numStepsRef.current;
|
||||||
|
// ticksPerStep = MASTER_TICK_RATE / sixteenthsPerSecond
|
||||||
|
// sixteenthsPerSecond = bpm * 4 / 60
|
||||||
|
const ticksPerStep = MASTER_TICK_RATE * 60 / (currentBpm * 4);
|
||||||
|
const stepIdx = Math.floor(ticks / ticksPerStep) % currentNumSteps;
|
||||||
|
|
||||||
|
if (stepIdx === lastStepIdx) return;
|
||||||
|
lastStepIdx = stepIdx;
|
||||||
|
|
||||||
|
// Turn off previous note at step boundary (no setTimeout needed)
|
||||||
|
if (lastGateOn) {
|
||||||
|
setSequencerSignals(moduleId, 0, false);
|
||||||
|
lastGateOn = false;
|
||||||
|
}
|
||||||
|
|
||||||
const seq = new Tone.Sequence((time, stepIdx) => {
|
|
||||||
const s = stepsRef.current[stepIdx];
|
const s = stepsRef.current[stepIdx];
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
setCurrentStep(stepIdx);
|
|
||||||
|
currentStepRef.current = stepIdx;
|
||||||
|
|
||||||
if (s.gate) {
|
if (s.gate) {
|
||||||
setSequencerSignals(moduleId, midiToFreq(s.midi), true);
|
setSequencerSignals(moduleId, midiToFreq(s.midi), true);
|
||||||
Tone.getTransport().scheduleOnce(() => {
|
lastGateOn = true;
|
||||||
setSequencerSignals(moduleId, midiToFreq(s.midi), false);
|
|
||||||
}, time + Tone.Time('16n').toSeconds() * 0.8);
|
|
||||||
} else {
|
|
||||||
setSequencerSignals(moduleId, midiToFreq(s.midi), false);
|
|
||||||
}
|
}
|
||||||
}, Array.from({ length: numSteps }, (_, i) => i), '16n');
|
});
|
||||||
|
|
||||||
seq.start(0);
|
|
||||||
if (Tone.getTransport().state !== 'started') {
|
|
||||||
Tone.getTransport().start();
|
|
||||||
}
|
|
||||||
seqRef.current = seq;
|
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (seqRef.current) { seqRef.current.stop(); seqRef.current.dispose(); seqRef.current = null; }
|
unsubscribeTick(`seq-${moduleId}`);
|
||||||
};
|
};
|
||||||
}, [state.isRunning, moduleId, numSteps]);
|
}, [state.isRunning, moduleId]);
|
||||||
|
|
||||||
// Update BPM live
|
|
||||||
useEffect(() => {
|
|
||||||
if (state.isRunning) Tone.getTransport().bpm.value = bpm;
|
|
||||||
}, [bpm]);
|
|
||||||
|
|
||||||
const toggleGate = (idx) => {
|
const toggleGate = (idx) => {
|
||||||
steps[idx] = { ...steps[idx], gate: !steps[idx].gate };
|
steps[idx] = { ...steps[idx], gate: !steps[idx].gate };
|
||||||
@@ -99,20 +128,17 @@ export default function SequencerWidget({ moduleId }) {
|
|||||||
return (
|
return (
|
||||||
<div style={{ width: W + 4, overflow: 'hidden' }}>
|
<div style={{ width: W + 4, overflow: 'hidden' }}>
|
||||||
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: W, height: H, display: 'block' }}>
|
<svg viewBox={`0 0 ${W} ${H}`} style={{ width: W, height: H, display: 'block' }}>
|
||||||
{/* Steps */}
|
|
||||||
{steps.slice(0, numSteps).map((s, i) => {
|
{steps.slice(0, numSteps).map((s, i) => {
|
||||||
const x = i * CELL_W;
|
const x = i * CELL_W;
|
||||||
const isActive = i === currentStep;
|
const isActive = i === visualStep;
|
||||||
const barHeight = ((s.midi - 36) / 60) * (CELL_H - 4);
|
const barHeight = ((s.midi - 36) / 60) * (CELL_H - 4);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<g key={i}>
|
<g key={i}>
|
||||||
{/* Background */}
|
|
||||||
<rect x={x + 1} y={0} width={CELL_W - 2} height={CELL_H}
|
<rect x={x + 1} y={0} width={CELL_W - 2} height={CELL_H}
|
||||||
rx={2} fill={isActive ? '#1a2a40' : '#0c0c18'}
|
rx={2} fill={isActive ? '#1a2a40' : '#0c0c18'}
|
||||||
stroke={isActive ? '#00e5ff' : '#222'} strokeWidth={isActive ? 1.5 : 0.5}
|
stroke={isActive ? '#00e5ff' : '#222'} strokeWidth={isActive ? 1.5 : 0.5}
|
||||||
/>
|
/>
|
||||||
{/* Note bar */}
|
|
||||||
{s.gate && (
|
{s.gate && (
|
||||||
<rect x={x + 3} y={CELL_H - barHeight - 2} width={CELL_W - 6} height={barHeight}
|
<rect x={x + 3} y={CELL_H - barHeight - 2} width={CELL_W - 6} height={barHeight}
|
||||||
rx={1}
|
rx={1}
|
||||||
@@ -120,17 +146,14 @@ export default function SequencerWidget({ moduleId }) {
|
|||||||
opacity={0.9}
|
opacity={0.9}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{/* Inactive marker */}
|
|
||||||
{!s.gate && (
|
{!s.gate && (
|
||||||
<line x1={x + CELL_W / 2} y1={CELL_H / 2 - 3} x2={x + CELL_W / 2} y2={CELL_H / 2 + 3}
|
<line x1={x + CELL_W / 2} y1={CELL_H / 2 - 3} x2={x + CELL_W / 2} y2={CELL_H / 2 + 3}
|
||||||
stroke="#333" strokeWidth={1.5} />
|
stroke="#333" strokeWidth={1.5} />
|
||||||
)}
|
)}
|
||||||
{/* Note name */}
|
|
||||||
<text x={x + CELL_W / 2} y={CELL_H + 10} textAnchor="middle"
|
<text x={x + CELL_W / 2} y={CELL_H + 10} textAnchor="middle"
|
||||||
fontSize={6} fill={s.gate ? '#88ccff' : '#333'} fontFamily="monospace">
|
fontSize={6} fill={s.gate ? '#88ccff' : '#333'} fontFamily="monospace">
|
||||||
{noteLabel(s.midi)}
|
{noteLabel(s.midi)}
|
||||||
</text>
|
</text>
|
||||||
{/* Click areas: top half = note up, bottom half = note down, middle = toggle gate */}
|
|
||||||
<rect x={x} y={0} width={CELL_W} height={CELL_H / 3}
|
<rect x={x} y={0} width={CELL_W} height={CELL_H / 3}
|
||||||
fill="transparent" style={{ cursor: 'pointer' }}
|
fill="transparent" style={{ cursor: 'pointer' }}
|
||||||
onClick={() => changeNote(i, 1)}
|
onClick={() => changeNote(i, 1)}
|
||||||
@@ -146,11 +169,10 @@ export default function SequencerWidget({ moduleId }) {
|
|||||||
</g>
|
</g>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
{/* Playhead line */}
|
{visualStep >= 0 && (
|
||||||
{currentStep >= 0 && (
|
|
||||||
<line
|
<line
|
||||||
x1={currentStep * CELL_W + CELL_W / 2} y1={0}
|
x1={visualStep * CELL_W + CELL_W / 2} y1={0}
|
||||||
x2={currentStep * CELL_W + CELL_W / 2} y2={CELL_H}
|
x2={visualStep * CELL_W + CELL_W / 2} y2={CELL_H}
|
||||||
stroke="#00e5ff" strokeWidth={1} opacity={0.4}
|
stroke="#00e5ff" strokeWidth={1} opacity={0.4}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -12,6 +12,48 @@ const audioNodes = {};
|
|||||||
// Active keyboard state
|
// Active keyboard state
|
||||||
const keyboardState = { frequency: 440, gate: false };
|
const keyboardState = { frequency: 440, gate: false };
|
||||||
|
|
||||||
|
// ==================== Global Master Clock ====================
|
||||||
|
// Single clock with integer tick counter. All sequencers/piano rolls
|
||||||
|
// derive their step positions from this shared tick count.
|
||||||
|
// Using integers avoids floating-point drift entirely.
|
||||||
|
export const MASTER_TICK_RATE = 120; // Hz — 6x headroom for 300 BPM sixteenths (20 Hz). Lower = less main thread pressure.
|
||||||
|
let _masterClock = null;
|
||||||
|
const _tickListeners = new Map(); // id → callback(audioTime, ticks)
|
||||||
|
|
||||||
|
export function subscribeTick(id, callback) {
|
||||||
|
_tickListeners.set(id, callback);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function unsubscribeTick(id) {
|
||||||
|
_tickListeners.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startMasterClock() {
|
||||||
|
if (_masterClock) return;
|
||||||
|
let _startTime = 0;
|
||||||
|
let _started = false;
|
||||||
|
_masterClock = new Tone.Clock((time) => {
|
||||||
|
if (!_started) { _startTime = time; _started = true; }
|
||||||
|
// Derive ticks from precise AudioContext.currentTime, not a counter.
|
||||||
|
// Counters fall behind when callbacks are delayed (GC, UI, tab throttle).
|
||||||
|
// The time parameter is always accurate regardless of callback jitter.
|
||||||
|
const ticks = Math.round((time - _startTime) * MASTER_TICK_RATE);
|
||||||
|
for (const cb of _tickListeners.values()) {
|
||||||
|
cb(time, ticks);
|
||||||
|
}
|
||||||
|
}, MASTER_TICK_RATE);
|
||||||
|
_masterClock.start();
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopMasterClock() {
|
||||||
|
if (_masterClock) {
|
||||||
|
try { _masterClock.stop(); } catch {}
|
||||||
|
try { _masterClock.dispose(); } catch {}
|
||||||
|
_masterClock = null;
|
||||||
|
}
|
||||||
|
_tickListeners.clear();
|
||||||
|
}
|
||||||
|
|
||||||
// ==================== Node creation ====================
|
// ==================== Node creation ====================
|
||||||
|
|
||||||
function createNode(mod) {
|
function createNode(mod) {
|
||||||
@@ -84,13 +126,17 @@ function createNode(mod) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'vca': {
|
case 'vca': {
|
||||||
// Use a Multiply node: in × cv
|
|
||||||
const gain = new Tone.Gain(p.gain);
|
const gain = new Tone.Gain(p.gain);
|
||||||
|
// CV scaler: always gain=1 so envelope (0-1) passes through fully.
|
||||||
|
// When CV is connected, base gain is zeroed — envelope controls amplitude entirely.
|
||||||
|
const cvMod = new Tone.Gain(1);
|
||||||
|
cvMod.connect(gain.gain);
|
||||||
return {
|
return {
|
||||||
node: gain,
|
node: gain,
|
||||||
inputs: { in: gain, cv: gain.gain },
|
_cvMod: cvMod,
|
||||||
|
inputs: { in: gain, cv: cvMod },
|
||||||
outputs: { out: gain },
|
outputs: { out: gain },
|
||||||
dispose: () => gain.dispose(),
|
dispose: () => { cvMod.disconnect(); cvMod.dispose(); gain.dispose(); },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'delay': {
|
case 'delay': {
|
||||||
@@ -136,7 +182,7 @@ function createNode(mod) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'scope': {
|
case 'scope': {
|
||||||
const analyser = new Tone.Analyser('waveform', 256);
|
const analyser = new Tone.Analyser('waveform', 2048);
|
||||||
return {
|
return {
|
||||||
node: analyser,
|
node: analyser,
|
||||||
inputs: { in: analyser },
|
inputs: { in: analyser },
|
||||||
@@ -145,6 +191,20 @@ function createNode(mod) {
|
|||||||
dispose: () => analyser.dispose(),
|
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': {
|
case 'output': {
|
||||||
// True stereo output: separate left/right channels → merge → master gain → destination
|
// True stereo output: separate left/right channels → merge → master gain → destination
|
||||||
const leftGain = new Tone.Gain(1);
|
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 freqSig = new Tone.Signal(440);
|
||||||
const gateSig = new Tone.Signal(0);
|
const gateSig = new Tone.Signal(0);
|
||||||
return {
|
return {
|
||||||
@@ -245,6 +306,17 @@ export function connectWire(conn) {
|
|||||||
const toEntry = ensureNode(conn.to.moduleId);
|
const toEntry = ensureNode(conn.to.moduleId);
|
||||||
if (!fromEntry || !toEntry) return;
|
if (!fromEntry || !toEntry) return;
|
||||||
|
|
||||||
|
// Skip audio-graph connection for keyboard/sequencer/pianoroll freq → oscillator freq.
|
||||||
|
// These signals carry absolute Hz values that would be mangled by the oscillator's
|
||||||
|
// frequency-modulation Gain scaler. Instead, triggerKeyboard / setSequencerSignals
|
||||||
|
// set the oscillator frequency directly when notes are played.
|
||||||
|
const fromMod = state.modules.find(m => m.id === conn.from.moduleId);
|
||||||
|
const toMod = state.modules.find(m => m.id === conn.to.moduleId);
|
||||||
|
if (fromMod && ['keyboard', 'drumpad', 'sequencer', 'pianoroll'].includes(fromMod.type) &&
|
||||||
|
conn.from.port === 'freq' && toMod?.type === 'oscillator' && conn.to.port === 'freq') {
|
||||||
|
return; // handled imperatively in triggerKeyboard / setSequencerSignals
|
||||||
|
}
|
||||||
|
|
||||||
const output = fromEntry.outputs[conn.from.port];
|
const output = fromEntry.outputs[conn.from.port];
|
||||||
const input = toEntry.inputs[conn.to.port];
|
const input = toEntry.inputs[conn.to.port];
|
||||||
if (!output || input === undefined || input === null) return;
|
if (!output || input === undefined || input === null) return;
|
||||||
@@ -256,6 +328,11 @@ export function connectWire(conn) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('connect error', e);
|
console.warn('connect error', e);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When CV is connected to VCA, zero the base gain so only envelope controls it
|
||||||
|
if (toMod?.type === 'vca' && conn.to.port === 'cv') {
|
||||||
|
toEntry.node.gain.value = 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function disconnectWire(conn) {
|
export function disconnectWire(conn) {
|
||||||
@@ -274,6 +351,12 @@ export function disconnectWire(conn) {
|
|||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Tone.js may throw if not connected
|
// Tone.js may throw if not connected
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// When CV is disconnected from VCA, restore base gain from params
|
||||||
|
const toMod = state.modules.find(m => m.id === conn.to.moduleId);
|
||||||
|
if (toMod?.type === 'vca' && conn.to.port === 'cv') {
|
||||||
|
toEntry.node.gain.value = toMod.params?.gain ?? 0.8;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function updateParam(moduleId, paramName, value) {
|
export function updateParam(moduleId, paramName, value) {
|
||||||
@@ -318,7 +401,12 @@ export function updateParam(moduleId, paramName, value) {
|
|||||||
else if (paramName === 'release') entry.node.release = value;
|
else if (paramName === 'release') entry.node.release = value;
|
||||||
break;
|
break;
|
||||||
case 'vca':
|
case 'vca':
|
||||||
if (paramName === 'gain') entry.node.gain.value = value;
|
if (paramName === 'gain') {
|
||||||
|
// Only update base gain if no CV is connected (CV zeroes it)
|
||||||
|
const hasCV = state.connections.some(c => c.to.moduleId === moduleId && c.to.port === 'cv');
|
||||||
|
if (!hasCV) entry.node.gain.value = value;
|
||||||
|
// cvMod stays at 1 always — envelope controls full range
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case 'delay':
|
case 'delay':
|
||||||
if (paramName === 'delayTime') entry.node.delayTime.value = value;
|
if (paramName === 'delayTime') entry.node.delayTime.value = value;
|
||||||
@@ -343,6 +431,8 @@ export function updateParam(moduleId, paramName, value) {
|
|||||||
if (paramName === 'volume') entry.node.gain.value = Tone.dbToGain(value);
|
if (paramName === 'volume') entry.node.gain.value = Tone.dbToGain(value);
|
||||||
break;
|
break;
|
||||||
case 'keyboard':
|
case 'keyboard':
|
||||||
|
case 'drumpad':
|
||||||
|
case 'cv2gate':
|
||||||
case 'sequencer':
|
case 'sequencer':
|
||||||
case 'pianoroll':
|
case 'pianoroll':
|
||||||
// All params stored in state, managed by widgets
|
// All params stored in state, managed by widgets
|
||||||
@@ -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) {
|
export function setSequencerSignals(moduleId, freq, gate) {
|
||||||
const entry = audioNodes[moduleId];
|
const entry = audioNodes[moduleId];
|
||||||
if (!entry) return;
|
if (!entry) return;
|
||||||
if (entry._freqSig) entry._freqSig.value = freq;
|
if (entry._freqSig) entry._freqSig.value = freq;
|
||||||
if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0;
|
if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0;
|
||||||
|
|
||||||
|
// Set connected oscillator frequencies directly
|
||||||
|
for (const conn of getConnectionsFrom(moduleId, 'freq')) {
|
||||||
|
const oscEntry = audioNodes[conn.to.moduleId];
|
||||||
|
if (oscEntry?.node?.frequency) {
|
||||||
|
oscEntry.node.frequency.value = freq;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Trigger connected envelopes
|
// Trigger connected envelopes
|
||||||
for (const conn of state.connections) {
|
for (const conn of getConnectionsFrom(moduleId, 'gate')) {
|
||||||
if (conn.from.moduleId === moduleId && conn.from.port === 'gate') {
|
|
||||||
const envEntry = audioNodes[conn.to.moduleId];
|
const envEntry = audioNodes[conn.to.moduleId];
|
||||||
if (envEntry && envEntry.node instanceof Tone.Envelope) {
|
if (envEntry && envEntry.node instanceof Tone.Envelope) {
|
||||||
if (gate) envEntry.node.triggerAttack();
|
if (gate) envEntry.node.triggerAttack();
|
||||||
@@ -366,7 +485,6 @@ export function setSequencerSignals(moduleId, freq, gate) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export function triggerKeyboard(moduleId, freq, gate) {
|
export function triggerKeyboard(moduleId, freq, gate) {
|
||||||
const entry = audioNodes[moduleId];
|
const entry = audioNodes[moduleId];
|
||||||
@@ -374,9 +492,16 @@ export function triggerKeyboard(moduleId, freq, gate) {
|
|||||||
if (entry._freqSig) entry._freqSig.value = freq;
|
if (entry._freqSig) entry._freqSig.value = freq;
|
||||||
if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0;
|
if (entry._gateSig) entry._gateSig.value = gate ? 1 : 0;
|
||||||
|
|
||||||
// Also trigger any connected envelopes
|
// Set connected oscillator frequencies directly
|
||||||
for (const conn of state.connections) {
|
for (const conn of getConnectionsFrom(moduleId, 'freq')) {
|
||||||
if (conn.from.moduleId === moduleId && conn.from.port === 'gate') {
|
const oscEntry = audioNodes[conn.to.moduleId];
|
||||||
|
if (oscEntry?.node?.frequency) {
|
||||||
|
oscEntry.node.frequency.value = freq;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger connected envelopes
|
||||||
|
for (const conn of getConnectionsFrom(moduleId, 'gate')) {
|
||||||
const envEntry = audioNodes[conn.to.moduleId];
|
const envEntry = audioNodes[conn.to.moduleId];
|
||||||
if (envEntry && envEntry.node instanceof Tone.Envelope) {
|
if (envEntry && envEntry.node instanceof Tone.Envelope) {
|
||||||
if (gate) envEntry.node.triggerAttack();
|
if (gate) envEntry.node.triggerAttack();
|
||||||
@@ -384,17 +509,26 @@ export function triggerKeyboard(moduleId, freq, gate) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
export async function startAudio() {
|
export async function startAudio() {
|
||||||
await Tone.start();
|
await Tone.start();
|
||||||
state.isRunning = true;
|
state.isRunning = true;
|
||||||
|
startMasterClock();
|
||||||
|
|
||||||
// Rebuild entire audio graph
|
// Rebuild entire audio graph
|
||||||
rebuildGraph();
|
rebuildGraph();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function stopAudio() {
|
export function stopAudio() {
|
||||||
|
stopMasterClock();
|
||||||
|
|
||||||
|
// Stop and reset Transport
|
||||||
|
try {
|
||||||
|
Tone.getTransport().stop();
|
||||||
|
Tone.getTransport().cancel();
|
||||||
|
Tone.getTransport().position = 0;
|
||||||
|
} catch (e) {}
|
||||||
|
|
||||||
// Destroy all nodes
|
// Destroy all nodes
|
||||||
for (const id of Object.keys(audioNodes)) {
|
for (const id of Object.keys(audioNodes)) {
|
||||||
destroyNode(parseInt(id));
|
destroyNode(parseInt(id));
|
||||||
@@ -417,6 +551,55 @@ export function rebuildGraph() {
|
|||||||
for (const conn of state.connections) {
|
for (const conn of state.connections) {
|
||||||
connectWire(conn);
|
connectWire(conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Zero base gain on VCAs with active CV connection.
|
||||||
|
// When envelope controls VCA, base gain must be 0 so silence is possible.
|
||||||
|
for (const mod of state.modules) {
|
||||||
|
if (mod.type !== 'vca') continue;
|
||||||
|
const hasCV = state.connections.some(c => c.to.moduleId === mod.id && c.to.port === 'cv');
|
||||||
|
const entry = audioNodes[mod.id];
|
||||||
|
if (entry && hasCV) entry.node.gain.value = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-trigger envelopes that have no gate connection (free-running mode).
|
||||||
|
// This allows noise/ambient patches to work without a keyboard/sequencer.
|
||||||
|
for (const mod of state.modules) {
|
||||||
|
if (mod.type !== 'envelope') continue;
|
||||||
|
const hasGateInput = state.connections.some(
|
||||||
|
c => c.to.moduleId === mod.id && c.to.port === 'gate'
|
||||||
|
);
|
||||||
|
if (!hasGateInput) {
|
||||||
|
const entry = audioNodes[mod.id];
|
||||||
|
if (entry && entry.node && typeof entry.node.triggerAttack === 'function') {
|
||||||
|
entry.node.triggerAttack();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register CV→Gate modules on master clock for threshold detection
|
||||||
|
for (const mod of state.modules) {
|
||||||
|
if (mod.type !== 'cv2gate') continue;
|
||||||
|
const entry = audioNodes[mod.id];
|
||||||
|
if (!entry) continue;
|
||||||
|
subscribeTick(`cv2gate-${mod.id}`, () => {
|
||||||
|
const data = entry.node.getValue();
|
||||||
|
const sample = typeof data === 'number' ? data : (data?.[0] ?? 0);
|
||||||
|
const threshold = mod.params?.threshold ?? 0.5;
|
||||||
|
const gateOn = sample > threshold;
|
||||||
|
if (gateOn !== entry._gateState) {
|
||||||
|
entry._gateState = gateOn;
|
||||||
|
entry._gateSig.value = gateOn ? 1 : 0;
|
||||||
|
// Trigger/release connected envelopes
|
||||||
|
for (const conn of getConnectionsFrom(mod.id, 'gate')) {
|
||||||
|
const envEntry = audioNodes[conn.to.moduleId];
|
||||||
|
if (envEntry && envEntry.node instanceof Tone.Envelope) {
|
||||||
|
if (gateOn) envEntry.node.triggerAttack();
|
||||||
|
else envEntry.node.triggerRelease();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAnalyserData(moduleId) {
|
export function getAnalyserData(moduleId) {
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ defineModule('envelope', {
|
|||||||
attack: { type: 'knob', min: 0.001, max: 4, default: 0.01, unit: 's', label: 'Attack' },
|
attack: { type: 'knob', min: 0.001, max: 4, default: 0.01, unit: 's', label: 'Attack' },
|
||||||
decay: { type: 'knob', min: 0.001, max: 4, default: 0.2, unit: 's', label: 'Decay' },
|
decay: { type: 'knob', min: 0.001, max: 4, default: 0.2, unit: 's', label: 'Decay' },
|
||||||
sustain: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Sustain' },
|
sustain: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Sustain' },
|
||||||
release: { type: 'knob', min: 0, max: 8, default: 0.5, unit: 's', label: 'Release' },
|
release: { type: 'knob', min: 0.001, max: 8, default: 0.5, unit: 's', label: 'Release' },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -226,6 +226,23 @@ defineModule('scope', {
|
|||||||
params: {},
|
params: {},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==================== CV TO GATE ====================
|
||||||
|
|
||||||
|
defineModule('cv2gate', {
|
||||||
|
name: 'CV→Gate',
|
||||||
|
icon: '⚡',
|
||||||
|
category: 'Utility',
|
||||||
|
inputs: [
|
||||||
|
{ name: 'in', type: PORT_TYPE.CONTROL, label: 'CV In' },
|
||||||
|
],
|
||||||
|
outputs: [
|
||||||
|
{ name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' },
|
||||||
|
],
|
||||||
|
params: {
|
||||||
|
threshold: { type: 'knob', min: 0, max: 1, default: 0.5, unit: '', label: 'Thresh' },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// ==================== OUTPUT ====================
|
// ==================== OUTPUT ====================
|
||||||
|
|
||||||
defineModule('output', {
|
defineModule('output', {
|
||||||
@@ -258,6 +275,20 @@ defineModule('keyboard', {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ==================== DRUM PAD ====================
|
||||||
|
|
||||||
|
defineModule('drumpad', {
|
||||||
|
name: 'Drum Pad',
|
||||||
|
icon: '🥁',
|
||||||
|
category: 'Source',
|
||||||
|
inputs: [],
|
||||||
|
outputs: [
|
||||||
|
{ name: 'freq', type: PORT_TYPE.AUDIO, label: 'Freq' },
|
||||||
|
{ name: 'gate', type: PORT_TYPE.TRIGGER, label: 'Gate' },
|
||||||
|
],
|
||||||
|
params: {},
|
||||||
|
});
|
||||||
|
|
||||||
// ==================== SEQUENCER ====================
|
// ==================== SEQUENCER ====================
|
||||||
|
|
||||||
defineModule('sequencer', {
|
defineModule('sequencer', {
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
*/
|
*/
|
||||||
import { playConnect, playDisconnect, playModuleAdd, playModuleDelete } from './uiSounds.js';
|
import { playConnect, playDisconnect, playModuleAdd, playModuleDelete } from './uiSounds.js';
|
||||||
import { getModuleDef } from './moduleRegistry.js';
|
import { getModuleDef } from './moduleRegistry.js';
|
||||||
|
import { invalidateConnectionCache } from './audioEngine.js';
|
||||||
|
|
||||||
let _listeners = new Set();
|
let _listeners = new Set();
|
||||||
let _nextModuleId = 1;
|
let _nextModuleId = 1;
|
||||||
@@ -93,6 +94,7 @@ export function addConnection(fromModuleId, fromPort, toModuleId, toPort) {
|
|||||||
|
|
||||||
const id = _nextConnectionId++;
|
const id = _nextConnectionId++;
|
||||||
state.connections.push({ id, from: { moduleId: fromModuleId, port: fromPort }, to: { moduleId: toModuleId, port: toPort } });
|
state.connections.push({ id, from: { moduleId: fromModuleId, port: fromPort }, to: { moduleId: toModuleId, port: toPort } });
|
||||||
|
invalidateConnectionCache();
|
||||||
emit();
|
emit();
|
||||||
playConnect();
|
playConnect();
|
||||||
return id;
|
return id;
|
||||||
@@ -100,6 +102,7 @@ export function addConnection(fromModuleId, fromPort, toModuleId, toPort) {
|
|||||||
|
|
||||||
export function removeConnection(id, _silent = false) {
|
export function removeConnection(id, _silent = false) {
|
||||||
state.connections = state.connections.filter(c => c.id !== id);
|
state.connections = state.connections.filter(c => c.id !== id);
|
||||||
|
invalidateConnectionCache();
|
||||||
emit();
|
emit();
|
||||||
if (!_silent) playDisconnect();
|
if (!_silent) playDisconnect();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,10 +4,14 @@ import { startAudio, stopAudio, connectWire, rebuildGraph } from '../engine/audi
|
|||||||
import { getModuleDef } from '../engine/moduleRegistry.js';
|
import { getModuleDef } from '../engine/moduleRegistry.js';
|
||||||
import ModuleNode from '../components/ModuleNode.jsx';
|
import ModuleNode from '../components/ModuleNode.jsx';
|
||||||
import WireLayer from '../components/WireLayer.jsx';
|
import WireLayer from '../components/WireLayer.jsx';
|
||||||
|
import BottomSheet from '../components/BottomSheet.jsx';
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile.js';
|
||||||
|
import { usePinchZoom } from '../hooks/usePinchZoom.js';
|
||||||
import { playTarget, stopTarget, isTargetPlaying } from './targetAudio.js';
|
import { playTarget, stopTarget, isTargetPlaying } from './targetAudio.js';
|
||||||
import LevelComplete from './LevelComplete.jsx';
|
import LevelComplete from './LevelComplete.jsx';
|
||||||
import { completeLevel, 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 { 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 }) {
|
export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onNextLevel, adminMode }) {
|
||||||
const [, forceUpdate] = useState(0);
|
const [, forceUpdate] = useState(0);
|
||||||
@@ -19,6 +23,13 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
const [showHint, setShowHint] = useState(false);
|
const [showHint, setShowHint] = useState(false);
|
||||||
const [result, setResult] = useState(null);
|
const [result, setResult] = useState(null);
|
||||||
const [targetPlaying, setTargetPlaying] = useState(false);
|
const [targetPlaying, setTargetPlaying] = useState(false);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
const [mobileTab, setMobileTab] = useState('mission');
|
||||||
|
|
||||||
|
// Pinch-to-zoom on mobile
|
||||||
|
const getZoom = useCallback(() => state.zoom, []);
|
||||||
|
const setZoom = useCallback((z) => { state.zoom = z; emit(); }, []);
|
||||||
|
usePinchZoom(containerRef, getZoom, setZoom);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub = subscribe(() => {
|
const unsub = subscribe(() => {
|
||||||
@@ -48,7 +59,10 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadLevel();
|
loadLevel();
|
||||||
|
// Center view on modules after level loads and DOM settles
|
||||||
|
const timer = setTimeout(() => handleCenterView(), 100);
|
||||||
return () => {
|
return () => {
|
||||||
|
clearTimeout(timer);
|
||||||
stopAudio();
|
stopAudio();
|
||||||
stopTarget();
|
stopTarget();
|
||||||
};
|
};
|
||||||
@@ -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 };
|
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
} else if (e.button === 0 && !connectingRef.current) {
|
} else if (e.button === 0 && !connectingRef.current) {
|
||||||
|
// On mobile (touch), single finger on empty canvas = pan
|
||||||
|
if (isMobile && e.pointerType === 'touch') {
|
||||||
|
state.panning = true;
|
||||||
|
state.panStart = { x: e.clientX - state.camX, y: e.clientY - state.camY };
|
||||||
|
e.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
state.selectedModuleId = null;
|
state.selectedModuleId = null;
|
||||||
emit();
|
emit();
|
||||||
}
|
}
|
||||||
}, []);
|
}, [isMobile]);
|
||||||
|
|
||||||
const handlePointerMove = useCallback((e) => {
|
const handlePointerMove = useCallback((e) => {
|
||||||
if (state.panning && state.panStart) {
|
if (state.panning && state.panStart) {
|
||||||
@@ -221,6 +242,26 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
emit();
|
emit();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Center view on all modules
|
||||||
|
const handleCenterView = useCallback(() => {
|
||||||
|
if (state.modules.length === 0) { state.camX = 0; state.camY = 0; emit(); return; }
|
||||||
|
const container = containerRef.current;
|
||||||
|
const cw = container?.clientWidth || 800;
|
||||||
|
const ch = container?.clientHeight || 600;
|
||||||
|
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||||
|
for (const m of state.modules) {
|
||||||
|
minX = Math.min(minX, m.x);
|
||||||
|
minY = Math.min(minY, m.y);
|
||||||
|
maxX = Math.max(maxX, m.x + 200);
|
||||||
|
maxY = Math.max(maxY, m.y + 150);
|
||||||
|
}
|
||||||
|
const cx = (minX + maxX) / 2 * state.zoom;
|
||||||
|
const cy = (minY + maxY) / 2 * state.zoom;
|
||||||
|
state.camX = cw / 2 - cx;
|
||||||
|
state.camY = ch / 2 - cy;
|
||||||
|
emit();
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleAddModule = (type) => {
|
const handleAddModule = (type) => {
|
||||||
const x = (-state.camX + 250) / state.zoom + Math.random() * 30;
|
const x = (-state.camX + 250) / state.zoom + Math.random() * 30;
|
||||||
const y = (-state.camY + 150) / state.zoom + Math.random() * 30;
|
const y = (-state.camY + 150) / state.zoom + Math.random() * 30;
|
||||||
@@ -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)
|
// Reveal hint — PERMANENTLY caps this level at 2 stars (persisted, survives reload)
|
||||||
const handleRevealHint = () => {
|
const handleRevealHint = () => {
|
||||||
setHintUsed(true);
|
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 handleAutoSolve = () => {
|
||||||
const checks = level.checks.map(check => ({
|
const solution = SOLUTIONS[level.id];
|
||||||
...check,
|
if (!solution) {
|
||||||
passed: true,
|
console.warn(`No auto-solve solution for level ${level.id}`);
|
||||||
}));
|
return;
|
||||||
completeLevel(level.id, 3);
|
}
|
||||||
setResult({ stars: 3, checks, hintPenalty: false });
|
// Load the solution patch into the engine state
|
||||||
playLevelComplete();
|
deserialize(solution);
|
||||||
|
emit();
|
||||||
|
// Now run the normal check logic against the loaded patch
|
||||||
|
setTimeout(() => {
|
||||||
|
handleCheck();
|
||||||
|
}, 50);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isLastLevel = levelIndex >= worldLevels.length - 1;
|
const isLastLevel = levelIndex >= worldLevels.length - 1;
|
||||||
@@ -302,7 +355,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
<div className="gm-puzzle">
|
<div className="gm-puzzle">
|
||||||
{/* Top bar */}
|
{/* Top bar */}
|
||||||
<div className="gm-puzzle-bar">
|
<div className="gm-puzzle-bar">
|
||||||
<button className="gm-btn icon" onClick={() => { playNav(); onBack(); }}>← Mapa</button>
|
<button className="gm-btn icon" onClick={() => { playNav(); onBack(); }}>{isMobile ? '←' : '← Mapa'}</button>
|
||||||
<div className="gm-puzzle-title">
|
<div className="gm-puzzle-title">
|
||||||
<span className="gm-puzzle-num">{levelIndex + 1}/{worldLevels.length}</span>
|
<span className="gm-puzzle-num">{levelIndex + 1}/{worldLevels.length}</span>
|
||||||
<span className="gm-puzzle-name">{level.title}</span>
|
<span className="gm-puzzle-name">{level.title}</span>
|
||||||
@@ -312,16 +365,21 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
className={`gm-btn ${targetPlaying ? 'active' : 'target'}`}
|
className={`gm-btn ${targetPlaying ? 'active' : 'target'}`}
|
||||||
onClick={handlePlayTarget}
|
onClick={handlePlayTarget}
|
||||||
>
|
>
|
||||||
{targetPlaying ? '⏹ Parar' : '🎯 Objetivo'}
|
{targetPlaying ? '⏹' : '🎯'}{!isMobile && <span className="btn-label">{targetPlaying ? ' Parar' : ' Objetivo'}</span>}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className={`gm-btn ${state.isRunning ? 'active' : ''}`}
|
className={`gm-btn ${state.isRunning ? 'active' : ''}`}
|
||||||
onClick={handleToggleAudio}
|
onClick={handleToggleAudio}
|
||||||
>
|
>
|
||||||
{state.isRunning ? '⏹ Parar' : '▶ Mi Sonido'}
|
{state.isRunning ? '⏹' : '▶'}{!isMobile && <span className="btn-label">{state.isRunning ? ' Parar' : ' Mi Sonido'}</span>}
|
||||||
</button>
|
</button>
|
||||||
|
{!isMobile && (
|
||||||
|
<button className="gm-btn clear" onClick={handleClearCanvas} title="Limpiar canvas">
|
||||||
|
🗑 Limpiar
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
<button className="gm-btn check" onClick={handleCheck}>
|
<button className="gm-btn check" onClick={handleCheck}>
|
||||||
✓ Comprobar
|
✓{!isMobile && <span className="btn-label"> Comprobar</span>}
|
||||||
</button>
|
</button>
|
||||||
{adminMode && (
|
{adminMode && (
|
||||||
<button className="gm-btn admin-solve" onClick={handleAutoSolve} title="Admin: resolver nivel">
|
<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>
|
||||||
|
|
||||||
<div className="gm-puzzle-content">
|
<div className="gm-puzzle-content">
|
||||||
{/* Left sidebar */}
|
{/* Left sidebar (desktop only — hidden on mobile via CSS) */}
|
||||||
<div className="gm-puzzle-sidebar">
|
<div className="gm-puzzle-sidebar">
|
||||||
{/* Description — always visible */}
|
{/* Description — always visible */}
|
||||||
<div className="gm-concept-panel">
|
<div className="gm-concept-panel">
|
||||||
@@ -455,6 +513,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
{(state.zoom * 100).toFixed(0)}%
|
{(state.zoom * 100).toFixed(0)}%
|
||||||
</button>
|
</button>
|
||||||
<button className="zoom-btn" onClick={handleZoomOut} title="Alejar">−</button>
|
<button className="zoom-btn" onClick={handleZoomOut} title="Alejar">−</button>
|
||||||
|
<button className="zoom-btn" onClick={handleCenterView} title="Centrar vista">⌂</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{state.modules.length > 0 && state.connections.length === 0 && (
|
{state.modules.length > 0 && state.connections.length === 0 && (
|
||||||
@@ -465,6 +524,89 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile bottom sheet with tabs (replaces sidebar) */}
|
||||||
|
{isMobile && (
|
||||||
|
<BottomSheet
|
||||||
|
tabs={[
|
||||||
|
{ id: 'mission', label: 'MISION' },
|
||||||
|
{ id: 'objectives', label: 'OBJETIVOS' },
|
||||||
|
{ id: 'modules', label: 'MODULOS' },
|
||||||
|
]}
|
||||||
|
activeTab={mobileTab}
|
||||||
|
onTabChange={setMobileTab}
|
||||||
|
>
|
||||||
|
{mobileTab === 'mission' && (
|
||||||
|
<div>
|
||||||
|
<p className="puzzle-mission-text">{level.description}</p>
|
||||||
|
{!showHint ? (
|
||||||
|
<button className="puzzle-hint-btn" onClick={handleRevealHint}>
|
||||||
|
<span className="puzzle-hint-icon">💡</span>
|
||||||
|
<span className="puzzle-hint-label">Mostrar Pista</span>
|
||||||
|
<span className="puzzle-hint-penalty">max ★★</span>
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div style={{ marginTop: 8, padding: '10px 12px', background: 'var(--surface)', borderRadius: 8, border: '1px solid var(--yellow)' }}>
|
||||||
|
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--yellow)', marginBottom: 6 }}>💡 Pista <span className="puzzle-hint-penalty">max ★★</span></div>
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text)', lineHeight: 1.5 }}>{level.concept}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mobileTab === 'objectives' && (
|
||||||
|
<div>
|
||||||
|
{level.checks.map((check, i) => {
|
||||||
|
const passed = result?.checks?.[i]?.passed;
|
||||||
|
const cappedByStar = hintUsed && check.star === 3;
|
||||||
|
return (
|
||||||
|
<div key={i} className="puzzle-obj-item">
|
||||||
|
<span className="puzzle-obj-star">{'★'.repeat(check.star)}</span>
|
||||||
|
<span className="puzzle-obj-desc" style={passed === true ? { color: 'var(--green)' } : passed === false ? { color: 'var(--red)' } : {}}>
|
||||||
|
{check.desc}
|
||||||
|
{cappedByStar && ' 🔒'}
|
||||||
|
</span>
|
||||||
|
{passed === true && !cappedByStar && <span style={{ color: 'var(--green)', fontWeight: 700 }}>✓</span>}
|
||||||
|
{passed === false && <span style={{ color: 'var(--red)', fontWeight: 700 }}>✗</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{hintUsed && (
|
||||||
|
<div style={{ marginTop: 8, padding: '6px 8px', background: 'rgba(255,204,0,0.08)', borderRadius: 4, fontSize: 10, color: 'var(--yellow)' }}>
|
||||||
|
Pista usada — maximo 2 estrellas (permanente).
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{mobileTab === 'modules' && (
|
||||||
|
<div>
|
||||||
|
{level.availableModules.length > 0 ? (
|
||||||
|
<div className="mobile-module-grid" style={{ gridTemplateColumns: 'repeat(4, 1fr)' }}>
|
||||||
|
{level.availableModules.map(type => {
|
||||||
|
const def = getModuleDef(type);
|
||||||
|
if (!def) return null;
|
||||||
|
return (
|
||||||
|
<div key={type} className="mobile-module-tile" onClick={() => handleAddModule(type)}>
|
||||||
|
<span className="tile-icon">{def.icon}</span>
|
||||||
|
<span className="tile-name">{def.name}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p style={{ fontSize: 12, color: 'var(--text2)' }}>No hay modulos extra disponibles para este nivel.</p>
|
||||||
|
)}
|
||||||
|
<button className="gm-btn danger" onClick={() => loadLevel(true)} style={{ width: '100%', marginTop: 12, justifyContent: 'center' }}>
|
||||||
|
↺ Reiniciar Nivel
|
||||||
|
</button>
|
||||||
|
<button className="gm-btn clear" onClick={handleClearCanvas} style={{ width: '100%', marginTop: 6, justifyContent: 'center' }}>
|
||||||
|
🗑 Limpiar
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</BottomSheet>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Level complete overlay */}
|
{/* Level complete overlay */}
|
||||||
{result && result.stars >= 1 && (
|
{result && result.stars >= 1 && (
|
||||||
<LevelComplete
|
<LevelComplete
|
||||||
|
|||||||
@@ -1,4 +1,6 @@
|
|||||||
import React from 'react';
|
import React, { useState, useRef } from 'react';
|
||||||
|
import MobileTabBar from '../components/MobileTabBar.jsx';
|
||||||
|
import { useIsMobile } from '../hooks/useIsMobile.js';
|
||||||
import { WORLD_1 } from './levels/world1.js';
|
import { WORLD_1 } from './levels/world1.js';
|
||||||
import { WORLD_2 } from './levels/world2.js';
|
import { WORLD_2 } from './levels/world2.js';
|
||||||
import { WORLD_3 } from './levels/world3.js';
|
import { WORLD_3 } from './levels/world3.js';
|
||||||
@@ -39,9 +41,31 @@ function isWorldUnlocked(world) {
|
|||||||
return getTotalStars() >= world.unlockStars;
|
return getTotalStars() >= world.unlockStars;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const MOBILE_TABS = [
|
||||||
|
{ id: 'game', label: 'JUEGO', icon: '🎮' },
|
||||||
|
{ id: 'sandbox', label: 'SANDBOX', icon: '🎛' },
|
||||||
|
{ id: 'config', label: 'CONFIG', icon: '⚙' },
|
||||||
|
];
|
||||||
|
|
||||||
export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
|
export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
|
||||||
const totalStars = getTotalStars();
|
const totalStars = getTotalStars();
|
||||||
const maxStars = getMaxStars();
|
const maxStars = getMaxStars();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const searchRef = useRef(null);
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
const query = search.trim().toLowerCase();
|
||||||
|
|
||||||
|
// Build flat search results when there's a query
|
||||||
|
const searchResults = query ? worlds.flatMap((world, worldIdx) => {
|
||||||
|
return world.levels.map((level, idx) => ({ level, world, worldIdx, idx }))
|
||||||
|
.filter(({ level }) =>
|
||||||
|
level.title.toLowerCase().includes(query) ||
|
||||||
|
level.subtitle.toLowerCase().includes(query) ||
|
||||||
|
level.id.toLowerCase().includes(query) ||
|
||||||
|
world.name.toLowerCase().includes(query)
|
||||||
|
);
|
||||||
|
}) : [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="gm-worldmap">
|
<div className="gm-worldmap">
|
||||||
@@ -69,8 +93,64 @@ export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* All worlds */}
|
{/* Search bar */}
|
||||||
{worlds.map((world, worldIdx) => {
|
<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 unlocked = isWorldUnlocked(world);
|
||||||
const worldStars = world.levels.reduce((s, l) => {
|
const worldStars = world.levels.reduce((s, l) => {
|
||||||
const p = getLevelProgress(l.id);
|
const p = getLevelProgress(l.id);
|
||||||
@@ -136,7 +216,20 @@ export default function WorldMap({ onSelectLevel, onSandbox, onAdmin }) {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Mobile tab bar */}
|
||||||
|
{isMobile && (
|
||||||
|
<MobileTabBar
|
||||||
|
tabs={MOBILE_TABS}
|
||||||
|
activeTab="game"
|
||||||
|
onTabChange={(id) => {
|
||||||
|
if (id === 'sandbox') onSandbox?.();
|
||||||
|
if (id === 'config') onAdmin?.();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
1913
src/game/autoSolver.js
Normal file
1913
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: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -87,7 +94,14 @@ export const WORLD_10 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -148,7 +162,16 @@ export const WORLD_10 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -205,7 +228,16 @@ export const WORLD_10 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -262,7 +294,16 @@ export const WORLD_10 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -317,7 +358,16 @@ export const WORLD_10 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -374,7 +424,19 @@ export const WORLD_10 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -442,7 +504,21 @@ export const WORLD_10 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 950, y: 160, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
|
|||||||
@@ -25,7 +25,15 @@ export const WORLD_11 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -80,7 +88,14 @@ export const WORLD_11 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -140,7 +155,21 @@ export const WORLD_11 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -196,7 +225,17 @@ export const WORLD_11 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -266,7 +305,15 @@ export const WORLD_11 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -319,7 +366,18 @@ export const WORLD_11 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -10 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -375,7 +433,18 @@ export const WORLD_11 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -440,7 +509,26 @@ export const WORLD_11 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 950, y: 160, params: { volume: -10 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
|
|||||||
@@ -25,7 +25,19 @@ export const WORLD_12 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -86,7 +98,16 @@ export const WORLD_12 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -6 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -146,7 +167,18 @@ export const WORLD_12 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 850, y: 140, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -206,7 +238,19 @@ export const WORLD_12 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 850, y: 140, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -265,7 +309,20 @@ export const WORLD_12 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 850, y: 140, params: { volume: -6 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -323,7 +380,22 @@ export const WORLD_12 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 950, y: 160, params: { volume: -10 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -380,7 +452,20 @@ export const WORLD_12 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 850, y: 140, params: { volume: -10 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -441,7 +526,27 @@ export const WORLD_12 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 1000, y: 160, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
|
|||||||
@@ -80,7 +80,13 @@ export const WORLD_3 = {
|
|||||||
{ id: 2, type: 'vca', x: 400, y: 60, params: { gain: 0 }, locked: false },
|
{ 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 },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -135,7 +141,13 @@ export const WORLD_3 = {
|
|||||||
{ id: 2, type: 'vca', x: 400, y: 60, params: { gain: 0 }, locked: false },
|
{ 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 },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -191,7 +203,13 @@ export const WORLD_3 = {
|
|||||||
{ id: 2, type: 'vca', x: 400, y: 60, params: { gain: 0 }, locked: false },
|
{ 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 },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -243,7 +261,14 @@ export const WORLD_3 = {
|
|||||||
{ id: 2, type: 'vca', x: 500, y: 60, params: { gain: 0 }, locked: false },
|
{ id: 2, type: 'vca', x: 500, y: 60, params: { gain: 0 }, locked: false },
|
||||||
{ id: 3, type: 'output', x: 760, y: 80, params: { volume: -6 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -300,7 +325,14 @@ export const WORLD_3 = {
|
|||||||
{ id: 2, type: 'vca', x: 520, y: 40, params: { gain: 0 }, locked: false },
|
{ 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 },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
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: 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 },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -408,7 +446,14 @@ export const WORLD_3 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -6 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
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: 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 },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
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: 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 },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
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: 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 },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
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: 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 },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
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: 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 },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -288,7 +320,13 @@ export const WORLD_4 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 600, y: 120, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -354,7 +392,15 @@ export const WORLD_4 = {
|
|||||||
{ id: 3, type: 'vca', x: 480, y: 40, params: { gain: 0 }, locked: false },
|
{ id: 3, type: 'vca', x: 480, y: 40, params: { gain: 0 }, locked: false },
|
||||||
{ id: 4, type: 'output', x: 680, y: 60, params: { volume: -6 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -418,7 +464,15 @@ export const WORLD_4 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
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: 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 },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
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: 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 },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
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: 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 },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
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: 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 },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
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: 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 },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
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: 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 },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
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: 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 },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -398,7 +457,19 @@ export const WORLD_5 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 900, y: 120, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
|
|||||||
@@ -24,7 +24,13 @@ export const WORLD_6 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -84,7 +90,14 @@ export const WORLD_6 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -10 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -135,7 +148,15 @@ export const WORLD_6 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 800, y: 140, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -194,7 +215,19 @@ export const WORLD_6 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -250,7 +283,16 @@ export const WORLD_6 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -303,7 +345,13 @@ export const WORLD_6 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -368,7 +416,19 @@ export const WORLD_6 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -425,7 +485,22 @@ export const WORLD_6 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 950, y: 160, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
|
|||||||
@@ -25,7 +25,14 @@ export const WORLD_7 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -81,7 +88,17 @@ export const WORLD_7 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -137,7 +154,16 @@ export const WORLD_7 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 750, y: 120, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -194,7 +220,15 @@ export const WORLD_7 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -250,7 +284,14 @@ export const WORLD_7 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -308,7 +349,16 @@ export const WORLD_7 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 750, y: 120, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -362,7 +412,18 @@ export const WORLD_7 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -417,7 +478,22 @@ export const WORLD_7 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
|
|||||||
@@ -26,7 +26,13 @@ export const WORLD_8 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -79,7 +85,14 @@ export const WORLD_8 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -135,7 +148,15 @@ export const WORLD_8 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 750, y: 120, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -191,7 +212,14 @@ export const WORLD_8 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -10 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -246,7 +274,16 @@ export const WORLD_8 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -12 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -299,7 +336,13 @@ export const WORLD_8 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -351,7 +394,17 @@ export const WORLD_8 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -406,7 +459,25 @@ export const WORLD_8 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
|
|||||||
@@ -24,7 +24,14 @@ export const WORLD_9 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -83,7 +90,15 @@ export const WORLD_9 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -139,7 +154,14 @@ export const WORLD_9 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -197,7 +219,14 @@ export const WORLD_9 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -6 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -256,7 +285,16 @@ export const WORLD_9 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -316,7 +354,16 @@ export const WORLD_9 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 800, y: 120, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -372,7 +419,15 @@ export const WORLD_9 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 700, y: 120, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
@@ -427,7 +482,19 @@ export const WORLD_9 = {
|
|||||||
preplacedModules: [
|
preplacedModules: [
|
||||||
{ id: 1, type: 'output', x: 900, y: 140, params: { volume: -8 }, locked: true },
|
{ 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: [
|
checks: [
|
||||||
{
|
{
|
||||||
star: 1,
|
star: 1,
|
||||||
|
|||||||
@@ -1,12 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* targetAudio.js — Plays the "target" sound for a puzzle level
|
* targetAudio.js — Plays the "target" sound for a puzzle level
|
||||||
* Builds a temporary Tone.js graph from the level's target config
|
* Builds a temporary Tone.js graph from the level's target config
|
||||||
|
*
|
||||||
|
* Extended to support:
|
||||||
|
* - Envelopes (amplitude shaping)
|
||||||
|
* - LFO (modulation)
|
||||||
|
* - Effects (delay, reverb, distortion)
|
||||||
*/
|
*/
|
||||||
import * as Tone from 'tone';
|
import * as Tone from 'tone';
|
||||||
|
|
||||||
let _activeNodes = [];
|
let _activeNodes = [];
|
||||||
let _isPlaying = false;
|
let _isPlaying = false;
|
||||||
let _stopTimeout = null;
|
let _stopTimeout = null;
|
||||||
|
let _loops = []; // Track Tone.Loop instances for cleanup
|
||||||
|
|
||||||
export function isTargetPlaying() {
|
export function isTargetPlaying() {
|
||||||
return _isPlaying;
|
return _isPlaying;
|
||||||
@@ -25,19 +31,87 @@ export async function playTarget(target) {
|
|||||||
const output = new Tone.Gain(0.5).toDestination();
|
const output = new Tone.Gain(0.5).toDestination();
|
||||||
nodes.push(output);
|
nodes.push(output);
|
||||||
|
|
||||||
|
// Build 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
|
// Optional filter in the chain
|
||||||
let destination = output;
|
let destination = effectChain;
|
||||||
if (target.filter) {
|
if (target.filter) {
|
||||||
const filter = new Tone.Filter({
|
const filter = new Tone.Filter({
|
||||||
type: target.filter.type || 'lowpass',
|
type: target.filter.type || 'lowpass',
|
||||||
frequency: target.filter.frequency || 1000,
|
frequency: target.filter.frequency || 1000,
|
||||||
Q: target.filter.Q || 1,
|
Q: target.filter.Q || 1,
|
||||||
});
|
});
|
||||||
filter.connect(output);
|
filter.connect(effectChain);
|
||||||
destination = filter;
|
destination = filter;
|
||||||
nodes.push(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
|
// Build oscillators / noise from target.build
|
||||||
for (const spec of target.build) {
|
for (const spec of target.build) {
|
||||||
if (spec.type === 'oscillator') {
|
if (spec.type === 'oscillator') {
|
||||||
@@ -47,6 +121,12 @@ export async function playTarget(target) {
|
|||||||
detune: spec.params.detune || 0,
|
detune: spec.params.detune || 0,
|
||||||
});
|
});
|
||||||
osc.connect(destination);
|
osc.connect(destination);
|
||||||
|
|
||||||
|
// Connect LFO to frequency if specified
|
||||||
|
if (lfo && target.lfo?.target === 'frequency') {
|
||||||
|
lfo.connect(osc.frequency);
|
||||||
|
}
|
||||||
|
|
||||||
osc.start();
|
osc.start();
|
||||||
nodes.push(osc);
|
nodes.push(osc);
|
||||||
} else if (spec.type === 'noise') {
|
} else if (spec.type === 'noise') {
|
||||||
@@ -57,6 +137,27 @@ export async function playTarget(target) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle envelope retriggering with triggerPattern
|
||||||
|
if (envelope && target.triggerPattern) {
|
||||||
|
const pattern = target.triggerPattern;
|
||||||
|
const interval = pattern.interval ?? 0.5;
|
||||||
|
const count = pattern.count ?? Math.ceil((target.duration || 2) / interval);
|
||||||
|
|
||||||
|
const loop = new Tone.Loop((time) => {
|
||||||
|
envelope.triggerAttackRelease(
|
||||||
|
target.envelope.attack + target.envelope.decay + target.envelope.release,
|
||||||
|
time
|
||||||
|
);
|
||||||
|
}, interval);
|
||||||
|
|
||||||
|
loop.start(0);
|
||||||
|
nodes.push(loop);
|
||||||
|
_loops.push(loop);
|
||||||
|
} else if (envelope) {
|
||||||
|
// Single trigger if no pattern
|
||||||
|
envelope.triggerAttack();
|
||||||
|
}
|
||||||
|
|
||||||
_activeNodes = nodes;
|
_activeNodes = nodes;
|
||||||
|
|
||||||
// Auto-stop after duration
|
// Auto-stop after duration
|
||||||
@@ -69,6 +170,17 @@ export function stopTarget() {
|
|||||||
clearTimeout(_stopTimeout);
|
clearTimeout(_stopTimeout);
|
||||||
_stopTimeout = null;
|
_stopTimeout = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stop and cleanup loops
|
||||||
|
for (const loop of _loops) {
|
||||||
|
try {
|
||||||
|
loop.stop();
|
||||||
|
loop.dispose();
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
_loops = [];
|
||||||
|
|
||||||
|
// Stop and cleanup nodes
|
||||||
for (const node of _activeNodes) {
|
for (const node of _activeNodes) {
|
||||||
try {
|
try {
|
||||||
if (node.stop) node.stop();
|
if (node.stop) node.stop();
|
||||||
|
|||||||
14
src/hooks/useIsMobile.js
Normal file
14
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
src/hooks/usePinchZoom.js
Normal file
48
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]);
|
||||||
|
}
|
||||||
536
src/index.css
536
src/index.css
@@ -28,9 +28,16 @@ html, body, #root {
|
|||||||
width: 100%; height: 100%; overflow: hidden;
|
width: 100%; height: 100%; overflow: hidden;
|
||||||
background: var(--bg); color: var(--text);
|
background: var(--bg); color: var(--text);
|
||||||
font-family: 'Inter', 'SF Pro', -apple-system, system-ui, sans-serif;
|
font-family: 'Inter', 'SF Pro', -apple-system, system-ui, sans-serif;
|
||||||
font-size: 12px;
|
font-size: 13px;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
|
touch-action: pan-x pan-y;
|
||||||
|
-ms-touch-action: pan-x pan-y;
|
||||||
}
|
}
|
||||||
|
/* Block native browser zoom gestures globally */
|
||||||
|
html { touch-action: manipulation; }
|
||||||
|
/* Prevent text selection globally (except inputs/textareas) */
|
||||||
|
* { -webkit-user-select: none; user-select: none; }
|
||||||
|
input, textarea, [contenteditable] { -webkit-user-select: text; user-select: text; }
|
||||||
|
|
||||||
/* ===== Layout ===== */
|
/* ===== Layout ===== */
|
||||||
.app { display: flex; flex-direction: column; height: 100vh; }
|
.app { display: flex; flex-direction: column; height: 100vh; }
|
||||||
@@ -89,7 +96,7 @@ html, body, #root {
|
|||||||
|
|
||||||
/* ===== Modules ===== */
|
/* ===== Modules ===== */
|
||||||
.module {
|
.module {
|
||||||
position: absolute; width: 180px; min-width: 180px;
|
position: absolute; width: 200px; min-width: 200px;
|
||||||
background: var(--surface); border: 1px solid var(--border);
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
border-radius: 8px; user-select: none; z-index: 2;
|
border-radius: 8px; user-select: none; z-index: 2;
|
||||||
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
|
||||||
@@ -100,13 +107,13 @@ html, body, #root {
|
|||||||
|
|
||||||
.module-header {
|
.module-header {
|
||||||
display: flex; align-items: center; gap: 6px;
|
display: flex; align-items: center; gap: 6px;
|
||||||
padding: 6px 10px; border-bottom: 1px solid var(--border);
|
padding: 8px 12px; border-bottom: 1px solid var(--border);
|
||||||
cursor: grab; border-radius: 8px 8px 0 0;
|
cursor: grab; border-radius: 8px 8px 0 0;
|
||||||
background: var(--surface2);
|
background: var(--surface2);
|
||||||
}
|
}
|
||||||
.module-header .type-icon { font-size: 14px; }
|
.module-header .type-icon { font-size: 16px; }
|
||||||
.module-header .type-name {
|
.module-header .type-name {
|
||||||
font-size: 11px; font-weight: 600; text-transform: uppercase;
|
font-size: 12px; font-weight: 600; text-transform: uppercase;
|
||||||
letter-spacing: 0.5px; color: var(--text);
|
letter-spacing: 0.5px; color: var(--text);
|
||||||
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -117,18 +124,26 @@ html, body, #root {
|
|||||||
}
|
}
|
||||||
.module-header .close-btn:hover { background: var(--red); color: #fff; }
|
.module-header .close-btn:hover { background: var(--red); color: #fff; }
|
||||||
|
|
||||||
.module-body { padding: 8px 10px; display: flex; flex-direction: column; gap: 6px; }
|
.module-header .expand-btn {
|
||||||
|
width: 18px; height: 18px; border: none; background: transparent;
|
||||||
|
color: var(--text2); cursor: pointer; font-size: 13px; border-radius: 3px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
.module-header .expand-btn:hover { background: var(--accent); color: #000; }
|
||||||
|
|
||||||
|
.module-body { padding: 10px 12px; display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
|
||||||
/* Ports */
|
/* Ports */
|
||||||
.port-row {
|
.port-row {
|
||||||
display: flex; align-items: center; gap: 6px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
position: relative; height: 20px;
|
position: relative; height: 24px;
|
||||||
}
|
}
|
||||||
.port-row.input { flex-direction: row; }
|
.port-row.input { flex-direction: row; }
|
||||||
.port-row.output { flex-direction: row-reverse; }
|
.port-row.output { flex-direction: row-reverse; }
|
||||||
|
|
||||||
.port-dot {
|
.port-dot {
|
||||||
width: 12px; height: 12px; border-radius: 50%;
|
width: 14px; height: 14px; border-radius: 50%;
|
||||||
border: 2px solid var(--border); background: var(--surface);
|
border: 2px solid var(--border); background: var(--surface);
|
||||||
cursor: pointer; flex-shrink: 0; transition: all 0.15s;
|
cursor: pointer; flex-shrink: 0; transition: all 0.15s;
|
||||||
position: relative; z-index: 5;
|
position: relative; z-index: 5;
|
||||||
@@ -149,21 +164,21 @@ html, body, #root {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.port-label {
|
.port-label {
|
||||||
font-size: 10px; color: var(--text2); text-transform: uppercase;
|
font-size: 11px; color: var(--text2); text-transform: uppercase;
|
||||||
letter-spacing: 0.3px; white-space: nowrap;
|
letter-spacing: 0.3px; white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Knobs */
|
/* Knobs */
|
||||||
.param-row {
|
.param-row {
|
||||||
display: flex; align-items: center; gap: 6px;
|
display: flex; align-items: center; gap: 8px;
|
||||||
}
|
}
|
||||||
.param-label {
|
.param-label {
|
||||||
font-size: 10px; color: var(--text2); width: 48px;
|
font-size: 11px; color: var(--text2); width: 50px;
|
||||||
text-transform: uppercase; letter-spacing: 0.3px; flex-shrink: 0;
|
text-transform: uppercase; letter-spacing: 0.3px; flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.knob-container { position: relative; width: 32px; height: 32px; flex-shrink: 0; }
|
.knob-container { position: relative; width: 36px; height: 36px; flex-shrink: 0; }
|
||||||
.knob-svg { width: 32px; height: 32px; cursor: pointer; }
|
.knob-svg { width: 36px; height: 36px; cursor: pointer; }
|
||||||
.knob-track { fill: none; stroke: var(--knob-track); stroke-width: 3; stroke-linecap: round; }
|
.knob-track { fill: none; stroke: var(--knob-track); stroke-width: 3; stroke-linecap: round; }
|
||||||
.knob-fill { fill: none; stroke-width: 3; stroke-linecap: round; }
|
.knob-fill { fill: none; stroke-width: 3; stroke-linecap: round; }
|
||||||
.knob-dot { fill: var(--text); }
|
.knob-dot { fill: var(--text); }
|
||||||
@@ -211,8 +226,8 @@ html, body, #root {
|
|||||||
.knob-input:focus { box-shadow: 0 0 6px rgba(0,229,255,0.3); }
|
.knob-input:focus { box-shadow: 0 0 6px rgba(0,229,255,0.3); }
|
||||||
|
|
||||||
.param-value {
|
.param-value {
|
||||||
font-size: 10px; color: var(--accent); font-family: 'JetBrains Mono', monospace;
|
font-size: 11px; color: var(--accent); font-family: 'JetBrains Mono', monospace;
|
||||||
min-width: 40px; text-align: right;
|
min-width: 45px; text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Select param */
|
/* Select param */
|
||||||
@@ -225,7 +240,7 @@ html, body, #root {
|
|||||||
|
|
||||||
/* Scope canvas */
|
/* Scope canvas */
|
||||||
.scope-canvas {
|
.scope-canvas {
|
||||||
width: 100%; height: 60px; border-radius: 4px;
|
width: 100%; height: 70px; border-radius: 4px;
|
||||||
background: #050510; border: 1px solid var(--border);
|
background: #050510; border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,11 +326,11 @@ html, body, #root {
|
|||||||
width: 56px; height: 56px; display: flex; align-items: center; justify-content: center;
|
width: 56px; height: 56px; display: flex; align-items: center; justify-content: center;
|
||||||
border: 2px solid var(--accent); border-radius: 12px; background: rgba(0,229,255,0.05);
|
border: 2px solid var(--accent); border-radius: 12px; background: rgba(0,229,255,0.05);
|
||||||
}
|
}
|
||||||
.gm-title { font-size: 22px; font-weight: 800; color: var(--text); letter-spacing: 1px; }
|
.gm-title { font-size: 24px; font-weight: 800; color: var(--text); letter-spacing: 1px; }
|
||||||
.gm-tagline { font-size: 12px; color: var(--text2); margin-top: 2px; }
|
.gm-tagline { font-size: 13px; color: var(--text2); margin-top: 2px; }
|
||||||
|
|
||||||
.gm-header-right { display: flex; align-items: center; gap: 16px; }
|
.gm-header-right { display: flex; align-items: center; gap: 16px; }
|
||||||
.gm-total-stars { font-size: 16px; color: var(--yellow); font-weight: 700; }
|
.gm-total-stars { font-size: 18px; color: var(--yellow); font-weight: 700; }
|
||||||
.gm-sandbox-btn {
|
.gm-sandbox-btn {
|
||||||
padding: 8px 16px; border: 1px solid var(--border); border-radius: 6px;
|
padding: 8px 16px; border: 1px solid var(--border); border-radius: 6px;
|
||||||
background: var(--surface); color: var(--text2); cursor: pointer;
|
background: var(--surface); color: var(--text2); cursor: pointer;
|
||||||
@@ -323,25 +338,47 @@ html, body, #root {
|
|||||||
}
|
}
|
||||||
.gm-sandbox-btn:hover { border-color: var(--accent); color: var(--text); }
|
.gm-sandbox-btn:hover { border-color: var(--accent); color: var(--text); }
|
||||||
|
|
||||||
|
/* Level search bar */
|
||||||
|
.gm-search-bar {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||||||
|
padding: 8px 14px; margin-bottom: 24px; transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
.gm-search-bar:focus-within { border-color: var(--accent); }
|
||||||
|
.gm-search-icon { font-size: 14px; opacity: 0.5; flex-shrink: 0; }
|
||||||
|
.gm-search-input {
|
||||||
|
flex: 1; background: none; border: none; outline: none;
|
||||||
|
color: var(--text); font-size: 13px; font-family: inherit;
|
||||||
|
}
|
||||||
|
.gm-search-input::placeholder { color: var(--text2); }
|
||||||
|
.gm-search-clear {
|
||||||
|
background: none; border: none; color: var(--text2); cursor: pointer;
|
||||||
|
font-size: 14px; padding: 2px 4px; border-radius: 4px;
|
||||||
|
}
|
||||||
|
.gm-search-clear:hover { color: var(--text); background: var(--surface2); }
|
||||||
|
.gm-search-results { margin-bottom: 24px; }
|
||||||
|
.gm-search-empty { color: var(--text2); font-size: 13px; padding: 16px 0; text-align: center; }
|
||||||
|
.gm-search-count { color: var(--text2); font-size: 11px; margin-bottom: 12px; }
|
||||||
|
|
||||||
/* World sections */
|
/* World sections */
|
||||||
.gm-world-section { margin-bottom: 32px; }
|
.gm-world-section { margin-bottom: 32px; }
|
||||||
.gm-locked-world { opacity: 0.4; }
|
.gm-locked-world { opacity: 0.4; }
|
||||||
.gm-world-header {
|
.gm-world-header {
|
||||||
display: flex; align-items: center; gap: 12px; margin-bottom: 16px;
|
display: flex; align-items: center; gap: 12px; margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
.gm-world-icon { font-size: 28px; }
|
.gm-world-icon { font-size: 32px; }
|
||||||
.gm-world-name { font-size: 16px; font-weight: 700; color: var(--text); }
|
.gm-world-name { font-size: 18px; font-weight: 700; color: var(--text); }
|
||||||
.gm-world-sub { font-size: 11px; color: var(--text2); margin-top: 2px; }
|
.gm-world-sub { font-size: 12px; color: var(--text2); margin-top: 2px; }
|
||||||
|
|
||||||
/* Level grid */
|
/* Level grid */
|
||||||
.gm-level-grid {
|
.gm-level-grid {
|
||||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||||
gap: 12px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gm-level-card {
|
.gm-level-card {
|
||||||
display: flex; align-items: center; gap: 12px;
|
display: flex; align-items: center; gap: 14px;
|
||||||
padding: 14px 16px; border-radius: 10px; cursor: pointer;
|
padding: 16px 18px; border-radius: 10px; cursor: pointer;
|
||||||
background: var(--surface); border: 1px solid var(--border);
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
transition: all 0.2s; position: relative; overflow: hidden;
|
transition: all 0.2s; position: relative; overflow: hidden;
|
||||||
}
|
}
|
||||||
@@ -355,18 +392,18 @@ html, body, #root {
|
|||||||
.gm-level-card.perfect { border-color: var(--green); }
|
.gm-level-card.perfect { border-color: var(--green); }
|
||||||
|
|
||||||
.gm-level-number {
|
.gm-level-number {
|
||||||
width: 36px; height: 36px; border-radius: 50%;
|
width: 42px; height: 42px; border-radius: 50%;
|
||||||
background: var(--surface2); display: flex; align-items: center; justify-content: center;
|
background: var(--surface2); display: flex; align-items: center; justify-content: center;
|
||||||
font-weight: 800; font-size: 14px; color: var(--accent); flex-shrink: 0;
|
font-weight: 800; font-size: 16px; color: var(--accent); flex-shrink: 0;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.gm-level-info { flex: 1; min-width: 0; }
|
.gm-level-info { flex: 1; min-width: 0; }
|
||||||
.gm-level-title { font-size: 13px; font-weight: 600; color: var(--text); }
|
.gm-level-title { font-size: 14px; font-weight: 600; color: var(--text); }
|
||||||
.gm-level-subtitle { font-size: 10px; color: var(--text2); margin-top: 2px; }
|
.gm-level-subtitle { font-size: 11px; color: var(--text2); margin-top: 2px; }
|
||||||
|
|
||||||
.gm-stars { display: flex; gap: 2px; }
|
.gm-stars { display: flex; gap: 3px; }
|
||||||
.gm-stars .star { font-size: 16px; }
|
.gm-stars .star { font-size: 18px; }
|
||||||
.gm-stars .star.filled { color: var(--yellow); }
|
.gm-stars .star.filled { color: var(--yellow); }
|
||||||
.gm-stars .star.empty { color: var(--border); }
|
.gm-stars .star.empty { color: var(--border); }
|
||||||
|
|
||||||
@@ -390,14 +427,14 @@ html, body, #root {
|
|||||||
font-size: 10px; color: var(--text2); background: var(--surface);
|
font-size: 10px; color: var(--text2); background: var(--surface);
|
||||||
padding: 2px 8px; border-radius: 4px; font-weight: 600;
|
padding: 2px 8px; border-radius: 4px; font-weight: 600;
|
||||||
}
|
}
|
||||||
.gm-puzzle-name { font-size: 14px; font-weight: 700; color: var(--text); }
|
.gm-puzzle-name { font-size: 15px; font-weight: 700; color: var(--text); }
|
||||||
.gm-puzzle-actions { margin-left: auto; display: flex; gap: 8px; }
|
.gm-puzzle-actions { margin-left: auto; display: flex; gap: 8px; }
|
||||||
|
|
||||||
/* Buttons */
|
/* Buttons */
|
||||||
.gm-btn {
|
.gm-btn {
|
||||||
padding: 6px 14px; border: 1px solid var(--border); border-radius: 6px;
|
padding: 7px 16px; border: 1px solid var(--border); border-radius: 6px;
|
||||||
background: var(--surface); color: var(--text); cursor: pointer;
|
background: var(--surface); color: var(--text); cursor: pointer;
|
||||||
font-size: 12px; font-weight: 600; font-family: inherit; transition: all 0.15s;
|
font-size: 13px; font-weight: 600; font-family: inherit; transition: all 0.15s;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.gm-btn:hover { border-color: var(--accent); }
|
.gm-btn:hover { border-color: var(--accent); }
|
||||||
@@ -417,11 +454,13 @@ html, body, #root {
|
|||||||
.gm-puzzle-content { flex: 1; display: flex; overflow: hidden; min-height: 0; }
|
.gm-puzzle-content { flex: 1; display: flex; overflow: hidden; min-height: 0; }
|
||||||
|
|
||||||
.gm-puzzle-sidebar {
|
.gm-puzzle-sidebar {
|
||||||
width: 280px; flex-shrink: 0; background: var(--panel);
|
width: 320px; flex-shrink: 0; background: var(--panel);
|
||||||
border-right: 1px solid var(--border); overflow-y: auto;
|
border-right: 1px solid var(--border); overflow-y: auto;
|
||||||
padding: 12px; display: flex; flex-direction: column; gap: 12px;
|
padding: 14px; display: flex; flex-direction: column; gap: 14px;
|
||||||
min-height: 0; /* Allow flex item to shrink below content — enables scrolling */
|
min-height: 0; /* Allow flex item to shrink below content — enables scrolling */
|
||||||
}
|
}
|
||||||
|
/* Prevent sidebar children from shrinking — forces overflow → scroll */
|
||||||
|
.gm-puzzle-sidebar > * { flex-shrink: 0; }
|
||||||
|
|
||||||
.gm-puzzle-canvas-wrap {
|
.gm-puzzle-canvas-wrap {
|
||||||
flex: 1; position: relative; overflow: hidden;
|
flex: 1; position: relative; overflow: hidden;
|
||||||
@@ -433,11 +472,11 @@ html, body, #root {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.gm-concept-header {
|
.gm-concept-header {
|
||||||
padding: 10px 12px; cursor: pointer; display: flex; justify-content: space-between;
|
padding: 12px 14px; cursor: pointer; display: flex; justify-content: space-between;
|
||||||
align-items: center; font-size: 12px; font-weight: 600; color: var(--yellow);
|
align-items: center; font-size: 13px; font-weight: 600; color: var(--yellow);
|
||||||
}
|
}
|
||||||
.gm-concept-body { padding: 0 12px 12px; }
|
.gm-concept-body { padding: 0 14px 14px; }
|
||||||
.gm-concept-desc { font-size: 11px; color: var(--text); line-height: 1.5; margin-bottom: 8px; }
|
.gm-concept-desc { font-size: 12px; color: var(--text); line-height: 1.5; margin-bottom: 8px; }
|
||||||
.gm-concept-tip {
|
.gm-concept-tip {
|
||||||
font-size: 10px; color: var(--text2); line-height: 1.5;
|
font-size: 10px; color: var(--text2); line-height: 1.5;
|
||||||
padding: 8px; background: var(--bg); border-radius: 4px;
|
padding: 8px; background: var(--bg); border-radius: 4px;
|
||||||
@@ -447,15 +486,15 @@ html, body, #root {
|
|||||||
/* Objectives */
|
/* Objectives */
|
||||||
.gm-objectives {
|
.gm-objectives {
|
||||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||||||
padding: 10px 12px;
|
padding: 12px 14px;
|
||||||
}
|
}
|
||||||
.gm-obj-title {
|
.gm-obj-title {
|
||||||
font-size: 10px; font-weight: 700; color: var(--text2);
|
font-size: 11px; font-weight: 700; color: var(--text2);
|
||||||
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px;
|
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
.gm-obj {
|
.gm-obj {
|
||||||
display: flex; align-items: center; gap: 8px; padding: 6px 0;
|
display: flex; align-items: center; gap: 10px; padding: 8px 0;
|
||||||
border-bottom: 1px solid var(--border); font-size: 11px;
|
border-bottom: 1px solid var(--border); font-size: 12px;
|
||||||
}
|
}
|
||||||
.gm-obj:last-child { border-bottom: none; }
|
.gm-obj:last-child { border-bottom: none; }
|
||||||
.gm-obj-star { color: var(--yellow); font-size: 12px; flex-shrink: 0; width: 30px; }
|
.gm-obj-star { color: var(--yellow); font-size: 12px; flex-shrink: 0; width: 30px; }
|
||||||
@@ -475,14 +514,14 @@ html, body, #root {
|
|||||||
/* Hint panel */
|
/* Hint panel */
|
||||||
.gm-hint-panel { }
|
.gm-hint-panel { }
|
||||||
.gm-hint-btn {
|
.gm-hint-btn {
|
||||||
width: 100%; display: flex; align-items: center; gap: 8px;
|
width: 100%; display: flex; align-items: center; gap: 10px;
|
||||||
padding: 10px 12px; border: 1px dashed var(--yellow); border-radius: 8px;
|
padding: 12px 14px; border: 1px dashed var(--yellow); border-radius: 8px;
|
||||||
background: rgba(255,204,0,0.04); cursor: pointer;
|
background: rgba(255,204,0,0.04); cursor: pointer;
|
||||||
font-family: inherit; transition: all 0.15s;
|
font-family: inherit; transition: all 0.15s;
|
||||||
}
|
}
|
||||||
.gm-hint-btn:hover { background: rgba(255,204,0,0.1); border-style: solid; }
|
.gm-hint-btn:hover { background: rgba(255,204,0,0.1); border-style: solid; }
|
||||||
.gm-hint-icon { font-size: 16px; }
|
.gm-hint-icon { font-size: 18px; }
|
||||||
.gm-hint-label { font-size: 12px; font-weight: 600; color: var(--yellow); flex: 1; text-align: left; }
|
.gm-hint-label { font-size: 13px; font-weight: 600; color: var(--yellow); flex: 1; text-align: left; }
|
||||||
.gm-hint-penalty {
|
.gm-hint-penalty {
|
||||||
font-size: 9px; color: var(--red); background: rgba(255,68,102,0.15);
|
font-size: 9px; color: var(--red); background: rgba(255,68,102,0.15);
|
||||||
padding: 2px 6px; border-radius: 3px; font-weight: 700;
|
padding: 2px 6px; border-radius: 3px; font-weight: 700;
|
||||||
@@ -493,16 +532,16 @@ html, body, #root {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.gm-hint-header {
|
.gm-hint-header {
|
||||||
padding: 8px 12px; display: flex; justify-content: space-between; align-items: center;
|
padding: 10px 14px; display: flex; justify-content: space-between; align-items: center;
|
||||||
font-size: 12px; font-weight: 600; color: var(--yellow);
|
font-size: 13px; font-weight: 600; color: var(--yellow);
|
||||||
background: rgba(255,204,0,0.06);
|
background: rgba(255,204,0,0.06);
|
||||||
}
|
}
|
||||||
.gm-hint-penalty-tag {
|
.gm-hint-penalty-tag {
|
||||||
font-size: 9px; color: var(--red); background: rgba(255,68,102,0.15);
|
font-size: 10px; color: var(--red); background: rgba(255,68,102,0.15);
|
||||||
padding: 2px 6px; border-radius: 3px; font-weight: 700;
|
padding: 3px 7px; border-radius: 3px; font-weight: 700;
|
||||||
}
|
}
|
||||||
.gm-hint-text {
|
.gm-hint-text {
|
||||||
padding: 8px 12px 12px; font-size: 11px; color: var(--text); line-height: 1.5;
|
padding: 10px 14px 14px; font-size: 12px; color: var(--text); line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.gm-hint-penalty-msg {
|
.gm-hint-penalty-msg {
|
||||||
@@ -516,24 +555,24 @@ html, body, #root {
|
|||||||
/* Module palette (game) */
|
/* Module palette (game) */
|
||||||
.gm-module-palette {
|
.gm-module-palette {
|
||||||
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
|
||||||
padding: 10px 12px;
|
padding: 12px 14px;
|
||||||
}
|
}
|
||||||
.gm-palette-title {
|
.gm-palette-title {
|
||||||
font-size: 10px; font-weight: 700; color: var(--text2);
|
font-size: 11px; font-weight: 700; color: var(--text2);
|
||||||
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px;
|
text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
.gm-palette-item {
|
.gm-palette-item {
|
||||||
display: flex; align-items: center; gap: 8px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
padding: 8px; border-radius: 6px; cursor: pointer;
|
padding: 10px; border-radius: 6px; cursor: pointer;
|
||||||
transition: all 0.15s; font-size: 12px; color: var(--text);
|
transition: all 0.15s; font-size: 13px; color: var(--text);
|
||||||
}
|
}
|
||||||
.gm-palette-item:hover { background: var(--surface2); }
|
.gm-palette-item:hover { background: var(--surface2); }
|
||||||
.gm-palette-icon { font-size: 16px; width: 24px; text-align: center; }
|
.gm-palette-icon { font-size: 18px; width: 28px; text-align: center; }
|
||||||
.gm-palette-name { flex: 1; font-weight: 500; }
|
.gm-palette-name { flex: 1; font-weight: 500; }
|
||||||
.gm-palette-add {
|
.gm-palette-add {
|
||||||
width: 22px; height: 22px; border-radius: 50%;
|
width: 26px; height: 26px; border-radius: 50%;
|
||||||
background: var(--surface2); display: flex; align-items: center; justify-content: center;
|
background: var(--surface2); display: flex; align-items: center; justify-content: center;
|
||||||
font-size: 14px; color: var(--accent); font-weight: 700;
|
font-size: 16px; color: var(--accent); font-weight: 700;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Canvas hint */
|
/* Canvas hint */
|
||||||
@@ -659,10 +698,10 @@ html, body, #root {
|
|||||||
right: 12px;
|
right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Position zoom inside sandbox main-area (offset for palette sidebar) */
|
/* Position zoom inside sandbox main-area */
|
||||||
.main-area .zoom-controls {
|
.main-area .zoom-controls {
|
||||||
top: 12px;
|
top: 12px;
|
||||||
right: 220px;
|
right: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ===== Admin Panel ===== */
|
/* ===== Admin Panel ===== */
|
||||||
@@ -761,3 +800,366 @@ html, body, #root {
|
|||||||
.admin-star-btn.active { background: var(--yellow); color: var(--bg); border-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 { color: var(--red); }
|
||||||
.admin-star-btn.zero:hover { border-color: var(--red); color: var(--red); }
|
.admin-star-btn.zero:hover { border-color: var(--red); color: var(--red); }
|
||||||
|
|
||||||
|
/* ===== Fullscreen Keyboard ===== */
|
||||||
|
.keyboard-fullscreen {
|
||||||
|
position: fixed; inset: 0; z-index: 500;
|
||||||
|
background: #050510; display: flex; flex-direction: column;
|
||||||
|
animation: fadeIn 0.2s ease-out; touch-action: none;
|
||||||
|
}
|
||||||
|
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||||
|
|
||||||
|
.keyboard-fs-header {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
gap: 16px; padding: 8px 16px; background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--border); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.keyboard-fs-title {
|
||||||
|
font-size: 16px; font-weight: 700; color: var(--text);
|
||||||
|
font-family: 'JetBrains Mono', monospace; letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
.keyboard-fs-close {
|
||||||
|
width: 36px; height: 36px; border-radius: 8px;
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
color: var(--text); font-size: 16px; cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.keyboard-fs-oct-btn {
|
||||||
|
width: 44px; height: 36px; border-radius: 8px;
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
color: var(--accent); font-size: 16px; cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.keyboard-fs-oct-btn:active { background: var(--accent); color: #000; }
|
||||||
|
|
||||||
|
.keyboard-fs-keys {
|
||||||
|
flex: 1; position: relative; display: flex;
|
||||||
|
touch-action: none; user-select: none;
|
||||||
|
}
|
||||||
|
.keyboard-fs-white {
|
||||||
|
flex: 1; height: 100%; background: linear-gradient(to bottom, #1e1e38, #14142a);
|
||||||
|
border-right: 2px solid #0a0a18; position: relative;
|
||||||
|
display: flex; align-items: flex-end; justify-content: center;
|
||||||
|
padding-bottom: 20px; cursor: pointer; transition: background 0.05s;
|
||||||
|
}
|
||||||
|
.keyboard-fs-white.pressed, .keyboard-fs-white:active {
|
||||||
|
background: linear-gradient(to bottom, var(--accent), #0a8a9e);
|
||||||
|
}
|
||||||
|
.keyboard-fs-note-label {
|
||||||
|
font-size: 14px; font-weight: 600; color: var(--text2);
|
||||||
|
font-family: 'JetBrains Mono', monospace; pointer-events: none;
|
||||||
|
}
|
||||||
|
.keyboard-fs-white.pressed .keyboard-fs-note-label,
|
||||||
|
.keyboard-fs-white:active .keyboard-fs-note-label { color: #000; }
|
||||||
|
|
||||||
|
.keyboard-fs-black {
|
||||||
|
position: absolute; top: 0; height: 58%;
|
||||||
|
background: linear-gradient(to bottom, #0a0a16, #060610);
|
||||||
|
border: 2px solid #222; border-top: none;
|
||||||
|
border-radius: 0 0 6px 6px;
|
||||||
|
z-index: 2; cursor: pointer; transition: background 0.05s;
|
||||||
|
display: flex; align-items: flex-end; justify-content: center;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
}
|
||||||
|
.keyboard-fs-black.pressed, .keyboard-fs-black:active {
|
||||||
|
background: linear-gradient(to bottom, #0088aa, #006688);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.keyboard-fs-black-label {
|
||||||
|
font-size: 10px; font-weight: 600; color: #555;
|
||||||
|
font-family: 'JetBrains Mono', monospace; pointer-events: none;
|
||||||
|
}
|
||||||
|
.keyboard-fs-black.pressed .keyboard-fs-black-label,
|
||||||
|
.keyboard-fs-black:active .keyboard-fs-black-label { color: var(--accent); }
|
||||||
|
|
||||||
|
/* ===== Drum Pad ===== */
|
||||||
|
.drumpad-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 3px; padding: 2px 0;
|
||||||
|
}
|
||||||
|
.drumpad-pad {
|
||||||
|
aspect-ratio: 1; border-radius: 4px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
cursor: pointer; transition: all 0.05s; border: 1px solid var(--border);
|
||||||
|
font-size: 8px; font-weight: 600; color: var(--text2);
|
||||||
|
font-family: 'JetBrains Mono', monospace; user-select: none;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
.drumpad-pad:active { transform: scale(0.92); }
|
||||||
|
.drumpad-pad.active { border-color: var(--accent); box-shadow: 0 0 8px rgba(0,229,255,0.3); }
|
||||||
|
|
||||||
|
.drumpad-fullscreen {
|
||||||
|
position: fixed; inset: 0; z-index: 500;
|
||||||
|
background: #050510; display: flex; flex-direction: column;
|
||||||
|
animation: fadeIn 0.2s ease-out;
|
||||||
|
}
|
||||||
|
.drumpad-fs-header {
|
||||||
|
display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 12px 16px; background: var(--panel); border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.drumpad-fs-title { font-size: 14px; font-weight: 600; color: var(--text); }
|
||||||
|
.drumpad-fs-close {
|
||||||
|
width: 36px; height: 36px; border-radius: 8px;
|
||||||
|
background: var(--surface); border: 1px solid var(--border);
|
||||||
|
color: var(--text); font-size: 16px; cursor: pointer;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.drumpad-fs-grid {
|
||||||
|
flex: 1; display: grid; grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: 8px; padding: 16px; touch-action: none;
|
||||||
|
}
|
||||||
|
.drumpad-fs-pad {
|
||||||
|
border-radius: 8px; display: flex; flex-direction: column;
|
||||||
|
align-items: center; justify-content: center; gap: 4px;
|
||||||
|
cursor: pointer; transition: all 0.05s;
|
||||||
|
border: 2px solid var(--border); font-size: 12px;
|
||||||
|
font-weight: 600; color: var(--text2);
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
user-select: none; touch-action: none;
|
||||||
|
}
|
||||||
|
.drumpad-fs-pad:active { transform: scale(0.95); border-color: var(--accent); }
|
||||||
|
.drumpad-fs-pad .pad-label { font-size: 10px; color: var(--text2); }
|
||||||
|
|
||||||
|
/* ============================================
|
||||||
|
MOBILE RESPONSIVE — max-width: 768px
|
||||||
|
============================================ */
|
||||||
|
|
||||||
|
/* --- Bottom Sheet --- */
|
||||||
|
.bottom-sheet {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Mobile Tab Bar --- */
|
||||||
|
.mobile-tab-bar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
|
||||||
|
/* --- Prevent page scroll/bounce --- */
|
||||||
|
html, body, #root { overflow: hidden; position: fixed; width: 100%; height: 100%; }
|
||||||
|
.app, .gm-puzzle { overflow: hidden; height: 100dvh; }
|
||||||
|
|
||||||
|
/* --- Sandbox Toolbar --- */
|
||||||
|
.toolbar { height: 44px; padding: 0 12px; gap: 6px; }
|
||||||
|
.toolbar-title { font-size: 13px; letter-spacing: 0.8px; }
|
||||||
|
.toolbar-sep, .toolbar .status-text,
|
||||||
|
.toolbar-btn.save-btn, .toolbar-btn.load-btn,
|
||||||
|
.toolbar-btn.export-btn, .toolbar-btn.import-btn,
|
||||||
|
.toolbar-btn.demo-btn, .toolbar-btn.clear-btn { display: none; }
|
||||||
|
.toolbar-btn.start-btn { padding: 4px 10px; font-size: 11px; }
|
||||||
|
|
||||||
|
/* Hamburger menu button (added via JS) */
|
||||||
|
.mobile-menu-btn {
|
||||||
|
padding: 6px 10px; background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; color: var(--text2); cursor: pointer; font-size: 18px;
|
||||||
|
font-weight: 600; line-height: 1;
|
||||||
|
}
|
||||||
|
.mobile-menu-btn:hover { border-color: var(--accent); color: var(--text); }
|
||||||
|
|
||||||
|
.mobile-menu-overlay {
|
||||||
|
position: fixed; inset: 0; background: rgba(0,0,0,0.6);
|
||||||
|
z-index: 200; display: flex; justify-content: flex-end;
|
||||||
|
}
|
||||||
|
.mobile-menu-panel {
|
||||||
|
width: 260px; background: var(--panel); border-left: 1px solid var(--border);
|
||||||
|
padding: 16px; display: flex; flex-direction: column; gap: 6px;
|
||||||
|
animation: slideInRight 0.2s ease-out;
|
||||||
|
}
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from { transform: translateX(100%); }
|
||||||
|
to { transform: translateX(0); }
|
||||||
|
}
|
||||||
|
.mobile-menu-panel .toolbar-btn {
|
||||||
|
display: flex; width: 100%; padding: 10px 14px;
|
||||||
|
font-size: 13px; text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Mobile Action Bar (Sandbox) --- */
|
||||||
|
.mobile-action-bar {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 0 12px; height: 48px; background: var(--panel);
|
||||||
|
border-top: 1px solid var(--border); flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.mobile-action-bar .start-btn-mobile {
|
||||||
|
flex: 1; padding: 8px 14px; background: var(--accent); color: #000;
|
||||||
|
border: none; border-radius: 6px; font-size: 12px; font-weight: 700;
|
||||||
|
font-family: 'JetBrains Mono', monospace; letter-spacing: 1px; cursor: pointer;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.mobile-action-bar .start-btn-mobile.active {
|
||||||
|
background: var(--red);
|
||||||
|
}
|
||||||
|
.mobile-action-bar .action-icon-btn {
|
||||||
|
padding: 8px 12px; background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 6px; color: var(--text2); cursor: pointer; font-size: 14px;
|
||||||
|
}
|
||||||
|
.mobile-action-bar .action-icon-btn:hover { border-color: var(--accent); }
|
||||||
|
|
||||||
|
/* --- Status Bar --- */
|
||||||
|
.status-bar { display: none; }
|
||||||
|
|
||||||
|
/* --- Module Palette --- */
|
||||||
|
.palette { display: none; }
|
||||||
|
|
||||||
|
/* --- Bottom Sheet (visible on mobile) --- */
|
||||||
|
.bottom-sheet {
|
||||||
|
display: flex; flex-direction: column;
|
||||||
|
background: var(--panel); border-top: 1px solid var(--border);
|
||||||
|
border-radius: 16px 16px 0 0; flex-shrink: 0;
|
||||||
|
transition: max-height 0.3s ease;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bottom-sheet.collapsed { max-height: 42px; }
|
||||||
|
.bottom-sheet.collapsed:has(.bottom-sheet-tabs) { max-height: 76px; }
|
||||||
|
.bottom-sheet.expanded { max-height: 55vh; }
|
||||||
|
|
||||||
|
.bottom-sheet-handle {
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
gap: 8px; padding: 10px 0 6px; cursor: pointer; min-height: 34px;
|
||||||
|
}
|
||||||
|
.bottom-sheet-handle-bar {
|
||||||
|
width: 40px; height: 4px; background: var(--border);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
.bottom-sheet-peek-label {
|
||||||
|
font-size: 10px; font-weight: 600; color: var(--text2);
|
||||||
|
font-family: 'JetBrains Mono', monospace; letter-spacing: 1px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-sheet-tabs {
|
||||||
|
display: flex; padding: 0 16px; gap: 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.bottom-sheet-tab {
|
||||||
|
flex: 1; padding: 8px 0; background: none; border: none;
|
||||||
|
color: var(--text2); font-size: 10px; font-weight: 700;
|
||||||
|
font-family: 'JetBrains Mono', monospace; letter-spacing: 1px;
|
||||||
|
cursor: pointer; text-align: center; position: relative;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.bottom-sheet-tab.active { color: var(--accent); }
|
||||||
|
.bottom-sheet-tab-line {
|
||||||
|
position: absolute; bottom: 0; left: 50%; transform: translateX(-50%);
|
||||||
|
width: 100%; height: 2px; background: var(--accent); border-radius: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bottom-sheet-content {
|
||||||
|
padding: 12px 16px; overflow-y: auto; flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Module grid tiles (mobile palette) */
|
||||||
|
.mobile-module-grid {
|
||||||
|
display: grid; grid-template-columns: repeat(5, 1fr);
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.mobile-module-tile {
|
||||||
|
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
||||||
|
padding: 10px 4px; background: var(--surface); border: 1px solid var(--border);
|
||||||
|
border-radius: 8px; cursor: pointer; transition: all 0.15s;
|
||||||
|
}
|
||||||
|
.mobile-module-tile:hover, .mobile-module-tile:active {
|
||||||
|
border-color: var(--accent); background: var(--surface2);
|
||||||
|
}
|
||||||
|
.mobile-module-tile .tile-icon { font-size: 20px; }
|
||||||
|
.mobile-module-tile .tile-name {
|
||||||
|
font-size: 9px; font-weight: 600; color: var(--text2);
|
||||||
|
font-family: 'JetBrains Mono', monospace; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Canvas adjustments --- */
|
||||||
|
.node-canvas { cursor: default; touch-action: none; }
|
||||||
|
.zoom-controls { right: 8px; top: 8px; }
|
||||||
|
.zoom-btn { width: 40px; height: 36px; min-height: 44px; }
|
||||||
|
.port-dot { width: 18px; height: 18px; }
|
||||||
|
|
||||||
|
/* --- Mobile Tab Bar (visible on mobile) --- */
|
||||||
|
.mobile-tab-bar {
|
||||||
|
display: flex; align-items: center; height: 56px;
|
||||||
|
background: var(--panel); border-top: 1px solid var(--border);
|
||||||
|
flex-shrink: 0; z-index: 10;
|
||||||
|
}
|
||||||
|
.mobile-tab {
|
||||||
|
flex: 1; display: flex; flex-direction: column; align-items: center;
|
||||||
|
gap: 3px; padding: 6px 0; background: none; border: none;
|
||||||
|
cursor: pointer; color: var(--text2); transition: color 0.15s;
|
||||||
|
}
|
||||||
|
.mobile-tab.active { color: var(--accent); }
|
||||||
|
.mobile-tab-icon { font-size: 18px; }
|
||||||
|
.mobile-tab-label {
|
||||||
|
font-size: 9px; font-weight: 600; letter-spacing: 1px;
|
||||||
|
font-family: 'JetBrains Mono', monospace; text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- World Map Mobile --- */
|
||||||
|
.gm-worldmap { padding: 0 12px 80px; }
|
||||||
|
.gm-header { padding: 12px 0; gap: 6px; }
|
||||||
|
.gm-header-top { gap: 8px; }
|
||||||
|
.gm-logo-icon { width: 32px; height: 32px; font-size: 16px; }
|
||||||
|
.gm-title { font-size: 18px; }
|
||||||
|
.gm-tagline { display: none; }
|
||||||
|
.gm-header-actions .gm-btn { display: none; }
|
||||||
|
.gm-search-bar { margin: 0 0 12px; }
|
||||||
|
.gm-level-grid { grid-template-columns: 1fr; }
|
||||||
|
.gm-level-card { padding: 10px 12px; }
|
||||||
|
.gm-world-section { margin-bottom: 16px; }
|
||||||
|
|
||||||
|
/* --- Puzzle View Mobile --- */
|
||||||
|
.gm-puzzle-bar { height: 44px; padding: 0 10px; gap: 6px; }
|
||||||
|
.gm-puzzle-bar .gm-btn { padding: 6px 10px; }
|
||||||
|
.gm-puzzle-bar .gm-btn .btn-label { display: none; }
|
||||||
|
.gm-puzzle-name { font-size: 13px; }
|
||||||
|
.gm-puzzle-num { font-size: 9px; padding: 2px 6px; }
|
||||||
|
|
||||||
|
.gm-puzzle-sidebar { display: none; }
|
||||||
|
.gm-puzzle-canvas-wrap { width: 100%; }
|
||||||
|
|
||||||
|
/* Puzzle bottom sheet specific */
|
||||||
|
.puzzle-mission-text {
|
||||||
|
font-size: 12px; color: var(--text); line-height: 1.5;
|
||||||
|
}
|
||||||
|
.puzzle-hint-btn {
|
||||||
|
display: flex; align-items: center; gap: 8px;
|
||||||
|
padding: 10px 12px; border: 1px dashed var(--yellow);
|
||||||
|
border-radius: 8px; background: rgba(255,204,0,0.04);
|
||||||
|
cursor: pointer; width: 100%; margin-top: 8px;
|
||||||
|
}
|
||||||
|
.puzzle-hint-btn:hover { background: rgba(255,204,0,0.1); border-style: solid; }
|
||||||
|
.puzzle-hint-icon { font-size: 16px; }
|
||||||
|
.puzzle-hint-label { font-size: 12px; font-weight: 600; color: var(--yellow); flex: 1; }
|
||||||
|
.puzzle-hint-penalty {
|
||||||
|
font-size: 9px; font-weight: 700; color: var(--red);
|
||||||
|
background: rgba(255,68,102,0.15); padding: 2px 6px; border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.puzzle-obj-item {
|
||||||
|
display: flex; align-items: center; gap: 10px;
|
||||||
|
padding: 8px 0; border-bottom: 1px solid var(--border);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.puzzle-obj-item:last-child { border-bottom: none; }
|
||||||
|
.puzzle-obj-star { color: var(--yellow); width: 30px; flex-shrink: 0; }
|
||||||
|
.puzzle-obj-desc { color: var(--text2); flex: 1; }
|
||||||
|
|
||||||
|
/* --- Level Complete Modal Mobile --- */
|
||||||
|
.gm-complete-overlay { padding: 0 16px; }
|
||||||
|
.gm-complete-card {
|
||||||
|
min-width: unset; max-width: unset; width: 100%;
|
||||||
|
padding: 24px 20px;
|
||||||
|
}
|
||||||
|
.gm-complete-actions { flex-direction: column; width: 100%; }
|
||||||
|
.gm-complete-actions .gm-btn { width: 100%; justify-content: center; }
|
||||||
|
.gm-complete-actions .gm-btn.primary {
|
||||||
|
order: -1; padding: 14px;
|
||||||
|
font-size: 13px; font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* --- Preset Modal Mobile --- */
|
||||||
|
.modal { min-width: unset; max-width: unset; width: calc(100% - 32px); }
|
||||||
|
|
||||||
|
/* --- General touch targets --- */
|
||||||
|
.gm-btn { min-height: 44px; display: flex; align-items: center; }
|
||||||
|
.gm-palette-item { padding: 12px 10px; }
|
||||||
|
|
||||||
|
}
|
||||||
|
|||||||
20
src/main.jsx
20
src/main.jsx
@@ -15,3 +15,23 @@ function Root() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById('root')).render(<Root />);
|
createRoot(document.getElementById('root')).render(<Root />);
|
||||||
|
|
||||||
|
// Configure and unlock audio context on first user interaction
|
||||||
|
import * as Tone from 'tone';
|
||||||
|
Tone.getContext().lookAhead = 0.05; // 50ms — tighter than default 100ms
|
||||||
|
const unlockAudio = () => {
|
||||||
|
if (Tone.context.state !== 'running') {
|
||||||
|
Tone.start().catch(() => {});
|
||||||
|
}
|
||||||
|
document.removeEventListener('pointerdown', unlockAudio);
|
||||||
|
document.removeEventListener('keydown', unlockAudio);
|
||||||
|
};
|
||||||
|
document.addEventListener('pointerdown', unlockAudio);
|
||||||
|
document.addEventListener('keydown', unlockAudio);
|
||||||
|
|
||||||
|
// Register service worker for PWA
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
window.addEventListener('load', () => {
|
||||||
|
navigator.serviceWorker.register('/sw.js').catch(() => {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user