fix: Transport lifecycle, scope zoom, clear button, and freq routing
- Fix pianoroll/sequencer Transport not resetting on stop/restart (notes were scheduled in the past and never fired) - Stop and cancel Transport in stopAudio() to prevent stale events - Add zoom +/- buttons to scope widget (6 levels, 64–2048 samples) - Increase scope analyser buffer from 256 to 2048 for wider time view - Add vertical grid lines to scope display - Add "Limpiar" clear canvas button to PuzzleView - Skip audio-graph connection for keyboard/seq/pianoroll freq→osc freq (direct frequency setting prevents inaudible ultrasonic values) - Auto-trigger envelopes without gate connections for noise/ambient levels Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -235,12 +235,23 @@ export default function PianoRollWidget({ moduleId }) {
|
|||||||
// Playback
|
// Playback
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state.isRunning) {
|
if (!state.isRunning) {
|
||||||
if (partRef.current) { partRef.current.stop(); partRef.current.dispose(); partRef.current = null; }
|
if (partRef.current) {
|
||||||
|
try { partRef.current.stop(); } catch {}
|
||||||
|
try { partRef.current.dispose(); } catch {}
|
||||||
|
partRef.current = null;
|
||||||
|
}
|
||||||
setPlayPos(-1);
|
setPlayPos(-1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Tone.getTransport().bpm.value = bpm;
|
const transport = Tone.getTransport();
|
||||||
|
transport.bpm.value = bpm;
|
||||||
|
|
||||||
|
// Ensure Transport is at position 0 before scheduling
|
||||||
|
if (transport.state === 'started') {
|
||||||
|
transport.stop();
|
||||||
|
}
|
||||||
|
transport.position = 0;
|
||||||
|
|
||||||
// Build Tone.Part from notes using musical time (bars:quarters:sixteenths)
|
// Build Tone.Part from notes using musical time (bars:quarters:sixteenths)
|
||||||
// This lets the Transport BPM control actual playback speed
|
// This lets the Transport BPM control actual playback speed
|
||||||
@@ -263,7 +274,7 @@ export default function PianoRollWidget({ moduleId }) {
|
|||||||
// Note-off: convert duration beats to musical time for proper BPM-relative timing
|
// Note-off: convert duration beats to musical time for proper BPM-relative timing
|
||||||
const durSixteenths = Math.round(ev.dur * 4);
|
const durSixteenths = Math.round(ev.dur * 4);
|
||||||
const noteOffTime = time + (durSixteenths * (60 / (bpm * 4))) * 0.9;
|
const noteOffTime = time + (durSixteenths * (60 / (bpm * 4))) * 0.9;
|
||||||
Tone.getTransport().scheduleOnce(() => {
|
transport.scheduleOnce(() => {
|
||||||
setSequencerSignals(moduleId, midiToFreq(ev.note), false);
|
setSequencerSignals(moduleId, midiToFreq(ev.note), false);
|
||||||
}, noteOffTime);
|
}, noteOffTime);
|
||||||
}, events.map(ev => [ev.time, { note: ev.note, dur: ev.dur }]));
|
}, events.map(ev => [ev.time, { note: ev.note, dur: ev.dur }]));
|
||||||
@@ -271,16 +282,15 @@ export default function PianoRollWidget({ moduleId }) {
|
|||||||
part.loop = loop;
|
part.loop = loop;
|
||||||
part.loopEnd = `${bars}m`;
|
part.loopEnd = `${bars}m`;
|
||||||
part.start(0);
|
part.start(0);
|
||||||
|
|
||||||
if (Tone.getTransport().state !== 'started') {
|
|
||||||
Tone.getTransport().start();
|
|
||||||
}
|
|
||||||
partRef.current = part;
|
partRef.current = part;
|
||||||
|
|
||||||
|
// Start Transport fresh from position 0
|
||||||
|
transport.start();
|
||||||
|
|
||||||
// Track playhead position
|
// Track playhead position
|
||||||
const posInterval = setInterval(() => {
|
const posInterval = setInterval(() => {
|
||||||
if (Tone.getTransport().state === 'started') {
|
if (transport.state === 'started') {
|
||||||
const pos = Tone.getTransport().seconds;
|
const pos = transport.seconds;
|
||||||
const beatDuration = 60 / bpm;
|
const beatDuration = 60 / bpm;
|
||||||
const currentBeat = (pos / beatDuration) % totalBeats;
|
const currentBeat = (pos / beatDuration) % totalBeats;
|
||||||
setPlayPos(currentBeat);
|
setPlayPos(currentBeat);
|
||||||
@@ -289,7 +299,11 @@ export default function PianoRollWidget({ moduleId }) {
|
|||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
clearInterval(posInterval);
|
clearInterval(posInterval);
|
||||||
if (partRef.current) { partRef.current.stop(); partRef.current.dispose(); partRef.current = null; }
|
if (partRef.current) {
|
||||||
|
try { partRef.current.stop(); } catch {}
|
||||||
|
try { partRef.current.dispose(); } catch {}
|
||||||
|
partRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [state.isRunning, moduleId, bpm, bars, loop]);
|
}, [state.isRunning, moduleId, bpm, bars, loop]);
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -23,16 +33,25 @@ 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);
|
||||||
}
|
}
|
||||||
@@ -46,5 +65,43 @@ export default function ScopeDisplay({ moduleId }) {
|
|||||||
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,12 +39,23 @@ export default function SequencerWidget({ moduleId }) {
|
|||||||
// Start/stop sequencer when audio engine runs
|
// Start/stop sequencer when audio engine runs
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!state.isRunning) {
|
if (!state.isRunning) {
|
||||||
if (seqRef.current) { seqRef.current.stop(); seqRef.current.dispose(); seqRef.current = null; }
|
if (seqRef.current) {
|
||||||
|
try { seqRef.current.stop(); } catch {}
|
||||||
|
try { seqRef.current.dispose(); } catch {}
|
||||||
|
seqRef.current = null;
|
||||||
|
}
|
||||||
setCurrentStep(-1);
|
setCurrentStep(-1);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Tone.getTransport().bpm.value = bpm;
|
const transport = Tone.getTransport();
|
||||||
|
transport.bpm.value = bpm;
|
||||||
|
|
||||||
|
// Ensure Transport is at position 0
|
||||||
|
if (transport.state === 'started') {
|
||||||
|
transport.stop();
|
||||||
|
}
|
||||||
|
transport.position = 0;
|
||||||
|
|
||||||
const seq = new Tone.Sequence((time, stepIdx) => {
|
const seq = new Tone.Sequence((time, stepIdx) => {
|
||||||
const s = stepsRef.current[stepIdx];
|
const s = stepsRef.current[stepIdx];
|
||||||
@@ -53,7 +64,7 @@ export default function SequencerWidget({ moduleId }) {
|
|||||||
|
|
||||||
if (s.gate) {
|
if (s.gate) {
|
||||||
setSequencerSignals(moduleId, midiToFreq(s.midi), true);
|
setSequencerSignals(moduleId, midiToFreq(s.midi), true);
|
||||||
Tone.getTransport().scheduleOnce(() => {
|
transport.scheduleOnce(() => {
|
||||||
setSequencerSignals(moduleId, midiToFreq(s.midi), false);
|
setSequencerSignals(moduleId, midiToFreq(s.midi), false);
|
||||||
}, time + Tone.Time('16n').toSeconds() * 0.8);
|
}, time + Tone.Time('16n').toSeconds() * 0.8);
|
||||||
} else {
|
} else {
|
||||||
@@ -62,13 +73,15 @@ export default function SequencerWidget({ moduleId }) {
|
|||||||
}, Array.from({ length: numSteps }, (_, i) => i), '16n');
|
}, Array.from({ length: numSteps }, (_, i) => i), '16n');
|
||||||
|
|
||||||
seq.start(0);
|
seq.start(0);
|
||||||
if (Tone.getTransport().state !== 'started') {
|
transport.start();
|
||||||
Tone.getTransport().start();
|
|
||||||
}
|
|
||||||
seqRef.current = seq;
|
seqRef.current = seq;
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
if (seqRef.current) { seqRef.current.stop(); seqRef.current.dispose(); seqRef.current = null; }
|
if (seqRef.current) {
|
||||||
|
try { seqRef.current.stop(); } catch {}
|
||||||
|
try { seqRef.current.dispose(); } catch {}
|
||||||
|
seqRef.current = null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
}, [state.isRunning, moduleId, numSteps]);
|
}, [state.isRunning, moduleId, numSteps]);
|
||||||
|
|
||||||
|
|||||||
@@ -136,7 +136,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 },
|
||||||
@@ -245,6 +245,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', '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;
|
||||||
@@ -356,6 +367,17 @@ export function setSequencerSignals(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;
|
||||||
|
|
||||||
|
// Directly set connected oscillator frequencies (bypasses the modulation Gain)
|
||||||
|
for (const conn of state.connections) {
|
||||||
|
if (conn.from.moduleId === moduleId && conn.from.port === 'freq') {
|
||||||
|
const oscEntry = audioNodes[conn.to.moduleId];
|
||||||
|
const oscMod = state.modules.find(m => m.id === conn.to.moduleId);
|
||||||
|
if (oscEntry?.node && oscMod?.type === 'oscillator') {
|
||||||
|
oscEntry.node.frequency.value = freq;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Trigger connected envelopes
|
// Trigger connected envelopes
|
||||||
for (const conn of state.connections) {
|
for (const conn of state.connections) {
|
||||||
if (conn.from.moduleId === moduleId && conn.from.port === 'gate') {
|
if (conn.from.moduleId === moduleId && conn.from.port === 'gate') {
|
||||||
@@ -374,6 +396,17 @@ 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;
|
||||||
|
|
||||||
|
// Directly set connected oscillator frequencies (bypasses the modulation Gain)
|
||||||
|
for (const conn of state.connections) {
|
||||||
|
if (conn.from.moduleId === moduleId && conn.from.port === 'freq') {
|
||||||
|
const oscEntry = audioNodes[conn.to.moduleId];
|
||||||
|
const oscMod = state.modules.find(m => m.id === conn.to.moduleId);
|
||||||
|
if (oscEntry?.node && oscMod?.type === 'oscillator') {
|
||||||
|
oscEntry.node.frequency.value = freq;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Also trigger any connected envelopes
|
// Also trigger any connected envelopes
|
||||||
for (const conn of state.connections) {
|
for (const conn of state.connections) {
|
||||||
if (conn.from.moduleId === moduleId && conn.from.port === 'gate') {
|
if (conn.from.moduleId === moduleId && conn.from.port === 'gate') {
|
||||||
@@ -395,6 +428,13 @@ export async function startAudio() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function stopAudio() {
|
export function stopAudio() {
|
||||||
|
// Stop and reset Transport so pianoroll/sequencer Parts don't get stranded
|
||||||
|
try {
|
||||||
|
Tone.getTransport().stop();
|
||||||
|
Tone.getTransport().cancel(); // Remove all scheduled events
|
||||||
|
Tone.getTransport().position = 0;
|
||||||
|
} catch (e) { /* ignore if Transport not started */ }
|
||||||
|
|
||||||
// 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 +457,21 @@ export function rebuildGraph() {
|
|||||||
for (const conn of state.connections) {
|
for (const conn of state.connections) {
|
||||||
connectWire(conn);
|
connectWire(conn);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAnalyserData(moduleId) {
|
export function getAnalyserData(moduleId) {
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import ModuleNode from '../components/ModuleNode.jsx';
|
|||||||
import WireLayer from '../components/WireLayer.jsx';
|
import WireLayer from '../components/WireLayer.jsx';
|
||||||
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';
|
import { SOLUTIONS } from './autoSolver.js';
|
||||||
|
|
||||||
@@ -251,6 +251,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);
|
||||||
@@ -326,6 +333,9 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
>
|
>
|
||||||
{state.isRunning ? '⏹ Parar' : '▶ Mi Sonido'}
|
{state.isRunning ? '⏹ Parar' : '▶ Mi Sonido'}
|
||||||
</button>
|
</button>
|
||||||
|
<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
|
✓ Comprobar
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user