diff --git a/src/components/Knob.jsx b/src/components/Knob.jsx index 0d7cb73..a4fddc7 100644 --- a/src/components/Knob.jsx +++ b/src/components/Knob.jsx @@ -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}`; } -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 dragRef = useRef(null); const inputRef = useRef(null); @@ -122,9 +122,13 @@ export default function Knob({ value, min, max, onChange, color = 'var(--accent) } return ( -
+
+ {/* Modulation glow ring */} + {modulated && ( + + )} {fillPath && } diff --git a/src/components/ModuleNode.jsx b/src/components/ModuleNode.jsx index fd0d496..c1b01c3 100644 --- a/src/components/ModuleNode.jsx +++ b/src/components/ModuleNode.jsx @@ -8,6 +8,13 @@ import KeyboardWidget from './KeyboardWidget.jsx'; import SequencerWidget from './SequencerWidget.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 }) { const def = getModuleDef(mod.type); if (!def) return null; @@ -17,6 +24,15 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition } // Merge default 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) => { updateModuleParam(mod.id, name, value); updateParam(mod.id, name, value); @@ -117,6 +133,7 @@ export default function ModuleNode({ mod, zoom, onStartConnect, onPortPosition } max={paramDef.max} onChange={v => handleParamChange(name, v)} color={color} + modulated={modulatedParams.has(name)} /> {params[name] >= 1000 ? `${(params[name] / 1000).toFixed(1)}k` : diff --git a/src/engine/audioEngine.js b/src/engine/audioEngine.js index 14b70ab..ffd5eec 100644 --- a/src/engine/audioEngine.js +++ b/src/engine/audioEngine.js @@ -24,11 +24,16 @@ function createNode(mod) { case 'oscillator': { const osc = new Tone.Oscillator({ type: p.waveform, frequency: p.frequency, detune: p.detune }); 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 { node: osc, - inputs: { freq: osc.frequency, detune: osc.detune }, + _freqMod: freqMod, + inputs: { freq: freqMod, detune: osc.detune }, outputs: { out: osc }, - dispose: () => { osc.stop(); osc.dispose(); }, + dispose: () => { osc.stop(); freqMod.disconnect(); freqMod.dispose(); osc.dispose(); }, }; } case 'lfo': { @@ -53,11 +58,16 @@ function createNode(mod) { } case 'filter': { 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 { node: filter, - inputs: { in: filter, cutoff: filter.frequency }, + _cutoffMod: cutoffMod, + inputs: { in: filter, cutoff: cutoffMod }, outputs: { out: filter }, - dispose: () => filter.dispose(), + dispose: () => { cutoffMod.disconnect(); cutoffMod.dispose(); filter.dispose(); }, }; } case 'envelope': { @@ -277,7 +287,11 @@ export function updateParam(moduleId, paramName, value) { switch (mod.type) { case 'oscillator': 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; break; case 'lfo': @@ -290,7 +304,11 @@ export function updateParam(moduleId, paramName, value) { break; case 'filter': 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; break; case 'envelope': diff --git a/src/game/LevelComplete.jsx b/src/game/LevelComplete.jsx index f30214a..cc56095 100644 --- a/src/game/LevelComplete.jsx +++ b/src/game/LevelComplete.jsx @@ -14,7 +14,7 @@ export default function LevelComplete({ stars, checks, levelTitle, onNext, onRet const messages = [ '', '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.', ]; @@ -58,7 +58,7 @@ export default function LevelComplete({ stars, checks, levelTitle, onNext, onRet
{stars >= 1 && !isLastLevel && ( diff --git a/src/game/PuzzleView.jsx b/src/game/PuzzleView.jsx index a062807..a69f323 100644 --- a/src/game/PuzzleView.jsx +++ b/src/game/PuzzleView.jsx @@ -6,7 +6,7 @@ import ModuleNode from '../components/ModuleNode.jsx'; import WireLayer from '../components/WireLayer.jsx'; import { playTarget, stopTarget, isTargetPlaying } from './targetAudio.js'; 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 }) { const [, forceUpdate] = useState(0); @@ -76,8 +76,10 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN deserialize(data); } setResult(null); - setHintUsed(false); - setShowHint(false); + // Restore persisted hint state — no cheating by reloading! + const hintPersisted = wasHintUsed(level.id); + setHintUsed(hintPersisted); + setShowHint(hintPersisted); // If they used it before, show it again if (state.isRunning) stopAudio(); }, [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 = () => { setHintUsed(true); setShowHint(true); + markHintUsed(level.id); }; const handleCheck = () => { @@ -357,7 +360,7 @@ export default function PuzzleView({ level, levelIndex, worldLevels, onBack, onN })} {hintUsed && (
- Pista usada — maximo 2 estrellas. Reinicia para intentar sin pista. + Pista usada — maximo 2 estrellas en este nivel (permanente).
)}
diff --git a/src/game/gameState.js b/src/game/gameState.js index 8c09dba..0ac5fbe 100644 --- a/src/game/gameState.js +++ b/src/game/gameState.js @@ -61,8 +61,10 @@ export function isLevelUnlocked(levelId, worldLevels) { export function resetProgress() { _progress = { ...defaultProgress }; _patches = {}; + _hints = {}; saveProgress(); savePatches(); + saveHints(); } // ==================== Level patch persistence ==================== @@ -105,3 +107,43 @@ export function clearLevelPatch(levelId) { delete patches[levelId]; 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(); +} diff --git a/src/index.css b/src/index.css index 2de9818..c5d6197 100644 --- a/src/index.css +++ b/src/index.css @@ -168,6 +168,27 @@ html, body, #root { .knob-fill { fill: none; stroke-width: 3; stroke-linecap: round; } .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-input { width: 48px; height: 22px; padding: 0 4px;