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>
This commit is contained in:
Jose Luis
2026-03-21 03:03:29 +01:00
parent f0100eb64f
commit a1be6df355
17 changed files with 3317 additions and 24 deletions

View File

@@ -19,14 +19,17 @@ function describeArc(cx, cy, r, startDeg, endDeg) {
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${large} 0 ${end.x} ${end.y}`;
}
export default function Knob({ value, min, max, onChange, color = 'var(--accent)', label, unit, formatValue, modulated = false }) {
export default function Knob({ value, min, max, onChange, color = 'var(--accent)', label, unit, formatValue, modulated = false, liveValue }) {
const ref = useRef(null);
const dragRef = useRef(null);
const inputRef = useRef(null);
const [editing, setEditing] = useState(false);
const [editText, setEditText] = useState('');
const norm = Math.max(0, Math.min(1, (value - min) / (max - min)));
// Use liveValue for visual display when being modulated, base value for interaction
const displayNum = liveValue !== undefined ? liveValue : value;
const clampedDisplay = Math.max(min, Math.min(max, displayNum));
const norm = Math.max(0, Math.min(1, (clampedDisplay - min) / (max - min)));
const angleDeg = START_ANGLE - norm * RANGE;
const cx = SIZE / 2, cy = SIZE / 2;
@@ -36,11 +39,16 @@ export default function Knob({ value, min, max, onChange, color = 'var(--accent)
const dotPos = polarToCart(cx, cy, RADIUS - 4, angleDeg);
const displayVal = formatValue ? formatValue(value) :
value >= 1000 ? `${(value / 1000).toFixed(1)}k` :
value >= 100 ? Math.round(value) :
value >= 1 ? value.toFixed(1) :
value.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
// Also show base value indicator when modulated
const baseNorm = Math.max(0, Math.min(1, (value - min) / (max - min)));
const baseAngle = START_ANGLE - baseNorm * RANGE;
const baseDotPos = polarToCart(cx, cy, RADIUS - 4, baseAngle);
const displayVal = formatValue ? formatValue(displayNum) :
displayNum >= 1000 ? `${(displayNum / 1000).toFixed(1)}k` :
displayNum >= 100 ? Math.round(displayNum) :
displayNum >= 1 ? displayNum.toFixed(1) :
displayNum.toFixed(3).replace(/0+$/, '').replace(/\.$/, '');
const handlePointerDown = useCallback((e) => {
if (editing) return;
@@ -131,6 +139,10 @@ export default function Knob({ value, min, max, onChange, color = 'var(--accent)
)}
<path className="knob-track" d={trackPath} />
{fillPath && <path className="knob-fill" d={fillPath} style={{ stroke: color }} />}
{/* Ghost dot at base value position when modulated */}
{liveValue !== undefined && (
<circle className="knob-base-dot" cx={baseDotPos.x} cy={baseDotPos.y} r={1.5} />
)}
<circle className="knob-dot" cx={dotPos.x} cy={dotPos.y} r={2} />
</svg>
</div>