fix: LFO→cutoff modulation, visual knob feedback, persistent hints
- Fix LFO→Filter cutoff: add scaling Gain nodes so LFO (-1..1) maps to meaningful Hz modulation (±cutoff value). Same fix for LFO→Osc freq. Mod scale updates dynamically when user changes the base param value. - Visual modulation indicator: knobs receiving LFO/modulation show a pulsing dashed ring animation (spin + pulse) around the knob arc - Persist hint usage per level: using a hint permanently caps that level at 2 stars — survives reload/restart. No more cheating by restarting! - Hint state stored in separate localStorage key (synthquest-hints) - Admin reset also clears hint history Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,7 @@ function describeArc(cx, cy, r, startDeg, endDeg) {
|
|||||||
return `M ${start.x} ${start.y} A ${r} ${r} 0 ${large} 0 ${end.x} ${end.y}`;
|
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 }) {
|
export default function Knob({ value, min, max, onChange, color = 'var(--accent)', label, unit, formatValue, modulated = false }) {
|
||||||
const ref = useRef(null);
|
const ref = useRef(null);
|
||||||
const dragRef = useRef(null);
|
const dragRef = useRef(null);
|
||||||
const inputRef = useRef(null);
|
const inputRef = useRef(null);
|
||||||
@@ -122,9 +122,13 @@ export default function Knob({ value, min, max, onChange, color = 'var(--accent)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="knob-container" onWheel={handleWheel} onDoubleClick={handleDoubleClick}>
|
<div className={`knob-container ${modulated ? 'knob-modulated' : ''}`} onWheel={handleWheel} onDoubleClick={handleDoubleClick}>
|
||||||
<svg className="knob-svg" viewBox={`0 0 ${SIZE} ${SIZE}`}
|
<svg className="knob-svg" viewBox={`0 0 ${SIZE} ${SIZE}`}
|
||||||
onPointerDown={handlePointerDown} ref={ref}>
|
onPointerDown={handlePointerDown} ref={ref}>
|
||||||
|
{/* Modulation glow ring */}
|
||||||
|
{modulated && (
|
||||||
|
<circle className="knob-mod-ring" cx={cx} cy={cy} r={RADIUS + 1} style={{ stroke: color }} />
|
||||||
|
)}
|
||||||
<path className="knob-track" d={trackPath} />
|
<path className="knob-track" d={trackPath} />
|
||||||
{fillPath && <path className="knob-fill" d={fillPath} style={{ stroke: color }} />}
|
{fillPath && <path className="knob-fill" d={fillPath} style={{ stroke: color }} />}
|
||||||
<circle className="knob-dot" cx={dotPos.x} cy={dotPos.y} r={2} />
|
<circle className="knob-dot" cx={dotPos.x} cy={dotPos.y} r={2} />
|
||||||
|
|||||||
@@ -8,6 +8,13 @@ import KeyboardWidget from './KeyboardWidget.jsx';
|
|||||||
import SequencerWidget from './SequencerWidget.jsx';
|
import SequencerWidget from './SequencerWidget.jsx';
|
||||||
import PianoRollWidget from './PianoRollWidget.jsx';
|
import PianoRollWidget from './PianoRollWidget.jsx';
|
||||||
|
|
||||||
|
// Map input port names → the param name they modulate (for visual feedback)
|
||||||
|
const PORT_TO_PARAM = {
|
||||||
|
filter: { cutoff: 'frequency' },
|
||||||
|
oscillator: { freq: 'frequency', detune: 'detune' },
|
||||||
|
vca: { cv: 'gain' },
|
||||||
|
};
|
||||||
|
|
||||||
export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }) {
|
export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }) {
|
||||||
const def = getModuleDef(mod.type);
|
const def = getModuleDef(mod.type);
|
||||||
if (!def) return null;
|
if (!def) return null;
|
||||||
@@ -17,6 +24,15 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
|
|||||||
// 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 };
|
||||||
|
|
||||||
|
// Find which params are being modulated (have an incoming connection on their corresponding port)
|
||||||
|
const modulatedParams = new Set();
|
||||||
|
const portMap = PORT_TO_PARAM[mod.type] || {};
|
||||||
|
for (const conn of state.connections) {
|
||||||
|
if (conn.to.moduleId === mod.id && portMap[conn.to.port]) {
|
||||||
|
modulatedParams.add(portMap[conn.to.port]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleParamChange = useCallback((name, value) => {
|
const handleParamChange = useCallback((name, value) => {
|
||||||
updateModuleParam(mod.id, name, value);
|
updateModuleParam(mod.id, name, value);
|
||||||
updateParam(mod.id, name, value);
|
updateParam(mod.id, name, value);
|
||||||
@@ -117,6 +133,7 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition }
|
|||||||
max={paramDef.max}
|
max={paramDef.max}
|
||||||
onChange={v => handleParamChange(name, v)}
|
onChange={v => handleParamChange(name, v)}
|
||||||
color={color}
|
color={color}
|
||||||
|
modulated={modulatedParams.has(name)}
|
||||||
/>
|
/>
|
||||||
<span className="param-value">
|
<span className="param-value">
|
||||||
{params[name] >= 1000 ? `${(params[name] / 1000).toFixed(1)}k` :
|
{params[name] >= 1000 ? `${(params[name] / 1000).toFixed(1)}k` :
|
||||||
|
|||||||
@@ -24,11 +24,16 @@ function createNode(mod) {
|
|||||||
case 'oscillator': {
|
case 'oscillator': {
|
||||||
const osc = new Tone.Oscillator({ type: p.waveform, frequency: p.frequency, detune: p.detune });
|
const osc = new Tone.Oscillator({ type: p.waveform, frequency: p.frequency, detune: p.detune });
|
||||||
osc.start();
|
osc.start();
|
||||||
|
// Modulation scaler for freq input: LFO (-1..1) × scale → added to osc.frequency
|
||||||
|
// Scale = half the current frequency so modulation is musically meaningful
|
||||||
|
const freqMod = new Tone.Gain(p.frequency * 0.5);
|
||||||
|
freqMod.connect(osc.frequency);
|
||||||
return {
|
return {
|
||||||
node: osc,
|
node: osc,
|
||||||
inputs: { freq: osc.frequency, detune: osc.detune },
|
_freqMod: freqMod,
|
||||||
|
inputs: { freq: freqMod, detune: osc.detune },
|
||||||
outputs: { out: osc },
|
outputs: { out: osc },
|
||||||
dispose: () => { osc.stop(); osc.dispose(); },
|
dispose: () => { osc.stop(); freqMod.disconnect(); freqMod.dispose(); osc.dispose(); },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'lfo': {
|
case 'lfo': {
|
||||||
@@ -53,11 +58,16 @@ function createNode(mod) {
|
|||||||
}
|
}
|
||||||
case 'filter': {
|
case 'filter': {
|
||||||
const filter = new Tone.Filter({ type: p.type, frequency: p.frequency, Q: p.Q });
|
const filter = new Tone.Filter({ type: p.type, frequency: p.frequency, Q: p.Q });
|
||||||
|
// Modulation scaler for cutoff input: LFO (-1..1) × scale → added to filter.frequency
|
||||||
|
// Scale = cutoff value so full LFO sweep covers 0 to 2× the cutoff
|
||||||
|
const cutoffMod = new Tone.Gain(p.frequency);
|
||||||
|
cutoffMod.connect(filter.frequency);
|
||||||
return {
|
return {
|
||||||
node: filter,
|
node: filter,
|
||||||
inputs: { in: filter, cutoff: filter.frequency },
|
_cutoffMod: cutoffMod,
|
||||||
|
inputs: { in: filter, cutoff: cutoffMod },
|
||||||
outputs: { out: filter },
|
outputs: { out: filter },
|
||||||
dispose: () => filter.dispose(),
|
dispose: () => { cutoffMod.disconnect(); cutoffMod.dispose(); filter.dispose(); },
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
case 'envelope': {
|
case 'envelope': {
|
||||||
@@ -277,7 +287,11 @@ export function updateParam(moduleId, paramName, value) {
|
|||||||
switch (mod.type) {
|
switch (mod.type) {
|
||||||
case 'oscillator':
|
case 'oscillator':
|
||||||
if (paramName === 'waveform') entry.node.type = value;
|
if (paramName === 'waveform') entry.node.type = value;
|
||||||
else if (paramName === 'frequency') entry.node.frequency.value = value;
|
else if (paramName === 'frequency') {
|
||||||
|
entry.node.frequency.value = value;
|
||||||
|
// Update mod scaler proportionally
|
||||||
|
if (entry._freqMod) entry._freqMod.gain.value = value * 0.5;
|
||||||
|
}
|
||||||
else if (paramName === 'detune') entry.node.detune.value = value;
|
else if (paramName === 'detune') entry.node.detune.value = value;
|
||||||
break;
|
break;
|
||||||
case 'lfo':
|
case 'lfo':
|
||||||
@@ -290,7 +304,11 @@ export function updateParam(moduleId, paramName, value) {
|
|||||||
break;
|
break;
|
||||||
case 'filter':
|
case 'filter':
|
||||||
if (paramName === 'type') entry.node.type = value;
|
if (paramName === 'type') entry.node.type = value;
|
||||||
else if (paramName === 'frequency') entry.node.frequency.value = value;
|
else if (paramName === 'frequency') {
|
||||||
|
entry.node.frequency.value = value;
|
||||||
|
// Update mod scaler proportionally
|
||||||
|
if (entry._cutoffMod) entry._cutoffMod.gain.value = value;
|
||||||
|
}
|
||||||
else if (paramName === 'Q') entry.node.Q.value = value;
|
else if (paramName === 'Q') entry.node.Q.value = value;
|
||||||
break;
|
break;
|
||||||
case 'envelope':
|
case 'envelope':
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ export default function LevelComplete({ stars, checks, levelTitle, onNext, onRet
|
|||||||
const messages = [
|
const messages = [
|
||||||
'',
|
'',
|
||||||
'Has dado el primer paso...',
|
'Has dado el primer paso...',
|
||||||
hintPenalty ? 'Reinicia sin pista para conseguir 3 estrellas.' : 'Buen trabajo — casi perfecto.',
|
hintPenalty ? 'Pista usada — tercera estrella bloqueada permanentemente.' : 'Buen trabajo — casi perfecto.',
|
||||||
'Ejecucion impecable.',
|
'Ejecucion impecable.',
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -58,7 +58,7 @@ export default function LevelComplete({ stars, checks, levelTitle, onNext, onRet
|
|||||||
<div className="gm-complete-actions">
|
<div className="gm-complete-actions">
|
||||||
<button className="gm-btn secondary" onClick={onMap}>Mapa</button>
|
<button className="gm-btn secondary" onClick={onMap}>Mapa</button>
|
||||||
<button className="gm-btn secondary" onClick={onRetry}>
|
<button className="gm-btn secondary" onClick={onRetry}>
|
||||||
{hintPenalty ? '↺ Sin pista' : 'Reintentar'}
|
Reintentar
|
||||||
</button>
|
</button>
|
||||||
{stars >= 1 && !isLastLevel && (
|
{stars >= 1 && !isLastLevel && (
|
||||||
<button className="gm-btn primary" onClick={onNext}>Siguiente →</button>
|
<button className="gm-btn primary" onClick={onNext}>Siguiente →</button>
|
||||||
|
|||||||
@@ -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 } from './gameState.js';
|
import { completeLevel, saveLevelPatch, getLevelPatch, markHintUsed, wasHintUsed } from './gameState.js';
|
||||||
|
|
||||||
export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onNextLevel }) {
|
export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onNextLevel }) {
|
||||||
const [, forceUpdate] = useState(0);
|
const [, forceUpdate] = useState(0);
|
||||||
@@ -76,8 +76,10 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
deserialize(data);
|
deserialize(data);
|
||||||
}
|
}
|
||||||
setResult(null);
|
setResult(null);
|
||||||
setHintUsed(false);
|
// Restore persisted hint state — no cheating by reloading!
|
||||||
setShowHint(false);
|
const hintPersisted = wasHintUsed(level.id);
|
||||||
|
setHintUsed(hintPersisted);
|
||||||
|
setShowHint(hintPersisted); // If they used it before, show it again
|
||||||
if (state.isRunning) stopAudio();
|
if (state.isRunning) stopAudio();
|
||||||
}, [level]);
|
}, [level]);
|
||||||
|
|
||||||
@@ -245,10 +247,11 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Reveal hint — permanently caps this attempt at 2 stars
|
// Reveal hint — PERMANENTLY caps this level at 2 stars (persisted, survives reload)
|
||||||
const handleRevealHint = () => {
|
const handleRevealHint = () => {
|
||||||
setHintUsed(true);
|
setHintUsed(true);
|
||||||
setShowHint(true);
|
setShowHint(true);
|
||||||
|
markHintUsed(level.id);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleCheck = () => {
|
const handleCheck = () => {
|
||||||
@@ -357,7 +360,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN
|
|||||||
})}
|
})}
|
||||||
{hintUsed && (
|
{hintUsed && (
|
||||||
<div className="gm-hint-warning">
|
<div className="gm-hint-warning">
|
||||||
Pista usada — maximo 2 estrellas. Reinicia para intentar sin pista.
|
Pista usada — maximo 2 estrellas en este nivel (permanente).
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -61,8 +61,10 @@ export function isLevelUnlocked(levelId, worldLevels) {
|
|||||||
export function resetProgress() {
|
export function resetProgress() {
|
||||||
_progress = { ...defaultProgress };
|
_progress = { ...defaultProgress };
|
||||||
_patches = {};
|
_patches = {};
|
||||||
|
_hints = {};
|
||||||
saveProgress();
|
saveProgress();
|
||||||
savePatches();
|
savePatches();
|
||||||
|
saveHints();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==================== Level patch persistence ====================
|
// ==================== Level patch persistence ====================
|
||||||
@@ -105,3 +107,43 @@ export function clearLevelPatch(levelId) {
|
|||||||
delete patches[levelId];
|
delete patches[levelId];
|
||||||
savePatches();
|
savePatches();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==================== Hint tracking (persisted, no cheating!) ====================
|
||||||
|
|
||||||
|
const HINTS_KEY = 'synthquest-hints';
|
||||||
|
let _hints = null; // { levelId: true }
|
||||||
|
|
||||||
|
function loadHints() {
|
||||||
|
if (_hints) return _hints;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(HINTS_KEY);
|
||||||
|
_hints = raw ? JSON.parse(raw) : {};
|
||||||
|
} catch {
|
||||||
|
_hints = {};
|
||||||
|
}
|
||||||
|
return _hints;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveHints() {
|
||||||
|
if (!_hints) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(HINTS_KEY, JSON.stringify(_hints));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function markHintUsed(levelId) {
|
||||||
|
const hints = loadHints();
|
||||||
|
hints[levelId] = true;
|
||||||
|
saveHints();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function wasHintUsed(levelId) {
|
||||||
|
const hints = loadHints();
|
||||||
|
return !!hints[levelId];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearHintForLevel(levelId) {
|
||||||
|
const hints = loadHints();
|
||||||
|
delete hints[levelId];
|
||||||
|
saveHints();
|
||||||
|
}
|
||||||
|
|||||||
@@ -168,6 +168,27 @@ html, body, #root {
|
|||||||
.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); }
|
||||||
|
|
||||||
|
/* Modulation indicator: pulsing ring around modulated knobs */
|
||||||
|
.knob-mod-ring {
|
||||||
|
fill: none;
|
||||||
|
stroke-width: 1.5;
|
||||||
|
stroke-dasharray: 3 2;
|
||||||
|
opacity: 0.7;
|
||||||
|
animation: knob-mod-pulse 1.2s ease-in-out infinite alternate, knob-mod-spin 4s linear infinite;
|
||||||
|
}
|
||||||
|
@keyframes knob-mod-pulse {
|
||||||
|
from { opacity: 0.3; stroke-width: 1; }
|
||||||
|
to { opacity: 0.9; stroke-width: 2; }
|
||||||
|
}
|
||||||
|
@keyframes knob-mod-spin {
|
||||||
|
from { stroke-dashoffset: 0; }
|
||||||
|
to { stroke-dashoffset: 30; }
|
||||||
|
}
|
||||||
|
.knob-modulated .param-label,
|
||||||
|
.knob-container.knob-modulated + .param-value {
|
||||||
|
color: var(--accent2);
|
||||||
|
}
|
||||||
|
|
||||||
.knob-editing { display: flex; align-items: center; justify-content: center; }
|
.knob-editing { display: flex; align-items: center; justify-content: center; }
|
||||||
.knob-input {
|
.knob-input {
|
||||||
width: 48px; height: 22px; padding: 0 4px;
|
width: 48px; height: 22px; padding: 0 4px;
|
||||||
|
|||||||
Reference in New Issue
Block a user