Files
reaktor/src/App.jsx
Jose Luis a1be6df355 feat: UI sounds, live LFO visualization, wire fix, worlds 7-12, bug fixes
- Add procedural UI sound effects (connect/disconnect, engine start/stop,
  level complete/fail, star earned, hint, navigation) via Tone.js
- Live LFO modulation visualization: knobs animate in real-time showing
  modulated value, ghost dot shows base value, number glows cyan
- Fix wire recalculation on zoom/pan/level re-entry (post-layout refresh)
- Fix retry button to keep current patch instead of reloading level
- Fix default param detection: newly added modules now populate all
  default params so level checkers work without manual param changes
- Add worlds 7-12: Secuencias y Ritmos, Texturas de Ruido, Síntesis
  Sustractiva, Espacio y Stereo, Técnicas Avanzadas, Gran Final
  (48 new levels, 144 new possible stars, 288 total stars)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-21 03:03:29 +01:00

325 lines
12 KiB
JavaScript
Raw Blame History

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